From 95f2658ef5ecbe1c23ae51399926e74f7dcb90dc Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Fri, 26 Jun 2026 19:56:09 +0300 Subject: [PATCH 1/2] RawStringEquals, GetHashCodeUtf8, fix buf StartsWith(Span) if not ASCII --- src/StackExchange.Redis/RedisValue.cs | 120 +++++++++++++++++++------- 1 file changed, 87 insertions(+), 33 deletions(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index fb2a6c0b0..9c3c6a480 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -469,6 +469,18 @@ internal string RawString() // memory / short-blob / sequence) compare by raw bytes in any combination if (IsBlob(xType) && IsBlob(yType)) return BlobSequenceEqual(x, y); + switch (xType) + { + case StorageType.Sequence when yType == StorageType.String: + return y.RawStringEquals(x.RawSequence()); + case StorageType.String when yType == StorageType.Sequence: + return x.RawStringEquals(y.RawSequence()); + case StorageType.ByteArray or StorageType.MemoryManager or StorageType.ShortBlob when yType == StorageType.String: + return y.RawStringEquals(x.UnsafeRawSpan(out _)); + case StorageType.String when yType is StorageType.ByteArray or StorageType.MemoryManager or StorageType.ShortBlob: + return x.RawStringEquals(y.UnsafeRawSpan(out _)); + } + // otherwise (anything involving a string), compare as strings return (string?)x == (string?)y; } @@ -496,38 +508,16 @@ public override bool Equals(object? obj) private static int GetHashCode(RedisValue x) { x = x.Simplify(); - switch (x.Type) - { - case StorageType.Null: - return -1; - case StorageType.Double: - return x.OverlappedValueDouble.GetHashCode(); - case StorageType.Int64 or StorageType.UInt64: - return x._valueInt64.GetHashCode(); - case StorageType.String: - return x.RawString().GetHashCode(); - } - - // Everything else - byte/memory/sequence buffers - compares to each other (and to strings) "as - // strings" (see operator ==): e.g. "inf" the bytes equals "inf" the string. Anything that looked - // 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 - 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)); - 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 + return x.Type switch + { + StorageType.Null => -1, + StorageType.ByteArray or StorageType.MemoryManager or StorageType.ShortBlob => GetHashCode(x.UnsafeRawSpan(out _)), + StorageType.Sequence => GetHashCode(x.RawSequenceIterator()), + StorageType.Double => x.OverlappedValueDouble.GetHashCode(), + StorageType.Int64 or StorageType.UInt64 => x._valueInt64.GetHashCode(), + StorageType.String => GetHashCodeUtf8(x.RawString()), + _ => throw new InvalidOperationException("Invalid StorageType"), + }; } /// @@ -591,6 +581,69 @@ internal static int GetHashCode(ReadOnlySpan span) return AddHashCode(span, HashCodeStart); } + private static int GetHashCode(ReadOnlySequenceSegmentIterator span) + { + if (span.Length == 0) return 0; + + var acc = HashCodeStart; + while (span.TryNext(out var memory)) + { + acc = AddHashCode(memory.Span, acc); + } + return acc; + } + + private static int GetHashCodeUtf8(string str) + { + var length = str.Length; + if (length == 0) return 0; + + byte[]? leased = null; + var maxBytes = Encoding.UTF8.GetMaxByteCount(length); + Span bytes = maxBytes <= StackByteLimit ? stackalloc byte[maxBytes] : (leased = ArrayPool.Shared.Rent(maxBytes)); + var written = Encoding.UTF8.GetBytes(str, bytes); + var hashCode = GetHashCode(bytes.Slice(0, written)); + if (leased is not null) ArrayPool.Shared.Return(leased); + return hashCode; + } + + private bool RawStringEquals(ReadOnlySpan span) + { + string s = RawString(); + var length = s.Length; + if (length == 0) return span.IsEmpty; + var maxChars = Encoding.UTF8.GetMaxCharCount(span.Length); + if (length > maxChars) return false; + + byte[]? leased = null; + var maxBytes = Encoding.UTF8.GetMaxByteCount(length); + Span bytes = maxBytes <= StackByteLimit ? stackalloc byte[maxBytes] : (leased = ArrayPool.Shared.Rent(maxBytes)); + var written = Encoding.UTF8.GetBytes(s, bytes); + var result = span.SequenceEqual(bytes.Slice(0, written)); + if (leased is not null) ArrayPool.Shared.Return(leased); + return result; + } + + private bool RawStringEquals(ReadOnlySequence seq) + { + string s = RawString(); + var length = s.Length; + var seqLength = seq.Length; + if (length == 0) return seqLength == 0; + if (seq.Length > int.MaxValue) return false; + var maxChars = Encoding.UTF8.GetMaxCharCount(checked((int)seqLength)); + if (length > maxChars) return false; + + byte[]? leased = null; + var maxBytes = Encoding.UTF8.GetMaxByteCount(length); + Span bytes = maxBytes <= StackByteLimit ? stackalloc byte[maxBytes] : (leased = ArrayPool.Shared.Rent(maxBytes)); + var written = Encoding.UTF8.GetBytes(s, bytes); + var result = seq.SequenceEqual(bytes.Slice(0, written)); + if (leased is not null) ArrayPool.Shared.Return(leased); + return result; + } + + private const int StackByteLimit = 512; private const int HashCodeStart = 728271210; internal void AssertNotNull() @@ -1791,7 +1844,8 @@ public bool StartsWith(ReadOnlySpan value) return buffer.Slice(0, len).StartsWith(value); case StorageType.String: var s = RawString().AsSpan(); - if (s.Length < value.Length) return false; // not enough characters to match + // BUG if Not ASCII + // if (s.Length < value.Length) return false; // not enough characters to match if (s.Length > value.Length) s = s.Slice(0, value.Length); // only need to match the prefix var maxBytes = Encoding.UTF8.GetMaxByteCount(s.Length); byte[]? lease = null; From c24cdd233b6e8c94480e377ab785626d0c9e6849 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Fri, 26 Jun 2026 22:49:06 +0300 Subject: [PATCH 2/2] undo GetHashCode --- src/StackExchange.Redis/RedisValue.cs | 85 ++++++++++++--------------- 1 file changed, 37 insertions(+), 48 deletions(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 9c3c6a480..3d0c5714e 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -508,16 +508,38 @@ public override bool Equals(object? obj) private static int GetHashCode(RedisValue x) { x = x.Simplify(); - return x.Type switch - { - StorageType.Null => -1, - StorageType.ByteArray or StorageType.MemoryManager or StorageType.ShortBlob => GetHashCode(x.UnsafeRawSpan(out _)), - StorageType.Sequence => GetHashCode(x.RawSequenceIterator()), - StorageType.Double => x.OverlappedValueDouble.GetHashCode(), - StorageType.Int64 or StorageType.UInt64 => x._valueInt64.GetHashCode(), - StorageType.String => GetHashCodeUtf8(x.RawString()), - _ => throw new InvalidOperationException("Invalid StorageType"), - }; + switch (x.Type) + { + case StorageType.Null: + return -1; + case StorageType.Double: + return x.OverlappedValueDouble.GetHashCode(); + case StorageType.Int64 or StorageType.UInt64: + return x._valueInt64.GetHashCode(); + case StorageType.String: + return x.RawString().GetHashCode(); + } + + // Everything else - byte/memory/sequence buffers - compares to each other (and to strings) "as + // strings" (see operator ==): e.g. "inf" the bytes equals "inf" the string. Anything that looked + // 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 + 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)); + 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 } /// @@ -549,12 +571,15 @@ internal static unsafe bool Equals(byte[]? x, byte[]? y) return true; } - private static int AddHashCode(ReadOnlySpan span, int acc) + // used by RedisKey, whose equality is byte-based (unlike RedisValue, which treats non-numeric + // buffers as strings - see GetHashCode(RedisValue)) + internal static int GetHashCode(ReadOnlySpan span) { unchecked { int len = span.Length; - Debug.Assert(len > 0); + if (len == 0) return 0; + var acc = 728271210; var span64 = MemoryMarshal.Cast(span); for (int i = 0; i < span64.Length; i++) @@ -572,41 +597,6 @@ private static int AddHashCode(ReadOnlySpan span, int acc) } } - // used by RedisKey, whose equality is byte-based (unlike RedisValue, which treats non-numeric - // buffers as strings - see GetHashCode(RedisValue)) - internal static int GetHashCode(ReadOnlySpan span) - { - if (span.Length == 0) return 0; - - return AddHashCode(span, HashCodeStart); - } - - private static int GetHashCode(ReadOnlySequenceSegmentIterator span) - { - if (span.Length == 0) return 0; - - var acc = HashCodeStart; - while (span.TryNext(out var memory)) - { - acc = AddHashCode(memory.Span, acc); - } - return acc; - } - - private static int GetHashCodeUtf8(string str) - { - var length = str.Length; - if (length == 0) return 0; - - byte[]? leased = null; - var maxBytes = Encoding.UTF8.GetMaxByteCount(length); - Span bytes = maxBytes <= StackByteLimit ? stackalloc byte[maxBytes] : (leased = ArrayPool.Shared.Rent(maxBytes)); - var written = Encoding.UTF8.GetBytes(str, bytes); - var hashCode = GetHashCode(bytes.Slice(0, written)); - if (leased is not null) ArrayPool.Shared.Return(leased); - return hashCode; - } - private bool RawStringEquals(ReadOnlySpan span) { string s = RawString(); @@ -644,7 +634,6 @@ private bool RawStringEquals(ReadOnlySequence seq) } private const int StackByteLimit = 512; - private const int HashCodeStart = 728271210; internal void AssertNotNull() {