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);
+ }
+ }
+ }
+}