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 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")] + [InlineData("", "2d06800538d394c2")] + [InlineData("a", "e6c632b61e964e1f")] + 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.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.IsValueTest); + Assert.False(negCondition.IsDigestTest); + Assert.True(negCondition.IsNegated); + Assert.False(negCondition.IsExistenceTest); + + var negNegCondition = !negCondition; + Assert.Equal(condition, negNegCondition); + + var digest = condition.AsDigest(); + Assert.NotEqual(condition, digest); + Assert.Equal($"IFDEQ {GetXxh3Hex(InputValue)}", digest.ToString()); + 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.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.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] + 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()); + } + } +}