From 9c38f971ba7905a5f2f3cbfb28202a1b6aea14bb Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 30 Oct 2025 17:04:02 +0000 Subject: [PATCH 01/11] Completely untested start for CAD/CAD --- Directory.Packages.props | 1 + src/Directory.Build.props | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 14 ++ src/StackExchange.Redis/RedisValue.cs | 22 ++ .../StackExchange.Redis.csproj | 1 + src/StackExchange.Redis/ValueCondition.cs | 223 ++++++++++++++++++ 6 files changed, 262 insertions(+) create mode 100644 src/StackExchange.Redis/ValueCondition.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index df8c078a3..2088a054f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 06e403ebb..40f59348d 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,6 +4,7 @@ true true false + true diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..94f6a27ea 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,15 @@ #nullable enable +override StackExchange.Redis.ValueCondition.Equals(object? obj) -> bool +override StackExchange.Redis.ValueCondition.GetHashCode() -> int +override StackExchange.Redis.ValueCondition.ToString() -> string! +StackExchange.Redis.RedisValue.Digest() -> StackExchange.Redis.ValueCondition +StackExchange.Redis.ValueCondition +StackExchange.Redis.ValueCondition.Digest() -> StackExchange.Redis.ValueCondition +StackExchange.Redis.ValueCondition.Equal() -> StackExchange.Redis.ValueCondition +StackExchange.Redis.ValueCondition.HasValue.get -> bool +StackExchange.Redis.ValueCondition.IsDigest.get -> bool +StackExchange.Redis.ValueCondition.IsEqual.get -> bool +StackExchange.Redis.ValueCondition.NotEqual() -> StackExchange.Redis.ValueCondition +StackExchange.Redis.ValueCondition.Value.get -> StackExchange.Redis.RedisValue +StackExchange.Redis.ValueCondition.ValueCondition() -> void +static StackExchange.Redis.ValueCondition.Digest(System.ReadOnlySpan payload) -> StackExchange.Redis.ValueCondition diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index da33c803e..7b5ab9648 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -1223,5 +1223,27 @@ private ReadOnlyMemory AsMemory(out byte[]? leased) leased = null; return default; } + + /// + /// Get the digest (hash used for check-and-set/check-and-delete operations) of this value. + /// + public ValueCondition Digest() + { + switch (Type) + { + case StorageType.Raw: + return ValueCondition.Digest(_memory.Span); + case StorageType.Null: + return ValueCondition.Null; + default: + var len = GetByteCount(); + byte[]? oversized = null; + Span buffer = len <= 128 ? stackalloc byte[128] : (oversized = ArrayPool.Shared.Rent(len)); + CopyTo(buffer); + var digest = ValueCondition.Digest(buffer.Slice(0, len)); + if (oversized is not null) ArrayPool.Shared.Return(oversized); + return digest; + } + } } } diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index b03103656..983624bc0 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -19,6 +19,7 @@ + diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs new file mode 100644 index 000000000..3f0b5ea8f --- /dev/null +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -0,0 +1,223 @@ +using System; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO.Hashing; + +namespace StackExchange.Redis; + +/// +/// Represents a check for an existing value, for use in conditional operations such as DELEX or SET ... IFEQ. +/// +public readonly struct ValueCondition +{ + private const int HashLength = 8; // XXH3 is 64-bit + + private readonly MatchKind _kind; + private readonly RedisValue _value; + + /// + public override string ToString() + { + switch (_kind) + { + case MatchKind.ValueEquals: + return $"IFEQ {_value}"; + case MatchKind.ValueNotEquals: + return $"IFNE {_value}"; + case MatchKind.DigestEquals: + Span buffer = stackalloc char[2 * HashLength]; + WriteHex(_value.DirectOverlappedBits64, buffer); + return $"IFDEQ {buffer.ToString()}"; + case MatchKind.DigestNotEquals: + WriteHex(_value.DirectOverlappedBits64, buffer = stackalloc char[2 * HashLength]); + return $"IFDNE {buffer.ToString()}"; + case MatchKind.None: + return ""; + default: + return _kind.ToString(); + } + } + + /// + public override bool Equals(object? obj) => obj is ValueCondition other && _kind == other._kind && _value == other._value; + + /// + public override int GetHashCode() => _kind.GetHashCode() ^ _value.GetHashCode(); + + /// + /// Indicates whether this instance represents a value test. + /// + public bool HasValue => _kind != MatchKind.None; + + /// + /// Indicates whether this instance represents a digest test. + /// + public bool IsDigest => _kind is MatchKind.DigestEquals or MatchKind.DigestNotEquals; + + /// + /// Indicates whether this instance represents an equality test. + /// + public bool IsEqual => _kind is MatchKind.ValueEquals or MatchKind.DigestEquals; + + /// + /// Gets the underlying value for this condition. + /// + public RedisValue Value => _value; + + private ValueCondition(MatchKind kind, RedisValue value) + { + _kind = kind; + _value = value; + // if it's a digest operation, the value must be an int64 + Debug.Assert(_kind is not (MatchKind.DigestEquals or MatchKind.DigestNotEquals) || + value.Type == RedisValue.StorageType.Int64); + } + + private enum MatchKind : byte + { + None, + ValueEquals, + ValueNotEquals, + DigestEquals, + DigestNotEquals, + } + + /// + /// Create an equality match based on this value. If the value is already an equality match, no change is made. + /// The underlying nature of the test (digest vs value) is preserved. + /// + public ValueCondition Equal() => _kind switch + { + MatchKind.ValueEquals or MatchKind.DigestEquals => this, // no change needed + MatchKind.ValueNotEquals => new ValueCondition(MatchKind.ValueEquals, _value), + MatchKind.DigestNotEquals => new ValueCondition(MatchKind.DigestEquals, _value), + _ => throw new InvalidOperationException($"Unexpected match kind: {_kind}"), + }; + + /// + /// Create a non-equality match based on this value. If the value is already a non-equality match, no change is made. + /// The underlying nature of the test (digest vs value) is preserved. + /// + public ValueCondition NotEqual() => _kind switch + { + MatchKind.ValueNotEquals or MatchKind.DigestNotEquals => this, // no change needed + MatchKind.ValueEquals => new ValueCondition(MatchKind.ValueNotEquals, _value), + MatchKind.DigestEquals => new ValueCondition(MatchKind.DigestNotEquals, _value), + _ => throw new InvalidOperationException($"Unexpected match kind: {_kind}"), + }; + + /// + /// Create a digest match based on this value. If the value is already a digest match, no change is made. + /// The underlying equality/non-equality nature of the test is preserved. + /// + public ValueCondition Digest() => _kind switch + { + MatchKind.DigestEquals or MatchKind.DigestNotEquals => this, // no change needed + MatchKind.ValueEquals => _value.Digest(), + MatchKind.ValueNotEquals => _value.Digest().NotEqual(), + _ => throw new InvalidOperationException($"Unexpected match kind: {_kind}"), + }; + + internal static readonly ValueCondition Null = default; + + /// + /// Calculate the digest of a payload, as an equality test. For a non-equality test, use on the result. + /// + public static ValueCondition Digest(ReadOnlySpan payload) + { + long digest = unchecked((long)XxHash3.HashToUInt64(payload)); + return new ValueCondition(MatchKind.DigestEquals, digest); + } + + /// + /// Creates an equality match based on the specified digest bytes. + /// + internal static ValueCondition RawDigest(ReadOnlySpan digest) + { + Debug.Assert(digest.Length == HashLength); + // we receive 16 hex charactes, as bytes; parse that into a long, by + // first dealing with the nibbles + Span tmp = stackalloc byte[HashLength]; + int offset = 0; + for (int i = 0; i < tmp.Length; i++) + { + tmp[i] = (byte)( + (ToNibble(digest[offset++]) << 4) // hi + | ToNibble(digest[offset++])); // lo + } + // now interpret that as little-endian, so the first network bytes end + // up in the low integer bytes (this makes writing it easier, and matches + // basically all CPUs) + return new ValueCondition(MatchKind.DigestEquals, BinaryPrimitives.ReadInt64LittleEndian(tmp)); + + static byte ToNibble(int b) + { + if (b >= '0' & b <= '9') return (byte)(b - '0'); + if (b >= 'a' & b <= 'f') return (byte)(b - 'a' + 10); + if (b >= 'A' & b <= 'F') return (byte)(b - 'A' + 10); + return ThrowInvalidBytes(); + } + + static byte ThrowInvalidBytes() => throw new ArgumentException("Invalid digest bytes"); + } + + internal int TokenCount => _kind == MatchKind.None ? 0 : 2; + + internal void WriteTo(PhysicalConnection physical) + { + switch (_kind) + { + case MatchKind.ValueEquals: + physical.WriteBulkString("IFEQ"u8); + physical.WriteBulkString(_value); + break; + case MatchKind.ValueNotEquals: + physical.WriteBulkString("IFNE"u8); + physical.WriteBulkString(_value); + break; + case MatchKind.DigestEquals: + physical.WriteBulkString("IFDEQ"u8); + Span buffer = stackalloc byte[16]; + WriteHex(_value.DirectOverlappedBits64, buffer); + physical.WriteBulkString(buffer); + break; + case MatchKind.DigestNotEquals: + physical.WriteBulkString("IFDNE"u8); + WriteHex(_value.DirectOverlappedBits64, buffer = stackalloc byte[16]); + physical.WriteBulkString(buffer); + break; + } + } + + internal static void WriteHex(long value, Span target) + { + Debug.Assert(target.Length == 2 * HashLength); + // note: see RawDigest for notes on endianness here; for our convenience, + // we take the bytes in little-endian order, but as long as that + // matches how we store them: we're fine - it is transparent to the caller. + ReadOnlySpan hex = "0123456789abcdef"u8; + for (int i = 0; i < 2 * HashLength;) + { + var b = (byte)value; + target[i++] = hex[(b >> 4) & 0xF]; // hi nibble + target[i++] = hex[b & 0xF]; // lo nibble + value >>= 8; + } + } + + internal static void WriteHex(long value, Span target) + { + Debug.Assert(target.Length == 2 * HashLength); + // note: see RawDigest for notes on endianness here; for our convenience, + // we take the bytes in little-endian order, but as long as that + // matches how we store them: we're fine - it is transparent to the caller. + const string hex = "0123456789abcdef"; + for (int i = 0; i < 2 * HashLength;) + { + var b = (byte)value; + target[i++] = hex[(b >> 4) & 0xF]; // hi nibble + target[i++] = hex[b & 0xF]; // lo nibble + value >>= 8; + } + } +} From b6d0931c017ad7d40650436fe0cde5c42f5b11c1 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 31 Oct 2025 11:21:00 +0000 Subject: [PATCH 02/11] unit tests --- .../PublicAPI/PublicAPI.Unshipped.txt | 14 +- src/StackExchange.Redis/RedisValue.cs | 10 +- src/StackExchange.Redis/ValueCondition.cs | 182 ++++++++++++------ .../DigestUnitTests.cs | 159 +++++++++++++++ 4 files changed, 295 insertions(+), 70 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/DigestUnitTests.cs diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 94f6a27ea..145281900 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,15 +1,19 @@ #nullable enable +StackExchange.Redis.ValueCondition override StackExchange.Redis.ValueCondition.Equals(object? obj) -> bool override StackExchange.Redis.ValueCondition.GetHashCode() -> int override StackExchange.Redis.ValueCondition.ToString() -> string! -StackExchange.Redis.RedisValue.Digest() -> StackExchange.Redis.ValueCondition -StackExchange.Redis.ValueCondition StackExchange.Redis.ValueCondition.Digest() -> StackExchange.Redis.ValueCondition -StackExchange.Redis.ValueCondition.Equal() -> StackExchange.Redis.ValueCondition StackExchange.Redis.ValueCondition.HasValue.get -> bool StackExchange.Redis.ValueCondition.IsDigest.get -> bool StackExchange.Redis.ValueCondition.IsEqual.get -> bool -StackExchange.Redis.ValueCondition.NotEqual() -> StackExchange.Redis.ValueCondition StackExchange.Redis.ValueCondition.Value.get -> StackExchange.Redis.RedisValue StackExchange.Redis.ValueCondition.ValueCondition() -> void -static StackExchange.Redis.ValueCondition.Digest(System.ReadOnlySpan payload) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.CalculateDigest(System.ReadOnlySpan value) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.DigestEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.DigestNotEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.Equal(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.NotEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.operator !(in StackExchange.Redis.ValueCondition value) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.ParseDigest(System.ReadOnlySpan digest) -> StackExchange.Redis.ValueCondition +static StackExchange.Redis.ValueCondition.ParseDigest(System.ReadOnlySpan digest) -> StackExchange.Redis.ValueCondition diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 7b5ab9648..508731600 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -1227,23 +1227,25 @@ private ReadOnlyMemory AsMemory(out byte[]? leased) /// /// Get the digest (hash used for check-and-set/check-and-delete operations) of this value. /// - public ValueCondition Digest() + internal ValueCondition Digest() { switch (Type) { case StorageType.Raw: - return ValueCondition.Digest(_memory.Span); + return ValueCondition.CalculateDigest(_memory.Span); case StorageType.Null: - return ValueCondition.Null; + ThrowNull(); + goto case default; default: var len = GetByteCount(); byte[]? oversized = null; Span buffer = len <= 128 ? stackalloc byte[128] : (oversized = ArrayPool.Shared.Rent(len)); CopyTo(buffer); - var digest = ValueCondition.Digest(buffer.Slice(0, len)); + var digest = ValueCondition.CalculateDigest(buffer.Slice(0, len)); if (oversized is not null) ArrayPool.Shared.Return(oversized); return digest; } + static void ThrowNull() => throw new ArgumentNullException(nameof(RedisValue)); } } } diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 3f0b5ea8f..07fe6faba 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -2,6 +2,8 @@ using System.Buffers.Binary; using System.Diagnostics; using System.IO.Hashing; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace StackExchange.Redis; @@ -10,6 +12,13 @@ namespace StackExchange.Redis; /// public readonly struct ValueCondition { + // Supported: equality and non-equality checks for values and digests. Values are stored a RedisValue; + // digests are stored as a native (CPU-endian) Int64 (long) value, inside the same RedisValue (via the + // RedisValue.DirectOverlappedBits64 feature). This native Int64 value is an implementation detail that + // is not directly exposed to the consumer. + // + // The exchange format with Redis is hex of the bytes; for the purposes of interfacing this with our + // raw integer value, this should be considered big-endian, based on the behaviour of XxHash3. private const int HashLength = 8; // XXH3 is 64-bit private readonly MatchKind _kind; @@ -45,9 +54,9 @@ public override string ToString() public override int GetHashCode() => _kind.GetHashCode() ^ _value.GetHashCode(); /// - /// Indicates whether this instance represents a value test. + /// Indicates whether this instance represents a valid test. /// - public bool HasValue => _kind != MatchKind.None; + public bool HasValue => _kind is not MatchKind.None; /// /// Indicates whether this instance represents a digest test. @@ -64,7 +73,7 @@ public override string ToString() /// public RedisValue Value => _value; - private ValueCondition(MatchKind kind, RedisValue value) + private ValueCondition(MatchKind kind, in RedisValue value) { _kind = kind; _value = value; @@ -73,6 +82,26 @@ private ValueCondition(MatchKind kind, RedisValue value) value.Type == RedisValue.StorageType.Int64); } + /// + /// Create a value equality condition with the supplied value. + /// + public static ValueCondition Equal(in RedisValue value) => new(MatchKind.ValueEquals, value); + + /// + /// Create a value non-equality condition with the supplied value. + /// + public static ValueCondition NotEqual(in RedisValue value) => new(MatchKind.ValueNotEquals, value); + + /// + /// Create a digest equality condition, computing the digest of the supplied value. + /// + public static ValueCondition DigestEqual(in RedisValue value) => value.Digest(); + + /// + /// Create a digest non-equality condition, computing the digest of the supplied value. + /// + public static ValueCondition DigestNotEqual(in RedisValue value) => !value.Digest(); + private enum MatchKind : byte { None, @@ -83,59 +112,58 @@ private enum MatchKind : byte } /// - /// Create an equality match based on this value. If the value is already an equality match, no change is made. - /// The underlying nature of the test (digest vs value) is preserved. - /// - public ValueCondition Equal() => _kind switch - { - MatchKind.ValueEquals or MatchKind.DigestEquals => this, // no change needed - MatchKind.ValueNotEquals => new ValueCondition(MatchKind.ValueEquals, _value), - MatchKind.DigestNotEquals => new ValueCondition(MatchKind.DigestEquals, _value), - _ => throw new InvalidOperationException($"Unexpected match kind: {_kind}"), - }; - - /// - /// Create a non-equality match based on this value. If the value is already a non-equality match, no change is made. - /// The underlying nature of the test (digest vs value) is preserved. + /// Calculate the digest of a payload, as an equality test. For a non-equality test, use on the result. /// - public ValueCondition NotEqual() => _kind switch + public static ValueCondition CalculateDigest(ReadOnlySpan value) { - MatchKind.ValueNotEquals or MatchKind.DigestNotEquals => this, // no change needed - MatchKind.ValueEquals => new ValueCondition(MatchKind.ValueNotEquals, _value), - MatchKind.DigestEquals => new ValueCondition(MatchKind.DigestNotEquals, _value), - _ => throw new InvalidOperationException($"Unexpected match kind: {_kind}"), - }; + // the internal impl of XxHash3 uses ulong (not Span), so: use + // that to avoid extra steps, and store the CPU-endian value + var digest = XxHash3.HashToUInt64(value); + return new ValueCondition(MatchKind.DigestEquals, digest); + } /// - /// Create a digest match based on this value. If the value is already a digest match, no change is made. - /// The underlying equality/non-equality nature of the test is preserved. + /// Creates an equality match based on the specified digest bytes. /// - public ValueCondition Digest() => _kind switch + public static ValueCondition ParseDigest(ReadOnlySpan digest) { - MatchKind.DigestEquals or MatchKind.DigestNotEquals => this, // no change needed - MatchKind.ValueEquals => _value.Digest(), - MatchKind.ValueNotEquals => _value.Digest().NotEqual(), - _ => throw new InvalidOperationException($"Unexpected match kind: {_kind}"), - }; + if (digest.Length != 2 * HashLength) ThrowDigestLength(); - internal static readonly ValueCondition Null = default; + // we receive 16 hex characters, as bytes; parse that into a long, by + // first dealing with the nibbles + Span tmp = stackalloc byte[HashLength]; + int offset = 0; + for (int i = 0; i < tmp.Length; i++) + { + tmp[i] = (byte)( + (ParseNibble(digest[offset++]) << 4) // hi + | ParseNibble(digest[offset++])); // lo + } + // now interpret that as big-endian + var digestInt64 = BinaryPrimitives.ReadInt64BigEndian(tmp); + return new ValueCondition(MatchKind.DigestEquals, digestInt64); + } - /// - /// Calculate the digest of a payload, as an equality test. For a non-equality test, use on the result. - /// - public static ValueCondition Digest(ReadOnlySpan payload) + private static byte ParseNibble(int b) { - long digest = unchecked((long)XxHash3.HashToUInt64(payload)); - return new ValueCondition(MatchKind.DigestEquals, digest); + if (b >= '0' & b <= '9') return (byte)(b - '0'); + if (b >= 'a' & b <= 'f') return (byte)(b - 'a' + 10); + if (b >= 'A' & b <= 'F') return (byte)(b - 'A' + 10); + return ThrowInvalidBytes(); + + static byte ThrowInvalidBytes() => throw new ArgumentException("Invalid digest bytes"); } + private static void ThrowDigestLength() => throw new ArgumentException($"Invalid digest length; expected {2 * HashLength} bytes"); + /// /// Creates an equality match based on the specified digest bytes. /// - internal static ValueCondition RawDigest(ReadOnlySpan digest) + public static ValueCondition ParseDigest(ReadOnlySpan digest) { - Debug.Assert(digest.Length == HashLength); - // we receive 16 hex charactes, as bytes; parse that into a long, by + if (digest.Length != 2 * HashLength) ThrowDigestLength(); + + // we receive 16 hex characters, as bytes; parse that into a long, by // first dealing with the nibbles Span tmp = stackalloc byte[HashLength]; int offset = 0; @@ -145,10 +173,9 @@ internal static ValueCondition RawDigest(ReadOnlySpan digest) (ToNibble(digest[offset++]) << 4) // hi | ToNibble(digest[offset++])); // lo } - // now interpret that as little-endian, so the first network bytes end - // up in the low integer bytes (this makes writing it easier, and matches - // basically all CPUs) - return new ValueCondition(MatchKind.DigestEquals, BinaryPrimitives.ReadInt64LittleEndian(tmp)); + // now interpret that as big-endian + var digestInt64 = BinaryPrimitives.ReadInt64BigEndian(tmp); + return new ValueCondition(MatchKind.DigestEquals, digestInt64); static byte ToNibble(int b) { @@ -192,32 +219,65 @@ internal void WriteTo(PhysicalConnection physical) internal static void WriteHex(long value, Span target) { Debug.Assert(target.Length == 2 * HashLength); - // note: see RawDigest for notes on endianness here; for our convenience, - // we take the bytes in little-endian order, but as long as that - // matches how we store them: we're fine - it is transparent to the caller. + + // iterate over the bytes in big-endian order, writing the hi/lo nibbles, + // using pointer-like behaviour (rather than complex shifts and masks) + if (BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } + ref byte ptr = ref Unsafe.As(ref value); + int targetOffset = 0; ReadOnlySpan hex = "0123456789abcdef"u8; - for (int i = 0; i < 2 * HashLength;) + for (int sourceOffset = 0; sourceOffset < sizeof(long); sourceOffset++) { - var b = (byte)value; - target[i++] = hex[(b >> 4) & 0xF]; // hi nibble - target[i++] = hex[b & 0xF]; // lo nibble - value >>= 8; + byte b = Unsafe.Add(ref ptr, sourceOffset); + target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble + target[targetOffset++] = hex[b & 0xF]; // lo } } internal static void WriteHex(long value, Span target) { Debug.Assert(target.Length == 2 * HashLength); - // note: see RawDigest for notes on endianness here; for our convenience, - // we take the bytes in little-endian order, but as long as that - // matches how we store them: we're fine - it is transparent to the caller. + + // iterate over the bytes in big-endian order, writing the hi/lo nibbles, + // using pointer-like behaviour (rather than complex shifts and masks) + if (BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } + ref byte ptr = ref Unsafe.As(ref value); + int targetOffset = 0; const string hex = "0123456789abcdef"; - for (int i = 0; i < 2 * HashLength;) + for (int sourceOffset = 0; sourceOffset < sizeof(long); sourceOffset++) { - var b = (byte)value; - target[i++] = hex[(b >> 4) & 0xF]; // hi nibble - target[i++] = hex[b & 0xF]; // lo nibble - value >>= 8; + byte b = Unsafe.Add(ref ptr, sourceOffset); + target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble + target[targetOffset++] = hex[b & 0xF]; // lo } } + + /// + /// Negate this condition. The digest/value aspect of the condition is preserved. + /// + public static ValueCondition operator !(in ValueCondition value) => value._kind switch + { + MatchKind.ValueEquals => new(MatchKind.ValueNotEquals, value._value), + MatchKind.ValueNotEquals => new(MatchKind.ValueEquals, value._value), + MatchKind.DigestEquals => new(MatchKind.DigestNotEquals, value._value), + MatchKind.DigestNotEquals => new(MatchKind.DigestEquals, value._value), + _ => value, // GIGO + }; + + /// + /// Convert this condition to a digest condition. If this condition is not a value-based condition, it is returned as-is. + /// The equality or non-equality aspect of the condition is preserved. + /// + public ValueCondition Digest() => _kind switch + { + MatchKind.ValueEquals => _value.Digest(), + MatchKind.ValueNotEquals => !_value.Digest(), + _ => this, + }; } diff --git a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs new file mode 100644 index 000000000..ce73f4de8 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO.Hashing; +using System.Text; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class DigestUnitTests(ITestOutputHelper output) : TestBase(output) +{ + [Theory] + [MemberData(nameof(SimpleDigestTestValues))] + public void RedisValue_Digest(string equivalentValue, RedisValue value) + { + // first, use pure XxHash3 to see what we expect + var hashHex = GetXxh3Hex(equivalentValue); + + var digest = value.Digest(); + Assert.True(digest.HasValue); + Assert.True(digest.IsDigest); + Assert.True(digest.IsEqual); + + Assert.Equal($"IFDEQ {hashHex}", digest.ToString()); + } + + public static IEnumerable SimpleDigestTestValues() + { + yield return ["Hello World", (RedisValue)"Hello World"]; + yield return ["42", (RedisValue)"42"]; + yield return ["42", (RedisValue)42]; + } + + [Theory] + [InlineData("Hello World", "e34615aade2e6333")] + [InlineData("42", "1217cb28c0ef2191")] + public void ValueCondition_CalculateDigest(string source, string expected) + { + var digest = ValueCondition.CalculateDigest(Encoding.UTF8.GetBytes(source)); + Assert.Equal($"IFDEQ {expected}", digest.ToString()); + } + + [Theory] + [InlineData("e34615aade2e6333")] + [InlineData("1217cb28c0ef2191")] + public void ValueCondition_ParseDigest(string value) + { + // parse from hex chars + var digest = ValueCondition.ParseDigest(value.AsSpan()); + Assert.Equal($"IFDEQ {value}", digest.ToString()); + + // and the same, from hex bytes + digest = ValueCondition.ParseDigest(Encoding.UTF8.GetBytes(value).AsSpan()); + Assert.Equal($"IFDEQ {value}", digest.ToString()); + } + + [Theory] + [InlineData("Hello World", "e34615aade2e6333")] + [InlineData("42", "1217cb28c0ef2191")] + public void KnownXxh3Values(string source, string expected) + => Assert.Equal(expected, GetXxh3Hex(source)); + + private static string GetXxh3Hex(string source) + { + var len = Encoding.UTF8.GetMaxByteCount(source.Length); + var oversized = ArrayPool.Shared.Rent(len); + #if NET + var bytes = Encoding.UTF8.GetBytes(source, oversized); + #else + int bytes; + unsafe + { + fixed (byte* bPtr = oversized) + { + fixed (char* cPtr = source) + { + bytes = Encoding.UTF8.GetBytes(cPtr, source.Length, bPtr, len); + } + } + } + #endif + var result = GetXxh3Hex(oversized.AsSpan(0, bytes)); + ArrayPool.Shared.Return(oversized); + return result; + } + + private static string GetXxh3Hex(ReadOnlySpan source) + { + byte[] targetBytes = new byte[8]; + XxHash3.Hash(source, targetBytes); + return BitConverter.ToString(targetBytes).Replace("-", string.Empty).ToLowerInvariant(); + } + + [Fact] + public void ValueCondition_Mutations() + { + const string InputValue = + "Meantime we shall express our darker purpose.\nGive me the map there. Know we have divided\nIn three our kingdom; and 'tis our fast intent\nTo shake all cares and business from our age,\nConferring them on younger strengths while we\nUnburthen'd crawl toward death. Our son of Cornwall,\nAnd you, our no less loving son of Albany,\nWe have this hour a constant will to publish\nOur daughters' several dowers, that future strife\nMay be prevented now. The princes, France and Burgundy,\nGreat rivals in our youngest daughter's love,\nLong in our court have made their amorous sojourn,\nAnd here are to be answer'd."; + + var condition = ValueCondition.Equal(InputValue); + Assert.Equal($"IFEQ {InputValue}", condition.ToString()); + Assert.True(condition.HasValue); + Assert.False(condition.IsDigest); + Assert.True(condition.IsEqual); + + var negCondition = !condition; + Assert.NotEqual(condition, negCondition); + Assert.Equal($"IFNE {InputValue}", negCondition.ToString()); + Assert.True(negCondition.HasValue); + Assert.False(negCondition.IsDigest); + Assert.False(negCondition.IsEqual); + + var negNegCondition = !negCondition; + Assert.Equal(condition, negNegCondition); + + var digest = condition.Digest(); + Assert.NotEqual(condition, digest); + Assert.Equal($"IFDEQ {GetXxh3Hex(InputValue)}", digest.ToString()); + Assert.True(digest.HasValue); + Assert.True(digest.IsDigest); + Assert.True(digest.IsEqual); + + var negDigest = !digest; + Assert.NotEqual(digest, negDigest); + Assert.Equal($"IFDNE {GetXxh3Hex(InputValue)}", negDigest.ToString()); + Assert.True(negDigest.HasValue); + Assert.True(negDigest.IsDigest); + Assert.False(negDigest.IsEqual); + + var negNegDigest = !negDigest; + Assert.Equal(digest, negNegDigest); + + var @default = default(ValueCondition); + Assert.False(@default.HasValue); + Assert.False(@default.IsDigest); + Assert.False(@default.IsEqual); + Assert.Equal("", @default.ToString()); + } + + [Fact] + public void RandomBytes() + { + byte[] buffer = ArrayPool.Shared.Rent(8000); + var rand = new Random(); + + for (int i = 0; i < 100; i++) + { + var len = rand.Next(1, buffer.Length); + var span = buffer.AsSpan(0, len); +#if NET + rand.NextBytes(span); +#else + rand.NextBytes(buffer); +#endif + var digest = ValueCondition.CalculateDigest(span); + Assert.Equal($"IFDEQ {GetXxh3Hex(span)}", digest.ToString()); + } + } +} From f98927730a0a5a0a69dc851176e2f314eab6feb1 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 31 Oct 2025 14:11:57 +0000 Subject: [PATCH 03/11] propose the actual API --- docs/exp/SER002.md | 26 +++ src/StackExchange.Redis/Enums/RedisCommand.cs | 3 + src/StackExchange.Redis/Experiments.cs | 3 + .../Interfaces/IDatabase.cs | 33 ++++ .../Interfaces/IDatabaseAsync.cs | 15 ++ .../KeyspaceIsolation/KeyPrefixed.cs | 9 + .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 9 + .../Message.ValueCondition.cs | 53 ++++++ src/StackExchange.Redis/Message.cs | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 43 +++-- .../RedisDatabase.Strings.cs | 79 ++++++++ .../ResultProcessor.Digest.cs | 47 +++++ .../StackExchange.Redis.csproj | 1 + src/StackExchange.Redis/ValueCondition.cs | 176 ++++++++++++------ .../DigestUnitTests.cs | 65 +++++-- 15 files changed, 470 insertions(+), 94 deletions(-) create mode 100644 docs/exp/SER002.md create mode 100644 src/StackExchange.Redis/Message.ValueCondition.cs create mode 100644 src/StackExchange.Redis/RedisDatabase.Strings.cs create mode 100644 src/StackExchange.Redis/ResultProcessor.Digest.cs diff --git a/docs/exp/SER002.md b/docs/exp/SER002.md new file mode 100644 index 000000000..6e5100a6e --- /dev/null +++ b/docs/exp/SER002.md @@ -0,0 +1,26 @@ +Redis 8.4 is currently in preview and may be subject to change. + +New features in Redis 8.4 include: + +- [`MSETEX`](https://github.com/redis/redis/pull/14434) for setting multiple strings with expiry +- [`XREADGROUP ... CLAIM`](https://github.com/redis/redis/pull/14402) for simplifed stream consumption +- [`SET ... {IFEQ|IFNE|IFDEQ|IFDNE}`, `DELEX` and `DIGEST`](https://github.com/redis/redis/pull/14434) for checked (CAS/CAD) string operations + +The corresponding library feature must also be considered subject to change: + +1. Existing bindings may cease working correctly if the underlying server API changes. +2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time + or run-time breaks. + +While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress +this warning by adding the following to your `csproj` file: + +```xml +$(NoWarn);SER002 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER002 +``` \ No newline at end of file diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 7a0c2f08d..0f314ed56 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -30,6 +30,8 @@ internal enum RedisCommand DECR, DECRBY, DEL, + DELEX, + DIGEST, DISCARD, DUMP, @@ -299,6 +301,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.DECR: case RedisCommand.DECRBY: case RedisCommand.DEL: + case RedisCommand.DELEX: case RedisCommand.EXPIRE: case RedisCommand.EXPIREAT: case RedisCommand.FLUSHALL: diff --git a/src/StackExchange.Redis/Experiments.cs b/src/StackExchange.Redis/Experiments.cs index 577c9f8c9..441b0ec54 100644 --- a/src/StackExchange.Redis/Experiments.cs +++ b/src/StackExchange.Redis/Experiments.cs @@ -8,7 +8,10 @@ namespace StackExchange.Redis internal static class Experiments { public const string UrlFormat = "https://stackexchange.github.io/StackExchange.Redis/exp/"; + public const string VectorSets = "SER001"; + // ReSharper disable once InconsistentNaming + public const string Server_8_4 = "SER002"; } } diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 6c52e89bd..1cc5598ac 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Net; // ReSharper disable once CheckNamespace @@ -3141,6 +3142,16 @@ IEnumerable SortedSetScan( /// long StringDecrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None); + /// + /// Deletes if it matches the given condition. + /// + /// The key of the string. + /// The condition to enforce. + /// The flags to use for this operation. + /// See . + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + bool StringDelete(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None); + /// /// Decrements the string representing a floating point number stored at key by the specified decrement. /// If the key does not exist, it is set to 0 before performing the operation. @@ -3153,6 +3164,15 @@ IEnumerable SortedSetScan( /// double StringDecrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None); + /// + /// Gets the digest (hash) value of the specified key, represented as a digest equality . + /// + /// The key of the string. + /// The flags to use for this operation. + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None); + /// /// Get the value of key. If the key does not exist the special value is returned. /// An error is returned if the value stored at key is not a string, because GET only handles string values. @@ -3360,6 +3380,19 @@ IEnumerable SortedSetScan( /// bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); + /// + /// Set to hold the string , if it matches the given condition. + /// + /// The key of the string. + /// The value to set. + /// The condition to enforce. + /// The flags to use for this operation. + /// See . + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0027 + bool StringSet(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0027 + /// /// Sets the given keys to their respective values. /// If is specified, this will not perform any operation at all even if just a single key already exists. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 0bc7b4867..96667932a 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading.Tasks; @@ -768,9 +769,17 @@ IAsyncEnumerable SortedSetScanAsync( /// Task StringDecrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + Task StringDeleteAsync(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None); + /// Task StringDecrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None); + /// Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None); @@ -830,6 +839,12 @@ IAsyncEnumerable SortedSetScanAsync( /// Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0027 + Task StringSetAsync(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0027 + /// Task StringSetAsync(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 61a6f44c4..e74261495 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -726,9 +726,15 @@ public Task StringBitPositionAsync(RedisKey key, bool bit, long start, lon public Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) => Inner.StringBitPositionAsync(ToInner(key), bit, start, end, indexType, flags); + public Task StringDeleteAsync(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) => + Inner.StringDeleteAsync(ToInner(key), when, flags); + public Task StringDecrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) => Inner.StringDecrementAsync(ToInner(key), value, flags); + public Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => + Inner.StringDigestAsync(ToInner(key), flags); + public Task StringDecrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) => Inner.StringDecrementAsync(ToInner(key), value, flags); @@ -771,6 +777,9 @@ public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlag public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLengthAsync(ToInner(key), flags); + public Task StringSetAsync(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) + => Inner.StringSetAsync(ToInner(key), value, when, flags); + public Task StringSetAsync(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) => Inner.StringSetAsync(ToInner(values), when, flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 2a139694e..4a3a9db6f 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -708,9 +708,15 @@ public long StringBitPosition(RedisKey key, bool bit, long start, long end, Comm public long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) => Inner.StringBitPosition(ToInner(key), bit, start, end, indexType, flags); + public bool StringDelete(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) => + Inner.StringDelete(ToInner(key), when, flags); + public double StringDecrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) => Inner.StringDecrement(ToInner(key), value, flags); + public ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None) => + Inner.StringDigest(ToInner(key), flags); + public long StringDecrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) => Inner.StringDecrement(ToInner(key), value, flags); @@ -753,6 +759,9 @@ public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = C public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLength(ToInner(key), flags); + public bool StringSet(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) + => Inner.StringSet(ToInner(key), value, when, flags); + public bool StringSet(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) => Inner.StringSet(ToInner(values), when, flags); diff --git a/src/StackExchange.Redis/Message.ValueCondition.cs b/src/StackExchange.Redis/Message.ValueCondition.cs new file mode 100644 index 000000000..8399a2d1c --- /dev/null +++ b/src/StackExchange.Redis/Message.ValueCondition.cs @@ -0,0 +1,53 @@ +namespace StackExchange.Redis; + +internal partial class Message +{ + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in ValueCondition when) + => new KeyConditionMessage(db, flags, command, key, when); + + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, in ValueCondition when) + => new KeyValueConditionMessage(db, flags, command, key, value, when); + + private sealed class KeyConditionMessage( + int db, + CommandFlags flags, + RedisCommand command, + in RedisKey key, + in ValueCondition when) + : CommandKeyBase(db, flags, command, key) + { + private readonly ValueCondition _when = when; + + public override int ArgCount => 1 + _when.TokenCount; + + protected override void WriteImpl(PhysicalConnection physical) + { + physical.WriteHeader(Command, ArgCount); + physical.Write(Key); + _when.WriteTo(physical); + } + } + + private sealed class KeyValueConditionMessage( + int db, + CommandFlags flags, + RedisCommand command, + in RedisKey key, + in RedisValue value, + in ValueCondition when) + : CommandKeyBase(db, flags, command, key) + { + private readonly RedisValue _value = value; + private readonly ValueCondition _when = when; + + public override int ArgCount => 2 + _when.TokenCount; + + protected override void WriteImpl(PhysicalConnection physical) + { + physical.WriteHeader(Command, ArgCount); + physical.Write(Key); + physical.WriteBulkString(_value); + _when.WriteTo(physical); + } + } +} diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 0c9eb4c92..6a5d2f8d7 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -49,7 +49,7 @@ protected override void WriteImpl(PhysicalConnection physical) public ILogger Log => log; } - internal abstract class Message : ICompletable + internal abstract partial class Message : ICompletable { public readonly int Db; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 145281900..376bd4391 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,19 +1,26 @@ #nullable enable -StackExchange.Redis.ValueCondition -override StackExchange.Redis.ValueCondition.Equals(object? obj) -> bool -override StackExchange.Redis.ValueCondition.GetHashCode() -> int -override StackExchange.Redis.ValueCondition.ToString() -> string! -StackExchange.Redis.ValueCondition.Digest() -> StackExchange.Redis.ValueCondition -StackExchange.Redis.ValueCondition.HasValue.get -> bool -StackExchange.Redis.ValueCondition.IsDigest.get -> bool -StackExchange.Redis.ValueCondition.IsEqual.get -> bool -StackExchange.Redis.ValueCondition.Value.get -> StackExchange.Redis.RedisValue -StackExchange.Redis.ValueCondition.ValueCondition() -> void -static StackExchange.Redis.ValueCondition.CalculateDigest(System.ReadOnlySpan value) -> StackExchange.Redis.ValueCondition -static StackExchange.Redis.ValueCondition.DigestEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition -static StackExchange.Redis.ValueCondition.DigestNotEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition -static StackExchange.Redis.ValueCondition.Equal(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition -static StackExchange.Redis.ValueCondition.NotEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition -static StackExchange.Redis.ValueCondition.operator !(in StackExchange.Redis.ValueCondition value) -> StackExchange.Redis.ValueCondition -static StackExchange.Redis.ValueCondition.ParseDigest(System.ReadOnlySpan digest) -> StackExchange.Redis.ValueCondition -static StackExchange.Redis.ValueCondition.ParseDigest(System.ReadOnlySpan digest) -> StackExchange.Redis.ValueCondition +[SER002]override StackExchange.Redis.ValueCondition.Equals(object? obj) -> bool +[SER002]override StackExchange.Redis.ValueCondition.GetHashCode() -> int +[SER002]override StackExchange.Redis.ValueCondition.ToString() -> string! +[SER002]StackExchange.Redis.IDatabase.StringDelete(StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER002]StackExchange.Redis.IDatabase.StringDigest(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ValueCondition? +[SER002]StackExchange.Redis.IDatabase.StringSet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER002]StackExchange.Redis.IDatabaseAsync.StringDeleteAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER002]StackExchange.Redis.IDatabaseAsync.StringDigestAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER002]StackExchange.Redis.IDatabaseAsync.StringSetAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER002]StackExchange.Redis.ValueCondition +[SER002]StackExchange.Redis.ValueCondition.AsDigest() -> StackExchange.Redis.ValueCondition +[SER002]StackExchange.Redis.ValueCondition.Value.get -> StackExchange.Redis.RedisValue +[SER002]StackExchange.Redis.ValueCondition.ValueCondition() -> void +[SER002]static StackExchange.Redis.ValueCondition.Always.get -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.CalculateDigest(System.ReadOnlySpan value) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.DigestEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.DigestNotEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.Equal(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.Exists.get -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.implicit operator StackExchange.Redis.ValueCondition(StackExchange.Redis.When when) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.NotEqual(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.NotExists.get -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.operator !(in StackExchange.Redis.ValueCondition value) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.ParseDigest(System.ReadOnlySpan digest) -> StackExchange.Redis.ValueCondition +[SER002]static StackExchange.Redis.ValueCondition.ParseDigest(System.ReadOnlySpan digest) -> StackExchange.Redis.ValueCondition diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs new file mode 100644 index 000000000..f2f069a40 --- /dev/null +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -0,0 +1,79 @@ +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal partial class RedisDatabase +{ + public bool StringDelete(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringDeleteMessage(key, when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public Task StringDeleteAsync(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringDeleteMessage(key, when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + private Message GetStringDeleteMessage(in RedisKey key, in ValueCondition when, CommandFlags flags, [CallerMemberName] string? operation = null) + { + switch (when.Kind) + { + case ValueCondition.ConditionKind.Always: + return Message.Create(Database, flags, RedisCommand.DEL, key); + case ValueCondition.ConditionKind.ValueEquals: + case ValueCondition.ConditionKind.ValueNotEquals: + case ValueCondition.ConditionKind.DigestEquals: + case ValueCondition.ConditionKind.DigestNotEquals: + return Message.Create(Database, flags, RedisCommand.DELEX, key, when); + default: + when.ThrowInvalidOperation(operation); + goto case ValueCondition.ConditionKind.Always; // not reached + } + } + + public ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(Database, flags, RedisCommand.DIGEST, key); + return ExecuteSync(msg, ResultProcessor.Digest); + } + + public Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(Database, flags, RedisCommand.DIGEST, key); + return ExecuteAsync(msg, ResultProcessor.Digest); + } + + public Task StringSetAsync(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringSetMessage(key, value, when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public bool StringSet(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringSetMessage(key, value, when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + private Message GetStringSetMessage(in RedisKey key, in RedisValue value, in ValueCondition when, CommandFlags flags, [CallerMemberName] string? operation = null) + { + switch (when.Kind) + { + case ValueCondition.ConditionKind.Exists: + case ValueCondition.ConditionKind.NotExists: + case ValueCondition.ConditionKind.Always: + return GetStringSetMessage(key, value, when: when.AsWhen(), flags: flags); + case ValueCondition.ConditionKind.ValueEquals: + case ValueCondition.ConditionKind.ValueNotEquals: + case ValueCondition.ConditionKind.DigestEquals: + case ValueCondition.ConditionKind.DigestNotEquals: + return Message.Create(Database, flags, RedisCommand.SET, key, value, when); + default: + when.ThrowInvalidOperation(operation); + goto case ValueCondition.ConditionKind.Always; // not reached + } + } +} diff --git a/src/StackExchange.Redis/ResultProcessor.Digest.cs b/src/StackExchange.Redis/ResultProcessor.Digest.cs new file mode 100644 index 000000000..b3e3c0773 --- /dev/null +++ b/src/StackExchange.Redis/ResultProcessor.Digest.cs @@ -0,0 +1,47 @@ +using System; +using System.Buffers; + +namespace StackExchange.Redis; + +internal abstract partial class ResultProcessor +{ + // VectorSet result processors + public static readonly ResultProcessor Digest = + new DigestProcessor(); + + private sealed class DigestProcessor : ResultProcessor + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + { + if (result.IsNull) + { + SetResult(message, null); + return true; + } + + switch (result.Resp2TypeBulkString) + { + case ResultType.BulkString: + var payload = result.Payload; + if (payload.Length == ValueCondition.DigestBytes * 2) + { + if (payload.IsSingleSegment) + { + SetResult(message, ValueCondition.ParseDigest(payload.First.Span)); + } + else + { + // linearize (note we already checked the length) + Span copy = stackalloc byte[ValueCondition.DigestBytes * 2]; + payload.CopyTo(copy); + SetResult(message, ValueCondition.ParseDigest(copy)); + } + return true; + } + + break; + } + return false; + } + } +} diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 983624bc0..e66b3874c 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -12,6 +12,7 @@ $(DefineConstants);VECTOR_SAFE $(DefineConstants);UNIX_SOCKET README.md + $(NoWarn);SER002 diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 07fe6faba..29b637d41 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -1,17 +1,29 @@ using System; using System.Buffers.Binary; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO.Hashing; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; namespace StackExchange.Redis; /// /// Represents a check for an existing value, for use in conditional operations such as DELEX or SET ... IFEQ. /// +[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] public readonly struct ValueCondition { + internal enum ConditionKind : byte + { + Always, // default, importantly + Exists, + NotExists, + ValueEquals, + ValueNotEquals, + DigestEquals, + DigestNotEquals, + } + // Supported: equality and non-equality checks for values and digests. Values are stored a RedisValue; // digests are stored as a native (CPU-endian) Int64 (long) value, inside the same RedisValue (via the // RedisValue.DirectOverlappedBits64 feature). This native Int64 value is an implementation detail that @@ -19,31 +31,52 @@ public readonly struct ValueCondition // // The exchange format with Redis is hex of the bytes; for the purposes of interfacing this with our // raw integer value, this should be considered big-endian, based on the behaviour of XxHash3. - private const int HashLength = 8; // XXH3 is 64-bit + internal const int DigestBytes = 8; // XXH3 is 64-bit - private readonly MatchKind _kind; + private readonly ConditionKind _kind; private readonly RedisValue _value; + internal ConditionKind Kind => _kind; + + /// + /// Always perform the operation; equivalent to . + /// + public static ValueCondition Always { get; } = new(ConditionKind.Always, RedisValue.Null); + + /// + /// Only perform the operation if the value exists; equivalent to . + /// + public static ValueCondition Exists { get; } = new(ConditionKind.Exists, RedisValue.Null); + + /// + /// Only perform the operation if the value does not exist; equivalent to . + /// + public static ValueCondition NotExists { get; } = new(ConditionKind.NotExists, RedisValue.Null); + /// public override string ToString() { switch (_kind) { - case MatchKind.ValueEquals: + case ConditionKind.Exists: + return "XX"; + case ConditionKind.NotExists: + return "NX"; + case ConditionKind.ValueEquals: return $"IFEQ {_value}"; - case MatchKind.ValueNotEquals: + case ConditionKind.ValueNotEquals: return $"IFNE {_value}"; - case MatchKind.DigestEquals: - Span buffer = stackalloc char[2 * HashLength]; + case ConditionKind.DigestEquals: + Span buffer = stackalloc char[2 * DigestBytes]; WriteHex(_value.DirectOverlappedBits64, buffer); return $"IFDEQ {buffer.ToString()}"; - case MatchKind.DigestNotEquals: - WriteHex(_value.DirectOverlappedBits64, buffer = stackalloc char[2 * HashLength]); + case ConditionKind.DigestNotEquals: + WriteHex(_value.DirectOverlappedBits64, buffer = stackalloc char[2 * DigestBytes]); return $"IFDNE {buffer.ToString()}"; - case MatchKind.None: + case ConditionKind.Always: return ""; default: - return _kind.ToString(); + return ThrowInvalidOperation().ToString(); } } @@ -54,43 +87,48 @@ public override string ToString() public override int GetHashCode() => _kind.GetHashCode() ^ _value.GetHashCode(); /// - /// Indicates whether this instance represents a valid test. + /// Indicates whether this instance represents a value comparison test. /// - public bool HasValue => _kind is not MatchKind.None; + internal bool IsValueTest => _kind is ConditionKind.ValueEquals or ConditionKind.ValueNotEquals; /// /// Indicates whether this instance represents a digest test. /// - public bool IsDigest => _kind is MatchKind.DigestEquals or MatchKind.DigestNotEquals; + internal bool IsDigestTest => _kind is ConditionKind.DigestEquals or ConditionKind.DigestNotEquals; + + /// + /// Indicates whether this instance represents an existence test. + /// + internal bool IsExistenceTest => _kind is ConditionKind.Exists or ConditionKind.NotExists; /// - /// Indicates whether this instance represents an equality test. + /// Indicates whether this instance represents a negative test (not-equals, not-exists, digest-not-equals). /// - public bool IsEqual => _kind is MatchKind.ValueEquals or MatchKind.DigestEquals; + internal bool IsNegated => _kind is ConditionKind.ValueNotEquals or ConditionKind.DigestNotEquals or ConditionKind.NotExists; /// /// Gets the underlying value for this condition. /// public RedisValue Value => _value; - private ValueCondition(MatchKind kind, in RedisValue value) + private ValueCondition(ConditionKind kind, in RedisValue value) { _kind = kind; _value = value; // if it's a digest operation, the value must be an int64 - Debug.Assert(_kind is not (MatchKind.DigestEquals or MatchKind.DigestNotEquals) || + Debug.Assert(_kind is not (ConditionKind.DigestEquals or ConditionKind.DigestNotEquals) || value.Type == RedisValue.StorageType.Int64); } /// /// Create a value equality condition with the supplied value. /// - public static ValueCondition Equal(in RedisValue value) => new(MatchKind.ValueEquals, value); + public static ValueCondition Equal(in RedisValue value) => new(ConditionKind.ValueEquals, value); /// /// Create a value non-equality condition with the supplied value. /// - public static ValueCondition NotEqual(in RedisValue value) => new(MatchKind.ValueNotEquals, value); + public static ValueCondition NotEqual(in RedisValue value) => new(ConditionKind.ValueNotEquals, value); /// /// Create a digest equality condition, computing the digest of the supplied value. @@ -102,15 +140,6 @@ private ValueCondition(MatchKind kind, in RedisValue value) /// public static ValueCondition DigestNotEqual(in RedisValue value) => !value.Digest(); - private enum MatchKind : byte - { - None, - ValueEquals, - ValueNotEquals, - DigestEquals, - DigestNotEquals, - } - /// /// Calculate the digest of a payload, as an equality test. For a non-equality test, use on the result. /// @@ -119,7 +148,7 @@ public static ValueCondition CalculateDigest(ReadOnlySpan value) // the internal impl of XxHash3 uses ulong (not Span), so: use // that to avoid extra steps, and store the CPU-endian value var digest = XxHash3.HashToUInt64(value); - return new ValueCondition(MatchKind.DigestEquals, digest); + return new ValueCondition(ConditionKind.DigestEquals, digest); } /// @@ -127,11 +156,11 @@ public static ValueCondition CalculateDigest(ReadOnlySpan value) /// public static ValueCondition ParseDigest(ReadOnlySpan digest) { - if (digest.Length != 2 * HashLength) ThrowDigestLength(); + if (digest.Length != 2 * DigestBytes) ThrowDigestLength(); // we receive 16 hex characters, as bytes; parse that into a long, by // first dealing with the nibbles - Span tmp = stackalloc byte[HashLength]; + Span tmp = stackalloc byte[DigestBytes]; int offset = 0; for (int i = 0; i < tmp.Length; i++) { @@ -141,7 +170,7 @@ public static ValueCondition ParseDigest(ReadOnlySpan digest) } // now interpret that as big-endian var digestInt64 = BinaryPrimitives.ReadInt64BigEndian(tmp); - return new ValueCondition(MatchKind.DigestEquals, digestInt64); + return new ValueCondition(ConditionKind.DigestEquals, digestInt64); } private static byte ParseNibble(int b) @@ -154,18 +183,18 @@ private static byte ParseNibble(int b) static byte ThrowInvalidBytes() => throw new ArgumentException("Invalid digest bytes"); } - private static void ThrowDigestLength() => throw new ArgumentException($"Invalid digest length; expected {2 * HashLength} bytes"); + private static void ThrowDigestLength() => throw new ArgumentException($"Invalid digest length; expected {2 * DigestBytes} bytes"); /// /// Creates an equality match based on the specified digest bytes. /// public static ValueCondition ParseDigest(ReadOnlySpan digest) { - if (digest.Length != 2 * HashLength) ThrowDigestLength(); + if (digest.Length != 2 * DigestBytes) ThrowDigestLength(); // we receive 16 hex characters, as bytes; parse that into a long, by // first dealing with the nibbles - Span tmp = stackalloc byte[HashLength]; + Span tmp = stackalloc byte[DigestBytes]; int offset = 0; for (int i = 0; i < tmp.Length; i++) { @@ -175,7 +204,7 @@ public static ValueCondition ParseDigest(ReadOnlySpan digest) } // now interpret that as big-endian var digestInt64 = BinaryPrimitives.ReadInt64BigEndian(tmp); - return new ValueCondition(MatchKind.DigestEquals, digestInt64); + return new ValueCondition(ConditionKind.DigestEquals, digestInt64); static byte ToNibble(int b) { @@ -188,27 +217,38 @@ static byte ToNibble(int b) static byte ThrowInvalidBytes() => throw new ArgumentException("Invalid digest bytes"); } - internal int TokenCount => _kind == MatchKind.None ? 0 : 2; + internal int TokenCount => _kind switch + { + ConditionKind.Exists or ConditionKind.NotExists => 1, + ConditionKind.ValueEquals or ConditionKind.ValueNotEquals or ConditionKind.DigestEquals or ConditionKind.DigestNotEquals => 2, + _ => 0, + }; internal void WriteTo(PhysicalConnection physical) { switch (_kind) { - case MatchKind.ValueEquals: + case ConditionKind.Exists: + physical.WriteBulkString("XX"u8); + break; + case ConditionKind.NotExists: + physical.WriteBulkString("NX"u8); + break; + case ConditionKind.ValueEquals: physical.WriteBulkString("IFEQ"u8); physical.WriteBulkString(_value); break; - case MatchKind.ValueNotEquals: + case ConditionKind.ValueNotEquals: physical.WriteBulkString("IFNE"u8); physical.WriteBulkString(_value); break; - case MatchKind.DigestEquals: + case ConditionKind.DigestEquals: physical.WriteBulkString("IFDEQ"u8); Span buffer = stackalloc byte[16]; WriteHex(_value.DirectOverlappedBits64, buffer); physical.WriteBulkString(buffer); break; - case MatchKind.DigestNotEquals: + case ConditionKind.DigestNotEquals: physical.WriteBulkString("IFDNE"u8); WriteHex(_value.DirectOverlappedBits64, buffer = stackalloc byte[16]); physical.WriteBulkString(buffer); @@ -218,7 +258,7 @@ internal void WriteTo(PhysicalConnection physical) internal static void WriteHex(long value, Span target) { - Debug.Assert(target.Length == 2 * HashLength); + Debug.Assert(target.Length == 2 * DigestBytes); // iterate over the bytes in big-endian order, writing the hi/lo nibbles, // using pointer-like behaviour (rather than complex shifts and masks) @@ -239,7 +279,7 @@ internal static void WriteHex(long value, Span target) internal static void WriteHex(long value, Span target) { - Debug.Assert(target.Length == 2 * HashLength); + Debug.Assert(target.Length == 2 * DigestBytes); // iterate over the bytes in big-endian order, writing the hi/lo nibbles, // using pointer-like behaviour (rather than complex shifts and masks) @@ -259,25 +299,49 @@ internal static void WriteHex(long value, Span target) } /// - /// Negate this condition. The digest/value aspect of the condition is preserved. + /// Negate this condition. The nature of the condition is preserved. /// public static ValueCondition operator !(in ValueCondition value) => value._kind switch { - MatchKind.ValueEquals => new(MatchKind.ValueNotEquals, value._value), - MatchKind.ValueNotEquals => new(MatchKind.ValueEquals, value._value), - MatchKind.DigestEquals => new(MatchKind.DigestNotEquals, value._value), - MatchKind.DigestNotEquals => new(MatchKind.DigestEquals, value._value), - _ => value, // GIGO + ConditionKind.ValueEquals => new(ConditionKind.ValueNotEquals, value._value), + ConditionKind.ValueNotEquals => new(ConditionKind.ValueEquals, value._value), + ConditionKind.DigestEquals => new(ConditionKind.DigestNotEquals, value._value), + ConditionKind.DigestNotEquals => new(ConditionKind.DigestEquals, value._value), + ConditionKind.Exists => new(ConditionKind.NotExists, value._value), + ConditionKind.NotExists => new(ConditionKind.Exists, value._value), + // ReSharper disable once ExplicitCallerInfoArgument + _ => value.ThrowInvalidOperation("operator !"), + }; + + /// + /// Convert a to a . + /// + public static implicit operator ValueCondition(When when) => when switch + { + When.Always => Always, + When.Exists => Exists, + When.NotExists => NotExists, + _ => throw new ArgumentOutOfRangeException(nameof(when)), }; /// - /// Convert this condition to a digest condition. If this condition is not a value-based condition, it is returned as-is. - /// The equality or non-equality aspect of the condition is preserved. + /// Convert a value condition to a digest condition. /// - public ValueCondition Digest() => _kind switch + public ValueCondition AsDigest() => _kind switch + { + ConditionKind.ValueEquals => _value.Digest(), + ConditionKind.ValueNotEquals => !_value.Digest(), + _ => ThrowInvalidOperation(), + }; + + internal ValueCondition ThrowInvalidOperation([CallerMemberName] string? operation = null) + => throw new InvalidOperationException($"{operation} cannot be used with a {_kind} condition."); + + internal When AsWhen() => _kind switch { - MatchKind.ValueEquals => _value.Digest(), - MatchKind.ValueNotEquals => !_value.Digest(), - _ => this, + ConditionKind.Always => When.Always, + ConditionKind.Exists => When.Exists, + ConditionKind.NotExists => When.NotExists, + _ => ThrowInvalidOperation().AsWhen(), }; } diff --git a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs index ce73f4de8..916b9a497 100644 --- a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs @@ -7,6 +7,8 @@ namespace StackExchange.Redis.Tests; +#pragma warning disable SER002 // 8.4 + public class DigestUnitTests(ITestOutputHelper output) : TestBase(output) { [Theory] @@ -17,9 +19,7 @@ public void RedisValue_Digest(string equivalentValue, RedisValue value) var hashHex = GetXxh3Hex(equivalentValue); var digest = value.Digest(); - Assert.True(digest.HasValue); - Assert.True(digest.IsDigest); - Assert.True(digest.IsEqual); + Assert.Equal(ValueCondition.ConditionKind.DigestEquals, digest.Kind); Assert.Equal($"IFDEQ {hashHex}", digest.ToString()); } @@ -99,42 +99,69 @@ public void ValueCondition_Mutations() var condition = ValueCondition.Equal(InputValue); Assert.Equal($"IFEQ {InputValue}", condition.ToString()); - Assert.True(condition.HasValue); - Assert.False(condition.IsDigest); - Assert.True(condition.IsEqual); + Assert.True(condition.IsValueTest); + Assert.False(condition.IsDigestTest); + Assert.False(condition.IsNegated); + Assert.False(condition.IsExistenceTest); var negCondition = !condition; Assert.NotEqual(condition, negCondition); Assert.Equal($"IFNE {InputValue}", negCondition.ToString()); - Assert.True(negCondition.HasValue); - Assert.False(negCondition.IsDigest); - Assert.False(negCondition.IsEqual); + Assert.True(negCondition.IsValueTest); + Assert.False(negCondition.IsDigestTest); + Assert.True(negCondition.IsNegated); + Assert.False(negCondition.IsExistenceTest); var negNegCondition = !negCondition; Assert.Equal(condition, negNegCondition); - var digest = condition.Digest(); + var digest = condition.AsDigest(); Assert.NotEqual(condition, digest); Assert.Equal($"IFDEQ {GetXxh3Hex(InputValue)}", digest.ToString()); - Assert.True(digest.HasValue); - Assert.True(digest.IsDigest); - Assert.True(digest.IsEqual); + Assert.False(digest.IsValueTest); + Assert.True(digest.IsDigestTest); + Assert.False(digest.IsNegated); + Assert.False(digest.IsExistenceTest); var negDigest = !digest; Assert.NotEqual(digest, negDigest); Assert.Equal($"IFDNE {GetXxh3Hex(InputValue)}", negDigest.ToString()); - Assert.True(negDigest.HasValue); - Assert.True(negDigest.IsDigest); - Assert.False(negDigest.IsEqual); + Assert.False(negDigest.IsValueTest); + Assert.True(negDigest.IsDigestTest); + Assert.True(negDigest.IsNegated); + Assert.False(negDigest.IsExistenceTest); var negNegDigest = !negDigest; Assert.Equal(digest, negNegDigest); var @default = default(ValueCondition); - Assert.False(@default.HasValue); - Assert.False(@default.IsDigest); - Assert.False(@default.IsEqual); + Assert.False(@default.IsValueTest); + Assert.False(@default.IsDigestTest); + Assert.False(@default.IsNegated); + Assert.False(@default.IsExistenceTest); Assert.Equal("", @default.ToString()); + Assert.Equal(ValueCondition.Always, @default); + + var ex = Assert.Throws(() => !@default); + Assert.Equal("operator ! cannot be used with a Always condition.", ex.Message); + + var exists = ValueCondition.Exists; + Assert.False(exists.IsValueTest); + Assert.False(exists.IsDigestTest); + Assert.False(exists.IsNegated); + Assert.True(exists.IsExistenceTest); + Assert.Equal("XX", exists.ToString()); + + var notExists = ValueCondition.NotExists; + Assert.False(notExists.IsValueTest); + Assert.False(notExists.IsDigestTest); + Assert.True(notExists.IsNegated); + Assert.True(notExists.IsExistenceTest); + Assert.Equal("NX", notExists.ToString()); + + Assert.NotEqual(exists, notExists); + Assert.Equal(exists, !notExists); + Assert.Equal(notExists, !exists); } [Fact] From 9cebaf4af53585a0c5ceea9f6af4a8d8afe85219 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 31 Oct 2025 15:07:05 +0000 Subject: [PATCH 04/11] DIGEST integration tests --- src/StackExchange.Redis/Enums/RedisCommand.cs | 1 + .../RedisDatabase.Strings.cs | 1 + src/StackExchange.Redis/RedisFeatures.cs | 3 +- src/StackExchange.Redis/RedisValue.cs | 4 +- src/StackExchange.Redis/ValueCondition.cs | 16 ++- .../DigestIntegrationTests.cs | 136 ++++++++++++++++++ 6 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 0f314ed56..72ca0bc9f 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -302,6 +302,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.DECRBY: case RedisCommand.DEL: case RedisCommand.DELEX: + case RedisCommand.DIGEST: case RedisCommand.EXPIRE: case RedisCommand.EXPIREAT: case RedisCommand.FLUSHALL: diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index f2f069a40..87f5bf60f 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -22,6 +22,7 @@ private Message GetStringDeleteMessage(in RedisKey key, in ValueCondition when, switch (when.Kind) { case ValueCondition.ConditionKind.Always: + case ValueCondition.ConditionKind.Exists: return Message.Create(Database, flags, RedisCommand.DEL, key); case ValueCondition.ConditionKind.ValueEquals: case ValueCondition.ConditionKind.ValueNotEquals: diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index 87bcbf20c..9bc9af6d2 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -46,7 +46,8 @@ namespace StackExchange.Redis v7_4_0_rc1 = new Version(7, 3, 240), // 7.4 RC1 is version 7.3.240 v7_4_0_rc2 = new Version(7, 3, 241), // 7.4 RC2 is version 7.3.241 v8_0_0_M04 = new Version(7, 9, 227), // 8.0 M04 is version 7.9.227 - v8_2_0_rc1 = new Version(8, 1, 240); // 8.2 RC1 is version 8.1.240 + v8_2_0_rc1 = new Version(8, 1, 240), // 8.2 RC1 is version 8.1.240 + v8_4_0_rc1 = new Version(8, 3, 224); // 8.4 RC1 is version 8.3.224 #pragma warning restore SA1310 // Field names should not contain underscore #pragma warning restore SA1311 // Static readonly fields should begin with upper-case letter diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 508731600..d306ca0d0 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -1234,8 +1234,7 @@ internal ValueCondition Digest() case StorageType.Raw: return ValueCondition.CalculateDigest(_memory.Span); case StorageType.Null: - ThrowNull(); - goto case default; + return ValueCondition.NotExists; // interpret === null as "not exists" default: var len = GetByteCount(); byte[]? oversized = null; @@ -1245,7 +1244,6 @@ internal ValueCondition Digest() if (oversized is not null) ArrayPool.Shared.Return(oversized); return digest; } - static void ThrowNull() => throw new ArgumentNullException(nameof(RedisValue)); } } } diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 29b637d41..a99fdf562 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -113,6 +113,20 @@ public override string ToString() private ValueCondition(ConditionKind kind, in RedisValue value) { + if (value.IsNull) + { + kind = kind switch + { + // interpret === null as "does not exist" + ConditionKind.DigestEquals or ConditionKind.ValueEquals => ConditionKind.NotExists, + + // interpret !== null as "exists" + ConditionKind.DigestNotEquals or ConditionKind.ValueNotEquals => ConditionKind.Exists, + + // otherwise: leave alone + _ => kind, + }; + } _kind = kind; _value = value; // if it's a digest operation, the value must be an int64 @@ -147,7 +161,7 @@ public static ValueCondition CalculateDigest(ReadOnlySpan value) { // the internal impl of XxHash3 uses ulong (not Span), so: use // that to avoid extra steps, and store the CPU-endian value - var digest = XxHash3.HashToUInt64(value); + var digest = unchecked((long)XxHash3.HashToUInt64(value)); return new ValueCondition(ConditionKind.DigestEquals, digest); } diff --git a/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs new file mode 100644 index 000000000..c982c7e90 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO.Hashing; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +#pragma warning disable SER002 // 8.4 + +public class DigestIntegrationTests(ITestOutputHelper output, SharedConnectionFixture fixture) + : TestBase(output, fixture) +{ + [Fact] + public async Task ReadDigest() + { + await using var conn = Create(require: RedisFeatures.v8_4_0_rc1); + byte[] blob = new byte[1024]; + new Random().NextBytes(blob); + var local = ValueCondition.CalculateDigest(blob); + Assert.Equal(ValueCondition.ConditionKind.DigestEquals, local.Kind); + Assert.Equal(RedisValue.StorageType.Int64, local.Value.Type); + Log("Local digest: " + local); + + var key = Me(); + var db = conn.GetDatabase(); + await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); + + // test without a value + var digest = await db.StringDigestAsync(key); + Assert.Null(digest); + + // test with a value + await db.StringSetAsync(key, blob, flags: CommandFlags.FireAndForget); + digest = await db.StringDigestAsync(key); + Assert.NotNull(digest); + Assert.Equal(ValueCondition.ConditionKind.DigestEquals, digest.Value.Kind); + Assert.Equal(RedisValue.StorageType.Int64, digest.Value.Value.Type); + Log("Server digest: " + digest); + Assert.Equal(local, digest.Value); + } + + [Theory] + [InlineData(null, (int)ValueCondition.ConditionKind.NotExists)] + [InlineData("new value", (int)ValueCondition.ConditionKind.NotExists)] + [InlineData(null, (int)ValueCondition.ConditionKind.ValueEquals)] + [InlineData(null, (int)ValueCondition.ConditionKind.DigestEquals)] + public async Task InvalidConditionalDelete(string? testValue, int rawKind) + { + await using var conn = Create(); // no server requirement, since fails locally + var key = Me(); + var db = conn.GetDatabase(); + var condition = CreateCondition(testValue, rawKind); + + var ex = await Assert.ThrowsAsync(async () => + { + await db.StringDeleteAsync(key, when: condition); + }); + Assert.StartsWith("StringDeleteAsync cannot be used with a NotExists condition.", ex.Message); + } + + [Theory] + [InlineData(null, null, (int)ValueCondition.ConditionKind.Always)] + [InlineData(null, "new value", (int)ValueCondition.ConditionKind.Always)] + [InlineData("old value", "new value", (int)ValueCondition.ConditionKind.Always, true)] + [InlineData("new value", "new value", (int)ValueCondition.ConditionKind.Always, true)] + + [InlineData(null, null, (int)ValueCondition.ConditionKind.Exists)] + [InlineData(null, "new value", (int)ValueCondition.ConditionKind.Exists)] + [InlineData("old value", "new value", (int)ValueCondition.ConditionKind.Exists, true)] + [InlineData("new value", "new value", (int)ValueCondition.ConditionKind.Exists, true)] + + [InlineData(null, "new value", (int)ValueCondition.ConditionKind.DigestEquals)] + [InlineData("old value", "new value", (int)ValueCondition.ConditionKind.DigestEquals)] + [InlineData("new value", "new value", (int)ValueCondition.ConditionKind.DigestEquals, true)] + + [InlineData(null, "new value", (int)ValueCondition.ConditionKind.ValueEquals)] + [InlineData("old value", "new value", (int)ValueCondition.ConditionKind.ValueEquals)] + [InlineData("new value", "new value", (int)ValueCondition.ConditionKind.ValueEquals, true)] + + [InlineData(null, null, (int)ValueCondition.ConditionKind.DigestNotEquals)] + [InlineData(null, "new value", (int)ValueCondition.ConditionKind.DigestNotEquals)] + [InlineData("old value", "new value", (int)ValueCondition.ConditionKind.DigestNotEquals, true)] + [InlineData("new value", "new value", (int)ValueCondition.ConditionKind.DigestNotEquals)] + + [InlineData(null, null, (int)ValueCondition.ConditionKind.ValueNotEquals)] + [InlineData(null, "new value", (int)ValueCondition.ConditionKind.ValueNotEquals)] + [InlineData("old value", "new value", (int)ValueCondition.ConditionKind.ValueNotEquals, true)] + [InlineData("new value", "new value", (int)ValueCondition.ConditionKind.ValueNotEquals)] + public async Task ConditionalDelete(string? dbValue, string? testValue, int rawKind, bool expectDelete = false) + { + await using var conn = Create(require: RedisFeatures.v8_4_0_rc1); + var key = Me(); + var db = conn.GetDatabase(); + await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); + if (dbValue != null) await db.StringSetAsync(key, dbValue, flags: CommandFlags.FireAndForget); + + var condition = CreateCondition(testValue, rawKind); + + var pendingDelete = db.StringDeleteAsync(key, when: condition); + var exists = await db.KeyExistsAsync(key); + var deleted = await pendingDelete; + + if (dbValue is null) + { + // didn't exist to be deleted + Assert.False(expectDelete); + Assert.False(exists); + Assert.False(deleted); + } + else + { + Assert.Equal(expectDelete, deleted); + Assert.Equal(!expectDelete, exists); + } + } + + private ValueCondition CreateCondition(string? testValue, int rawKind) + { + var condition = (ValueCondition.ConditionKind)rawKind switch + { + ValueCondition.ConditionKind.Always => ValueCondition.Always, + ValueCondition.ConditionKind.Exists => ValueCondition.Exists, + ValueCondition.ConditionKind.NotExists => ValueCondition.NotExists, + ValueCondition.ConditionKind.ValueEquals => ValueCondition.Equal(testValue), + ValueCondition.ConditionKind.ValueNotEquals => ValueCondition.NotEqual(testValue), + ValueCondition.ConditionKind.DigestEquals => ValueCondition.DigestEqual(testValue), + ValueCondition.ConditionKind.DigestNotEquals => ValueCondition.DigestNotEqual(testValue), + _ => throw new ArgumentOutOfRangeException(nameof(rawKind)), + }; + Log($"Condition: {condition}"); + return condition; + } +} From 3dcf69df40ebf6066ce698a639fa9b796bb043e5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 31 Oct 2025 16:46:04 +0000 Subject: [PATCH 05/11] compensate for leading-zero oddness --- .../ResultProcessor.Digest.cs | 27 ++++++----- src/StackExchange.Redis/ValueCondition.cs | 48 ++++++++++++++----- .../DigestIntegrationTests.cs | 29 +++++++++-- .../DigestUnitTests.cs | 3 +- 4 files changed, 78 insertions(+), 29 deletions(-) diff --git a/src/StackExchange.Redis/ResultProcessor.Digest.cs b/src/StackExchange.Redis/ResultProcessor.Digest.cs index b3e3c0773..1b5b00890 100644 --- a/src/StackExchange.Redis/ResultProcessor.Digest.cs +++ b/src/StackExchange.Redis/ResultProcessor.Digest.cs @@ -23,22 +23,25 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { case ResultType.BulkString: var payload = result.Payload; - if (payload.Length == ValueCondition.DigestBytes * 2) + var len = checked((int)payload.Length); + if (len == 2 * ValueCondition.DigestBytes & payload.IsSingleSegment) { - if (payload.IsSingleSegment) - { - SetResult(message, ValueCondition.ParseDigest(payload.First.Span)); - } - else - { - // linearize (note we already checked the length) - Span copy = stackalloc byte[ValueCondition.DigestBytes * 2]; - payload.CopyTo(copy); - SetResult(message, ValueCondition.ParseDigest(copy)); - } + // full-size hash in a single chunk - fast path + SetResult(message, ValueCondition.ParseDigest(payload.First.Span)); return true; } + if (len >= 1 & len <= ValueCondition.DigestBytes * 2) + { + // Either multi-segment, or isn't long enough (missing leading zeros, + // see https://github.com/redis/redis/issues/14496). + Span buffer = new byte[2 * ValueCondition.DigestBytes]; + int start = (2 * ValueCondition.DigestBytes) - len; + if (start != 0) buffer.Slice(0, start).Fill((byte)'0'); // pad + payload.CopyTo(buffer.Slice(start)); // linearize + SetResult(message, ValueCondition.ParseDigest(buffer)); + return true; + } break; } return false; diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index a99fdf562..ca1b38cc8 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -67,12 +67,11 @@ public override string ToString() case ConditionKind.ValueNotEquals: return $"IFNE {_value}"; case ConditionKind.DigestEquals: - Span buffer = stackalloc char[2 * DigestBytes]; - WriteHex(_value.DirectOverlappedBits64, buffer); - return $"IFDEQ {buffer.ToString()}"; + var written = WriteHex(_value.DirectOverlappedBits64, stackalloc char[2 * DigestBytes]); + return $"IFDEQ {written.ToString()}"; case ConditionKind.DigestNotEquals: - WriteHex(_value.DirectOverlappedBits64, buffer = stackalloc char[2 * DigestBytes]); - return $"IFDNE {buffer.ToString()}"; + written = WriteHex(_value.DirectOverlappedBits64, stackalloc char[2 * DigestBytes]); + return $"IFDNE {written.ToString()}"; case ConditionKind.Always: return ""; default: @@ -258,19 +257,18 @@ internal void WriteTo(PhysicalConnection physical) break; case ConditionKind.DigestEquals: physical.WriteBulkString("IFDEQ"u8); - Span buffer = stackalloc byte[16]; - WriteHex(_value.DirectOverlappedBits64, buffer); - physical.WriteBulkString(buffer); + var written = WriteHex(_value.DirectOverlappedBits64, stackalloc byte[2 * DigestBytes]); + physical.WriteBulkString(written); break; case ConditionKind.DigestNotEquals: physical.WriteBulkString("IFDNE"u8); - WriteHex(_value.DirectOverlappedBits64, buffer = stackalloc byte[16]); - physical.WriteBulkString(buffer); + written = WriteHex(_value.DirectOverlappedBits64, stackalloc byte[2 * DigestBytes]); + physical.WriteBulkString(written); break; } } - internal static void WriteHex(long value, Span target) + internal static Span WriteHex(long value, Span target) { Debug.Assert(target.Length == 2 * DigestBytes); @@ -289,9 +287,22 @@ internal static void WriteHex(long value, Span target) target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble target[targetOffset++] = hex[b & 0xF]; // lo } + + // see https://github.com/redis/redis/issues/14496, hopefully temporary + int leadingZeros = 0; + if (target[0] == '0') + { + leadingZeros = 1; + for (int i = 1; i < (2 * DigestBytes) - 1; i++) + { + if (target[i] != (byte)'0') break; + leadingZeros++; + } + } + return target.Slice(leadingZeros, (2 * DigestBytes) - leadingZeros); } - internal static void WriteHex(long value, Span target) + internal static Span WriteHex(long value, Span target) { Debug.Assert(target.Length == 2 * DigestBytes); @@ -310,6 +321,19 @@ internal static void WriteHex(long value, Span target) target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble target[targetOffset++] = hex[b & 0xF]; // lo } + + // see https://github.com/redis/redis/issues/14496, hopefully temporary + int leadingZeros = 0; + if (target[0] == '0') + { + leadingZeros = 1; + for (int i = 1; i < (2 * DigestBytes) - 1; i++) + { + if ((byte)target[i] != (byte)'0') break; + leadingZeros++; + } + } + return target.Slice(leadingZeros, (2 * DigestBytes) - leadingZeros); } /// diff --git a/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs index c982c7e90..07f12d29c 100644 --- a/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs +++ b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs @@ -1,8 +1,4 @@ using System; -using System.Buffers; -using System.Collections.Generic; -using System.IO.Hashing; -using System.Text; using System.Threading.Tasks; using Xunit; @@ -133,4 +129,29 @@ private ValueCondition CreateCondition(string? testValue, int rawKind) Log($"Condition: {condition}"); return condition; } + + [Fact] + public async Task LeadingZeroFormatting() + { + // Example generated that hashes to 0x00006c38adf31777; see https://github.com/redis/redis/issues/14496 + var localDigest = + ValueCondition.CalculateDigest("v8lf0c11xh8ymlqztfd3eeq16kfn4sspw7fqmnuuq3k3t75em5wdizgcdw7uc26nnf961u2jkfzkjytls2kwlj7626sd"u8); + Log($"local: {localDigest}"); + Assert.Equal("IFDEQ 6c38adf31777", localDigest.ToString()); + + await using var conn = Create(require: RedisFeatures.v8_4_0_rc1); + var key = Me(); + var db = conn.GetDatabase(); + await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); + await db.StringSetAsync(key, "v8lf0c11xh8ymlqztfd3eeq16kfn4sspw7fqmnuuq3k3t75em5wdizgcdw7uc26nnf961u2jkfzkjytls2kwlj7626sd", flags: CommandFlags.FireAndForget); + var pendingDigest = db.StringDigestAsync(key); + var pendingDeleted = db.StringDeleteAsync(key, when: localDigest); + var existsAfter = await db.KeyExistsAsync(key); + + var serverDigest = await pendingDigest; + Log($"server: {serverDigest}"); + Assert.Equal(localDigest, serverDigest); + Assert.True(await pendingDeleted); + Assert.False(existsAfter); + } } diff --git a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs index 916b9a497..293c628fa 100644 --- a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs @@ -88,7 +88,8 @@ private static string GetXxh3Hex(ReadOnlySpan source) { byte[] targetBytes = new byte[8]; XxHash3.Hash(source, targetBytes); - return BitConverter.ToString(targetBytes).Replace("-", string.Empty).ToLowerInvariant(); + var s = BitConverter.ToString(targetBytes).Replace("-", string.Empty).TrimStart('0').ToLowerInvariant(); + return s.Length == 0 ? "0" : s; // guess; I'm not actually sure what the "correct" format of 0x000..000 is! } [Fact] From aaefd7049887335ceb82119c28ec077f019d22b3 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 3 Nov 2025 10:32:40 +0000 Subject: [PATCH 06/11] apply fixes for server hash format changes --- src/StackExchange.Redis/ValueCondition.cs | 32 +++---------------- .../DigestIntegrationTests.cs | 2 +- .../DigestUnitTests.cs | 3 +- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index ca1b38cc8..94e9850c4 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -270,7 +270,7 @@ internal void WriteTo(PhysicalConnection physical) internal static Span WriteHex(long value, Span target) { - Debug.Assert(target.Length == 2 * DigestBytes); + Debug.Assert(target.Length >= 2 * DigestBytes); // iterate over the bytes in big-endian order, writing the hi/lo nibbles, // using pointer-like behaviour (rather than complex shifts and masks) @@ -287,24 +287,12 @@ internal static Span WriteHex(long value, Span target) target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble target[targetOffset++] = hex[b & 0xF]; // lo } - - // see https://github.com/redis/redis/issues/14496, hopefully temporary - int leadingZeros = 0; - if (target[0] == '0') - { - leadingZeros = 1; - for (int i = 1; i < (2 * DigestBytes) - 1; i++) - { - if (target[i] != (byte)'0') break; - leadingZeros++; - } - } - return target.Slice(leadingZeros, (2 * DigestBytes) - leadingZeros); + return target.Slice(0, 2 * DigestBytes); } internal static Span WriteHex(long value, Span target) { - Debug.Assert(target.Length == 2 * DigestBytes); + Debug.Assert(target.Length >= 2 * DigestBytes); // iterate over the bytes in big-endian order, writing the hi/lo nibbles, // using pointer-like behaviour (rather than complex shifts and masks) @@ -321,19 +309,7 @@ internal static Span WriteHex(long value, Span target) target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble target[targetOffset++] = hex[b & 0xF]; // lo } - - // see https://github.com/redis/redis/issues/14496, hopefully temporary - int leadingZeros = 0; - if (target[0] == '0') - { - leadingZeros = 1; - for (int i = 1; i < (2 * DigestBytes) - 1; i++) - { - if ((byte)target[i] != (byte)'0') break; - leadingZeros++; - } - } - return target.Slice(leadingZeros, (2 * DigestBytes) - leadingZeros); + return target.Slice(0, 2 * DigestBytes); } /// diff --git a/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs index 07f12d29c..a71e2f910 100644 --- a/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs +++ b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs @@ -137,7 +137,7 @@ public async Task LeadingZeroFormatting() var localDigest = ValueCondition.CalculateDigest("v8lf0c11xh8ymlqztfd3eeq16kfn4sspw7fqmnuuq3k3t75em5wdizgcdw7uc26nnf961u2jkfzkjytls2kwlj7626sd"u8); Log($"local: {localDigest}"); - Assert.Equal("IFDEQ 6c38adf31777", localDigest.ToString()); + Assert.Equal("IFDEQ 00006c38adf31777", localDigest.ToString()); await using var conn = Create(require: RedisFeatures.v8_4_0_rc1); var key = Me(); diff --git a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs index 293c628fa..916b9a497 100644 --- a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs @@ -88,8 +88,7 @@ private static string GetXxh3Hex(ReadOnlySpan source) { byte[] targetBytes = new byte[8]; XxHash3.Hash(source, targetBytes); - var s = BitConverter.ToString(targetBytes).Replace("-", string.Empty).TrimStart('0').ToLowerInvariant(); - return s.Length == 0 ? "0" : s; // guess; I'm not actually sure what the "correct" format of 0x000..000 is! + return BitConverter.ToString(targetBytes).Replace("-", string.Empty).ToLowerInvariant(); } [Fact] From 9252c16c2489f2892155d9706f22f15ba22b5c97 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 3 Nov 2025 11:32:26 +0000 Subject: [PATCH 07/11] use CAS/CAD in locking operations --- .../Interfaces/IDatabase.cs | 3 +- .../Interfaces/IDatabaseAsync.cs | 4 +- .../KeyspaceIsolation/KeyPrefixed.cs | 4 +- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 4 +- .../Message.ValueCondition.cs | 28 ++++++++++--- .../PublicAPI/PublicAPI.Unshipped.txt | 6 ++- .../RedisDatabase.Strings.cs | 17 ++++---- src/StackExchange.Redis/RedisDatabase.cs | 41 ++++++++++++++++--- src/StackExchange.Redis/RedisFeatures.cs | 10 +++++ 9 files changed, 90 insertions(+), 27 deletions(-) diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 1cc5598ac..31dd744e2 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -3385,12 +3385,13 @@ IEnumerable SortedSetScan( /// /// The key of the string. /// The value to set. + /// The expiry to set. /// The condition to enforce. /// The flags to use for this operation. /// See . [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] #pragma warning disable RS0027 - bool StringSet(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None); + bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None); #pragma warning restore RS0027 /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 96667932a..aebbbb4af 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -839,10 +839,10 @@ IAsyncEnumerable SortedSetScanAsync( /// Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); - /// + /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] #pragma warning disable RS0027 - Task StringSetAsync(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None); + Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None); #pragma warning restore RS0027 /// diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index e74261495..33a2e5e33 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -777,8 +777,8 @@ public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlag public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLengthAsync(ToInner(key), flags); - public Task StringSetAsync(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) - => Inner.StringSetAsync(ToInner(key), value, when, flags); + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) + => Inner.StringSetAsync(ToInner(key), value, expiry, when, flags); public Task StringSetAsync(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) => Inner.StringSetAsync(ToInner(values), when, flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 4a3a9db6f..bb1c67e4b 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -759,8 +759,8 @@ public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = C public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLength(ToInner(key), flags); - public bool StringSet(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) - => Inner.StringSet(ToInner(key), value, when, flags); + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) + => Inner.StringSet(ToInner(key), value, expiry, when, flags); public bool StringSet(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) => Inner.StringSet(ToInner(values), when, flags); diff --git a/src/StackExchange.Redis/Message.ValueCondition.cs b/src/StackExchange.Redis/Message.ValueCondition.cs index 8399a2d1c..c8b5febc4 100644 --- a/src/StackExchange.Redis/Message.ValueCondition.cs +++ b/src/StackExchange.Redis/Message.ValueCondition.cs @@ -1,12 +1,14 @@ -namespace StackExchange.Redis; +using System; + +namespace StackExchange.Redis; internal partial class Message { public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in ValueCondition when) => new KeyConditionMessage(db, flags, command, key, when); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, in ValueCondition when) - => new KeyValueConditionMessage(db, flags, command, key, value, when); + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, TimeSpan? expiry, in ValueCondition when) + => new KeyValueExpiryConditionMessage(db, flags, command, key, value, expiry, when); private sealed class KeyConditionMessage( int db, @@ -28,25 +30,41 @@ protected override void WriteImpl(PhysicalConnection physical) } } - private sealed class KeyValueConditionMessage( + private sealed class KeyValueExpiryConditionMessage( int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, + TimeSpan? expiry, in ValueCondition when) : CommandKeyBase(db, flags, command, key) { private readonly RedisValue _value = value; private readonly ValueCondition _when = when; + private readonly TimeSpan? _expiry = expiry == TimeSpan.MaxValue ? null : expiry; - public override int ArgCount => 2 + _when.TokenCount; + public override int ArgCount => 2 + _when.TokenCount + (_expiry is null ? 0 : 2); protected override void WriteImpl(PhysicalConnection physical) { physical.WriteHeader(Command, ArgCount); physical.Write(Key); physical.WriteBulkString(_value); + if (_expiry.HasValue) + { + var ms = (long)_expiry.GetValueOrDefault().TotalMilliseconds; + if ((ms % 1000) == 0) + { + physical.WriteBulkString("EX"u8); + physical.WriteBulkString(ms / 1000); + } + else + { + physical.WriteBulkString("PX"u8); + physical.WriteBulkString(ms); + } + } _when.WriteTo(physical); } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 376bd4391..1b8aba3b0 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,13 +1,15 @@ #nullable enable +StackExchange.Redis.RedisFeatures.DeleteWithValueCheck.get -> bool +StackExchange.Redis.RedisFeatures.SetWithValueCheck.get -> bool [SER002]override StackExchange.Redis.ValueCondition.Equals(object? obj) -> bool [SER002]override StackExchange.Redis.ValueCondition.GetHashCode() -> int [SER002]override StackExchange.Redis.ValueCondition.ToString() -> string! [SER002]StackExchange.Redis.IDatabase.StringDelete(StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool [SER002]StackExchange.Redis.IDatabase.StringDigest(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ValueCondition? -[SER002]StackExchange.Redis.IDatabase.StringSet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER002]StackExchange.Redis.IDatabase.StringSet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool [SER002]StackExchange.Redis.IDatabaseAsync.StringDeleteAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! [SER002]StackExchange.Redis.IDatabaseAsync.StringDigestAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER002]StackExchange.Redis.IDatabaseAsync.StringSetAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER002]StackExchange.Redis.IDatabaseAsync.StringSetAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry, StackExchange.Redis.ValueCondition when, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! [SER002]StackExchange.Redis.ValueCondition [SER002]StackExchange.Redis.ValueCondition.AsDigest() -> StackExchange.Redis.ValueCondition [SER002]StackExchange.Redis.ValueCondition.Value.get -> StackExchange.Redis.RedisValue diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index 87f5bf60f..1323246f9 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System; +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace StackExchange.Redis; @@ -47,31 +48,31 @@ private Message GetStringDeleteMessage(in RedisKey key, in ValueCondition when, return ExecuteAsync(msg, ResultProcessor.Digest); } - public Task StringSetAsync(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) { - var msg = GetStringSetMessage(key, value, when, flags); + var msg = GetStringSetMessage(key, value, expiry, when, flags); return ExecuteAsync(msg, ResultProcessor.Boolean); } - public bool StringSet(RedisKey key, RedisValue value, ValueCondition when, CommandFlags flags = CommandFlags.None) + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) { - var msg = GetStringSetMessage(key, value, when, flags); + var msg = GetStringSetMessage(key, value, expiry, when, flags); return ExecuteSync(msg, ResultProcessor.Boolean); } - private Message GetStringSetMessage(in RedisKey key, in RedisValue value, in ValueCondition when, CommandFlags flags, [CallerMemberName] string? operation = null) + private Message GetStringSetMessage(in RedisKey key, in RedisValue value, TimeSpan? expiry, in ValueCondition when, CommandFlags flags, [CallerMemberName] string? operation = null) { switch (when.Kind) { case ValueCondition.ConditionKind.Exists: case ValueCondition.ConditionKind.NotExists: case ValueCondition.ConditionKind.Always: - return GetStringSetMessage(key, value, when: when.AsWhen(), flags: flags); + return GetStringSetMessage(key, value, expiry: expiry, when: when.AsWhen(), flags: flags); case ValueCondition.ConditionKind.ValueEquals: case ValueCondition.ConditionKind.ValueNotEquals: case ValueCondition.ConditionKind.DigestEquals: case ValueCondition.ConditionKind.DigestNotEquals: - return Message.Create(Database, flags, RedisCommand.SET, key, value, when); + return Message.Create(Database, flags, RedisCommand.SET, key, value, expiry, when); default: when.ThrowInvalidOperation(operation); goto case ValueCondition.ConditionKind.Always; // not reached diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index bcda4146b..3db3294b7 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Net; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Pipelines.Sockets.Unofficial.Arenas; @@ -1770,18 +1771,33 @@ public Task ListTrimAsync(RedisKey key, long start, long stop, CommandFlags flag public bool LockExtend(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) { - if (value.IsNull) throw new ArgumentNullException(nameof(value)); - var tran = GetLockExtendTransaction(key, value, expiry); + var msg = TryGetLockExtendMessage(key, value, expiry, flags, out var server); + if (msg is not null) return ExecuteSync(msg, ResultProcessor.Boolean, server); + var tran = GetLockExtendTransaction(key, value, expiry); if (tran != null) return tran.Execute(flags); // without transactions (twemproxy etc), we can't enforce the "value" part return KeyExpire(key, expiry, flags); } - public Task LockExtendAsync(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) + private Message? TryGetLockExtendMessage(in RedisKey key, in RedisValue value, TimeSpan expiry, CommandFlags flags, out ServerEndPoint? server, [CallerMemberName] string? caller = null) { if (value.IsNull) throw new ArgumentNullException(nameof(value)); + + // note that lock tokens are expected to be small, so: we'll use IFEQ rather than IFDEQ, for reliability + // note possible future extension:[P]EXPIRE ... IF* https://github.com/redis/redis/issues/14505 + var features = GetFeatures(key, flags, RedisCommand.SET, out server); + return features.SetWithValueCheck + ? GetStringSetMessage(key, value, expiry, ValueCondition.Equal(value), flags, caller) // use check-and-set + : null; + } + + public Task LockExtendAsync(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) + { + var msg = TryGetLockExtendMessage(key, value, expiry, flags, out var server); + if (msg is not null) return ExecuteAsync(msg, ResultProcessor.Boolean, server); + var tran = GetLockExtendTransaction(key, value, expiry); if (tran != null) return tran.ExecuteAsync(flags); @@ -1801,7 +1817,9 @@ public Task LockQueryAsync(RedisKey key, CommandFlags flags = Comman public bool LockRelease(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) { - if (value.IsNull) throw new ArgumentNullException(nameof(value)); + var msg = TryGetLockReleaseMessage(key, value, flags, out var server); + if (msg is not null) return ExecuteSync(msg, ResultProcessor.Boolean, server); + var tran = GetLockReleaseTransaction(key, value); if (tran != null) return tran.Execute(flags); @@ -1809,9 +1827,22 @@ public bool LockRelease(RedisKey key, RedisValue value, CommandFlags flags = Com return KeyDelete(key, flags); } - public Task LockReleaseAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + private Message? TryGetLockReleaseMessage(in RedisKey key, in RedisValue value, CommandFlags flags, out ServerEndPoint? server, [CallerMemberName] string? caller = null) { if (value.IsNull) throw new ArgumentNullException(nameof(value)); + + // note that lock tokens are expected to be small, so: we'll use IFEQ rather than IFDEQ, for reliability + var features = GetFeatures(key, flags, RedisCommand.SET, out server); + return features.DeleteWithValueCheck + ? GetStringDeleteMessage(key, ValueCondition.Equal(value), flags, caller) // use check-and-delete + : null; + } + + public Task LockReleaseAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + { + var msg = TryGetLockReleaseMessage(key, value, flags, out var server); + if (msg is not null) return ExecuteAsync(msg, ResultProcessor.Boolean, server); + var tran = GetLockReleaseTransaction(key, value); if (tran != null) return tran.ExecuteAsync(flags); diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index 9bc9af6d2..0e6b410a9 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -285,6 +285,16 @@ public RedisFeatures(Version version) /// public bool Resp3 => Version.IsAtLeast(v6_0_0); + /// + /// Are the IF* modifiers on SET available? + /// + public bool SetWithValueCheck => Version.IsAtLeast(v8_4_0_rc1); + + /// + /// Are the IF* modifiers on DEL available? + /// + public bool DeleteWithValueCheck => Version.IsAtLeast(v8_4_0_rc1); + #pragma warning restore 1629 // Documentation text should end with a period. /// From 3e3386c035c86ca61b8ed35b14abf198710a756d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 3 Nov 2025 11:48:49 +0000 Subject: [PATCH 08/11] release notes --- docs/ReleaseNotes.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index fa2773519..75c04b1f5 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,6 +8,9 @@ Current package versions: ## Unreleased +- Support Redis 8.4 CAS/CAD operations (`DIGEST`, and the `IFEQ`, `IFNE`, `IFDEQ`, `IFDNE` modifiers on `SET` / `DEL`) + via the new `ValueCondition` abstraction, and use CAS/CAD operations for `Lock*` APIs when possible ([#2978 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2978)) + ## 2.9.32 - Fix `SSUBSCRIBE` routing during slot migrations ([#2969 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2969)) From 59351140e5381f0722b246bbca80cc86a5429ecf Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 3 Nov 2025 12:30:11 +0000 Subject: [PATCH 09/11] remove mitigations for receiving under-length digests from the server (now fixed at server) --- .../ResultProcessor.Digest.cs | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/StackExchange.Redis/ResultProcessor.Digest.cs b/src/StackExchange.Redis/ResultProcessor.Digest.cs index 1b5b00890..757009ea5 100644 --- a/src/StackExchange.Redis/ResultProcessor.Digest.cs +++ b/src/StackExchange.Redis/ResultProcessor.Digest.cs @@ -13,36 +13,28 @@ private sealed class DigestProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.IsNull) + if (result.IsNull) // for example, key doesn't exist { SetResult(message, null); return true; } - switch (result.Resp2TypeBulkString) + if (result.Resp2TypeBulkString == ResultType.BulkString + && result.Payload is { Length: 2 * ValueCondition.DigestBytes } payload) { - case ResultType.BulkString: - var payload = result.Payload; - var len = checked((int)payload.Length); - if (len == 2 * ValueCondition.DigestBytes & payload.IsSingleSegment) - { - // full-size hash in a single chunk - fast path - SetResult(message, ValueCondition.ParseDigest(payload.First.Span)); - return true; - } - - if (len >= 1 & len <= ValueCondition.DigestBytes * 2) - { - // Either multi-segment, or isn't long enough (missing leading zeros, - // see https://github.com/redis/redis/issues/14496). - Span buffer = new byte[2 * ValueCondition.DigestBytes]; - int start = (2 * ValueCondition.DigestBytes) - len; - if (start != 0) buffer.Slice(0, start).Fill((byte)'0'); // pad - payload.CopyTo(buffer.Slice(start)); // linearize - SetResult(message, ValueCondition.ParseDigest(buffer)); - return true; - } - break; + ValueCondition digest; + if (payload.IsSingleSegment) // single chunk - fast path + { + digest = ValueCondition.ParseDigest(payload.First.Span); + } + else // linearize + { + Span buffer = stackalloc byte[2 * ValueCondition.DigestBytes]; + payload.CopyTo(buffer); + digest = ValueCondition.ParseDigest(buffer); + } + SetResult(message, digest); + return true; } return false; } From 1163af867b8623f9c5b7fa28ea32345c3c7363fc Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 3 Nov 2025 12:58:12 +0000 Subject: [PATCH 10/11] add zero-length digest test --- src/StackExchange.Redis/RedisDatabase.cs | 2 +- tests/StackExchange.Redis.Tests/DigestUnitTests.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 3db3294b7..70b894bf0 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -1832,7 +1832,7 @@ public bool LockRelease(RedisKey key, RedisValue value, CommandFlags flags = Com if (value.IsNull) throw new ArgumentNullException(nameof(value)); // note that lock tokens are expected to be small, so: we'll use IFEQ rather than IFDEQ, for reliability - var features = GetFeatures(key, flags, RedisCommand.SET, out server); + var features = GetFeatures(key, flags, RedisCommand.DELEX, out server); return features.DeleteWithValueCheck ? GetStringDeleteMessage(key, ValueCondition.Equal(value), flags, caller) // use check-and-delete : null; diff --git a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs index 916b9a497..9f04342d1 100644 --- a/tests/StackExchange.Redis.Tests/DigestUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs @@ -57,6 +57,8 @@ public void ValueCondition_ParseDigest(string value) [Theory] [InlineData("Hello World", "e34615aade2e6333")] [InlineData("42", "1217cb28c0ef2191")] + [InlineData("", "2d06800538d394c2")] + [InlineData("a", "e6c632b61e964e1f")] public void KnownXxh3Values(string source, string expected) => Assert.Equal(expected, GetXxh3Hex(source)); From f29a4cfa16977a3533ec5e72c4363d31d9c5dd38 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 3 Nov 2025 18:01:15 +0000 Subject: [PATCH 11/11] Update src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs Co-authored-by: Philo --- src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index aebbbb4af..c8d051d3a 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -841,7 +841,7 @@ IAsyncEnumerable SortedSetScanAsync( /// [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] -#pragma warning disable RS0027 +#pragma warning disable RS0027 // Public API with optional parameter(s) should have the most parameters amongst its public overloads Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None); #pragma warning restore RS0027