diff --git a/Directory.Build.props b/Directory.Build.props index 42de5875c..9f10eddcd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,7 @@ true $(MSBuildThisFileDirectory)Shared.ruleset NETSDK1069 - $(NoWarn);NU5105;NU1507;SER001 + $(NoWarn);NU5105;NU1507;SER001;SER002 https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes https://stackexchange.github.io/StackExchange.Redis/ MIT diff --git a/StackExchange.Redis.sln.DotSettings b/StackExchange.Redis.sln.DotSettings index b72a49d2c..216edbcca 100644 --- a/StackExchange.Redis.sln.DotSettings +++ b/StackExchange.Redis.sln.DotSettings @@ -1,5 +1,26 @@  OK PONG + True + True + True + True + True + True + True + True + True + True + True True - True \ No newline at end of file + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index fa2773519..d11ddb7fd 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,6 +8,8 @@ Current package versions: ## Unreleased +- Support `MSETEX` (Redis 8.4.0) for multi-key operations with expiration ([#2977 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2977)) + ## 2.9.32 - Fix `SSUBSCRIBE` routing during slot migrations ([#2969 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2969)) diff --git a/docs/exp/SER002.md b/docs/exp/SER002.md new file mode 100644 index 000000000..21a2990c6 --- /dev/null +++ b/docs/exp/SER002.md @@ -0,0 +1,26 @@ +Redis 8.4 is currently in preview and may be subject to change. + +New features in Redis 8.4 include: + +- [`MSETEX`](https://github.com/redis/redis/pull/14434) for setting multiple strings with expiry +- [`XREADGROUP ... CLAIM`](https://github.com/redis/redis/pull/14402) for simplifed stream consumption +- [`SET ... {IFEQ|IFNE|IFDEQ|IFDNE}`, `DELEX` and `DIGEST`](https://github.com/redis/redis/pull/14434) for checked (CAS/CAD) string operations + +The corresponding library feature must also be considered subject to change: + +1. Existing bindings may cease working correctly if the underlying server API changes. +2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time + or run-time breaks. + +While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress +this warning by adding the following to your `csproj` file: + +```xml +$(NoWarn);SER002 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER002 +``` \ No newline at end of file diff --git a/src/StackExchange.Redis/CommandMap.cs b/src/StackExchange.Redis/CommandMap.cs index 1f31d3224..663c61b36 100644 --- a/src/StackExchange.Redis/CommandMap.cs +++ b/src/StackExchange.Redis/CommandMap.cs @@ -27,7 +27,7 @@ public sealed class CommandMap RedisCommand.KEYS, RedisCommand.MIGRATE, RedisCommand.MOVE, RedisCommand.OBJECT, RedisCommand.RANDOMKEY, RedisCommand.RENAME, RedisCommand.RENAMENX, RedisCommand.SCAN, - RedisCommand.BITOP, RedisCommand.MSETNX, + RedisCommand.BITOP, RedisCommand.MSETEX, RedisCommand.MSETNX, RedisCommand.BLPOP, RedisCommand.BRPOP, RedisCommand.BRPOPLPUSH, // yeah, me neither! @@ -53,7 +53,7 @@ public sealed class CommandMap RedisCommand.KEYS, RedisCommand.MIGRATE, RedisCommand.MOVE, RedisCommand.OBJECT, RedisCommand.RANDOMKEY, RedisCommand.RENAME, RedisCommand.RENAMENX, RedisCommand.SORT, RedisCommand.SCAN, - RedisCommand.BITOP, RedisCommand.MSETNX, + RedisCommand.BITOP, RedisCommand.MSETEX, RedisCommand.MSETNX, RedisCommand.BLPOP, RedisCommand.BRPOP, RedisCommand.BRPOPLPUSH, // yeah, me neither! diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 7a0c2f08d..6138d2609 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -122,6 +122,7 @@ internal enum RedisCommand MONITOR, MOVE, MSET, + MSETEX, MSETNX, MULTI, @@ -336,6 +337,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.MIGRATE: case RedisCommand.MOVE: case RedisCommand.MSET: + case RedisCommand.MSETEX: case RedisCommand.MSETNX: case RedisCommand.PERSIST: case RedisCommand.PEXPIRE: diff --git a/src/StackExchange.Redis/Experiments.cs b/src/StackExchange.Redis/Experiments.cs index 577c9f8c9..9234f9f4e 100644 --- a/src/StackExchange.Redis/Experiments.cs +++ b/src/StackExchange.Redis/Experiments.cs @@ -9,6 +9,8 @@ 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/Expiration.cs b/src/StackExchange.Redis/Expiration.cs new file mode 100644 index 000000000..786cc928c --- /dev/null +++ b/src/StackExchange.Redis/Expiration.cs @@ -0,0 +1,273 @@ +using System; + +namespace StackExchange.Redis; + +/// +/// Configures the expiration behaviour of a command. +/// +public readonly struct Expiration +{ + /* + Redis expiration supports different modes: + - (nothing) - do nothing; implicit wipe for writes, nothing for reads + - PERSIST - explicit wipe of expiry + - KEEPTTL - sets no expiry, but leaves any existing expiry alone + - EX {s} - relative expiry in seconds + - PX {ms} - relative expiry in milliseconds + - EXAT {s} - absolute expiry in seconds + - PXAT {ms} - absolute expiry in milliseconds + + We need to distinguish between these 6 scenarios, which we can logically do with 3 bits (8 options). + So; we'll use a ulong for the value, reserving the top 3 bits for the mode. + */ + + /// + /// Default expiration behaviour. For writes, this is typically no expiration. For reads, this is typically no action. + /// + public static Expiration Default => s_Default; + + /// + /// Explicitly retain the existing expiry, if one. This is valid in some (not all) write scenarios. + /// + public static Expiration KeepTtl => s_KeepTtl; + + /// + /// Explicitly remove the existing expiry, if one. This is valid in some (not all) read scenarios. + /// + public static Expiration Persist => s_Persist; + + /// + /// Expire at the specified absolute time. + /// + public Expiration(DateTime when) + { + if (when == DateTime.MaxValue) + { + _valueAndMode = s_Default._valueAndMode; + return; + } + + long millis = GetUnixTimeMilliseconds(when); + if ((millis % 1000) == 0) + { + Init(ExpirationMode.AbsoluteSeconds, millis / 1000, out _valueAndMode); + } + else + { + Init(ExpirationMode.AbsoluteMilliseconds, millis, out _valueAndMode); + } + } + + /// + /// Expire at the specified absolute time. + /// + public static implicit operator Expiration(DateTime when) => new(when); + + /// + /// Expire at the specified absolute time. + /// + public static implicit operator Expiration(TimeSpan ttl) => new(ttl); + + /// + /// Expire at the specified relative time. + /// + public Expiration(TimeSpan ttl) + { + if (ttl == TimeSpan.MaxValue) + { + _valueAndMode = s_Default._valueAndMode; + return; + } + + var millis = ttl.Ticks / TimeSpan.TicksPerMillisecond; + if ((millis % 1000) == 0) + { + Init(ExpirationMode.RelativeSeconds, millis / 1000, out _valueAndMode); + } + else + { + Init(ExpirationMode.RelativeMilliseconds, millis, out _valueAndMode); + } + } + + private readonly ulong _valueAndMode; + + private static void Init(ExpirationMode mode, long value, out ulong valueAndMode) + { + // check the caller isn't using the top 3 bits that we have reserved; this includes checking for -ve values + ulong uValue = (ulong)value; + if ((uValue & ~ValueMask) != 0) Throw(); + valueAndMode = (uValue & ValueMask) | ((ulong)mode << 61); + static void Throw() => throw new ArgumentOutOfRangeException(nameof(value)); + } + + private Expiration(ExpirationMode mode, long value) => Init(mode, value, out _valueAndMode); + + private enum ExpirationMode : byte + { + Default = 0, + RelativeSeconds = 1, + RelativeMilliseconds = 2, + AbsoluteSeconds = 3, + AbsoluteMilliseconds = 4, + KeepTtl = 5, + Persist = 6, + NotUsed = 7, // just to ensure all 8 possible values are covered + } + + private const ulong ValueMask = (~0UL) >> 3; + internal long Value => unchecked((long)(_valueAndMode & ValueMask)); + private ExpirationMode Mode => (ExpirationMode)(_valueAndMode >> 61); // note unsigned, no need to mask + + internal bool IsKeepTtl => Mode is ExpirationMode.KeepTtl; + internal bool IsPersist => Mode is ExpirationMode.Persist; + internal bool IsNone => Mode is ExpirationMode.Default; + internal bool IsNoneOrKeepTtl => Mode is ExpirationMode.Default or ExpirationMode.KeepTtl; + internal bool IsAbsolute => Mode is ExpirationMode.AbsoluteSeconds or ExpirationMode.AbsoluteMilliseconds; + internal bool IsRelative => Mode is ExpirationMode.RelativeSeconds or ExpirationMode.RelativeMilliseconds; + + internal bool IsMilliseconds => + Mode is ExpirationMode.RelativeMilliseconds or ExpirationMode.AbsoluteMilliseconds; + + internal bool IsSeconds => Mode is ExpirationMode.RelativeSeconds or ExpirationMode.AbsoluteSeconds; + + private static readonly Expiration s_Default = new(ExpirationMode.Default, 0); + + private static readonly Expiration s_KeepTtl = new(ExpirationMode.KeepTtl, 0), + s_Persist = new(ExpirationMode.Persist, 0); + + private static void ThrowExpiryAndKeepTtl() => + // ReSharper disable once NotResolvedInText + throw new ArgumentException(message: "Cannot specify both expiry and keepTtl.", paramName: "keepTtl"); + + private static void ThrowExpiryAndPersist() => + // ReSharper disable once NotResolvedInText + throw new ArgumentException(message: "Cannot specify both expiry and persist.", paramName: "persist"); + + internal static Expiration CreateOrPersist(in TimeSpan? ttl, bool persist) + { + if (persist) + { + if (ttl.HasValue) ThrowExpiryAndPersist(); + return s_Persist; + } + + return ttl.HasValue ? new(ttl.GetValueOrDefault()) : s_Default; + } + + internal static Expiration CreateOrKeepTtl(in TimeSpan? ttl, bool keepTtl) + { + if (keepTtl) + { + if (ttl.HasValue) ThrowExpiryAndKeepTtl(); + return s_KeepTtl; + } + + return ttl.HasValue ? new(ttl.GetValueOrDefault()) : s_Default; + } + + internal static long GetUnixTimeMilliseconds(DateTime when) + { + return when.Kind switch + { + DateTimeKind.Local or DateTimeKind.Utc => (when.ToUniversalTime() - RedisBase.UnixEpoch).Ticks / + TimeSpan.TicksPerMillisecond, + _ => ThrowKind(), + }; + + static long ThrowKind() => + throw new ArgumentException("Expiry time must be either Utc or Local", nameof(when)); + } + + internal static Expiration CreateOrPersist(in DateTime? when, bool persist) + { + if (persist) + { + if (when.HasValue) ThrowExpiryAndPersist(); + return s_Persist; + } + + return when.HasValue ? new(when.GetValueOrDefault()) : s_Default; + } + + internal static Expiration CreateOrKeepTtl(in DateTime? ttl, bool keepTtl) + { + if (keepTtl) + { + if (ttl.HasValue) ThrowExpiryAndKeepTtl(); + return s_KeepTtl; + } + + return ttl.HasValue ? new(ttl.GetValueOrDefault()) : s_Default; + } + + internal RedisValue Operand => GetOperand(out _); + + internal RedisValue GetOperand(out long value) + { + value = Value; + var mode = Mode; + return mode switch + { + ExpirationMode.KeepTtl => RedisLiterals.KEEPTTL, + ExpirationMode.Persist => RedisLiterals.PERSIST, + ExpirationMode.RelativeSeconds => RedisLiterals.EX, + ExpirationMode.RelativeMilliseconds => RedisLiterals.PX, + ExpirationMode.AbsoluteSeconds => RedisLiterals.EXAT, + ExpirationMode.AbsoluteMilliseconds => RedisLiterals.PXAT, + _ => RedisValue.Null, + }; + } + + private static void ThrowMode(ExpirationMode mode) => + throw new InvalidOperationException("Unknown mode: " + mode); + + /// + public override string ToString() => Mode switch + { + ExpirationMode.Default or ExpirationMode.NotUsed => "", + ExpirationMode.KeepTtl => "KEEPTTL", + ExpirationMode.Persist => "PERSIST", + _ => $"{Operand} {Value}", + }; + + /// + public override int GetHashCode() => _valueAndMode.GetHashCode(); + + /// + public override bool Equals(object? obj) => obj is Expiration other && _valueAndMode == other._valueAndMode; + + internal int Tokens => Mode switch + { + ExpirationMode.Default or ExpirationMode.NotUsed => 0, + ExpirationMode.KeepTtl or ExpirationMode.Persist => 1, + _ => 2, + }; + + internal void WriteTo(PhysicalConnection physical) + { + var mode = Mode; + switch (Mode) + { + case ExpirationMode.Default or ExpirationMode.NotUsed: + break; + case ExpirationMode.KeepTtl: + physical.WriteBulkString("KEEPTTL"u8); + break; + case ExpirationMode.Persist: + physical.WriteBulkString("PERSIST"u8); + break; + default: + physical.WriteBulkString(mode switch + { + ExpirationMode.RelativeSeconds => "EX"u8, + ExpirationMode.RelativeMilliseconds => "PX"u8, + ExpirationMode.AbsoluteSeconds => "EXAT"u8, + ExpirationMode.AbsoluteMilliseconds => "PXAT"u8, + _ => default, + }); + physical.WriteBulkString(Value); + break; + } + } +} diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 6c52e89bd..fd4fb3e30 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -95,7 +95,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// /// Removes the specified member from the geo sorted set stored at key. - /// Non existing members are ignored. + /// Non-existing members are ignored. /// /// The key of the set. /// The geo value to remove. @@ -144,7 +144,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The flags to use for this operation. /// /// The command returns an array where each element is a two elements array representing longitude and latitude (x,y) of each member name passed as argument to the command. - /// Non existing elements are reported as NULL elements of the array. + /// Non-existing elements are reported as NULL elements of the array. /// /// GeoPosition?[] GeoPosition(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); @@ -157,7 +157,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The flags to use for this operation. /// /// The command returns an array where each element is a two elements array representing longitude and latitude (x,y) of each member name passed as argument to the command. - /// Non existing elements are reported as NULL elements of the array. + /// Non-existing elements are reported as NULL elements of the array. /// /// GeoPosition? GeoPosition(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); @@ -203,7 +203,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The set member to use as the center of the shape. /// The shape to use to bound the geo search. /// The maximum number of results to pull back. - /// Whether or not to terminate the search after finding results. Must be true of count is -1. + /// Whether to terminate the search after finding results. Must be true of count is -1. /// The order to sort by (defaults to unordered). /// The search options to use. /// The flags for this operation. @@ -220,7 +220,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The latitude of the center point. /// The shape to use to bound the geo search. /// The maximum number of results to pull back. - /// Whether or not to terminate the search after finding results. Must be true of count is -1. + /// Whether to terminate the search after finding results. Must be true of count is -1. /// The order to sort by (defaults to unordered). /// The search options to use. /// The flags for this operation. @@ -237,7 +237,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The set member to use as the center of the shape. /// The shape to use to bound the geo search. /// The maximum number of results to pull back. - /// Whether or not to terminate the search after finding results. Must be true of count is -1. + /// Whether to terminate the search after finding results. Must be true of count is -1. /// The order to sort by (defaults to unordered). /// If set to true, the resulting set will be a regular sorted-set containing only distances, rather than a geo-encoded sorted-set. /// The flags for this operation. @@ -255,7 +255,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The latitude of the center point. /// The shape to use to bound the geo search. /// The maximum number of results to pull back. - /// Whether or not to terminate the search after finding results. Must be true of count is -1. + /// Whether to terminate the search after finding results. Must be true of count is -1. /// The order to sort by (defaults to unordered). /// If set to true, the resulting set will be a regular sorted-set containing only distances, rather than a geo-encoded sorted-set. /// The flags for this operation. @@ -274,13 +274,13 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The flags to use for this operation. /// The value at field after the decrement operation. /// - /// The range of values supported by HINCRBY is limited to 64 bit signed integers. + /// The range of values supported by HINCRBY is limited to 64-bit signed integers. /// /// long HashDecrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None); /// - /// Decrement the specified field of an hash stored at key, and representing a floating point number, by the specified decrement. + /// Decrement the specified field of a hash stored at key, and representing a floating point number, by the specified decrement. /// If the field does not exist, it is set to 0 before performing the operation. /// /// The key of the hash. @@ -336,7 +336,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// under which condition the expiration will be set using . /// The flags to use for this operation. /// - /// Empty array if the key does not exist. Otherwise returns an array where each item is the result of operation for given fields: + /// Empty array if the key does not exist. Otherwise, returns an array where each item is the result of operation for given fields: /// /// /// Result @@ -363,7 +363,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); /// - /// Set the time out on a field of the given set of fields of hash. + /// Set the time-out on a field of the given set of fields of hash. /// After the timeout has expired, the field of the hash will automatically be deleted. /// /// The key of the hash. @@ -372,7 +372,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// under which condition the expiration will be set using . /// The flags to use for this operation. /// - /// Empty array if the key does not exist. Otherwise returns an array where each item is the result of operation for given fields: + /// Empty array if the key does not exist. Otherwise, returns an array where each item is the result of operation for given fields: /// /// /// Result @@ -405,7 +405,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The fields in the hash to get expire time. /// The flags to use for this operation. /// - /// Empty array if the key does not exist. Otherwise returns the result of operation for given fields: + /// Empty array if the key does not exist. Otherwise, returns the result of operation for given fields: /// /// /// Result @@ -434,7 +434,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The fields in the hash to remove expire time. /// The flags to use for this operation. /// - /// Empty array if the key does not exist. Otherwise returns the result of operation for given fields: + /// Empty array if the key does not exist. Otherwise, returns the result of operation for given fields: /// /// /// Result @@ -463,7 +463,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The fields in the hash to get expire time. /// The flags to use for this operation. /// - /// Empty array if the key does not exist. Otherwise returns the result of operation for given fields: + /// Empty array if the key does not exist. Otherwise, returns the result of operation for given fields: /// /// /// Result @@ -680,13 +680,13 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// The flags to use for this operation. /// The value at field after the increment operation. /// - /// The range of values supported by HINCRBY is limited to 64 bit signed integers. + /// The range of values supported by HINCRBY is limited to 64-bit signed integers. /// /// long HashIncrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None); /// - /// Increment the specified field of an hash stored at key, and representing a floating point number, by the specified increment. + /// Increment the specified field of a hash stored at key, and representing a floating point number, by the specified increment. /// If the field does not exist, it is set to 0 before performing the operation. /// /// The key of the hash. @@ -810,7 +810,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// if field is a new field in the hash and value was set, if field already exists in the hash and the value was updated. /// /// See - /// , + /// and /// . /// bool HashSet(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None); @@ -873,7 +873,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync long HyperLogLogLength(RedisKey[] keys, CommandFlags flags = CommandFlags.None); /// - /// Merge multiple HyperLogLog values into an unique value that will approximate the cardinality of the union of the observed Sets of the source HyperLogLog structures. + /// Merge multiple HyperLogLog values into a unique value that will approximate the cardinality of the union of the observed Sets of the source HyperLogLog structures. /// /// The key of the merged hyperloglog. /// The key of the first hyperloglog to merge. @@ -883,7 +883,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync void HyperLogLogMerge(RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None); /// - /// Merge multiple HyperLogLog values into an unique value that will approximate the cardinality of the union of the observed Sets of the source HyperLogLog structures. + /// Merge multiple HyperLogLog values into a unique value that will approximate the cardinality of the union of the observed Sets of the source HyperLogLog structures. /// /// The key of the merged hyperloglog. /// The keys of the hyperloglogs to merge. @@ -920,7 +920,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// if the key was removed. /// /// See - /// , + /// and /// . /// bool KeyDelete(RedisKey key, CommandFlags flags = CommandFlags.None); @@ -1045,8 +1045,8 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// /// /// See - /// , - /// , + /// or + /// or /// . /// /// @@ -1116,7 +1116,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync bool KeyMove(RedisKey key, int database, CommandFlags flags = CommandFlags.None); /// - /// Remove the existing timeout on key, turning the key from volatile (a key with an expire set) to persistent (a key that will never expire as no timeout is associated). + /// Remove the existing timeout on key, turning the key from volatile (a key with an expiry set) to persistent (a key that will never expire as no timeout is associated). /// /// The key to persist. /// The flags to use for this operation. @@ -3314,8 +3314,8 @@ IEnumerable SortedSetScan( /// /// Implements the longest common subsequence algorithm between the values at and , - /// returning the legnth of the common sequence. - /// Note that this is different than the longest common string algorithm, + /// returning the length of the common sequence. + /// Note that this is different to the longest common string algorithm, /// since matching characters in the string does not need to be contiguous. /// /// The key of the first string. @@ -3372,8 +3372,26 @@ IEnumerable SortedSetScan( /// See /// , /// . + /// . + /// + bool StringSet(KeyValuePair[] values, When when, CommandFlags flags); + + /// + /// Sets the given keys to their respective values, optionally including expiration. + /// If is specified, this will not perform any operation at all even if just a single key already exists. + /// + /// The keys and values to set. + /// Which condition to set the value under (defaults to always). + /// The expiry to set. + /// The flags to use for this operation. + /// if the keys were set, otherwise. + /// + /// See + /// , + /// . + /// . /// - bool StringSet(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None); + bool StringSet(KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None); /// /// Atomically sets key to value and returns the previous value (if any) stored at . diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 0bc7b4867..6515740af 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -831,7 +831,10 @@ IAsyncEnumerable SortedSetScanAsync( Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); /// - Task StringSetAsync(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None); + Task StringSetAsync(KeyValuePair[] values, When when, CommandFlags flags); + + /// + Task StringSetAsync(KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None); /// Task StringSetAndGetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 61a6f44c4..1651d1069 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -774,6 +774,9 @@ public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFl public Task StringSetAsync(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) => Inner.StringSetAsync(ToInner(values), when, flags); + public Task StringSetAsync(KeyValuePair[] values, When when, Expiration expiry, CommandFlags flags) => + Inner.StringSetAsync(ToInner(values), when, expiry, flags); + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when) => Inner.StringSetAsync(ToInner(key), value, expiry, when); public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) => diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 2a139694e..3965625f9 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -756,6 +756,9 @@ public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) = public bool StringSet(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) => Inner.StringSet(ToInner(values), when, flags); + public bool StringSet(KeyValuePair[] values, When when, Expiration expiry, CommandFlags flags) => + Inner.StringSet(ToInner(values), when, expiry, flags); + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when) => Inner.StringSet(ToInner(key), value, expiry, when); public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) => diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 0c9eb4c92..c8d433d17 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -391,6 +391,9 @@ public static Message Create( public static Message CreateInSlot(int db, int slot, CommandFlags flags, RedisCommand command, RedisValue[] values) => new CommandSlotValuesMessage(db, slot, flags, command, values); + public static Message Create(int db, CommandFlags flags, RedisCommand command, KeyValuePair[] values, Expiration expiry, When when) + => new MultiSetMessage(db, flags, command, values, expiry, when); + /// Gets whether this is primary-only. /// /// Note that the constructor runs the switch statement above, so @@ -842,13 +845,13 @@ protected override void WriteImpl(PhysicalConnection physical) physical.WriteBulkString(_protocolVersion); if (!string.IsNullOrWhiteSpace(_password)) { - physical.WriteBulkString(RedisLiterals.AUTH); + physical.WriteBulkString("AUTH"u8); physical.WriteBulkString(string.IsNullOrWhiteSpace(_username) ? RedisLiterals.@default : _username); physical.WriteBulkString(_password); } if (!string.IsNullOrWhiteSpace(_clientName)) { - physical.WriteBulkString(RedisLiterals.SETNAME); + physical.WriteBulkString("SETNAME"u8); physical.WriteBulkString(_clientName); } } @@ -1691,6 +1694,55 @@ protected override void WriteImpl(PhysicalConnection physical) public override int ArgCount => values.Length; } + private sealed class MultiSetMessage(int db, CommandFlags flags, RedisCommand command, KeyValuePair[] values, Expiration expiry, When when) : Message(db, flags, command) + { + public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) + { + int slot = ServerSelectionStrategy.NoSlot; + for (int i = 0; i < values.Length; i++) + { + slot = serverSelectionStrategy.CombineSlot(slot, values[i].Key); + } + return slot; + } + + // we support: + // - MSET {key1} {value1} [{key2} {value2}...] + // - MSETNX {key1} {value1} [{key2} {value2}...] + // - MSETEX {count} {key1} {value1} [{key2} {value2}...] [standard-expiry-tokens] + public override int ArgCount => Command == RedisCommand.MSETEX + ? (1 + (2 * values.Length) + expiry.Tokens + (when is When.Exists or When.NotExists ? 1 : 0)) + : (2 * values.Length); // MSET/MSETNX only support simple syntax + + protected override void WriteImpl(PhysicalConnection physical) + { + var cmd = Command; + physical.WriteHeader(cmd, ArgCount); + if (cmd == RedisCommand.MSETEX) // need count prefix + { + physical.WriteBulkString(values.Length); + } + for (int i = 0; i < values.Length; i++) + { + physical.Write(values[i].Key); + physical.WriteBulkString(values[i].Value); + } + if (cmd == RedisCommand.MSETEX) // allow expiry/mode tokens + { + expiry.WriteTo(physical); + switch (when) + { + case When.Exists: + physical.WriteBulkString("XX"u8); + break; + case When.NotExists: + physical.WriteBulkString("NX"u8); + break; + } + } + } + } + private sealed class CommandValueChannelMessage : CommandChannelBase { private readonly RedisValue value; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 0abb20043..ba60cdc8c 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -779,7 +779,7 @@ StackExchange.Redis.IDatabase.StringLongestCommonSubsequenceWithMatches(StackExc StackExchange.Redis.IDatabase.StringSet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool StackExchange.Redis.IDatabase.StringSet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry, StackExchange.Redis.When when) -> bool StackExchange.Redis.IDatabase.StringSet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry, StackExchange.Redis.When when, StackExchange.Redis.CommandFlags flags) -> bool -StackExchange.Redis.IDatabase.StringSet(System.Collections.Generic.KeyValuePair[]! values, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +StackExchange.Redis.IDatabase.StringSet(System.Collections.Generic.KeyValuePair[]! values, StackExchange.Redis.When when, StackExchange.Redis.CommandFlags flags) -> bool StackExchange.Redis.IDatabase.StringSetAndGet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue StackExchange.Redis.IDatabase.StringSetAndGet(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry, StackExchange.Redis.When when, StackExchange.Redis.CommandFlags flags) -> StackExchange.Redis.RedisValue StackExchange.Redis.IDatabase.StringSetBit(StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool @@ -1026,7 +1026,7 @@ StackExchange.Redis.IDatabaseAsync.StringSetAndGetAsync(StackExchange.Redis.Redi StackExchange.Redis.IDatabaseAsync.StringSetAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringSetAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry, StackExchange.Redis.When when) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringSetAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry, StackExchange.Redis.When when, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabaseAsync.StringSetAsync(System.Collections.Generic.KeyValuePair[]! values, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.StringSetAsync(System.Collections.Generic.KeyValuePair[]! values, StackExchange.Redis.When when, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringSetBitAsync(StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringSetRangeAsync(StackExchange.Redis.RedisKey key, long offset, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.InternalErrorEventArgs @@ -2052,3 +2052,17 @@ StackExchange.Redis.IServer.ExecuteAsync(int? database, string! command, System. [SER001]static StackExchange.Redis.VectorSetSimilaritySearchRequest.ByMember(StackExchange.Redis.RedisValue member) -> StackExchange.Redis.VectorSetSimilaritySearchRequest! [SER001]static StackExchange.Redis.VectorSetSimilaritySearchRequest.ByVector(System.ReadOnlyMemory vector) -> StackExchange.Redis.VectorSetSimilaritySearchRequest! StackExchange.Redis.RedisChannel.WithKeyRouting() -> StackExchange.Redis.RedisChannel +StackExchange.Redis.IDatabase.StringSet(System.Collections.Generic.KeyValuePair[]! values, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +StackExchange.Redis.IDatabaseAsync.StringSetAsync(System.Collections.Generic.KeyValuePair[]! values, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.Expiration +StackExchange.Redis.Expiration.Expiration() -> void +StackExchange.Redis.Expiration.Expiration(System.DateTime when) -> void +StackExchange.Redis.Expiration.Expiration(System.TimeSpan ttl) -> void +override StackExchange.Redis.Expiration.Equals(object? obj) -> bool +override StackExchange.Redis.Expiration.GetHashCode() -> int +override StackExchange.Redis.Expiration.ToString() -> string! +static StackExchange.Redis.Expiration.Default.get -> StackExchange.Redis.Expiration +static StackExchange.Redis.Expiration.KeepTtl.get -> StackExchange.Redis.Expiration +static StackExchange.Redis.Expiration.Persist.get -> StackExchange.Redis.Expiration +static StackExchange.Redis.Expiration.implicit operator StackExchange.Redis.Expiration(System.DateTime when) -> StackExchange.Redis.Expiration +static StackExchange.Redis.Expiration.implicit operator StackExchange.Redis.Expiration(System.TimeSpan ttl) -> StackExchange.Redis.Expiration diff --git a/src/StackExchange.Redis/RedisDatabase.ExpiryToken.cs b/src/StackExchange.Redis/RedisDatabase.ExpiryToken.cs deleted file mode 100644 index 42cfdcb18..000000000 --- a/src/StackExchange.Redis/RedisDatabase.ExpiryToken.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; - -namespace StackExchange.Redis; - -internal partial class RedisDatabase -{ - /// - /// Parses, validates and represents, for example: "EX 10", "KEEPTTL" or "". - /// - internal readonly struct ExpiryToken - { - private static readonly ExpiryToken s_Persist = new(RedisLiterals.PERSIST), s_KeepTtl = new(RedisLiterals.KEEPTTL), s_Null = new(RedisValue.Null); - - public RedisValue Operand { get; } - public long Value { get; } - public int Tokens => Value == long.MinValue ? (Operand.IsNull ? 0 : 1) : 2; - public bool HasValue => Value != long.MinValue; - public bool HasOperand => !Operand.IsNull; - - public static ExpiryToken Persist(TimeSpan? expiry, bool persist) - { - if (expiry.HasValue) - { - if (persist) throw new ArgumentException("Cannot specify both expiry and persist", nameof(persist)); - return new(expiry.GetValueOrDefault()); // EX 10 - } - - return persist ? s_Persist : s_Null; // PERSIST (or nothing) - } - - public static ExpiryToken KeepTtl(TimeSpan? expiry, bool keepTtl) - { - if (expiry.HasValue) - { - if (keepTtl) throw new ArgumentException("Cannot specify both expiry and keepTtl", nameof(keepTtl)); - return new(expiry.GetValueOrDefault()); // EX 10 - } - - return keepTtl ? s_KeepTtl : s_Null; // KEEPTTL (or nothing) - } - - private ExpiryToken(RedisValue operand, long value = long.MinValue) - { - Operand = operand; - Value = value; - } - - public ExpiryToken(TimeSpan expiry) - { - long milliseconds = expiry.Ticks / TimeSpan.TicksPerMillisecond; - var useSeconds = milliseconds % 1000 == 0; - - Operand = useSeconds ? RedisLiterals.EX : RedisLiterals.PX; - Value = useSeconds ? (milliseconds / 1000) : milliseconds; - } - - public ExpiryToken(DateTime expiry) - { - long milliseconds = GetUnixTimeMilliseconds(expiry); - var useSeconds = milliseconds % 1000 == 0; - - Operand = useSeconds ? RedisLiterals.EXAT : RedisLiterals.PXAT; - Value = useSeconds ? (milliseconds / 1000) : milliseconds; - } - - public override string ToString() => Tokens switch - { - 2 => $"{Operand} {Value}", - 1 => Operand.ToString(), - _ => "", - }; - - public override int GetHashCode() => throw new NotSupportedException(); - public override bool Equals(object? obj) => throw new NotSupportedException(); - } -} diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index bcda4146b..f0a4ed39f 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -396,7 +396,7 @@ public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, Tim public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) { - long milliseconds = GetUnixTimeMilliseconds(expiry); + long milliseconds = Expiration.GetUnixTimeMilliseconds(expiry); return HashFieldExpireExecute(key, milliseconds, when, PickExpireAtCommandByPrecision, SyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, hashFields); } @@ -408,7 +408,7 @@ public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hash public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) { - long milliseconds = GetUnixTimeMilliseconds(expiry); + long milliseconds = Expiration.GetUnixTimeMilliseconds(expiry); return HashFieldExpireExecute(key, milliseconds, when, PickExpireAtCommandByPrecision, AsyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, hashFields); } @@ -487,7 +487,7 @@ public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue[] return ExecuteAsync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); } - private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, in RedisValue hashField, ExpiryToken expiry, CommandFlags flags) => + private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, in RedisValue hashField, Expiration expiry, CommandFlags flags) => expiry.Tokens switch { // expiry, for example EX 10 @@ -498,7 +498,7 @@ private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, in RedisValue h _ => Message.Create(Database, flags, RedisCommand.HGETEX, key, RedisLiterals.FIELDS, 1, hashField), }; - private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, RedisValue[] hashFields, ExpiryToken expiry, CommandFlags flags) + private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, RedisValue[] hashFields, Expiration expiry, CommandFlags flags) { if (hashFields is null) throw new ArgumentNullException(nameof(hashFields)); if (hashFields.Length == 1) @@ -537,7 +537,7 @@ private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, RedisValue[] ha public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) { - var msg = HashFieldGetAndSetExpiryMessage(key, hashField, ExpiryToken.Persist(expiry, persist), flags); + var msg = HashFieldGetAndSetExpiryMessage(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags); return ExecuteSync(msg, ResultProcessor.RedisValueFromArray); } @@ -549,7 +549,7 @@ public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, D public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) { - var msg = HashFieldGetAndSetExpiryMessage(key, hashField, ExpiryToken.Persist(expiry, persist), flags); + var msg = HashFieldGetAndSetExpiryMessage(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags); return ExecuteSync(msg, ResultProcessor.LeaseFromArray); } @@ -563,7 +563,7 @@ public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFiel { if (hashFields == null) throw new ArgumentNullException(nameof(hashFields)); if (hashFields.Length == 0) return Array.Empty(); - var msg = HashFieldGetAndSetExpiryMessage(key, hashFields, ExpiryToken.Persist(expiry, persist), flags); + var msg = HashFieldGetAndSetExpiryMessage(key, hashFields, Expiration.CreateOrPersist(expiry, persist), flags); return ExecuteSync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); } @@ -577,7 +577,7 @@ public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFiel public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) { - var msg = HashFieldGetAndSetExpiryMessage(key, hashField, ExpiryToken.Persist(expiry, persist), flags); + var msg = HashFieldGetAndSetExpiryMessage(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags); return ExecuteAsync(msg, ResultProcessor.RedisValueFromArray); } @@ -589,7 +589,7 @@ public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue h public Task?> HashFieldGetLeaseAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) { - var msg = HashFieldGetAndSetExpiryMessage(key, hashField, ExpiryToken.Persist(expiry, persist), flags); + var msg = HashFieldGetAndSetExpiryMessage(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags); return ExecuteAsync(msg, ResultProcessor.LeaseFromArray); } @@ -603,7 +603,7 @@ public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue { if (hashFields == null) throw new ArgumentNullException(nameof(hashFields)); if (hashFields.Length == 0) return CompletedTask.FromDefault(Array.Empty(), asyncState); - var msg = HashFieldGetAndSetExpiryMessage(key, hashFields, ExpiryToken.Persist(expiry, persist), flags); + var msg = HashFieldGetAndSetExpiryMessage(key, hashFields, Expiration.CreateOrPersist(expiry, persist), flags); return ExecuteAsync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); } @@ -615,7 +615,7 @@ public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue return ExecuteAsync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); } - private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, in RedisValue field, in RedisValue value, ExpiryToken expiry, When when, CommandFlags flags) + private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, in RedisValue field, in RedisValue value, Expiration expiry, When when, CommandFlags flags) { if (when == When.Always) { @@ -645,7 +645,7 @@ private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, in RedisValue f } } - private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, HashEntry[] hashFields, ExpiryToken expiry, When when, CommandFlags flags) + private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, HashEntry[] hashFields, Expiration expiry, When when, CommandFlags flags) { if (hashFields.Length == 1) { @@ -693,7 +693,7 @@ private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, HashEntry[] has public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = HashFieldSetAndSetExpiryMessage(key, field, value, ExpiryToken.KeepTtl(expiry, keepTtl), when, flags); + var msg = HashFieldSetAndSetExpiryMessage(key, field, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); return ExecuteSync(msg, ResultProcessor.RedisValue); } @@ -706,7 +706,7 @@ public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, Redis public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) { if (hashFields == null) throw new ArgumentNullException(nameof(hashFields)); - var msg = HashFieldSetAndSetExpiryMessage(key, hashFields, ExpiryToken.KeepTtl(expiry, keepTtl), when, flags); + var msg = HashFieldSetAndSetExpiryMessage(key, hashFields, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); return ExecuteSync(msg, ResultProcessor.RedisValue); } public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) @@ -718,7 +718,7 @@ public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, public Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = HashFieldSetAndSetExpiryMessage(key, field, value, ExpiryToken.KeepTtl(expiry, keepTtl), when, flags); + var msg = HashFieldSetAndSetExpiryMessage(key, field, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); return ExecuteAsync(msg, ResultProcessor.RedisValue); } @@ -731,7 +731,7 @@ public Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue f public Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) { if (hashFields == null) throw new ArgumentNullException(nameof(hashFields)); - var msg = HashFieldSetAndSetExpiryMessage(key, hashFields, ExpiryToken.KeepTtl(expiry, keepTtl), when, flags); + var msg = HashFieldSetAndSetExpiryMessage(key, hashFields, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); return ExecuteAsync(msg, ResultProcessor.RedisValue); } public Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) @@ -1333,8 +1333,8 @@ protected override void WriteImpl(PhysicalConnection physical) physical.Write(Key); physical.WriteBulkString(toDatabase); physical.WriteBulkString(timeoutMilliseconds); - if (isCopy) physical.WriteBulkString(RedisLiterals.COPY); - if (isReplace) physical.WriteBulkString(RedisLiterals.REPLACE); + if (isCopy) physical.WriteBulkString("COPY"u8); + if (isReplace) physical.WriteBulkString("REPLACE"u8); } public override int ArgCount @@ -3512,25 +3512,25 @@ public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None public RedisValue StringGetSetExpiry(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) { - var msg = GetStringGetExMessage(key, expiry, flags); + var msg = GetStringGetExMessage(key, Expiration.CreateOrPersist(expiry, !expiry.HasValue), flags); return ExecuteSync(msg, ResultProcessor.RedisValue); } public RedisValue StringGetSetExpiry(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) { - var msg = GetStringGetExMessage(key, expiry, flags); + var msg = GetStringGetExMessage(key, new(expiry), flags); return ExecuteSync(msg, ResultProcessor.RedisValue); } public Task StringGetSetExpiryAsync(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) { - var msg = GetStringGetExMessage(key, expiry, flags); + var msg = GetStringGetExMessage(key, Expiration.CreateOrPersist(expiry, !expiry.HasValue), flags); return ExecuteAsync(msg, ResultProcessor.RedisValue); } public Task StringGetSetExpiryAsync(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) { - var msg = GetStringGetExMessage(key, expiry, flags); + var msg = GetStringGetExMessage(key, new(expiry), flags); return ExecuteAsync(msg, ResultProcessor.RedisValue); } @@ -3674,13 +3674,19 @@ public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When whe public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = GetStringSetMessage(key, value, expiry, keepTtl, when, flags); + var msg = GetStringSetMessage(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); return ExecuteSync(msg, ResultProcessor.Boolean); } public bool StringSet(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = GetStringSetMessage(values, when, flags); + var msg = GetStringSetMessage(values, when, Expiration.Default, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public bool StringSet(KeyValuePair[] values, When when, Expiration expiry, CommandFlags flags) + { + var msg = GetStringSetMessage(values, when, expiry, flags); return ExecuteSync(msg, ResultProcessor.Boolean); } @@ -3692,13 +3698,19 @@ public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expir public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = GetStringSetMessage(key, value, expiry, keepTtl, when, flags); + var msg = GetStringSetMessage(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); return ExecuteAsync(msg, ResultProcessor.Boolean); } public Task StringSetAsync(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = GetStringSetMessage(values, when, flags); + var msg = GetStringSetMessage(values, when, Expiration.Default, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public Task StringSetAsync(KeyValuePair[] values, When when, Expiration expiry, CommandFlags flags) + { + var msg = GetStringSetMessage(values, when, expiry, flags); return ExecuteAsync(msg, ResultProcessor.Boolean); } @@ -3778,12 +3790,6 @@ public Task StringSetRangeAsync(RedisKey key, long offset, RedisValu return ExecuteAsync(msg, ResultProcessor.RedisValue); } - private static long GetUnixTimeMilliseconds(DateTime when) => when.Kind switch - { - DateTimeKind.Local or DateTimeKind.Utc => (when.ToUniversalTime() - RedisBase.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond, - _ => throw new ArgumentException("Expiry time must be either Utc or Local", nameof(when)), - }; - private Message GetCopyMessage(in RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase, bool replace, CommandFlags flags) => destinationDatabase switch { @@ -3822,7 +3828,7 @@ private Message GetExpiryMessage(in RedisKey key, CommandFlags flags, DateTime? }; } - long milliseconds = GetUnixTimeMilliseconds(expiry.Value); + long milliseconds = Expiration.GetUnixTimeMilliseconds(expiry.Value); return GetExpiryMessage(key, RedisCommand.PEXPIREAT, RedisCommand.EXPIREAT, milliseconds, when, flags, out server); } @@ -4991,15 +4997,15 @@ private Message GetStringBitOperationMessage(Bitwise operation, RedisKey destina return Message.CreateInSlot(Database, slot, flags, RedisCommand.BITOP, new[] { op, destination.AsRedisValue(), first.AsRedisValue(), second.AsRedisValue() }); } - private Message GetStringGetExMessage(in RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) => expiry switch + private Message GetStringGetExMessage(in RedisKey key, Expiration expiry, CommandFlags flags = CommandFlags.None) { - null => Message.Create(Database, flags, RedisCommand.GETEX, key, RedisLiterals.PERSIST), - _ => Message.Create(Database, flags, RedisCommand.GETEX, key, RedisLiterals.PX, (long)expiry.Value.TotalMilliseconds), - }; - - private Message GetStringGetExMessage(in RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) => expiry == DateTime.MaxValue - ? Message.Create(Database, flags, RedisCommand.GETEX, key, RedisLiterals.PERSIST) - : Message.Create(Database, flags, RedisCommand.GETEX, key, RedisLiterals.PXAT, GetUnixTimeMilliseconds(expiry)); + return expiry.Tokens switch + { + 0 => Message.Create(Database, flags, RedisCommand.GETEX, key), + 1 => Message.Create(Database, flags, RedisCommand.GETEX, key, expiry.Operand), + _ => Message.Create(Database, flags, RedisCommand.GETEX, key, expiry.Operand, expiry.Value), + }; + } private Message GetStringGetWithExpiryMessage(RedisKey key, CommandFlags flags, out ResultProcessor processor, out ServerEndPoint? server) { @@ -5018,73 +5024,79 @@ private Message GetStringGetWithExpiryMessage(RedisKey key, CommandFlags flags, return new StringGetWithExpiryMessage(Database, flags, RedisCommand.TTL, key); } - private Message? GetStringSetMessage(KeyValuePair[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) + private Message? GetStringSetMessage(KeyValuePair[] values, When when, Expiration expiry, CommandFlags flags) { if (values == null) throw new ArgumentNullException(nameof(values)); switch (values.Length) { case 0: return null; - case 1: return GetStringSetMessage(values[0].Key, values[0].Value, null, false, when, flags); + case 1: return GetStringSetMessage(values[0].Key, values[0].Value, expiry, when, flags); default: - WhenAlwaysOrNotExists(when); - int slot = ServerSelectionStrategy.NoSlot, offset = 0; - var args = new RedisValue[values.Length * 2]; - var serverSelectionStrategy = multiplexer.ServerSelectionStrategy; - for (int i = 0; i < values.Length; i++) + // assume MSETEX in the general case, but look for scenarios where we can use the simpler + // MSET/MSETNX commands (which have wider applicability in terms of server versions) + // (note that when/expiry is ignored when not MSETEX; no need to explicitly wipe) + WhenAlwaysOrExistsOrNotExists(when); + var cmd = when switch { - args[offset++] = values[i].Key.AsRedisValue(); - args[offset++] = values[i].Value; - slot = serverSelectionStrategy.CombineSlot(slot, values[i].Key); - } - return Message.CreateInSlot(Database, slot, flags, when == When.NotExists ? RedisCommand.MSETNX : RedisCommand.MSET, args); + When.Always when expiry.IsNone => RedisCommand.MSET, + When.NotExists when expiry.IsNoneOrKeepTtl => RedisCommand.MSETNX, // "keepttl" with "not exists" is the same as "no expiry" + _ => RedisCommand.MSETEX, + }; + return Message.Create(Database, flags, cmd, values, expiry, when); } } private Message GetStringSetMessage( RedisKey key, RedisValue value, - TimeSpan? expiry = null, - bool keepTtl = false, + Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) { WhenAlwaysOrExistsOrNotExists(when); + static Message ThrowWhen() => throw new ArgumentOutOfRangeException(nameof(when)); + if (value.IsNull) return Message.Create(Database, flags, RedisCommand.DEL, key); - if (expiry == null || expiry.Value == TimeSpan.MaxValue) + if (expiry.IsPersist) throw new NotSupportedException("SET+PERSIST is not supported"); // we don't expect to get here ever + + if (expiry.IsNone) { - // no expiry return when switch { - When.Always when !keepTtl => Message.Create(Database, flags, RedisCommand.SET, key, value), - When.Always when keepTtl => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.KEEPTTL), - When.NotExists when !keepTtl => Message.Create(Database, flags, RedisCommand.SETNX, key, value), - When.NotExists when keepTtl => Message.Create(Database, flags, RedisCommand.SETNX, key, value, RedisLiterals.KEEPTTL), - When.Exists when !keepTtl => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.XX), - When.Exists when keepTtl => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.XX, RedisLiterals.KEEPTTL), - _ => throw new ArgumentOutOfRangeException(nameof(when)), + When.Always => Message.Create(Database, flags, RedisCommand.SET, key, value), + When.NotExists => Message.Create(Database, flags, RedisCommand.SETNX, key, value), + When.Exists => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.XX), + _ => ThrowWhen(), }; } - long milliseconds = expiry.Value.Ticks / TimeSpan.TicksPerMillisecond; - if ((milliseconds % 1000) == 0) + if (expiry.IsKeepTtl) { - // a nice round number of seconds - long seconds = milliseconds / 1000; return when switch { - When.Always => Message.Create(Database, flags, RedisCommand.SETEX, key, seconds, value), - When.Exists => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.EX, seconds, RedisLiterals.XX), - When.NotExists => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.EX, seconds, RedisLiterals.NX), - _ => throw new ArgumentOutOfRangeException(nameof(when)), + When.Always => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.KEEPTTL), + When.NotExists => Message.Create(Database, flags, RedisCommand.SETNX, key, value), // (there would be no existing TTL to keep) + When.Exists => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.XX, RedisLiterals.KEEPTTL), + _ => ThrowWhen(), }; } + if (when is When.Always & expiry.IsRelative) + { + // special case to SETEX/PSETEX + return expiry.IsSeconds + ? Message.Create(Database, flags, RedisCommand.SETEX, key, expiry.Value, value) + : Message.Create(Database, flags, RedisCommand.PSETEX, key, expiry.Value, value); + } + + // use SET with EX/PX/EXAT/PXAT and possibly XX/NX + var expiryOperand = expiry.GetOperand(out var expiryValue); return when switch { - When.Always => Message.Create(Database, flags, RedisCommand.PSETEX, key, milliseconds, value), - When.Exists => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.PX, milliseconds, RedisLiterals.XX), - When.NotExists => Message.Create(Database, flags, RedisCommand.SET, key, value, RedisLiterals.PX, milliseconds, RedisLiterals.NX), + When.Always => Message.Create(Database, flags, RedisCommand.SET, key, value, expiryOperand, expiryValue), + When.Exists => Message.Create(Database, flags, RedisCommand.SET, key, value, expiryOperand, expiryValue, RedisLiterals.XX), + When.NotExists => Message.Create(Database, flags, RedisCommand.SET, key, value, expiryOperand, expiryValue, RedisLiterals.NX), _ => throw new ArgumentOutOfRangeException(nameof(when)), }; } @@ -5332,7 +5344,7 @@ public ScriptLoadMessage(CommandFlags flags, string script) protected override void WriteImpl(PhysicalConnection physical) { physical.WriteHeader(Command, 2); - physical.WriteBulkString(RedisLiterals.LOAD); + physical.WriteBulkString("LOAD"u8); physical.WriteBulkString((RedisValue)Script); } public override int ArgCount => 2; diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index 87bcbf20c..9bc9af6d2 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -46,7 +46,8 @@ namespace StackExchange.Redis v7_4_0_rc1 = new Version(7, 3, 240), // 7.4 RC1 is version 7.3.240 v7_4_0_rc2 = new Version(7, 3, 241), // 7.4 RC2 is version 7.3.241 v8_0_0_M04 = new Version(7, 9, 227), // 8.0 M04 is version 7.9.227 - v8_2_0_rc1 = new Version(8, 1, 240); // 8.2 RC1 is version 8.1.240 + v8_2_0_rc1 = new Version(8, 1, 240), // 8.2 RC1 is version 8.1.240 + v8_4_0_rc1 = new Version(8, 3, 224); // 8.4 RC1 is version 8.3.224 #pragma warning restore SA1310 // Field names should not contain underscore #pragma warning restore SA1311 // Static readonly fields should begin with upper-case letter diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index 46a64cc88..9a8c15613 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -60,7 +60,6 @@ public static readonly RedisValue ANDOR = "ANDOR", ANY = "ANY", ASC = "ASC", - AUTH = "AUTH", BEFORE = "BEFORE", BIT = "BIT", BY = "BY", @@ -69,7 +68,6 @@ public static readonly RedisValue BYTE = "BYTE", CH = "CH", CHANNELS = "CHANNELS", - COPY = "COPY", COUNT = "COUNT", DB = "DB", @default = "default", @@ -105,7 +103,6 @@ public static readonly RedisValue lib_ver = "lib-ver", LIMIT = "LIMIT", LIST = "LIST", - LOAD = "LOAD", LT = "LT", MATCH = "MATCH", MALLOC_STATS = "MALLOC-STATS", diff --git a/tests/StackExchange.Redis.Tests/ExpiryTokenTests.cs b/tests/StackExchange.Redis.Tests/ExpiryTokenTests.cs index 3f0d39f28..fea4d4885 100644 --- a/tests/StackExchange.Redis.Tests/ExpiryTokenTests.cs +++ b/tests/StackExchange.Redis.Tests/ExpiryTokenTests.cs @@ -1,16 +1,15 @@ using System; using Xunit; -using static StackExchange.Redis.RedisDatabase; -using static StackExchange.Redis.RedisDatabase.ExpiryToken; +using static StackExchange.Redis.Expiration; namespace StackExchange.Redis.Tests; -public class ExpiryTokenTests // pure tests, no DB +public class ExpirationTests // pure tests, no DB { [Fact] public void Persist_Seconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5000); - var ex = Persist(time, false); + var ex = CreateOrPersist(time, false); Assert.Equal(2, ex.Tokens); Assert.Equal("EX 5", ex.ToString()); } @@ -19,7 +18,7 @@ public void Persist_Seconds() public void Persist_Milliseconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5001); - var ex = Persist(time, false); + var ex = CreateOrPersist(time, false); Assert.Equal(2, ex.Tokens); Assert.Equal("PX 5001", ex.ToString()); } @@ -28,7 +27,7 @@ public void Persist_Milliseconds() public void Persist_None_False() { TimeSpan? time = null; - var ex = Persist(time, false); + var ex = CreateOrPersist(time, false); Assert.Equal(0, ex.Tokens); Assert.Equal("", ex.ToString()); } @@ -37,7 +36,7 @@ public void Persist_None_False() public void Persist_None_True() { TimeSpan? time = null; - var ex = Persist(time, true); + var ex = CreateOrPersist(time, true); Assert.Equal(1, ex.Tokens); Assert.Equal("PERSIST", ex.ToString()); } @@ -46,7 +45,7 @@ public void Persist_None_True() public void Persist_Both() { TimeSpan? time = TimeSpan.FromMilliseconds(5000); - var ex = Assert.Throws(() => Persist(time, true)); + var ex = Assert.Throws(() => CreateOrPersist(time, true)); Assert.Equal("persist", ex.ParamName); Assert.StartsWith("Cannot specify both expiry and persist", ex.Message); } @@ -55,7 +54,7 @@ public void Persist_Both() public void KeepTtl_Seconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5000); - var ex = KeepTtl(time, false); + var ex = CreateOrKeepTtl(time, false); Assert.Equal(2, ex.Tokens); Assert.Equal("EX 5", ex.ToString()); } @@ -64,7 +63,7 @@ public void KeepTtl_Seconds() public void KeepTtl_Milliseconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5001); - var ex = KeepTtl(time, false); + var ex = CreateOrKeepTtl(time, false); Assert.Equal(2, ex.Tokens); Assert.Equal("PX 5001", ex.ToString()); } @@ -73,7 +72,7 @@ public void KeepTtl_Milliseconds() public void KeepTtl_None_False() { TimeSpan? time = null; - var ex = KeepTtl(time, false); + var ex = CreateOrKeepTtl(time, false); Assert.Equal(0, ex.Tokens); Assert.Equal("", ex.ToString()); } @@ -82,7 +81,7 @@ public void KeepTtl_None_False() public void KeepTtl_None_True() { TimeSpan? time = null; - var ex = KeepTtl(time, true); + var ex = CreateOrKeepTtl(time, true); Assert.Equal(1, ex.Tokens); Assert.Equal("KEEPTTL", ex.ToString()); } @@ -91,7 +90,7 @@ public void KeepTtl_None_True() public void KeepTtl_Both() { TimeSpan? time = TimeSpan.FromMilliseconds(5000); - var ex = Assert.Throws(() => KeepTtl(time, true)); + var ex = Assert.Throws(() => CreateOrKeepTtl(time, true)); Assert.Equal("keepTtl", ex.ParamName); Assert.StartsWith("Cannot specify both expiry and keepTtl", ex.Message); } @@ -100,7 +99,7 @@ public void KeepTtl_Both() public void DateTime_Seconds() { var when = new DateTime(2025, 7, 23, 10, 4, 14, DateTimeKind.Utc); - var ex = new ExpiryToken(when); + var ex = new Expiration(when); Assert.Equal(2, ex.Tokens); Assert.Equal("EXAT 1753265054", ex.ToString()); } @@ -110,7 +109,7 @@ public void DateTime_Milliseconds() { var when = new DateTime(2025, 7, 23, 10, 4, 14, DateTimeKind.Utc); when = when.AddMilliseconds(14); - var ex = new ExpiryToken(when); + var ex = new Expiration(when); Assert.Equal(2, ex.Tokens); Assert.Equal("PXAT 1753265054014", ex.ToString()); } diff --git a/tests/StackExchange.Redis.Tests/MSetTests.cs b/tests/StackExchange.Redis.Tests/MSetTests.cs new file mode 100644 index 000000000..8657c9d5a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MSetTests.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class MSetTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + [Theory] + [InlineData(0, When.Always)] + [InlineData(1, When.Always)] + [InlineData(2, When.Always)] + [InlineData(10, When.Always)] + [InlineData(0, When.NotExists)] + [InlineData(1, When.NotExists)] + [InlineData(2, When.NotExists)] + [InlineData(10, When.NotExists)] + [InlineData(0, When.NotExists, true)] + [InlineData(1, When.NotExists, true)] + [InlineData(2, When.NotExists, true)] + [InlineData(10, When.NotExists, true)] + [InlineData(0, When.Exists)] + [InlineData(1, When.Exists)] + [InlineData(2, When.Exists)] + [InlineData(10, When.Exists)] + [InlineData(0, When.Exists, true)] + [InlineData(1, When.Exists, true)] + [InlineData(2, When.Exists, true)] + [InlineData(10, When.Exists, true)] + public async Task AddWithoutExpiration(int count, When when, bool precreate = false) + { + await using var conn = Create(require: (when == When.Exists && count > 1) ? RedisFeatures.v8_4_0_rc1 : null); + var pairs = new KeyValuePair[count]; + var key = Me(); + for (int i = 0; i < count; i++) + { + // note the unusual braces; this is to force (on cluster) a hash-slot based on key + pairs[i] = new KeyValuePair($"{{{key}}}_{i}", $"value {i}"); + } + + var keys = Array.ConvertAll(pairs, pair => pair.Key); + var db = conn.GetDatabase(); + // set initial state + await db.KeyDeleteAsync(keys, flags: CommandFlags.FireAndForget); + if (precreate) + { + foreach (var pair in pairs) + { + await db.StringSetAsync(pair.Key, "dummy value", flags: CommandFlags.FireAndForget); + } + } + + bool expected = count != 0 & when switch + { + When.Always => true, + When.Exists => precreate, + When.NotExists => !precreate, + _ => throw new ArgumentOutOfRangeException(nameof(when)), + }; + + // issue the test command + var actualPending = db.StringSetAsync(pairs, when); + var values = await db.StringGetAsync(keys); // pipelined + var actual = await actualPending; + + // check the state *after* the command + Assert.Equal(expected, actual); + Assert.Equal(count, values.Length); + for (int i = 0; i < count; i++) + { + if (expected) + { + Assert.Equal(pairs[i].Value, values[i]); + } + else + { + Assert.NotEqual(pairs[i].Value, values[i]); + } + } + } + + [Theory] + [InlineData(0, When.Always)] + [InlineData(1, When.Always)] + [InlineData(2, When.Always)] + [InlineData(10, When.Always)] + [InlineData(0, When.NotExists)] + [InlineData(1, When.NotExists)] + [InlineData(2, When.NotExists)] + [InlineData(10, When.NotExists)] + [InlineData(0, When.NotExists, true)] + [InlineData(1, When.NotExists, true)] + [InlineData(2, When.NotExists, true)] + [InlineData(10, When.NotExists, true)] + [InlineData(0, When.Exists)] + [InlineData(1, When.Exists)] + [InlineData(2, When.Exists)] + [InlineData(10, When.Exists)] + [InlineData(0, When.Exists, true)] + [InlineData(1, When.Exists, true)] + [InlineData(2, When.Exists, true)] + [InlineData(10, When.Exists, true)] + public async Task AddWithRelativeExpiration(int count, When when, bool precreate = false) + { + await using var conn = Create(require: count > 1 ? RedisFeatures.v8_4_0_rc1 : null); + var pairs = new KeyValuePair[count]; + var key = Me(); + for (int i = 0; i < count; i++) + { + // note the unusual braces; this is to force (on cluster) a hash-slot based on key + pairs[i] = new KeyValuePair($"{{{key}}}_{i}", $"value {i}"); + } + var expiry = TimeSpan.FromMinutes(10); + + var keys = Array.ConvertAll(pairs, pair => pair.Key); + var db = conn.GetDatabase(); + // set initial state + await db.KeyDeleteAsync(keys, flags: CommandFlags.FireAndForget); + if (precreate) + { + foreach (var pair in pairs) + { + await db.StringSetAsync(pair.Key, "dummy value", flags: CommandFlags.FireAndForget); + } + } + + bool expected = count != 0 & when switch + { + When.Always => true, + When.Exists => precreate, + When.NotExists => !precreate, + _ => throw new ArgumentOutOfRangeException(nameof(when)), + }; + + // issue the test command + var actualPending = db.StringSetAsync(pairs, when, expiry); + Task[] ttls = new Task[count]; + for (int i = 0; i < count; i++) + { + ttls[i] = db.KeyTimeToLiveAsync(keys[i]); + } + await Task.WhenAll(ttls); + var values = await db.StringGetAsync(keys); // pipelined + var actual = await actualPending; + + // check the state *after* the command + Assert.Equal(expected, actual); + Assert.Equal(count, values.Length); + for (int i = 0; i < count; i++) + { + var ttl = await ttls[i]; + if (expected) + { + Assert.Equal(pairs[i].Value, values[i]); + Assert.NotNull(ttl); + Assert.True(ttl > TimeSpan.Zero && ttl <= expiry); + } + else + { + Assert.NotEqual(pairs[i].Value, values[i]); + Assert.Null(ttl); + } + } + } +}