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/docs/ReleaseNotes.md b/docs/ReleaseNotes.md
index e6c010116..58de2287b 100644
--- a/docs/ReleaseNotes.md
+++ b/docs/ReleaseNotes.md
@@ -8,6 +8,8 @@ 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))
- Support `XREADGROUP CLAIM` ([#2972 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2972))
- Support `MSETEX` (Redis 8.4.0) for multi-key operations with expiration ([#2977 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2977))
diff --git a/docs/exp/SER002.md b/docs/exp/SER002.md
index 21a2990c6..6e5100a6e 100644
--- a/docs/exp/SER002.md
+++ b/docs/exp/SER002.md
@@ -2,7 +2,7 @@ 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
+- [`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
@@ -10,7 +10,7 @@ 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.
+ 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:
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/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs
index 6138d2609..14f304a35 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,
@@ -300,6 +302,8 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
case RedisCommand.DECR:
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/Experiments.cs b/src/StackExchange.Redis/Experiments.cs
index 9234f9f4e..441b0ec54 100644
--- a/src/StackExchange.Redis/Experiments.cs
+++ b/src/StackExchange.Redis/Experiments.cs
@@ -8,6 +8,7 @@ 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 e15b4bbdb..5556990df 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
@@ -3174,6 +3175,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.
@@ -3186,6 +3197,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.
@@ -3393,6 +3413,20 @@ 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 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, TimeSpan? expiry, 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 6e411cbd3..b35a685a5 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;
@@ -771,9 +772,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);
@@ -833,6 +842,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 // 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
+
///
Task StringSetAsync(KeyValuePair[] values, When when, CommandFlags flags);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
index 378c90704..9119035a3 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
@@ -732,9 +732,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);
@@ -777,6 +783,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, 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 dfb906f32..3c7e34aa9 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
@@ -714,9 +714,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);
@@ -759,6 +765,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, 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
new file mode 100644
index 000000000..c8b5febc4
--- /dev/null
+++ b/src/StackExchange.Redis/Message.ValueCondition.cs
@@ -0,0 +1,71 @@
+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, TimeSpan? expiry, in ValueCondition when)
+ => new KeyValueExpiryConditionMessage(db, flags, command, key, value, expiry, 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 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 + (_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/Message.cs b/src/StackExchange.Redis/Message.cs
index c8d433d17..0eff3ff8d 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 ab058de62..1b8aba3b0 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -1 +1,28 @@
#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, 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, 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
+[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..1323246f9
--- /dev/null
+++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs
@@ -0,0 +1,81 @@
+using System;
+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:
+ case ValueCondition.ConditionKind.Exists:
+ 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, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = GetStringSetMessage(key, value, expiry, when, flags);
+ return ExecuteAsync(msg, ResultProcessor.Boolean);
+ }
+
+ public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, ValueCondition when, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = GetStringSetMessage(key, value, expiry, when, flags);
+ return ExecuteSync(msg, ResultProcessor.Boolean);
+ }
+
+ 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, 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, 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 6f39bf5be..948a9e894 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.DELEX, 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 1218a9f80..0e6b410a9 100644
--- a/src/StackExchange.Redis/RedisFeatures.cs
+++ b/src/StackExchange.Redis/RedisFeatures.cs
@@ -47,7 +47,7 @@ namespace StackExchange.Redis
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_4_0_rc1 = new Version(8, 3, 224); // 8.2 RC1 is version 8.3.224
+ 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
@@ -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.
///
diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs
index da33c803e..d306ca0d0 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.
+ ///
+ internal ValueCondition Digest()
+ {
+ switch (Type)
+ {
+ case StorageType.Raw:
+ return ValueCondition.CalculateDigest(_memory.Span);
+ case StorageType.Null:
+ return ValueCondition.NotExists; // interpret === null as "not exists"
+ default:
+ var len = GetByteCount();
+ byte[]? oversized = null;
+ Span buffer = len <= 128 ? stackalloc byte[128] : (oversized = ArrayPool.Shared.Rent(len));
+ CopyTo(buffer);
+ var digest = ValueCondition.CalculateDigest(buffer.Slice(0, len));
+ if (oversized is not null) ArrayPool.Shared.Return(oversized);
+ return digest;
+ }
+ }
}
}
diff --git a/src/StackExchange.Redis/ResultProcessor.Digest.cs b/src/StackExchange.Redis/ResultProcessor.Digest.cs
new file mode 100644
index 000000000..757009ea5
--- /dev/null
+++ b/src/StackExchange.Redis/ResultProcessor.Digest.cs
@@ -0,0 +1,42 @@
+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) // for example, key doesn't exist
+ {
+ SetResult(message, null);
+ return true;
+ }
+
+ if (result.Resp2TypeBulkString == ResultType.BulkString
+ && result.Payload is { Length: 2 * ValueCondition.DigestBytes } payload)
+ {
+ 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;
+ }
+ }
+}
diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj
index b03103656..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
@@ -19,6 +20,7 @@
+
diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs
new file mode 100644
index 000000000..94e9850c4
--- /dev/null
+++ b/src/StackExchange.Redis/ValueCondition.cs
@@ -0,0 +1,361 @@
+using System;
+using System.Buffers.Binary;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.IO.Hashing;
+using System.Runtime.CompilerServices;
+
+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
+ // 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.
+ internal const int DigestBytes = 8; // XXH3 is 64-bit
+
+ 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 ConditionKind.Exists:
+ return "XX";
+ case ConditionKind.NotExists:
+ return "NX";
+ case ConditionKind.ValueEquals:
+ return $"IFEQ {_value}";
+ case ConditionKind.ValueNotEquals:
+ return $"IFNE {_value}";
+ case ConditionKind.DigestEquals:
+ var written = WriteHex(_value.DirectOverlappedBits64, stackalloc char[2 * DigestBytes]);
+ return $"IFDEQ {written.ToString()}";
+ case ConditionKind.DigestNotEquals:
+ written = WriteHex(_value.DirectOverlappedBits64, stackalloc char[2 * DigestBytes]);
+ return $"IFDNE {written.ToString()}";
+ case ConditionKind.Always:
+ return "";
+ default:
+ return ThrowInvalidOperation().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 comparison test.
+ ///
+ internal bool IsValueTest => _kind is ConditionKind.ValueEquals or ConditionKind.ValueNotEquals;
+
+ ///
+ /// Indicates whether this instance represents a digest test.
+ ///
+ 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 a negative test (not-equals, not-exists, digest-not-equals).
+ ///
+ 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(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
+ 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(ConditionKind.ValueEquals, value);
+
+ ///
+ /// Create a value non-equality condition with the supplied value.
+ ///
+ public static ValueCondition NotEqual(in RedisValue value) => new(ConditionKind.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();
+
+ ///
+ /// Calculate the digest of a payload, as an equality test. For a non-equality test, use on the result.
+ ///
+ 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 = unchecked((long)XxHash3.HashToUInt64(value));
+ return new ValueCondition(ConditionKind.DigestEquals, digest);
+ }
+
+ ///
+ /// Creates an equality match based on the specified digest bytes.
+ ///
+ public static ValueCondition ParseDigest(ReadOnlySpan digest)
+ {
+ 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[DigestBytes];
+ 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(ConditionKind.DigestEquals, digestInt64);
+ }
+
+ private static byte ParseNibble(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");
+ }
+
+ 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 * DigestBytes) ThrowDigestLength();
+
+ // we receive 16 hex characters, as bytes; parse that into a long, by
+ // first dealing with the nibbles
+ Span tmp = stackalloc byte[DigestBytes];
+ 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 big-endian
+ var digestInt64 = BinaryPrimitives.ReadInt64BigEndian(tmp);
+ return new ValueCondition(ConditionKind.DigestEquals, digestInt64);
+
+ 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 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 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 ConditionKind.ValueNotEquals:
+ physical.WriteBulkString("IFNE"u8);
+ physical.WriteBulkString(_value);
+ break;
+ case ConditionKind.DigestEquals:
+ physical.WriteBulkString("IFDEQ"u8);
+ var written = WriteHex(_value.DirectOverlappedBits64, stackalloc byte[2 * DigestBytes]);
+ physical.WriteBulkString(written);
+ break;
+ case ConditionKind.DigestNotEquals:
+ physical.WriteBulkString("IFDNE"u8);
+ written = WriteHex(_value.DirectOverlappedBits64, stackalloc byte[2 * DigestBytes]);
+ physical.WriteBulkString(written);
+ break;
+ }
+ }
+
+ internal static Span WriteHex(long value, Span target)
+ {
+ 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)
+ if (BitConverter.IsLittleEndian)
+ {
+ value = BinaryPrimitives.ReverseEndianness(value);
+ }
+ ref byte ptr = ref Unsafe.As(ref value);
+ int targetOffset = 0;
+ ReadOnlySpan hex = "0123456789abcdef"u8;
+ for (int sourceOffset = 0; sourceOffset < sizeof(long); sourceOffset++)
+ {
+ byte b = Unsafe.Add(ref ptr, sourceOffset);
+ target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble
+ target[targetOffset++] = hex[b & 0xF]; // lo
+ }
+ return target.Slice(0, 2 * DigestBytes);
+ }
+
+ internal static Span WriteHex(long value, Span target)
+ {
+ 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)
+ if (BitConverter.IsLittleEndian)
+ {
+ value = BinaryPrimitives.ReverseEndianness(value);
+ }
+ ref byte ptr = ref Unsafe.As(ref value);
+ int targetOffset = 0;
+ const string hex = "0123456789abcdef";
+ for (int sourceOffset = 0; sourceOffset < sizeof(long); sourceOffset++)
+ {
+ byte b = Unsafe.Add(ref ptr, sourceOffset);
+ target[targetOffset++] = hex[(b >> 4) & 0xF]; // hi nibble
+ target[targetOffset++] = hex[b & 0xF]; // lo
+ }
+ return target.Slice(0, 2 * DigestBytes);
+ }
+
+ ///
+ /// Negate this condition. The nature of the condition is preserved.
+ ///
+ public static ValueCondition operator !(in ValueCondition value) => value._kind switch
+ {
+ 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 a value condition to a digest condition.
+ ///
+ 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
+ {
+ ConditionKind.Always => When.Always,
+ ConditionKind.Exists => When.Exists,
+ ConditionKind.NotExists => When.NotExists,
+ _ => ThrowInvalidOperation().AsWhen(),
+ };
+}
diff --git a/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs
new file mode 100644
index 000000000..a71e2f910
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/DigestIntegrationTests.cs
@@ -0,0 +1,157 @@
+using System;
+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;
+ }
+
+ [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 00006c38adf31777", 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
new file mode 100644
index 000000000..9f04342d1
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/DigestUnitTests.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO.Hashing;
+using System.Text;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+#pragma warning disable SER002 // 8.4
+
+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.Equal(ValueCondition.ConditionKind.DigestEquals, digest.Kind);
+
+ Assert.Equal($"IFDEQ {hashHex}", digest.ToString());
+ }
+
+ public static IEnumerable