diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 0b3820f61..ed7236263 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.IO.Hashing; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -655,7 +656,7 @@ private static int GetHashCode(RedisValue x) case StorageType.Int64 or StorageType.UInt64: return x._valueInt64.GetHashCode(); case StorageType.String: - return x.RawString().GetHashCode(); + return HashChars(x.RawString().AsSpan()); } // Everything else - byte/memory/sequence buffers - compares to each other (and to strings) "as @@ -663,21 +664,50 @@ private static int GetHashCode(RedisValue x) // numeric was already reduced to Int64/Double by Simplify() above, so the equality-consistent // hash for what remains is the hash of the string form. (We must NOT hash raw bytes: that would // give byte buffers a different hash from the equal string.) -#if NET - // hash the decoded UTF8 chars directly, which avoids allocating a transient string; this matches - // string.GetHashCode() for the equivalent text + // hash the decoded UTF8 chars directly, which avoids allocating a transient string const int StackLimit = 256; var maxChars = x.GetMaxCharCount(); char[]? leased = null; Span chars = maxChars <= StackLimit ? stackalloc char[StackLimit] : (leased = ArrayPool.Shared.Rent(maxChars)); var written = x.CopyTo(chars); - var hashCode = string.GetHashCode(chars.Slice(0, written)); + var hashCode = HashChars(chars.Slice(0, written)); if (leased is not null) ArrayPool.Shared.Return(leased); return hashCode; -#else - // no string.GetHashCode(ReadOnlySpan) on these targets, so fall back to the string form - return ((string)x!).GetHashCode(); -#endif + } + + /// + /// Per-process entropy, so that hash codes are not predictable between runs. + /// + /// + /// This is what gets from Marvin, and the reason hash codes must + /// never be persisted or sent between processes - which was already true. + /// + private static readonly long HashSeed = BitConverter.ToInt64(Guid.NewGuid().ToByteArray(), 0); + + /// + /// Hashes text, over its whole content. + /// + /// + /// + /// Equality here is defined by the decoded characters (see operator ==), so the hash has to be + /// taken over those same characters: every representation of a value - string, byte[], sequence - + /// then lands in the same bucket. + /// + /// + /// rather than the framework's Marvin, because hashing was measured at roughly + /// 2 GB/s and dominated the cost of hashing any large value - the UTF8 decode alongside it accounted + /// for under 4%. The seed supplies per-process entropy, but note what it does not do: Marvin is a + /// *keyed* hash, built so that collisions cannot be constructed without the key, whereas xxHash's + /// seed offers no such guarantee. That is a deliberate trade of hash-flooding resistance for speed, + /// on the basis that a is rarely a dictionary key and practically never an + /// attacker-chosen one. + /// + /// + private static int HashChars(scoped ReadOnlySpan chars) + { + if (chars.IsEmpty) return 0; + var hash = XxHash3.HashToUInt64(MemoryMarshal.AsBytes(chars), HashSeed); + return unchecked((int)hash ^ (int)(hash >> 32)); } ///