From c096d2930401beee8fe3c7432ea7947400f7a436 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Wed, 24 Jun 2026 11:53:23 +0300 Subject: [PATCH 01/21] Merge pull request #4 from pairbit/RedisValueSequence Redis value sequence --- src/StackExchange.Redis/EncodingExtensions.cs | 24 +++ src/StackExchange.Redis/MessageWriter.cs | 27 +++- .../PublicAPI/PublicAPI.Unshipped.txt | 3 + .../ReadOnlySequenceExtensions.cs | 138 ++++++++++++++++++ src/StackExchange.Redis/RedisValue.cs | 100 ++++++++++++- src/StackExchange.Redis/ValueCondition.cs | 35 ++++- 6 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 src/StackExchange.Redis/EncodingExtensions.cs create mode 100644 src/StackExchange.Redis/ReadOnlySequenceExtensions.cs diff --git a/src/StackExchange.Redis/EncodingExtensions.cs b/src/StackExchange.Redis/EncodingExtensions.cs new file mode 100644 index 000000000..2d51dcdfe --- /dev/null +++ b/src/StackExchange.Redis/EncodingExtensions.cs @@ -0,0 +1,24 @@ +#if NET461 || NET472 || NETSTANDARD2_0 +using System; +using System.Buffers; +using System.Text; + +namespace StackExchange.Redis; + +internal static class EncodingExtensions +{ + public static int GetChars(this Encoding encoding, in ReadOnlySequence bytes, Span chars) + { + if (encoding == null) throw new ArgumentNullException(nameof(encoding)); + + if (bytes.IsSingleSegment) + { + return encoding.GetChars(bytes.First.Span, chars); + } + else + { + throw new NotImplementedException(); + } + } +} +#endif diff --git a/src/StackExchange.Redis/MessageWriter.cs b/src/StackExchange.Redis/MessageWriter.cs index 1b654532d..ad9f2864a 100644 --- a/src/StackExchange.Redis/MessageWriter.cs +++ b/src/StackExchange.Redis/MessageWriter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Diagnostics; using System.Runtime.CompilerServices; @@ -102,6 +102,9 @@ internal static void WriteBulkString(in RedisValue value, IBufferWriter wr case RedisValue.StorageType.MemoryManager or RedisValue.StorageType.ByteArray: WriteUnifiedSpan(writer, value.RawSpan()); break; + case RedisValue.StorageType.Sequence: + WriteUnifiedSequence(writer, value.RawSequence()); + break; default: throw new InvalidOperationException($"Unexpected {value.Type} value: '{value}'"); } @@ -571,6 +574,28 @@ private static void WriteUnifiedSpan(IBufferWriter writer, ReadOnlySpan writer, in ReadOnlySequence value) + { + if (value.IsSingleSegment) + { + WriteUnifiedSpan(writer, value.First.Span); + } + else + { + var span = writer.GetSpan(3 + Format.MaxInt32TextLen); + span[0] = (byte)'$'; + int bytes = WriteRaw(span, value.Length, offset: 1); + writer.Advance(bytes); + + foreach (var memory in value) + { + writer.Write(memory.Span); + } + + WriteCrlf(writer); + } + } + private static int AppendToSpan(Span span, ReadOnlySpan value, int offset = 0) { offset = WriteRaw(span, value.Length, offset: offset); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 0f75682cb..c9f89f8f9 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -4,6 +4,9 @@ StackExchange.Redis.ConfigurationOptions.RequestBufferPool.set -> void StackExchange.Redis.ConfigurationOptions.ResponseBufferPool.get -> System.Buffers.MemoryPool? StackExchange.Redis.ConfigurationOptions.ResponseBufferPool.set -> void static StackExchange.Redis.Lease.Create(int length, System.Buffers.MemoryPool? pool, bool clear = true) -> StackExchange.Redis.Lease! +static StackExchange.Redis.RedisValue.implicit operator StackExchange.Redis.RedisValue(System.Buffers.ReadOnlySequence value) -> StackExchange.Redis.RedisValue +static StackExchange.Redis.RedisValue.implicit operator System.Buffers.ReadOnlySequence(StackExchange.Redis.RedisValue value) -> System.Buffers.ReadOnlySequence +[SER002]static StackExchange.Redis.ValueCondition.CalculateDigest(in System.Buffers.ReadOnlySequence value) -> StackExchange.Redis.ValueCondition [SER005]StackExchange.Redis.TestHarness [SER005]StackExchange.Redis.TestHarness.BufferValidator [SER005]StackExchange.Redis.TestHarness.ChannelPrefix.get -> StackExchange.Redis.RedisChannel diff --git a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs new file mode 100644 index 000000000..06c5a793e --- /dev/null +++ b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs @@ -0,0 +1,138 @@ +using System; +using System.Buffers; +using System.Diagnostics; + +namespace StackExchange.Redis; + +internal static class ReadOnlySequenceExtensions +{ + public static bool StartsWith(this in ReadOnlySequence sequence, in ReadOnlySequence value) + { + throw new NotImplementedException(); + } + + public static bool StartsWith(this in ReadOnlySequence sequence, ReadOnlySpan value) + => StartsWith(sequence, value, sequence.Start); + + public static bool StartsWith(this in ReadOnlySequence sequence, ReadOnlySpan value, SequencePosition start) + { + var valueLength = value.Length; + int valueLengthPart = 0; + while (sequence.TryGet(ref start, out var memory)) + { + var spanLength = memory.Length; + if (spanLength == 0) continue; + + var span = memory.Span; + if (valueLengthPart > 0) + { + Debug.Assert(valueLength > valueLengthPart); + + var remainder = valueLength - valueLengthPart; + if (remainder > spanLength) + { + if (span.SequenceEqual(value.Slice(valueLengthPart, spanLength))) + { + valueLengthPart += spanLength; + continue; + } + } + else if (remainder == spanLength) + { + if (span.SequenceEqual(value.Slice(valueLengthPart))) + { + return true; + } + } + else if (span.StartsWith(value.Slice(valueLengthPart))) + { + return true; + } + return false; + } + + if (spanLength >= valueLength) + { + return span.StartsWith(value); + } + + if (!value.Slice(0, spanLength).SequenceEqual(span)) + return false; + + valueLengthPart = spanLength; + } + + return false; + } + + public static bool SequenceEqual(this in ReadOnlySequence first, ReadOnlySpan other) + { + if (first.IsSingleSegment) return first.First.Span.SequenceEqual(other); + if (first.Length != other.Length) return false; + + var position = first.Start; + while (first.TryGet(ref position, out var memory)) + { + var span = memory.Span; + + if (!span.SequenceEqual(other.Slice(0, span.Length))) return false; + + other = other.Slice(span.Length); + } + + return true; + } + + public static bool SequenceEqual(this in ReadOnlySequence first, in ReadOnlySequence other) + { + if (first.IsSingleSegment) return other.SequenceEqual(first.First.Span); + if (other.IsSingleSegment) return first.SequenceEqual(other.First.Span); + if (first.Length != other.Length) return false; + + var firstPosition = first.Start; + var otherPosition = other.Start; + ReadOnlySpan firstSpan; + ReadOnlySpan otherSpan = default; + while (first.TryGet(ref firstPosition, out var firstMemory)) + { + firstSpan = firstMemory.Span; + if (firstSpan.Length == 0) continue; + + if (otherSpan.Length > 0) + { + if (otherSpan.Length >= firstSpan.Length) + { + if (!firstSpan.SequenceEqual(otherSpan.Slice(0, firstSpan.Length))) return false; + otherSpan = otherSpan.Slice(firstSpan.Length); + continue; + } + + if (!firstSpan.Slice(0, otherSpan.Length).SequenceEqual(otherSpan)) return false; + firstSpan = firstSpan.Slice(otherSpan.Length); + } + + while (other.TryGet(ref otherPosition, out var otherMemory)) + { + otherSpan = otherMemory.Span; + if (otherSpan.Length == 0) continue; + + if (otherSpan.Length >= firstSpan.Length) + { + if (!firstSpan.SequenceEqual(otherSpan.Slice(0, firstSpan.Length))) return false; + otherSpan = otherSpan.Slice(firstSpan.Length); + break; + } + + if (!firstSpan.Slice(0, otherSpan.Length).SequenceEqual(otherSpan)) return false; + firstSpan = firstSpan.Slice(otherSpan.Length); + } + } + + return true; + } + + public static int SequenceCompareTo(this in ReadOnlySequence first, in ReadOnlySequence other) + { + throw new NotImplementedException(); + } +} diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index ac9274fcd..a50558849 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -255,6 +255,28 @@ public bool IsNullOrEmpty /// The second to compare. public static bool operator !=(RedisValue x, RedisValue y) => !(x == y); + private static ReadOnlySequence GetSequence(ReadOnlySequenceSegment startSegment, int startIndex, int length) + { + var endIndex = length - (startSegment.Memory.Length - startIndex); + var endSegment = startSegment; + do + { + endSegment = endSegment.Next ?? throw new InvalidOperationException("EndSegment is null"); + var len = endSegment.Memory.Length; + if (endIndex <= len) break; + endIndex -= len; + } + while (true); + return new ReadOnlySequence(startSegment, startIndex, endSegment, endIndex); + } + + internal ReadOnlySequence RawSequence() + { + if (_obj is ReadOnlySequenceSegment s) return GetSequence(s, _index, _length); + ThrowRawType(); + return default; + } + internal ReadOnlySpan RawSpan() { if (_obj is byte[] b) return new ReadOnlySpan(b, _index, _length); @@ -300,6 +322,8 @@ internal string RawString() return x.RawString() == y.RawString(); case StorageType.ByteArray or StorageType.MemoryManager: return x.RawSpan().SequenceEqual(y.RawSpan()); + case StorageType.Sequence: + return x.RawSequence().SequenceEqual(y.RawSequence()); } } @@ -320,6 +344,14 @@ internal string RawString() return false; } + if (xType == StorageType.Sequence && + (yType == StorageType.ByteArray || yType == StorageType.MemoryManager)) + return x.RawSequence().SequenceEqual(y.RawSpan()); + + if (yType == StorageType.Sequence && + (xType == StorageType.ByteArray || xType == StorageType.MemoryManager)) + return y.RawSequence().SequenceEqual(x.RawSpan()); + // otherwise, compare as strings return (string?)x == (string?)y; } @@ -425,6 +457,7 @@ internal enum StorageType MemoryManager, ByteArray, String, + Sequence, Unknown, } @@ -440,6 +473,7 @@ internal StorageType Type if (obj is byte[]) return StorageType.ByteArray; if (obj == Sentinel_UnsignedInteger) return StorageType.UInt64; if (obj is MemoryManager) return StorageType.MemoryManager; + if (obj is ReadOnlySequenceSegment) return StorageType.Sequence; return StorageType.Unknown; } } @@ -483,7 +517,7 @@ internal bool TryGetForeign([NotNullWhen(true)] out T? value, out int index, public long Length() => Type switch { StorageType.Null => 0, - StorageType.MemoryManager or StorageType.ByteArray => _length, + StorageType.MemoryManager or StorageType.ByteArray or StorageType.Sequence => _length, StorageType.String => Encoding.UTF8.GetByteCount(RawString()), StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), @@ -522,6 +556,8 @@ private static int CompareTo(RedisValue x, RedisValue y) return string.CompareOrdinal(x.RawString(), y.RawString()); case StorageType.MemoryManager or StorageType.ByteArray: return x.RawSpan().SequenceCompareTo(y.RawSpan()); + case StorageType.Sequence: + return x.RawSequence().SequenceCompareTo(y.RawSequence()); } } @@ -656,6 +692,20 @@ internal static RedisValue TryParse(object? obj, out bool valid) /// The to convert to a . public static implicit operator RedisValue(ReadOnlyMemory value) => new(value); + /// + /// Creates a new from a . + /// + /// The to cast to a . + public static implicit operator RedisValue(ReadOnlySequence value) + { + if (value.IsSingleSegment) return new(value.First); + + var length = checked((int)value.Length); + var pos = value.Start; + var segment = pos.GetObject() ?? throw new InvalidOperationException("StartSegment is null"); + return new((ReadOnlySequenceSegment)segment, length, pos.GetInteger()); + } + /// /// Creates a new from a . /// @@ -908,6 +958,10 @@ private static bool TryParseDouble(ReadOnlySpan blob, out double value) { return ToHex(span); } + case StorageType.Sequence: + var seq = value.RawSequence(); + if (seq.IsEmpty) return ""; + return Format.GetString(seq); default: throw new InvalidOperationException(); } @@ -951,6 +1005,8 @@ private static string ToHex(ReadOnlySpan src) return arr; case StorageType.ByteArray or StorageType.MemoryManager: return value.RawSpan().ToArray(); + case StorageType.Sequence: + return value.RawSequence().ToArray(); case StorageType.Int64: Debug.Assert(Format.MaxInt64TextLen <= 24); Span span = stackalloc byte[24]; @@ -978,7 +1034,7 @@ private static string ToHex(ReadOnlySpan src) public int GetByteCount() => Type switch { StorageType.Null => 0, - StorageType.MemoryManager or StorageType.ByteArray => _length, + StorageType.MemoryManager or StorageType.ByteArray or StorageType.Sequence => _length, StorageType.String => Encoding.UTF8.GetByteCount(RawString()), StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), @@ -992,7 +1048,7 @@ private static string ToHex(ReadOnlySpan src) internal int GetMaxByteCount() => Type switch { StorageType.Null => 0, - StorageType.MemoryManager or StorageType.ByteArray => _length, + StorageType.MemoryManager or StorageType.ByteArray or StorageType.Sequence => _length, StorageType.String => Encoding.UTF8.GetMaxByteCount(RawString().Length), StorageType.Int64 => Format.MaxInt64TextLen, StorageType.UInt64 => Format.MaxInt64TextLen, @@ -1049,6 +1105,9 @@ public int CopyTo(Span destination) case StorageType.MemoryManager or StorageType.ByteArray: RawSpan().CopyTo(destination); return _length; + case StorageType.Sequence: + RawSequence().CopyTo(destination); + return _length; case StorageType.String: return Encoding.UTF8.GetBytes(RawString().AsSpan(), destination); case StorageType.Int64: @@ -1073,6 +1132,8 @@ internal int CopyTo(Span destination) return 0; case StorageType.MemoryManager or StorageType.ByteArray: return Encoding.UTF8.GetChars(RawSpan(), destination); + case StorageType.Sequence: + return Encoding.UTF8.GetChars(RawSequence(), destination); case StorageType.String: var span = RawString().AsSpan(); span.CopyTo(destination); @@ -1100,6 +1161,16 @@ public static implicit operator ReadOnlyMemory(RedisValue value) return (byte[]?)value; } + /// + /// Converts a to a . + /// + /// The to convert. + public static implicit operator ReadOnlySequence(RedisValue value) + { + if (value._obj is ReadOnlySequenceSegment s) return GetSequence(s, value._index, value._length); + return new((ReadOnlyMemory)value); + } + TypeCode IConvertible.GetTypeCode() => TypeCode.Object; bool IConvertible.ToBoolean(IFormatProvider? provider) => (bool)this; @@ -1333,7 +1404,8 @@ public bool StartsWith(RedisValue value) if (IsNullOrEmpty) return false; var thisType = Type; - if (thisType == value.Type) // same? can often optimize + var otherType = value.Type; + if (thisType == otherType) // same? can often optimize { switch (thisType) { @@ -1343,8 +1415,20 @@ public bool StartsWith(RedisValue value) return sThis.StartsWith(sOther, StringComparison.Ordinal); case StorageType.MemoryManager or StorageType.ByteArray: return RawSpan().StartsWith(value.RawSpan()); + case StorageType.Sequence: + return RawSequence().StartsWith(value.RawSequence()); } } + if (thisType == StorageType.Sequence && + (otherType == StorageType.MemoryManager || otherType == StorageType.ByteArray)) + { + return RawSequence().StartsWith(value.RawSpan()); + } + if (otherType == StorageType.Sequence && + (thisType == StorageType.MemoryManager || thisType == StorageType.ByteArray)) + { + return value.RawSequence().StartsWith(RawSpan()); + } byte[]? arr0 = null, arr1 = null; try { @@ -1370,6 +1454,10 @@ private ReadOnlyMemory AsMemory(out byte[]? leased) case StorageType.ByteArray: leased = null; return new ReadOnlyMemory((byte[])_obj!, _index, _length); + case StorageType.Sequence: + leased = ArrayPool.Shared.Rent(_length); + RawSequence().CopyTo(leased); + return new ReadOnlyMemory(leased, 0, _length); case StorageType.String: string s = RawString(); HaveString: @@ -1408,6 +1496,8 @@ internal ValueCondition Digest() { case StorageType.MemoryManager or StorageType.ByteArray: return ValueCondition.CalculateDigest(RawSpan()); + case StorageType.Sequence: + return ValueCondition.CalculateDigest(RawSequence()); case StorageType.Null: return ValueCondition.NotExists; // interpret === null as "not exists" default: @@ -1453,6 +1543,8 @@ public bool StartsWith(ReadOnlySpan value) { case StorageType.MemoryManager or StorageType.ByteArray: return RawSpan().StartsWith(value); + case StorageType.Sequence: + return RawSequence().StartsWith(value); case StorageType.Int64: Span buffer = stackalloc byte[Format.MaxInt64TextLen]; len = Format.FormatInt64(OverlappedValueInt64, buffer); diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 240cdc951..82dd5dd6e 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Buffers; using System.Buffers.Binary; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -162,6 +163,38 @@ private ValueCondition(ConditionKind kind, in RedisValue value) [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] public static ValueCondition DigestNotEqual(in RedisValue value) => !value.Digest(); + [ThreadStatic] + private static XxHash3? _xxh; + + /// + /// Calculate the digest of a payload, as an equality test. For a non-equality test, use on the result. + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + public static ValueCondition CalculateDigest(in ReadOnlySequence value) + { + if (value.IsSingleSegment) + { + return CalculateDigest(value.First.Span); + } + + var xxh = _xxh; + xxh ??= _xxh = new XxHash3(); + + try + { + foreach (var memory in value) + { + xxh.Append(memory.Span); + } + var digest = unchecked((long)xxh.GetCurrentHashAsUInt64()); + return new ValueCondition(ConditionKind.DigestEquals, digest); + } + finally + { + xxh.Reset(); + } + } + /// /// Calculate the digest of a payload, as an equality test. For a non-equality test, use on the result. /// From f4a133ec1d653f32782f0e7361ae803cd0f33bce Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 12:02:44 +0300 Subject: [PATCH 02/21] fix order --- src/StackExchange.Redis/RedisValue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index a50558849..e1f6f79fe 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -703,7 +703,7 @@ public static implicit operator RedisValue(ReadOnlySequence value) var length = checked((int)value.Length); var pos = value.Start; var segment = pos.GetObject() ?? throw new InvalidOperationException("StartSegment is null"); - return new((ReadOnlySequenceSegment)segment, length, pos.GetInteger()); + return new((ReadOnlySequenceSegment)segment, pos.GetInteger(), length); } /// From cb87bd8419a80f65ab47e8175b47472574014874 Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 19:46:29 +0300 Subject: [PATCH 03/21] fix --- .../ReadOnlySequenceExtensions.cs | 2 +- src/StackExchange.Redis/ValueCondition.cs | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs index 06c5a793e..8c86f514d 100644 --- a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs +++ b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs @@ -80,7 +80,7 @@ public static bool SequenceEqual(this in ReadOnlySequence first, ReadOnlyS other = other.Slice(span.Length); } - return true; + return other.IsEmpty; } public static bool SequenceEqual(this in ReadOnlySequence first, in ReadOnlySequence other) diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 82dd5dd6e..b63c42044 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -179,20 +179,13 @@ public static ValueCondition CalculateDigest(in ReadOnlySequence value) var xxh = _xxh; xxh ??= _xxh = new XxHash3(); - - try + xxh.Reset(); + foreach (var memory in value) { - foreach (var memory in value) - { - xxh.Append(memory.Span); - } - var digest = unchecked((long)xxh.GetCurrentHashAsUInt64()); - return new ValueCondition(ConditionKind.DigestEquals, digest); - } - finally - { - xxh.Reset(); + xxh.Append(memory.Span); } + var digest = unchecked((long)xxh.GetCurrentHashAsUInt64()); + return new ValueCondition(ConditionKind.DigestEquals, digest); } /// From c3aaa85cea335ee568b84c49b608cc69a5aaef15 Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 20:04:51 +0300 Subject: [PATCH 04/21] PublicAPI.Unshipped --- .../PublicAPI/PublicAPI.Unshipped.txt | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 471a72592..e8a8710ae 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,27 +1,4 @@ #nullable enable -StackExchange.Redis.ConfigurationOptions.RequestBufferPool.get -> System.Buffers.MemoryPool? -StackExchange.Redis.ConfigurationOptions.RequestBufferPool.set -> void -StackExchange.Redis.ConfigurationOptions.ResponseBufferPool.get -> System.Buffers.MemoryPool? -StackExchange.Redis.ConfigurationOptions.ResponseBufferPool.set -> void -static StackExchange.Redis.Lease.Create(int length, System.Buffers.MemoryPool? pool, bool clear = true) -> StackExchange.Redis.Lease! static StackExchange.Redis.RedisValue.implicit operator StackExchange.Redis.RedisValue(System.Buffers.ReadOnlySequence value) -> StackExchange.Redis.RedisValue static StackExchange.Redis.RedisValue.implicit operator System.Buffers.ReadOnlySequence(StackExchange.Redis.RedisValue value) -> System.Buffers.ReadOnlySequence -[SER002]static StackExchange.Redis.ValueCondition.CalculateDigest(in System.Buffers.ReadOnlySequence value) -> StackExchange.Redis.ValueCondition -[SER005]StackExchange.Redis.TestHarness -[SER005]StackExchange.Redis.TestHarness.BufferValidator -[SER005]StackExchange.Redis.TestHarness.ChannelPrefix.get -> StackExchange.Redis.RedisChannel -[SER005]StackExchange.Redis.TestHarness.CommandMap.get -> StackExchange.Redis.CommandMap! -[SER005]StackExchange.Redis.TestHarness.KeyPrefix.get -> StackExchange.Redis.RedisKey -[SER005]StackExchange.Redis.TestHarness.Read(System.ReadOnlySpan value) -> StackExchange.Redis.RedisResult! -[SER005]StackExchange.Redis.TestHarness.TestHarness(StackExchange.Redis.CommandMap? commandMap = null, StackExchange.Redis.RedisChannel channelPrefix = default(StackExchange.Redis.RedisChannel), StackExchange.Redis.RedisKey keyPrefix = default(StackExchange.Redis.RedisKey)) -> void -[SER005]StackExchange.Redis.TestHarness.ValidateResp(System.ReadOnlySpan expected, string! command, params System.Collections.Generic.ICollection! args) -> void -[SER005]StackExchange.Redis.TestHarness.ValidateResp(string! expected, string! command, params System.Collections.Generic.ICollection! args) -> void -[SER005]StackExchange.Redis.TestHarness.ValidateRouting(in StackExchange.Redis.RedisKey expected, params System.Collections.Generic.ICollection! args) -> void -[SER005]StackExchange.Redis.TestHarness.Write(System.Buffers.IBufferWriter! target, string! command, params System.Collections.Generic.ICollection! args) -> void -[SER005]StackExchange.Redis.TestHarness.Write(string! command, params System.Collections.Generic.ICollection! args) -> byte[]! -[SER005]static StackExchange.Redis.TestHarness.AssertEqual(System.ReadOnlySpan expected, System.ReadOnlySpan actual, System.Action, System.ReadOnlyMemory>! handler) -> void -[SER005]static StackExchange.Redis.TestHarness.AssertEqual(string! expected, System.ReadOnlySpan actual, System.Action! handler) -> void -[SER005]virtual StackExchange.Redis.TestHarness.BufferValidator.Invoke(scoped System.ReadOnlySpan buffer) -> void -[SER005]virtual StackExchange.Redis.TestHarness.OnValidateFail(System.ReadOnlyMemory expected, System.ReadOnlyMemory actual) -> void -[SER005]virtual StackExchange.Redis.TestHarness.OnValidateFail(in StackExchange.Redis.RedisKey expected, in StackExchange.Redis.RedisKey actual) -> void -[SER005]virtual StackExchange.Redis.TestHarness.OnValidateFail(string! expected, string! actual) -> void +[SER002]static StackExchange.Redis.ValueCondition.CalculateDigest(in System.Buffers.ReadOnlySequence value) -> StackExchange.Redis.ValueCondition \ No newline at end of file From 858663b2e816562c7005b7639c4ce4e29001a519 Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 22:27:12 +0300 Subject: [PATCH 05/21] GetCharCount and GetMaxCharCount --- src/StackExchange.Redis/EncodingExtensions.cs | 30 ++++++++++++++----- src/StackExchange.Redis/RedisValue.cs | 3 +- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/StackExchange.Redis/EncodingExtensions.cs b/src/StackExchange.Redis/EncodingExtensions.cs index 2d51dcdfe..efa3aa9a0 100644 --- a/src/StackExchange.Redis/EncodingExtensions.cs +++ b/src/StackExchange.Redis/EncodingExtensions.cs @@ -1,5 +1,4 @@ -#if NET461 || NET472 || NETSTANDARD2_0 -using System; +using System; using System.Buffers; using System.Text; @@ -7,18 +6,35 @@ namespace StackExchange.Redis; internal static class EncodingExtensions { - public static int GetChars(this Encoding encoding, in ReadOnlySequence bytes, Span chars) + public static int GetCharCount(this Encoding encoding, in ReadOnlySequence seq) + { + var count = 0; + foreach (var memory in seq) + { + count += encoding.GetCharCount(memory.Span); + } + return count; + } + +#if NET461 || NET472 || NETSTANDARD2_0 + public static int GetChars(this Encoding encoding, in ReadOnlySequence seq, Span chars) { if (encoding == null) throw new ArgumentNullException(nameof(encoding)); - if (bytes.IsSingleSegment) + if (seq.IsSingleSegment) { - return encoding.GetChars(bytes.First.Span, chars); + return encoding.GetChars(seq.First.Span, chars); } else { - throw new NotImplementedException(); + var count = 0; + foreach (var memory in seq) + { + count += encoding.GetChars(memory.Span, chars); + chars = chars.Slice(memory.Length); + } + return count; } } -} #endif +} diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index e1f6f79fe..e8dad2ec8 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -1063,6 +1063,7 @@ private static string ToHex(ReadOnlySpan src) { StorageType.Null => 0, StorageType.MemoryManager or StorageType.ByteArray => Encoding.UTF8.GetCharCount(RawSpan()), + StorageType.Sequence => Encoding.UTF8.GetCharCount(RawSequence()), StorageType.String => _length, StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), @@ -1076,7 +1077,7 @@ private static string ToHex(ReadOnlySpan src) internal int GetMaxCharCount() => Type switch { StorageType.Null => 0, - StorageType.MemoryManager or StorageType.ByteArray => Encoding.UTF8.GetMaxCharCount(_length), + StorageType.MemoryManager or StorageType.ByteArray or StorageType.Sequence => Encoding.UTF8.GetMaxCharCount(_length), StorageType.String => _length, StorageType.Int64 => Format.MaxInt64TextLen, StorageType.UInt64 => Format.MaxInt64TextLen, From f98fe24d3a7b0fac958cd38a82648d081e48118b Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 22:39:16 +0300 Subject: [PATCH 06/21] add checking OverflowException --- src/StackExchange.Redis/RedisValue.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index e8dad2ec8..56685aa8a 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -699,11 +699,13 @@ internal static RedisValue TryParse(object? obj, out bool valid) public static implicit operator RedisValue(ReadOnlySequence value) { if (value.IsSingleSegment) return new(value.First); + // what is the maximum length? Array.MaxLength? 512MB? + if (value.Length > int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(value)); - var length = checked((int)value.Length); var pos = value.Start; var segment = pos.GetObject() ?? throw new InvalidOperationException("StartSegment is null"); - return new((ReadOnlySequenceSegment)segment, pos.GetInteger(), length); + return new((ReadOnlySequenceSegment)segment, pos.GetInteger(), checked((int)value.Length)); } /// From f0eb178fefd6d3496e5b2ec4027eb20974215dcd Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 22:44:54 +0300 Subject: [PATCH 07/21] comment SequenceCompareTo --- src/StackExchange.Redis/ReadOnlySequenceExtensions.cs | 8 ++++---- src/StackExchange.Redis/RedisValue.cs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs index 8c86f514d..2d22e214f 100644 --- a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs +++ b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs @@ -131,8 +131,8 @@ public static bool SequenceEqual(this in ReadOnlySequence first, in ReadOn return true; } - public static int SequenceCompareTo(this in ReadOnlySequence first, in ReadOnlySequence other) - { - throw new NotImplementedException(); - } + // public static int SequenceCompareTo(this in ReadOnlySequence first, in ReadOnlySequence other) + // { + // throw new NotImplementedException(); + // } } diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 56685aa8a..298c25503 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -556,8 +556,8 @@ private static int CompareTo(RedisValue x, RedisValue y) return string.CompareOrdinal(x.RawString(), y.RawString()); case StorageType.MemoryManager or StorageType.ByteArray: return x.RawSpan().SequenceCompareTo(y.RawSpan()); - case StorageType.Sequence: - return x.RawSequence().SequenceCompareTo(y.RawSequence()); + // case StorageType.Sequence: + // return x.RawSequence().SequenceCompareTo(y.RawSequence()); } } From 8232a63fa00caf17853af400bce89f61f4a956ca Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 23:28:47 +0300 Subject: [PATCH 08/21] implement StartsWith --- .../ReadOnlySequenceExtensions.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs index 2d22e214f..77f41110f 100644 --- a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs +++ b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs @@ -8,7 +8,25 @@ internal static class ReadOnlySequenceExtensions { public static bool StartsWith(this in ReadOnlySequence sequence, in ReadOnlySequence value) { - throw new NotImplementedException(); + if (sequence.IsSingleSegment) return sequence.First.Span.StartsWith(value); + if (value.IsSingleSegment) return sequence.StartsWith(value.First.Span); + if (value.Length > sequence.Length) return false; + + return sequence.Slice(0, value.Length).SequenceEqual(value); + } + + public static bool StartsWith(this ReadOnlySpan span, in ReadOnlySequence value) + { + if (value.IsSingleSegment) return span.StartsWith(value.First.Span); + if (value.Length > span.Length) return false; + foreach (var memory in value) + { + if (!memory.Span.SequenceEqual(span.Slice(0, memory.Length))) + return false; + + span = span.Slice(memory.Length); + } + return true; } public static bool StartsWith(this in ReadOnlySequence sequence, ReadOnlySpan value) From f27bee26593e6034539e4f27f8bcfcb96e6a9bec Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 23:32:45 +0300 Subject: [PATCH 09/21] fix StartsWith --- src/StackExchange.Redis/RedisValue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 298c25503..5f7f1a100 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -1430,7 +1430,7 @@ public bool StartsWith(RedisValue value) if (otherType == StorageType.Sequence && (thisType == StorageType.MemoryManager || thisType == StorageType.ByteArray)) { - return value.RawSequence().StartsWith(RawSpan()); + return RawSpan().StartsWith(value.RawSequence()); } byte[]? arr0 = null, arr1 = null; try From e9d37c6adea43eb01605b0bc9feae91989b24d25 Mon Sep 17 00:00:00 2001 From: ITikhonov Date: Wed, 24 Jun 2026 23:47:31 +0300 Subject: [PATCH 10/21] refact --- src/StackExchange.Redis/Format.cs | 5 +++-- src/StackExchange.Redis/RedisValue.cs | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index e28913e90..ba10f6b7b 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -396,8 +396,9 @@ internal static string GetString(ReadOnlySequence buffer) { if (buffer.IsSingleSegment) return GetString(buffer.First.Span); - var arr = ArrayPool.Shared.Rent(checked((int)buffer.Length)); - var span = new Span(arr, 0, (int)buffer.Length); + var length = checked((int)buffer.Length); + var arr = ArrayPool.Shared.Rent(length); + var span = new Span(arr, 0, length); buffer.CopyTo(span); string s = GetString(span); ArrayPool.Shared.Return(arr); diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 5f7f1a100..3fef43fdc 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -961,6 +961,7 @@ private static bool TryParseDouble(ReadOnlySpan blob, out double value) return ToHex(span); } case StorageType.Sequence: + if (value._length == 0) return ""; var seq = value.RawSequence(); if (seq.IsEmpty) return ""; return Format.GetString(seq); From 91466e13f5574f3587e16b395bda6e1498040bfc Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Thu, 25 Jun 2026 01:07:12 +0300 Subject: [PATCH 11/21] GetHashCode --- src/StackExchange.Redis/RedisValue.cs | 32 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 3fef43fdc..67730dc8f 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -382,6 +382,8 @@ private static int GetHashCode(RedisValue x) return x.Type switch { StorageType.Null => -1, + StorageType.MemoryManager or StorageType.ByteArray => GetHashCode(x.RawSpan()), + StorageType.Sequence => GetHashCode(x.RawSequence()), StorageType.Double => x.OverlappedValueDouble.GetHashCode(), StorageType.Int64 or StorageType.UInt64 => x._valueInt64.GetHashCode(), StorageType.String => x.RawString().GetHashCode(), @@ -418,14 +420,12 @@ internal static unsafe bool Equals(byte[]? x, byte[]? y) return true; } - internal static unsafe int GetHashCode(ReadOnlySpan span) + private static int AddHashCode(ReadOnlySpan span, int acc) { unchecked { int len = span.Length; - if (len == 0) return 0; - - int acc = 728271210; + Debug.Assert(len > 0); var span64 = MemoryMarshal.Cast(span); for (int i = 0; i < span64.Length; i++) @@ -443,6 +443,30 @@ internal static unsafe int GetHashCode(ReadOnlySpan span) } } + internal static int GetHashCode(ReadOnlySpan span) + { + if (span.Length == 0) return 0; + + return AddHashCode(span, HashCodeStart); + } + + private const int HashCodeStart = 728271210; + + private static int GetHashCode(ReadOnlySequence seq) + { + if (seq.Length == 0) return 0; + + int acc = HashCodeStart; + + foreach (var memory in seq) + { + if (!memory.IsEmpty) + acc = AddHashCode(memory.Span, acc); + } + + return acc; + } + internal void AssertNotNull() { if (IsNull) throw new ArgumentException("A null value is not valid in this context"); From 971d97444a8641b0ddfec39df8fc57948c3b22bb Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Thu, 25 Jun 2026 01:25:14 +0300 Subject: [PATCH 12/21] add Equals, CompareTo, StartsWith for ByteArray or MemoryManager --- src/StackExchange.Redis/RedisValue.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 67730dc8f..b14a91d89 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -352,6 +352,10 @@ internal string RawString() (xType == StorageType.ByteArray || xType == StorageType.MemoryManager)) return y.RawSequence().SequenceEqual(x.RawSpan()); + if ((xType == StorageType.ByteArray && yType == StorageType.MemoryManager) || + (xType == StorageType.MemoryManager && yType == StorageType.ByteArray)) + return x.RawSpan().SequenceEqual(y.RawSpan()); + // otherwise, compare as strings return (string?)x == (string?)y; } @@ -599,6 +603,9 @@ private static int CompareTo(RedisValue x, RedisValue y) if (yType == StorageType.Double) return ((double)x.OverlappedValueUInt64).CompareTo(y.OverlappedValueDouble); if (yType == StorageType.Int64) return -1; // we only use unsigned if > int64, so: x is bigger break; + case StorageType.MemoryManager or StorageType.ByteArray: + if (yType == StorageType.MemoryManager || yType == StorageType.ByteArray) return x.RawSpan().SequenceCompareTo(y.RawSpan()); + break; } // otherwise, compare as strings @@ -1457,6 +1464,11 @@ public bool StartsWith(RedisValue value) { return RawSpan().StartsWith(value.RawSequence()); } + if ((thisType == StorageType.MemoryManager && otherType == StorageType.ByteArray) || + (thisType == StorageType.ByteArray && otherType == StorageType.MemoryManager)) + { + return RawSpan().StartsWith(value.RawSpan()); + } byte[]? arr0 = null, arr1 = null; try { From 60a5ddd0c5ad3741296ff39f6155218b20f45a97 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Thu, 25 Jun 2026 10:39:56 +0300 Subject: [PATCH 13/21] add ReadOnlySequenceIterator --- src/StackExchange.Redis/MessageWriter.cs | 18 +++++++- .../ReadOnlySequenceIterator.cs | 44 +++++++++++++++++++ src/StackExchange.Redis/RedisValue.cs | 7 +++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/StackExchange.Redis/ReadOnlySequenceIterator.cs diff --git a/src/StackExchange.Redis/MessageWriter.cs b/src/StackExchange.Redis/MessageWriter.cs index ad9f2864a..5f5a1a8be 100644 --- a/src/StackExchange.Redis/MessageWriter.cs +++ b/src/StackExchange.Redis/MessageWriter.cs @@ -103,7 +103,8 @@ internal static void WriteBulkString(in RedisValue value, IBufferWriter wr WriteUnifiedSpan(writer, value.RawSpan()); break; case RedisValue.StorageType.Sequence: - WriteUnifiedSequence(writer, value.RawSequence()); + var rosi = value.RawSequenceIterator(); + WriteUnifiedSequenceIterator(writer, ref rosi); break; default: throw new InvalidOperationException($"Unexpected {value.Type} value: '{value}'"); @@ -596,6 +597,21 @@ private static void WriteUnifiedSequence(IBufferWriter writer, in ReadOnly } } + private static void WriteUnifiedSequenceIterator(IBufferWriter writer, ref ReadOnlySequenceIterator rosi) + { + var span = writer.GetSpan(3 + Format.MaxInt32TextLen); + span[0] = (byte)'$'; + int bytes = WriteRaw(span, rosi.Length, offset: 1); + writer.Advance(bytes); + + while (rosi.TryNext(out var memory)) + { + writer.Write(memory.Span); + } + + WriteCrlf(writer); + } + private static int AppendToSpan(Span span, ReadOnlySpan value, int offset = 0) { offset = WriteRaw(span, value.Length, offset: offset); diff --git a/src/StackExchange.Redis/ReadOnlySequenceIterator.cs b/src/StackExchange.Redis/ReadOnlySequenceIterator.cs new file mode 100644 index 000000000..b1c2ef868 --- /dev/null +++ b/src/StackExchange.Redis/ReadOnlySequenceIterator.cs @@ -0,0 +1,44 @@ +using System; +using System.Buffers; + +namespace StackExchange.Redis; + +internal ref struct ReadOnlySequenceIterator(ReadOnlySequenceSegment segment, int startIndex, int length) +{ + private int _index = startIndex; + private int _length = length; + private ReadOnlySequenceSegment _segment = segment; + + public readonly int Length => _length; + + public bool TryNext(out ReadOnlyMemory memory) + { + if (_length > 0) + { + memory = _segment.Memory; + + // first + if (_index > 0) + { + memory = memory.Slice(_index); + _index = 0; + } + + // end + if (_length <= memory.Length) + { + memory = memory.Slice(0, _length); + _length = 0; + _segment = null!; + } + else + { + _length -= memory.Length; + _segment = _segment.Next ?? throw new InvalidOperationException("EndSegment is null"); + } + return true; + } + memory = default; + return false; + } +} diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index b14a91d89..332cebd33 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -270,6 +270,13 @@ private static ReadOnlySequence GetSequence(ReadOnlySequenceSegment return new ReadOnlySequence(startSegment, startIndex, endSegment, endIndex); } + internal ReadOnlySequenceIterator RawSequenceIterator() + { + if (_obj is ReadOnlySequenceSegment s) return new(s, _index, _length); + ThrowRawType(); + return default; + } + internal ReadOnlySequence RawSequence() { if (_obj is ReadOnlySequenceSegment s) return GetSequence(s, _index, _length); From bf8d40e03c5c775f417671c6f439d7701f86185b Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Thu, 25 Jun 2026 12:42:53 +0300 Subject: [PATCH 14/21] refac --- src/StackExchange.Redis/MessageWriter.cs | 9 ++++----- ...nceIterator.cs => ReadOnlySequenceSegmentIterator.cs} | 2 +- src/StackExchange.Redis/RedisValue.cs | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) rename src/StackExchange.Redis/{ReadOnlySequenceIterator.cs => ReadOnlySequenceSegmentIterator.cs} (89%) diff --git a/src/StackExchange.Redis/MessageWriter.cs b/src/StackExchange.Redis/MessageWriter.cs index 5f5a1a8be..76abe8661 100644 --- a/src/StackExchange.Redis/MessageWriter.cs +++ b/src/StackExchange.Redis/MessageWriter.cs @@ -103,8 +103,7 @@ internal static void WriteBulkString(in RedisValue value, IBufferWriter wr WriteUnifiedSpan(writer, value.RawSpan()); break; case RedisValue.StorageType.Sequence: - var rosi = value.RawSequenceIterator(); - WriteUnifiedSequenceIterator(writer, ref rosi); + WriteUnifiedSequenceIterator(writer, value.RawSequenceIterator()); break; default: throw new InvalidOperationException($"Unexpected {value.Type} value: '{value}'"); @@ -597,14 +596,14 @@ private static void WriteUnifiedSequence(IBufferWriter writer, in ReadOnly } } - private static void WriteUnifiedSequenceIterator(IBufferWriter writer, ref ReadOnlySequenceIterator rosi) + private static void WriteUnifiedSequenceIterator(IBufferWriter writer, ReadOnlySequenceSegmentIterator seq) { var span = writer.GetSpan(3 + Format.MaxInt32TextLen); span[0] = (byte)'$'; - int bytes = WriteRaw(span, rosi.Length, offset: 1); + int bytes = WriteRaw(span, seq.Length, offset: 1); writer.Advance(bytes); - while (rosi.TryNext(out var memory)) + while (seq.TryNext(out var memory)) { writer.Write(memory.Span); } diff --git a/src/StackExchange.Redis/ReadOnlySequenceIterator.cs b/src/StackExchange.Redis/ReadOnlySequenceSegmentIterator.cs similarity index 89% rename from src/StackExchange.Redis/ReadOnlySequenceIterator.cs rename to src/StackExchange.Redis/ReadOnlySequenceSegmentIterator.cs index b1c2ef868..8967c2117 100644 --- a/src/StackExchange.Redis/ReadOnlySequenceIterator.cs +++ b/src/StackExchange.Redis/ReadOnlySequenceSegmentIterator.cs @@ -3,7 +3,7 @@ namespace StackExchange.Redis; -internal ref struct ReadOnlySequenceIterator(ReadOnlySequenceSegment segment, int startIndex, int length) +internal struct ReadOnlySequenceSegmentIterator(ReadOnlySequenceSegment segment, int startIndex, int length) { private int _index = startIndex; private int _length = length; diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 332cebd33..483aba5d8 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -270,7 +270,7 @@ private static ReadOnlySequence GetSequence(ReadOnlySequenceSegment return new ReadOnlySequence(startSegment, startIndex, endSegment, endIndex); } - internal ReadOnlySequenceIterator RawSequenceIterator() + internal ReadOnlySequenceSegmentIterator RawSequenceIterator() { if (_obj is ReadOnlySequenceSegment s) return new(s, _index, _length); ThrowRawType(); From df5904eb3249650d8621fe81c75d190b7a9bd38c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 13:34:58 +0100 Subject: [PATCH 15/21] - GetHashCode - GetChars/GetCharCount - Simplify - normalize .First.Span --- src/RESPite/Buffers/CycleBuffer.cs | 10 +- src/RESPite/Messages/RespFrameScanner.cs | 4 - src/RESPite/Messages/RespReader.cs | 4 - src/RESPite/Shared/FrameworkShims.Encoding.cs | 33 ++++ src/RESPite/Shared/FrameworkShims.Sequence.cs | 19 +++ src/StackExchange.Redis/EncodingExtensions.cs | 87 +++++++++-- src/StackExchange.Redis/Format.cs | 2 +- src/StackExchange.Redis/MessageWriter.cs | 2 +- .../PhysicalConnection.Read.cs | 4 - .../ReadOnlySequenceExtensions.cs | 12 +- src/StackExchange.Redis/RedisValue.cs | 71 ++++++--- src/StackExchange.Redis/ValueCondition.cs | 2 +- tests/RESPite.Tests/CycleBufferTests.cs | 44 ++++++ .../FragmentedSegment.cs | 46 ++++++ tests/StackExchange.Redis.Tests/ParseTests.cs | 30 +--- .../RedisValueEquivalencyTests.cs | 49 ++++-- .../RedisValueSequenceTests.cs | 147 ++++++++++++++++++ 17 files changed, 454 insertions(+), 112 deletions(-) create mode 100644 src/RESPite/Shared/FrameworkShims.Sequence.cs create mode 100644 tests/StackExchange.Redis.Tests/FragmentedSegment.cs create mode 100644 tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs diff --git a/src/RESPite/Buffers/CycleBuffer.cs b/src/RESPite/Buffers/CycleBuffer.cs index 5a4f53c29..c674e6bec 100644 --- a/src/RESPite/Buffers/CycleBuffer.cs +++ b/src/RESPite/Buffers/CycleBuffer.cs @@ -712,11 +712,7 @@ public void Write(in ReadOnlySequence value) { if (value.IsSingleSegment) { -#if NET Write(value.FirstSpan); -#else - Write(value.First.Span); -#endif } else { @@ -727,11 +723,7 @@ static void WriteMultiSegment(ref CycleBuffer @this, in ReadOnlySequence v { foreach (var segment in value) { -#if NET - @this.Write(value.FirstSpan); -#else - @this.Write(value.First.Span); -#endif + @this.Write(segment.Span); } } } diff --git a/src/RESPite/Messages/RespFrameScanner.cs b/src/RESPite/Messages/RespFrameScanner.cs index 35650ca1d..28cd92de7 100644 --- a/src/RESPite/Messages/RespFrameScanner.cs +++ b/src/RESPite/Messages/RespFrameScanner.cs @@ -125,11 +125,7 @@ public OperationStatus TryRead(ref RespScanState state, in ReadOnlySequence public RespReader(scoped in ReadOnlySequence value) -#if NET : this(value.FirstSpan) -#else - : this(value.First.Span) -#endif { if (!value.IsSingleSegment) { diff --git a/src/RESPite/Shared/FrameworkShims.Encoding.cs b/src/RESPite/Shared/FrameworkShims.Encoding.cs index b5937dd17..bc1515cda 100644 --- a/src/RESPite/Shared/FrameworkShims.Encoding.cs +++ b/src/RESPite/Shared/FrameworkShims.Encoding.cs @@ -47,6 +47,39 @@ public static unsafe string GetString(this Encoding encoding, ReadOnlySpan return encoding.GetString(bPtr, source.Length); } } + + public static unsafe int GetChars(this Decoder decoder, ReadOnlySpan bytes, Span chars, bool flush) + { + // empty input cannot flush any held-over bytes (verified on netfx), so 0 is correct either way + if (bytes.IsEmpty) return 0; + fixed (byte* bPtr = &MemoryMarshal.GetReference(bytes)) + { + fixed (char* cPtr = &MemoryMarshal.GetReference(chars)) + { + return decoder.GetChars(bPtr, bytes.Length, cPtr, chars.Length, flush); + } + } + } + + public static unsafe void Convert(this Decoder decoder, ReadOnlySpan bytes, Span chars, bool flush, out int bytesUsed, out int charsUsed, out bool completed) + { + fixed (char* cPtr = &MemoryMarshal.GetReference(chars)) + { + if (bytes.IsEmpty) + { + // a valid non-null pointer for the empty-input (flush-only) case + byte dummy = 0; + decoder.Convert(&dummy, 0, cPtr, chars.Length, flush, out bytesUsed, out charsUsed, out completed); + } + else + { + fixed (byte* bPtr = &MemoryMarshal.GetReference(bytes)) + { + decoder.Convert(bPtr, bytes.Length, cPtr, chars.Length, flush, out bytesUsed, out charsUsed, out completed); + } + } + } + } } } #endif diff --git a/src/RESPite/Shared/FrameworkShims.Sequence.cs b/src/RESPite/Shared/FrameworkShims.Sequence.cs new file mode 100644 index 000000000..3a2353526 --- /dev/null +++ b/src/RESPite/Shared/FrameworkShims.Sequence.cs @@ -0,0 +1,19 @@ +#if !NET +// ReSharper disable once CheckNamespace +namespace System.Buffers +{ + internal static class FrameworkSequenceShims + { + // by-value receiver (not 'in'): the returned span references the underlying heap segment, not the + // sequence struct, so it must not be scoped to the receiver - matching the BCL FirstSpan semantics + extension(ReadOnlySequence sequence) + { + /// + /// Gets the first segment of the sequence as a span. On modern runtimes this is a BCL instance + /// property; this shim supplies it for older targets. + /// + public ReadOnlySpan FirstSpan => sequence.First.Span; + } + } +} +#endif diff --git a/src/StackExchange.Redis/EncodingExtensions.cs b/src/StackExchange.Redis/EncodingExtensions.cs index efa3aa9a0..8045282e8 100644 --- a/src/StackExchange.Redis/EncodingExtensions.cs +++ b/src/StackExchange.Redis/EncodingExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Text; @@ -6,35 +6,92 @@ namespace StackExchange.Redis; internal static class EncodingExtensions { + // Above this length we stream through a Decoder; at or below it we linearize onto the stack and use the + // contiguous span overloads, avoiding the Decoder heap allocation for the common (small) case. + private const int MaxStackLinearizeBytes = 128; + + // Note: there is no BCL Encoding.GetCharCount(in ReadOnlySequence) on any TFM (unlike GetChars, + // which the BCL provides on modern runtimes), so we supply it for all targets. public static int GetCharCount(this Encoding encoding, in ReadOnlySequence seq) { - var count = 0; - foreach (var memory in seq) + // common case: a single segment can be measured directly, with no decoder state to track + if (seq.IsSingleSegment) return encoding.GetCharCount(seq.FirstSpan); + + // small payloads: linearize onto the stack and measure contiguously - no Decoder allocation, and no + // glyph-straddles-a-boundary problem once the bytes are contiguous + long length = seq.Length; + if (length <= MaxStackLinearizeBytes) + { + Span linear = stackalloc byte[(int)length]; + seq.CopyTo(linear); + return encoding.GetCharCount(linear); + } + + // larger multi-segment: a multi-byte glyph can straddle a segment boundary, so we *must* decode with + // a stateful decoder rather than summing per-segment counts (which would over-count split glyphs). + // Note we cannot use Decoder.GetCharCount: unlike GetChars/Convert it does not carry partial-glyph + // state between calls, so it too over-counts. Decoder.Convert reports the chars produced without us + // having to keep them, so we decode into a small scratch buffer and discard the output, flushing on + // the final segment. + var decoder = encoding.GetDecoder(); + Span scratch = stackalloc char[128]; + int count = 0; + var position = seq.Start; + bool have = seq.TryGet(ref position, out var current); + while (have) { - count += encoding.GetCharCount(memory.Span); + var nextPosition = position; + bool haveNext = seq.TryGet(ref nextPosition, out var next); + var bytes = current.Span; + bool flush = !haveNext; + bool completed; + do + { + decoder.Convert(bytes, scratch, flush, out int bytesUsed, out int charsUsed, out completed); + count += charsUsed; + bytes = bytes.Slice(bytesUsed); + } + while (!bytes.IsEmpty || (flush && !completed)); + current = next; + position = nextPosition; + have = haveNext; } return count; } #if NET461 || NET472 || NETSTANDARD2_0 + // modern runtimes have a BCL Encoding.GetChars(in ReadOnlySequence, Span); we only need to + // supply it for the older targets. The flush-on-final-segment logic mirrors GetCharCount above, which + // guarantees the two agree on the char count - so a buffer sized via GetCharCount cannot overflow here. public static int GetChars(this Encoding encoding, in ReadOnlySequence seq, Span chars) { - if (encoding == null) throw new ArgumentNullException(nameof(encoding)); + if (seq.IsSingleSegment) return encoding.GetChars(seq.FirstSpan, chars); - if (seq.IsSingleSegment) + // small payloads: linearize onto the stack and decode contiguously - no Decoder allocation + long length = seq.Length; + if (length <= MaxStackLinearizeBytes) { - return encoding.GetChars(seq.First.Span, chars); + Span linear = stackalloc byte[(int)length]; + seq.CopyTo(linear); + return encoding.GetChars(linear, chars); } - else + + var decoder = encoding.GetDecoder(); + int total = 0; + var position = seq.Start; + bool have = seq.TryGet(ref position, out var current); + while (have) { - var count = 0; - foreach (var memory in seq) - { - count += encoding.GetChars(memory.Span, chars); - chars = chars.Slice(memory.Length); - } - return count; + var nextPosition = position; + bool haveNext = seq.TryGet(ref nextPosition, out var next); + int written = decoder.GetChars(current.Span, chars, flush: !haveNext); + chars = chars.Slice(written); // advance by chars *written*, not by bytes consumed + total += written; + current = next; + position = nextPosition; + have = haveNext; } + return total; } #endif } diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index ba10f6b7b..70cf7dbc0 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -394,7 +394,7 @@ internal static bool TryParseEndPoint(string? addressWithPort, [NotNullWhen(true internal static string GetString(ReadOnlySequence buffer) { - if (buffer.IsSingleSegment) return GetString(buffer.First.Span); + if (buffer.IsSingleSegment) return GetString(buffer.FirstSpan); var length = checked((int)buffer.Length); var arr = ArrayPool.Shared.Rent(length); diff --git a/src/StackExchange.Redis/MessageWriter.cs b/src/StackExchange.Redis/MessageWriter.cs index 76abe8661..7dc4e389b 100644 --- a/src/StackExchange.Redis/MessageWriter.cs +++ b/src/StackExchange.Redis/MessageWriter.cs @@ -578,7 +578,7 @@ private static void WriteUnifiedSequence(IBufferWriter writer, in ReadOnly { if (value.IsSingleSegment) { - WriteUnifiedSpan(writer, value.First.Span); + WriteUnifiedSpan(writer, value.FirstSpan); } else { diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs index 1f75e3879..0de5487cd 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Read.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -392,11 +392,7 @@ private void OnResponseFrame(RespPrefix prefix, ReadOnlySequence payload) { if (payload.IsSingleSegment) { -#if NET OnResponseFrame(prefix, payload.FirstSpan, ref SharedNoLease); -#else - OnResponseFrame(prefix, payload.First.Span, ref SharedNoLease); -#endif } else { diff --git a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs index 77f41110f..a298eb2b8 100644 --- a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs +++ b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs @@ -8,8 +8,8 @@ internal static class ReadOnlySequenceExtensions { public static bool StartsWith(this in ReadOnlySequence sequence, in ReadOnlySequence value) { - if (sequence.IsSingleSegment) return sequence.First.Span.StartsWith(value); - if (value.IsSingleSegment) return sequence.StartsWith(value.First.Span); + if (sequence.IsSingleSegment) return sequence.FirstSpan.StartsWith(value); + if (value.IsSingleSegment) return sequence.StartsWith(value.FirstSpan); if (value.Length > sequence.Length) return false; return sequence.Slice(0, value.Length).SequenceEqual(value); @@ -17,7 +17,7 @@ public static bool StartsWith(this in ReadOnlySequence sequence, in ReadOn public static bool StartsWith(this ReadOnlySpan span, in ReadOnlySequence value) { - if (value.IsSingleSegment) return span.StartsWith(value.First.Span); + if (value.IsSingleSegment) return span.StartsWith(value.FirstSpan); if (value.Length > span.Length) return false; foreach (var memory in value) { @@ -85,7 +85,7 @@ public static bool StartsWith(this in ReadOnlySequence sequence, ReadOnlyS public static bool SequenceEqual(this in ReadOnlySequence first, ReadOnlySpan other) { - if (first.IsSingleSegment) return first.First.Span.SequenceEqual(other); + if (first.IsSingleSegment) return first.FirstSpan.SequenceEqual(other); if (first.Length != other.Length) return false; var position = first.Start; @@ -103,8 +103,8 @@ public static bool SequenceEqual(this in ReadOnlySequence first, ReadOnlyS public static bool SequenceEqual(this in ReadOnlySequence first, in ReadOnlySequence other) { - if (first.IsSingleSegment) return other.SequenceEqual(first.First.Span); - if (other.IsSingleSegment) return first.SequenceEqual(other.First.Span); + if (first.IsSingleSegment) return other.SequenceEqual(first.FirstSpan); + if (other.IsSingleSegment) return first.SequenceEqual(other.FirstSpan); if (first.Length != other.Length) return false; var firstPosition = first.Start; diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 483aba5d8..122ba8ac0 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -393,12 +393,14 @@ private static int GetHashCode(RedisValue x) return x.Type switch { StorageType.Null => -1, - StorageType.MemoryManager or StorageType.ByteArray => GetHashCode(x.RawSpan()), - StorageType.Sequence => GetHashCode(x.RawSequence()), StorageType.Double => x.OverlappedValueDouble.GetHashCode(), StorageType.Int64 or StorageType.UInt64 => x._valueInt64.GetHashCode(), - StorageType.String => x.RawString().GetHashCode(), - _ => ((string)x!).GetHashCode(), // to match equality + // Everything else - strings *and* byte/memory/sequence buffers - compares to each other "as + // strings" (see operator ==): e.g. "inf" the string equals "inf" the bytes. A buffer that + // looked numeric has already been reduced to Int64/Double by Simplify() above, so the only + // equality-consistent hash for what remains is the hash of the string form. (Note we must NOT + // hash raw bytes here: that would give byte buffers a different hash from the equal string.) + _ => ((string)x!).GetHashCode(), }; } @@ -454,6 +456,8 @@ 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; @@ -463,21 +467,6 @@ internal static int GetHashCode(ReadOnlySpan span) private const int HashCodeStart = 728271210; - private static int GetHashCode(ReadOnlySequence seq) - { - if (seq.Length == 0) return 0; - - int acc = HashCodeStart; - - foreach (var memory in seq) - { - if (!memory.IsEmpty) - acc = AddHashCode(memory.Span, acc); - } - - return acc; - } - internal void AssertNotNull() { if (IsNull) throw new ArgumentException("A null value is not valid in this context"); @@ -1285,14 +1274,19 @@ internal RedisValue Simplify() if (Format.TryParseDouble(s, out var f64) && !IsSpecialDouble(f64)) return f64; break; case StorageType.MemoryManager or StorageType.ByteArray: - var b = RawSpan(); - if (Format.CouldBeInteger(b)) + if (TrySimplify(RawSpan(), out var simplified)) return simplified; + break; + case StorageType.Sequence: + // numeric forms are short, so we only need to consider plausibly-numeric lengths; + // copy into a small stack buffer so we can reuse the exact same byte-based parsing + var seq = RawSequence(); + if (seq.Length <= Format.MaxDoubleTextLen) { - if (Format.TryParseInt64(b, out i64)) return i64; - if (Format.TryParseUInt64(b, out u64)) return u64; + Span tmp = stackalloc byte[Format.MaxDoubleTextLen]; + int len = (int)seq.Length; + seq.CopyTo(tmp); + if (TrySimplify(tmp.Slice(0, len), out simplified)) return simplified; } - // note: don't simplify inf/nan, as that causes equality semantic problems - if (TryParseDouble(b, out f64) && !IsSpecialDouble(f64)) return f64; break; case StorageType.Double: // is the double actually an integer? @@ -1301,6 +1295,33 @@ internal RedisValue Simplify() break; } return this; + + // shared by the ByteArray/MemoryManager and Sequence cases, so that identical bytes + // simplify identically regardless of how they happen to be stored + static bool TrySimplify(ReadOnlySpan bytes, out RedisValue value) + { + if (Format.CouldBeInteger(bytes)) + { + if (Format.TryParseInt64(bytes, out var i64)) + { + value = i64; + return true; + } + if (Format.TryParseUInt64(bytes, out var u64)) + { + value = u64; + return true; + } + } + // note: don't simplify inf/nan, as that causes equality semantic problems + if (TryParseDouble(bytes, out var f64) && !IsSpecialDouble(f64)) + { + value = f64; + return true; + } + value = default; + return false; + } } private static bool IsSpecialDouble(double d) => double.IsNaN(d) || double.IsInfinity(d); diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index b63c42044..7516e74c0 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -174,7 +174,7 @@ public static ValueCondition CalculateDigest(in ReadOnlySequence value) { if (value.IsSingleSegment) { - return CalculateDigest(value.First.Span); + return CalculateDigest(value.FirstSpan); } var xxh = _xxh; diff --git a/tests/RESPite.Tests/CycleBufferTests.cs b/tests/RESPite.Tests/CycleBufferTests.cs index 14dcd6f13..df45420c6 100644 --- a/tests/RESPite.Tests/CycleBufferTests.cs +++ b/tests/RESPite.Tests/CycleBufferTests.cs @@ -1,4 +1,6 @@ using System; +using System.Buffers; +using System.Linq; using RESPite.Buffers; using Xunit; @@ -6,6 +8,48 @@ namespace RESPite.Tests; public class CycleBufferTests() { + [Fact] + public void WriteMultiSegmentSequence_WritesEverySegment() + { + // three segments, each with distinct content and differing lengths; a regression guard against + // writing the first segment repeatedly instead of walking each segment + var expected = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }; + var seq = CreateSequence(new byte[] { 0, 1, 2 }, new byte[] { 3, 4, 5, 6 }, new byte[] { 7, 8 }); + Assert.False(seq.IsSingleSegment); + + var buffer = CycleBuffer.Create(); + buffer.Write(in seq); + + Assert.Equal(expected.Length, buffer.GetCommittedLength()); + Assert.Equal(expected, buffer.GetAllCommitted().ToArray()); + } + + private static ReadOnlySequence CreateSequence(params byte[][] chunks) + { + Segment? head = null, tail = null; + long runningIndex = 0; + foreach (var chunk in chunks) + { + var next = new Segment(chunk, runningIndex); + if (tail is null) head = next; + else tail.SetNext(next); + tail = next; + runningIndex += chunk.Length; + } + return new ReadOnlySequence(head!, 0, tail!, tail!.Memory.Length); + } + + private sealed class Segment : ReadOnlySequenceSegment + { + public Segment(ReadOnlyMemory memory, long runningIndex) + { + Memory = memory; + RunningIndex = runningIndex; + } + + public void SetNext(Segment next) => Next = next; + } + public enum Timing { CommitEverythingBeforeDiscard, diff --git a/tests/StackExchange.Redis.Tests/FragmentedSegment.cs b/tests/StackExchange.Redis.Tests/FragmentedSegment.cs new file mode 100644 index 000000000..2be7eff37 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/FragmentedSegment.cs @@ -0,0 +1,46 @@ +using System; +using System.Buffers; + +namespace StackExchange.Redis.Tests; + +internal sealed class FragmentedSegment : ReadOnlySequenceSegment +{ + public FragmentedSegment(long runningIndex, ReadOnlyMemory memory) + { + RunningIndex = runningIndex; + Memory = memory; + } + + public new FragmentedSegment? Next + { + get => (FragmentedSegment?)base.Next; + set => base.Next = value; + } + + /// + /// Builds a (deliberately) multi-segment from the supplied chunks, + /// one segment per chunk. Note that single-segment sequences may be collapsed by consumers. + /// + public static ReadOnlySequence Create(params ReadOnlyMemory[] chunks) + { + if (chunks is null || chunks.Length == 0) return ReadOnlySequence.Empty; + + FragmentedSegment? head = null, tail = null; + long runningIndex = 0; + foreach (var chunk in chunks) + { + var next = new FragmentedSegment(runningIndex, chunk); + if (tail is null) + { + head = next; + } + else + { + tail.Next = next; + } + tail = next; + runningIndex += chunk.Length; + } + return new ReadOnlySequence(head!, 0, tail!, tail!.Memory.Length); + } +} diff --git a/tests/StackExchange.Redis.Tests/ParseTests.cs b/tests/StackExchange.Redis.Tests/ParseTests.cs index 90a8ed6a2..4656a692a 100644 --- a/tests/StackExchange.Redis.Tests/ParseTests.cs +++ b/tests/StackExchange.Redis.Tests/ParseTests.cs @@ -43,21 +43,12 @@ public Task ParseAsSingleChunk(string ascii, int expected) public Task ParseAsLotsOfChunks(string ascii, int expected) { var bytes = Encoding.ASCII.GetBytes(ascii); - FragmentedSegment? chain = null, tail = null; + var chunks = new ReadOnlyMemory[bytes.Length]; for (int i = 0; i < bytes.Length; i++) { - var next = new FragmentedSegment(i, new ReadOnlyMemory(bytes, i, 1)); - if (tail == null) - { - chain = next; - } - else - { - tail.Next = next; - } - tail = next; + chunks[i] = new ReadOnlyMemory(bytes, i, 1); } - var buffer = new ReadOnlySequence(chain!, 0, tail!, 1); + var buffer = FragmentedSegment.Create(chunks); Assert.Equal(bytes.Length, buffer.Length); return ProcessMessagesAsync(buffer, expected); } @@ -102,19 +93,4 @@ private async Task ProcessMessagesAsync(ReadOnlySequence buffer, int expec cancel.ThrowIfCancellationRequested(); Assert.Equal(expected, found); } - - private sealed class FragmentedSegment : ReadOnlySequenceSegment - { - public FragmentedSegment(long runningIndex, ReadOnlyMemory memory) - { - RunningIndex = runningIndex; - Memory = memory; - } - - public new FragmentedSegment? Next - { - get => (FragmentedSegment?)base.Next; - set => base.Next = value; - } - } } diff --git a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs index e68448b54..db4d7ec41 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs @@ -32,16 +32,20 @@ static void Check(RedisValue known, RedisValue test) Check(42, 42.0); Check(42, "42"); Check(42, "42.0"); - Check(42, Bytes("42")); - Check(42, Bytes("42.0")); + Check(42, Bytes("42"u8)); + Check(42, Bytes("42.0"u8)); + Check(42, Bytes("4"u8, "2"u8)); // multi-segment sequence + Check(42, Bytes("4"u8, "2.0"u8)); // multi-segment sequence CheckString(42, "42"); Check(-42, -42); Check(-42, -42.0); Check(-42, "-42"); Check(-42, "-42.0"); - Check(-42, Bytes("-42")); - Check(-42, Bytes("-42.0")); + Check(-42, Bytes("-42"u8)); + Check(-42, Bytes("-42.0"u8)); + Check(-42, Bytes("-"u8, "42"u8)); // multi-segment sequence + Check(-42, Bytes("-4"u8, "2"u8, ".0"u8)); // multi-segment sequence (3 segments) CheckString(-42, "-42"); Check(1, true); @@ -71,16 +75,19 @@ static void Check(RedisValue known, RedisValue test) Check(1099511627848, 1099511627848.0); Check(1099511627848, "1099511627848"); Check(1099511627848, "1099511627848.0"); - Check(1099511627848, Bytes("1099511627848")); - Check(1099511627848, Bytes("1099511627848.0")); + Check(1099511627848, Bytes("1099511627848"u8)); + Check(1099511627848, Bytes("1099511627848.0"u8)); + Check(1099511627848, Bytes("109951"u8, "1627848"u8)); // multi-segment sequence + Check(1099511627848, Bytes("109951"u8, "1627848"u8, ".0"u8)); // multi-segment sequence CheckString(1099511627848, "1099511627848"); Check(-1099511627848, -1099511627848); Check(-1099511627848, -1099511627848); Check(-1099511627848, "-1099511627848"); Check(-1099511627848, "-1099511627848.0"); - Check(-1099511627848, Bytes("-1099511627848")); - Check(-1099511627848, Bytes("-1099511627848.0")); + Check(-1099511627848, Bytes("-1099511627848"u8)); + Check(-1099511627848, Bytes("-1099511627848.0"u8)); + Check(-1099511627848, Bytes("-109951"u8, "1627848"u8)); // multi-segment sequence CheckString(-1099511627848, "-1099511627848"); Check(1L, true); @@ -110,16 +117,18 @@ static void Check(RedisValue known, RedisValue test) Check(1099511627848.0, 1099511627848.0); Check(1099511627848.0, "1099511627848"); Check(1099511627848.0, "1099511627848.0"); - Check(1099511627848.0, Bytes("1099511627848")); - Check(1099511627848.0, Bytes("1099511627848.0")); + Check(1099511627848.0, Bytes("1099511627848"u8)); + Check(1099511627848.0, Bytes("1099511627848.0"u8)); + Check(1099511627848.0, Bytes("109951"u8, "1627848"u8)); // multi-segment sequence + Check(1099511627848.0, Bytes("1099511627848"u8, ".0"u8)); // multi-segment sequence CheckString(1099511627848.0, "1099511627848"); Check(-1099511627848.0, -1099511627848); Check(-1099511627848.0, -1099511627848); Check(-1099511627848.0, "-1099511627848"); Check(-1099511627848.0, "-1099511627848.0"); - Check(-1099511627848.0, Bytes("-1099511627848")); - Check(-1099511627848.0, Bytes("-1099511627848.0")); + Check(-1099511627848.0, Bytes("-1099511627848"u8)); + Check(-1099511627848.0, Bytes("-1099511627848.0"u8)); CheckString(-1099511627848.0, "-1099511627848"); Check(1.0, true); @@ -127,12 +136,13 @@ static void Check(RedisValue known, RedisValue test) Check(1099511627848.6001, 1099511627848.6001); Check(1099511627848.6001, "1099511627848.6001"); - Check(1099511627848.6001, Bytes("1099511627848.6001")); + Check(1099511627848.6001, Bytes("1099511627848.6001"u8)); + Check(1099511627848.6001, Bytes("1099511627848"u8, ".6001"u8)); // multi-segment sequence CheckString(1099511627848.6001, "1099511627848.6001"); Check(-1099511627848.6001, -1099511627848.6001); Check(-1099511627848.6001, "-1099511627848.6001"); - Check(-1099511627848.6001, Bytes("-1099511627848.6001")); + Check(-1099511627848.6001, Bytes("-1099511627848.6001"u8)); CheckString(-1099511627848.6001, "-1099511627848.6001"); Check(double.NegativeInfinity, double.NegativeInfinity); @@ -270,7 +280,16 @@ private static void CheckString(RedisValue value, string expected) Assert.True(s == expected, $"'{s}' vs '{expected}'"); } - private static byte[]? Bytes(string? s) => s == null ? null : Encoding.UTF8.GetBytes(s); + // single contiguous buffer => stored as a byte[] (StorageType.ByteArray) + private static RedisValue Bytes(ReadOnlySpan value) => value.ToArray(); + + // multiple chunks => a (deliberately) multi-segment ReadOnlySequence (StorageType.Sequence). + // We trust the single-segment collapse logic, so callers pass >= 2 chunks to exercise the sequence path. + private static RedisValue Bytes(ReadOnlySpan a, ReadOnlySpan b) + => FragmentedSegment.Create(a.ToArray(), b.ToArray()); + + private static RedisValue Bytes(ReadOnlySpan a, ReadOnlySpan b, ReadOnlySpan c) + => FragmentedSegment.Create(a.ToArray(), b.ToArray(), c.ToArray()); private static string LineNumber([CallerLineNumber] int lineNumber = 0) => lineNumber.ToString(); diff --git a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs new file mode 100644 index 000000000..db82bbdc5 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Buffers; +using System.Linq; +using System.Text; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Tests for backed by a multi-segment +/// (), focusing on text handling where a multi-byte UTF-8 +/// glyph can straddle a segment boundary. +/// +public class RedisValueSequenceTests +{ + [Theory] + [InlineData("")] // empty + [InlineData("hello")] // ASCII only + [InlineData("héllo")] // 2-byte glyph (é) + [InlineData("€100")] // 3-byte glyph (€) + [InlineData("a\U0001F389b")] // 4-byte glyph / surrogate pair (🎉) + [InlineData("é€\U0001F389")] // adjacent multi-byte glyphs of differing widths + [InlineData("héllo € wörld \U0001F389 mixed")] // mixed widths + public void MultiSegmentUtf8_DecodesAcrossBoundaries(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + + // split at *every* byte so multi-byte glyphs are guaranteed to straddle segments + RedisValue value = SplitEveryByte(bytes); + + // empty collapses to the empty string; anything else stays a genuine multi-segment sequence + if (bytes.Length == 0) + { + Assert.Equal(RedisValue.StorageType.String, value.Type); + } + else + { + Assert.Equal(RedisValue.StorageType.Sequence, value.Type); + } + + // byte length is unaffected by where we slice + Assert.Equal(bytes.Length, value.GetByteCount()); + + // the bug under test: a naive per-segment char count over-counts split glyphs; this must match + // the contiguous count + Assert.Equal(text.Length, value.GetCharCount()); + + // GetMaxCharCount must remain a safe upper bound + Assert.True(value.GetMaxCharCount() >= text.Length); + + // text round-trips via ToString / the string operator (Format.GetString linearizes first) + Assert.Equal(text, value.ToString()); + Assert.Equal(text, (string?)value); + + // ...and via the char-span copy (GetChars over the sequence), sized from GetCharCount + var dest = new char[value.GetCharCount()]; + int written = value.CopyTo(dest.AsSpan()); + Assert.Equal(text.Length, written); + Assert.Equal(text, new string(dest, 0, written)); + } + + [Fact] + public void LargeMultiSegmentUtf8_UsesStreamingDecoderPath() + { + // exceed the helper's stack-linearize threshold so the streaming Decoder path is exercised, with + // every byte in its own segment so multi-byte glyphs straddle boundaries throughout + var text = string.Concat(Enumerable.Repeat("héllo-€-\U0001F389-", 20)); + var bytes = Encoding.UTF8.GetBytes(text); + Assert.True(bytes.Length > 128, $"expected a payload over the linearize threshold, got {bytes.Length}"); + + RedisValue value = SplitEveryByte(bytes); + Assert.Equal(RedisValue.StorageType.Sequence, value.Type); + Assert.Equal(text.Length, value.GetCharCount()); + Assert.Equal(text, value.ToString()); + + var dest = new char[value.GetCharCount()]; + int written = value.CopyTo(dest.AsSpan()); + Assert.Equal(text.Length, written); + Assert.Equal(text, new string(dest, 0, written)); + } + + [Theory] + [InlineData("10")] // numeric: simplifies to an integer, so all forms hash as Int64 + [InlineData("10.5")] // numeric: simplifies to a double + [InlineData("hello")] // plain text: compared/hashed as a string + [InlineData("inf")] // special-case text that deliberately does NOT simplify to a double + [InlineData("nan")] + public void EqualValues_HashIdentically_AcrossAllStorageForms(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + + RedisValue asString = text; + RedisValue asBytes = bytes; // single-buffer (ByteArray) + RedisValue asSequence = SplitEveryByte(bytes); // multi-buffer (Sequence) + + Assert.Equal(RedisValue.StorageType.String, asString.Type); + Assert.Equal(RedisValue.StorageType.ByteArray, asBytes.Type); + Assert.Equal(RedisValue.StorageType.Sequence, asSequence.Type); + + // all forms are equal to one another... + Assert.True(asString == asBytes, "string == bytes"); + Assert.True(asString == asSequence, "string == sequence"); + Assert.True(asBytes == asSequence, "bytes == sequence"); + + // ...so the equality/hash contract demands identical hash codes + int expected = asString.GetHashCode(); + Assert.Equal(expected, asBytes.GetHashCode()); + Assert.Equal(expected, asSequence.GetHashCode()); + } + + [Fact] + public void IntegerAndTextForms_HashIdentically() + { + // the canonical example: 10, "10", and its bytes (single- and multi-buffer) all hash the same + RedisValue asInt = 10; + RedisValue asString = "10"; + RedisValue asBytes = new byte[] { (byte)'1', (byte)'0' }; + RedisValue asSequence = SplitEveryByte(new byte[] { (byte)'1', (byte)'0' }); + + Assert.Equal(RedisValue.StorageType.Sequence, asSequence.Type); + + int expected = asInt.GetHashCode(); + Assert.Equal(expected, asString.GetHashCode()); + Assert.Equal(expected, asBytes.GetHashCode()); + Assert.Equal(expected, asSequence.GetHashCode()); + } + + [Fact] + public void MultiSegmentBytes_RoundTripToArray() + { + var bytes = Encoding.UTF8.GetBytes("the quick brown fox"); + RedisValue value = SplitEveryByte(bytes); + + Assert.Equal(RedisValue.StorageType.Sequence, value.Type); + Assert.Equal(bytes, (byte[]?)value); + } + + private static RedisValue SplitEveryByte(byte[] bytes) + { + var chunks = new ReadOnlyMemory[bytes.Length]; + for (int i = 0; i < bytes.Length; i++) + { + chunks[i] = new ReadOnlyMemory(bytes, i, 1); + } + return FragmentedSegment.Create(chunks); + } +} From ec2571ec4a108cc24395a31e23bc1cba3fd821fe Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 14:02:16 +0100 Subject: [PATCH 16/21] TryParse --- src/RESPite/Shared/FrameworkShims.Sequence.cs | 4 +-- src/StackExchange.Redis/RedisValue.cs | 34 +++++++++++++++++++ .../RedisValueSequenceTests.cs | 27 +++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/RESPite/Shared/FrameworkShims.Sequence.cs b/src/RESPite/Shared/FrameworkShims.Sequence.cs index 3a2353526..0e029764f 100644 --- a/src/RESPite/Shared/FrameworkShims.Sequence.cs +++ b/src/RESPite/Shared/FrameworkShims.Sequence.cs @@ -4,9 +4,7 @@ namespace System.Buffers { internal static class FrameworkSequenceShims { - // by-value receiver (not 'in'): the returned span references the underlying heap segment, not the - // sequence struct, so it must not be scoped to the receiver - matching the BCL FirstSpan semantics - extension(ReadOnlySequence sequence) + extension(scoped in ReadOnlySequence sequence) { /// /// Gets the first segment of the sequence as a span. On modern runtimes this is a BCL instance diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 122ba8ac0..dabad2366 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -284,6 +284,22 @@ internal ReadOnlySequence RawSequence() return default; } + // Linearizes a Sequence payload into the supplied buffer (which must be at least _length long), + // walking the segments directly via the iterator - i.e. without paying to build a ReadOnlySequence - + // and returns the populated portion of the buffer. + private ReadOnlySpan CopyRawSequence(Span destination) + { + var iterator = RawSequenceIterator(); + int offset = 0; + while (iterator.TryNext(out var memory)) + { + memory.Span.CopyTo(destination.Slice(offset)); + offset += memory.Length; + } + Debug.Assert(offset == _length, "linearized length mismatch"); + return destination.Slice(0, offset); + } + internal ReadOnlySpan RawSpan() { if (_obj is byte[] b) return new ReadOnlySpan(b, _index, _length); @@ -1346,6 +1362,15 @@ public bool TryParse(out long val) return Format.TryParseInt64(RawString(), out val); case StorageType.MemoryManager or StorageType.ByteArray: return Format.TryParseInt64(RawSpan(), out val); + case StorageType.Sequence: + // longer than the largest possible Int64 text => cannot be an Int64; otherwise + // linearize onto the stack and reuse the span-based parse (matching the ByteArray path) + if (_length <= Format.MaxInt64TextLen) + { + Span buffer = stackalloc byte[Format.MaxInt64TextLen]; + return Format.TryParseInt64(CopyRawSequence(buffer), out val); + } + break; case StorageType.Double: var d = OverlappedValueDouble; try @@ -1406,6 +1431,15 @@ public bool TryParse(out double val) return Format.TryParseDouble(RawString(), out val); case StorageType.MemoryManager or StorageType.ByteArray: return TryParseDouble(RawSpan(), out val); + case StorageType.Sequence: + // longer than the largest possible double text => cannot be a double; otherwise + // linearize onto the stack and reuse the span-based parse (matching the ByteArray path) + if (_length <= Format.MaxDoubleTextLen) + { + Span buffer = stackalloc byte[Format.MaxDoubleTextLen]; + return TryParseDouble(CopyRawSequence(buffer), out val); + } + break; case StorageType.Null: // in redis-land 0 approx. equal null; so roll with it val = 0; diff --git a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs index db82bbdc5..78e51547b 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs @@ -125,6 +125,33 @@ public void IntegerAndTextForms_HashIdentically() Assert.Equal(expected, asSequence.GetHashCode()); } + [Theory] + [InlineData("123")] // integer + [InlineData("-123")] // negative integer + [InlineData("00")] // leading zeros, within length limit + [InlineData("123.5")] // non-integer double + [InlineData("-0.25")] // negative double + [InlineData("abc")] // not numeric at all + [InlineData("12x")] // partially numeric (must not parse) + [InlineData("99999999999999999999999")] // oversize: cannot be Int64 or double-as-int + public void MultiSegmentSequence_TryParse_MatchesByteArray(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + RedisValue asBytes = bytes; // single-buffer (ByteArray) + RedisValue asSequence = SplitEveryByte(bytes); // multi-buffer (Sequence) + Assert.Equal(RedisValue.StorageType.Sequence, asSequence.Type); + + // a sequence-backed value must parse exactly like the equivalent byte[] + Assert.Equal(asBytes.TryParse(out long expectedLong), asSequence.TryParse(out long actualLong)); + Assert.Equal(expectedLong, actualLong); + + Assert.Equal(asBytes.TryParse(out int expectedInt), asSequence.TryParse(out int actualInt)); + Assert.Equal(expectedInt, actualInt); + + Assert.Equal(asBytes.TryParse(out double expectedDouble), asSequence.TryParse(out double actualDouble)); + Assert.Equal(expectedDouble, actualDouble); + } + [Fact] public void MultiSegmentBytes_RoundTripToArray() { From 3328f641121da56bd9c3d109e115732e48121db5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 14:09:17 +0100 Subject: [PATCH 17/21] mark WriteUnifiedSequence(ROS) as redundant for now (and fix 64-bit length) --- src/StackExchange.Redis/MessageWriter.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/MessageWriter.cs b/src/StackExchange.Redis/MessageWriter.cs index 7dc4e389b..95dea4ea7 100644 --- a/src/StackExchange.Redis/MessageWriter.cs +++ b/src/StackExchange.Redis/MessageWriter.cs @@ -574,6 +574,7 @@ private static void WriteUnifiedSpan(IBufferWriter writer, ReadOnlySpan writer, in ReadOnlySequence value) { if (value.IsSingleSegment) @@ -582,7 +583,8 @@ private static void WriteUnifiedSequence(IBufferWriter writer, in ReadOnly } else { - var span = writer.GetSpan(3 + Format.MaxInt32TextLen); + // value.Length is a long, so reserve room for a 64-bit length ('$' + up to 20 digits + CRLF) + var span = writer.GetSpan(3 + Format.MaxInt64TextLen); span[0] = (byte)'$'; int bytes = WriteRaw(span, value.Length, offset: 1); writer.Advance(bytes); @@ -595,6 +597,7 @@ private static void WriteUnifiedSequence(IBufferWriter writer, in ReadOnly WriteCrlf(writer); } } + */ private static void WriteUnifiedSequenceIterator(IBufferWriter writer, ReadOnlySequenceSegmentIterator seq) { From 4ff86613a359ce63bd6d1e66a9b1c819544fcb52 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 14:26:37 +0100 Subject: [PATCH 18/21] CompareTo --- .../ReadOnlySequenceExtensions.cs | 39 ++++++++++++-- src/StackExchange.Redis/RedisValue.cs | 14 +++-- .../RedisValueSequenceTests.cs | 54 +++++++++++++++++++ 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs index a298eb2b8..fbedf6be9 100644 --- a/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs +++ b/src/StackExchange.Redis/ReadOnlySequenceExtensions.cs @@ -149,8 +149,39 @@ public static bool SequenceEqual(this in ReadOnlySequence first, in ReadOn return true; } - // public static int SequenceCompareTo(this in ReadOnlySequence first, in ReadOnlySequence other) - // { - // throw new NotImplementedException(); - // } + /// + /// Lexicographically compares two sequences (matching + /// semantics): the first differing byte decides the order, and if one is a prefix of the other, the + /// shorter sorts first. + /// + public static int SequenceCompareTo(this in ReadOnlySequence first, in ReadOnlySequence other) + { + if (first.IsSingleSegment && other.IsSingleSegment) + { + return first.FirstSpan.SequenceCompareTo(other.FirstSpan); + } + + // walk both sequences in tandem, comparing the overlapping window each step and short-circuiting on + // the first non-zero result; empty segments are skipped by the refill loops + var firstPos = first.Start; + var otherPos = other.Start; + ReadOnlySpan a = default, b = default; + while (true) + { + while (a.IsEmpty && first.TryGet(ref firstPos, out var aNext)) a = aNext.Span; + while (b.IsEmpty && other.TryGet(ref otherPos, out var bNext)) b = bNext.Span; + + if (a.IsEmpty || b.IsEmpty) break; // at least one sequence is exhausted + + var shared = Math.Min(a.Length, b.Length); + var cmp = a.Slice(0, shared).SequenceCompareTo(b.Slice(0, shared)); + if (cmp != 0) return cmp; + + a = a.Slice(shared); + b = b.Slice(shared); + } + + // everything in the overlap matched, so the longer sequence sorts after the shorter + return first.Length.CompareTo(other.Length); + } } diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index dabad2366..995054e49 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -280,6 +280,8 @@ internal ReadOnlySequenceSegmentIterator RawSequenceIterator() internal ReadOnlySequence RawSequence() { if (_obj is ReadOnlySequenceSegment s) return GetSequence(s, _index, _length); + if (_obj is byte[] a) return new(a, _index, _length); + if (_obj is MemoryManager m) return new(m.Memory.Slice(_index, _length)); ThrowRawType(); return default; } @@ -596,8 +598,8 @@ private static int CompareTo(RedisValue x, RedisValue y) return string.CompareOrdinal(x.RawString(), y.RawString()); case StorageType.MemoryManager or StorageType.ByteArray: return x.RawSpan().SequenceCompareTo(y.RawSpan()); - // case StorageType.Sequence: - // return x.RawSequence().SequenceCompareTo(y.RawSequence()); + case StorageType.Sequence: + return x.RawSequence().SequenceCompareTo(y.RawSequence()); } } @@ -615,9 +617,11 @@ private static int CompareTo(RedisValue x, RedisValue y) if (yType == StorageType.Double) return ((double)x.OverlappedValueUInt64).CompareTo(y.OverlappedValueDouble); if (yType == StorageType.Int64) return -1; // we only use unsigned if > int64, so: x is bigger break; - case StorageType.MemoryManager or StorageType.ByteArray: - if (yType == StorageType.MemoryManager || yType == StorageType.ByteArray) return x.RawSpan().SequenceCompareTo(y.RawSpan()); - break; + case StorageType.MemoryManager or StorageType.ByteArray when yType is StorageType.MemoryManager or StorageType.ByteArray: + return x.RawSpan().SequenceCompareTo(y.RawSpan()); + case StorageType.MemoryManager or StorageType.ByteArray when yType == StorageType.Sequence: + case StorageType.Sequence when yType is StorageType.MemoryManager or StorageType.ByteArray: + return x.RawSequence().SequenceCompareTo(y.RawSequence()); } // otherwise, compare as strings diff --git a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs index 78e51547b..16f0bdf5d 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs @@ -152,6 +152,60 @@ public void MultiSegmentSequence_TryParse_MatchesByteArray(string text) Assert.Equal(expectedDouble, actualDouble); } + [Theory] + [InlineData("abc", "abc")] // equal + [InlineData("abc", "abd")] // differ at last byte + [InlineData("abd", "abc")] + [InlineData("xbc", "abc")] // differ at first byte + [InlineData("abc", "abcd")] // prefix: shorter sorts first + [InlineData("abcd", "abc")] + [InlineData("abcdefardvark", "abcdefardwolf")] // longer, differ mid-way + public void MultiSegmentSequence_CompareTo_MatchesByteOrdinal(string x, string y) + { + var bx = Encoding.UTF8.GetBytes(x); + var by = Encoding.UTF8.GetBytes(y); + int expected = Math.Sign(((ReadOnlySpan)bx).SequenceCompareTo(by)); + + RedisValue seqX = SplitEveryByte(bx), seqY = SplitEveryByte(by); + RedisValue arrX = bx, arrY = by; + Assert.Equal(RedisValue.StorageType.Sequence, seqX.Type); + Assert.Equal(RedisValue.StorageType.Sequence, seqY.Type); + + Assert.Equal(expected, Math.Sign(seqX.CompareTo(seqY))); // sequence vs sequence + Assert.Equal(expected, Math.Sign(seqX.CompareTo(arrY))); // sequence vs byte[] + Assert.Equal(expected, Math.Sign(arrX.CompareTo(seqY))); // byte[] vs sequence + Assert.Equal(expected, Math.Sign(arrX.CompareTo(arrY))); // byte[] vs byte[] baseline + } + + [Fact] + public void MultiSegmentSequence_CompareTo_EqualContentDifferentBoundaries() + { + // identical content, but segmented differently on each side - the tandem walk must still see equality + var bytes = Encoding.UTF8.GetBytes("the quick brown fox"); + RedisValue a = FragmentedSegment.Create(bytes[..4], bytes[4..11], bytes[11..]); + RedisValue b = FragmentedSegment.Create(bytes[..2], bytes[2..9], bytes[9..15], bytes[15..]); + Assert.Equal(RedisValue.StorageType.Sequence, a.Type); + Assert.Equal(RedisValue.StorageType.Sequence, b.Type); + + Assert.Equal(0, a.CompareTo(b)); + Assert.Equal(0, b.CompareTo(a)); + Assert.True(a == b); + } + + [Fact] + public void MultiSegmentSequence_CompareTo_DifferenceAcrossBoundaries() + { + // the differing byte (index 5: 'f' vs 'X') sits in a different segment on each side + var x = Encoding.UTF8.GetBytes("abcdefgh"); + var y = Encoding.UTF8.GetBytes("abcdeXgh"); + RedisValue sx = FragmentedSegment.Create(x[..3], x[3..]); // [abc][defgh] + RedisValue sy = FragmentedSegment.Create(y[..6], y[6..]); // [abcdeX][gh] + + int expected = Math.Sign(((ReadOnlySpan)x).SequenceCompareTo(y)); // 'f' > 'X' => positive + Assert.Equal(expected, Math.Sign(sx.CompareTo(sy))); + Assert.Equal(-expected, Math.Sign(sy.CompareTo(sx))); // antisymmetry + } + [Fact] public void MultiSegmentBytes_RoundTripToArray() { From fbf6da3406e012d2cbed1f9a64b1ccb3ebf2f6cb Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 14:35:34 +0100 Subject: [PATCH 19/21] add integration test (and fix CI) --- .../RedisValueSequenceServerTests.cs | 62 +++++++++++++++++++ .../RedisValueSequenceTests.cs | 11 ++-- 2 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RedisValueSequenceServerTests.cs diff --git a/tests/StackExchange.Redis.Tests/RedisValueSequenceServerTests.cs b/tests/StackExchange.Redis.Tests/RedisValueSequenceServerTests.cs new file mode 100644 index 000000000..b5ef22d77 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RedisValueSequenceServerTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Buffers; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Round-trips a multi-segment -backed +/// () through the shared in-process server via +/// StringSet/StringGet, exercising the segmented write path in MessageWriter. +/// +public class RedisValueSequenceServerTests(ITestOutputHelper output, InProcServerFixture fixture) : TestBase(output, fixture) +{ + // one segment per byte => a genuinely multi-segment sequence (StorageType.Sequence) + private static RedisValue MultiSegment(byte[] payload) + { + var chunks = new ReadOnlyMemory[payload.Length]; + for (int i = 0; i < payload.Length; i++) + { + chunks[i] = new ReadOnlyMemory(payload, i, 1); + } + return FragmentedSegment.Create(chunks); + } + + [Fact] + public async Task StringSet_MultiSegmentSequence_RoundTrips() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + var payload = Encoding.UTF8.GetBytes("the quick brown fox jumps over the lazy dog"); + RedisValue value = MultiSegment(payload); + Assert.Equal(RedisValue.StorageType.Sequence, value.Type); + + Assert.True(db.StringSet(key, value)); + + var roundTripped = db.StringGet(key); + Assert.Equal(payload, (byte[]?)roundTripped); + } + + [Fact] + public async Task StringSetAsync_MultiSegmentSequence_RoundTrips() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + var key = Me(); + await db.KeyDeleteAsync(key); + + var payload = Encoding.UTF8.GetBytes("a multi-segment sequence payload long enough to span several segments"); + RedisValue value = MultiSegment(payload); + Assert.Equal(RedisValue.StorageType.Sequence, value.Type); + + Assert.True(await db.StringSetAsync(key, value)); + + var roundTripped = await db.StringGetAsync(key); + Assert.Equal(payload, (byte[]?)roundTripped); + } +} diff --git a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs index 16f0bdf5d..ff68908be 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs @@ -182,8 +182,8 @@ public void MultiSegmentSequence_CompareTo_EqualContentDifferentBoundaries() { // identical content, but segmented differently on each side - the tandem walk must still see equality var bytes = Encoding.UTF8.GetBytes("the quick brown fox"); - RedisValue a = FragmentedSegment.Create(bytes[..4], bytes[4..11], bytes[11..]); - RedisValue b = FragmentedSegment.Create(bytes[..2], bytes[2..9], bytes[9..15], bytes[15..]); + RedisValue a = FragmentedSegment.Create(Mem(bytes, 0, 4), Mem(bytes, 4, 7), Mem(bytes, 11, bytes.Length - 11)); + RedisValue b = FragmentedSegment.Create(Mem(bytes, 0, 2), Mem(bytes, 2, 7), Mem(bytes, 9, 6), Mem(bytes, 15, bytes.Length - 15)); Assert.Equal(RedisValue.StorageType.Sequence, a.Type); Assert.Equal(RedisValue.StorageType.Sequence, b.Type); @@ -198,8 +198,8 @@ public void MultiSegmentSequence_CompareTo_DifferenceAcrossBoundaries() // the differing byte (index 5: 'f' vs 'X') sits in a different segment on each side var x = Encoding.UTF8.GetBytes("abcdefgh"); var y = Encoding.UTF8.GetBytes("abcdeXgh"); - RedisValue sx = FragmentedSegment.Create(x[..3], x[3..]); // [abc][defgh] - RedisValue sy = FragmentedSegment.Create(y[..6], y[6..]); // [abcdeX][gh] + RedisValue sx = FragmentedSegment.Create(Mem(x, 0, 3), Mem(x, 3, x.Length - 3)); // [abc][defgh] + RedisValue sy = FragmentedSegment.Create(Mem(y, 0, 6), Mem(y, 6, y.Length - 6)); // [abcdeX][gh] int expected = Math.Sign(((ReadOnlySpan)x).SequenceCompareTo(y)); // 'f' > 'X' => positive Assert.Equal(expected, Math.Sign(sx.CompareTo(sy))); @@ -225,4 +225,7 @@ private static RedisValue SplitEveryByte(byte[] bytes) } return FragmentedSegment.Create(chunks); } + + // a slice of the source as ReadOnlyMemory (no array allocation, and avoids range syntax for net481) + private static ReadOnlyMemory Mem(byte[] source, int start, int length) => new(source, start, length); } From 0c025236d45a90a657b8ce98531e811b312e5752 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 14:51:17 +0100 Subject: [PATCH 20/21] polishing --- src/StackExchange.Redis/RedisValue.cs | 26 ++++++++++--------- .../RedisValueSequenceTests.cs | 19 ++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 995054e49..f935368fa 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -369,19 +369,20 @@ internal string RawString() return false; } - if (xType == StorageType.Sequence && - (yType == StorageType.ByteArray || yType == StorageType.MemoryManager)) - return x.RawSequence().SequenceEqual(y.RawSpan()); - - if (yType == StorageType.Sequence && - (xType == StorageType.ByteArray || xType == StorageType.MemoryManager)) - return y.RawSequence().SequenceEqual(x.RawSpan()); - - if ((xType == StorageType.ByteArray && yType == StorageType.MemoryManager) || - (xType == StorageType.MemoryManager && yType == StorageType.ByteArray)) - return x.RawSpan().SequenceEqual(y.RawSpan()); + // both are non-null, non-numeric, and of different kinds; the blob kinds (byte[] / memory / + // sequence) compare by bytes in any combination - and we keep the span-vs-span path for the + // case where neither side is a multi-segment sequence + switch (xType) + { + case StorageType.Sequence when yType is StorageType.ByteArray or StorageType.MemoryManager: + return x.RawSequence().SequenceEqual(y.RawSpan()); + case StorageType.ByteArray or StorageType.MemoryManager when yType == StorageType.Sequence: + return y.RawSequence().SequenceEqual(x.RawSpan()); + case StorageType.ByteArray or StorageType.MemoryManager when yType is StorageType.ByteArray or StorageType.MemoryManager: + return x.RawSpan().SequenceEqual(y.RawSpan()); + } - // otherwise, compare as strings + // otherwise (anything involving a string), compare as strings return (string?)x == (string?)y; } @@ -870,6 +871,7 @@ public static explicit operator double(RedisValue value) // special values like NaN/Inf are deliberately not handled by Simplify, but need to be considered for casting StorageType.String when Format.TryParseDouble(value.RawString(), out var d) => d, StorageType.MemoryManager or StorageType.ByteArray when TryParseDouble(value.RawSpan(), out var d) => d, + StorageType.Sequence when value.TryParse(out double d) => d, // linearizes + handles inf/nan, like the span case above // anything else: fail _ => throw new InvalidCastException($"Unable to cast from {value.Type} to double: '{value}'"), }; diff --git a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs index ff68908be..0e329da2c 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueSequenceTests.cs @@ -206,6 +206,25 @@ public void MultiSegmentSequence_CompareTo_DifferenceAcrossBoundaries() Assert.Equal(-expected, Math.Sign(sy.CompareTo(sx))); // antisymmetry } + [Theory] + [InlineData("123")] // integer-valued + [InlineData("-123")] + [InlineData("123.5")] // fractional + [InlineData("inf")] // special doubles: deliberately not simplified, so they exercise the cast's text fallback + [InlineData("+inf")] + [InlineData("-inf")] + [InlineData("nan")] + public void MultiSegmentSequence_DoubleCast_MatchesByteArray(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + RedisValue asBytes = bytes; // single-buffer (ByteArray) + RedisValue asSequence = SplitEveryByte(bytes); // multi-buffer (Sequence) + Assert.Equal(RedisValue.StorageType.Sequence, asSequence.Type); + + // the (double) cast must behave the same for a sequence as for the equivalent byte[] + Assert.Equal((double)asBytes, (double)asSequence); + } + [Fact] public void MultiSegmentBytes_RoundTripToArray() { From 92a6608bb72822977477eeaa8bf92efeaa823a91 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 15:08:02 +0100 Subject: [PATCH 21/21] avoid the string alloc in GetHashCode (when possible) --- src/StackExchange.Redis/RedisValue.cs | 42 ++++++++++++++++++++------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index f935368fa..33adb1b87 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -409,18 +409,38 @@ public override bool Equals(object? obj) private static int GetHashCode(RedisValue x) { x = x.Simplify(); - return x.Type switch + switch (x.Type) { - StorageType.Null => -1, - StorageType.Double => x.OverlappedValueDouble.GetHashCode(), - StorageType.Int64 or StorageType.UInt64 => x._valueInt64.GetHashCode(), - // Everything else - strings *and* byte/memory/sequence buffers - compares to each other "as - // strings" (see operator ==): e.g. "inf" the string equals "inf" the bytes. A buffer that - // looked numeric has already been reduced to Int64/Double by Simplify() above, so the only - // equality-consistent hash for what remains is the hash of the string form. (Note we must NOT - // hash raw bytes here: that would give byte buffers a different hash from the equal string.) - _ => ((string)x!).GetHashCode(), - }; + 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 } ///