diff --git a/src/StackExchange.Redis/Enums/ExpirationFlags.cs b/src/StackExchange.Redis/Enums/ExpirationFlags.cs new file mode 100644 index 000000000..9db24df41 --- /dev/null +++ b/src/StackExchange.Redis/Enums/ExpirationFlags.cs @@ -0,0 +1,21 @@ +using System; + +namespace StackExchange.Redis +{ + /// + /// Additional options for expiration-bearing commands. + /// + [Flags] + public enum ExpirationFlags + { + /// + /// No options specified. + /// + None = 0, + + /// + /// Apply the expiration only if no expiration already exists. + /// + ExpireIfNotExists = 1 << 0, + } +} diff --git a/src/StackExchange.Redis/Enums/IncrementOptions.cs b/src/StackExchange.Redis/Enums/IncrementOptions.cs new file mode 100644 index 000000000..4f19d380d --- /dev/null +++ b/src/StackExchange.Redis/Enums/IncrementOptions.cs @@ -0,0 +1,23 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Describes options for increment operations. +/// +[Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] +[Flags] +public enum IncrementOptions +{ + /// + /// No additional options. Out-of-bounds increments are rejected by the server without applying the increment. + /// + None = 0, + + /// + /// Clamp the result to the configured bound when the increment would exceed it. + /// + Saturate = 1, +} diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index d01c37a44..863904f93 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -119,6 +119,7 @@ internal enum RedisCommand INCR, INCRBY, INCRBYFLOAT, + INCREX, INFO, KEYS, @@ -371,6 +372,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.INCR: case RedisCommand.INCRBY: case RedisCommand.INCRBYFLOAT: + case RedisCommand.INCREX: case RedisCommand.LINSERT: case RedisCommand.LMOVE: case RedisCommand.LMPOP: diff --git a/src/StackExchange.Redis/Expiration.cs b/src/StackExchange.Redis/Expiration.cs index e04094358..c1a874331 100644 --- a/src/StackExchange.Redis/Expiration.cs +++ b/src/StackExchange.Redis/Expiration.cs @@ -16,9 +16,11 @@ public readonly struct Expiration - PX {ms} - relative expiry in milliseconds - EXAT {s} - absolute expiry in seconds - PXAT {ms} - absolute expiry in milliseconds + - ENX - only apply the expiration if no expiration currently exists - 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. + Historically this packed the mode and value into a single ulong. We now keep the raw long + separate from explicit flags so we can extend expiration behavior without stealing more bits + from the numeric payload. */ /// @@ -39,22 +41,29 @@ public readonly struct Expiration /// /// Expire at the specified absolute time. /// - public Expiration(DateTime when) + public Expiration(DateTime when) : this(when, ExpirationFlags.None) { } + + /// + /// Expire at the specified absolute time. + /// + public Expiration(DateTime when, ExpirationFlags flags) { if (when == DateTime.MaxValue) { - _valueAndMode = s_Default._valueAndMode; + _value = s_Default._value; + _flags = s_Default._flags; return; } long millis = GetUnixTimeMilliseconds(when); + var extraFlags = ToStateFlags(flags); if ((millis % 1000) == 0) { - Init(ExpirationMode.AbsoluteSeconds, millis / 1000, out _valueAndMode); + Init(ExpirationState.HasExpiration | ExpirationState.IsAbsolute | extraFlags, millis / 1000, out _value, out _flags); } else { - Init(ExpirationMode.AbsoluteMilliseconds, millis, out _valueAndMode); + Init(ExpirationState.HasExpiration | ExpirationState.IsAbsolute | ExpirationState.IsMillis | extraFlags, millis, out _value, out _flags); } } @@ -71,70 +80,88 @@ public Expiration(DateTime when) /// /// Expire at the specified relative time. /// - public Expiration(TimeSpan ttl) + public Expiration(TimeSpan ttl) : this(ttl, ExpirationFlags.None) { } + + /// + /// Expire at the specified relative time. + /// + public Expiration(TimeSpan ttl, ExpirationFlags flags) { if (ttl == TimeSpan.MaxValue) { - _valueAndMode = s_Default._valueAndMode; + _value = s_Default._value; + _flags = s_Default._flags; return; } var millis = ttl.Ticks / TimeSpan.TicksPerMillisecond; + var extraFlags = ToStateFlags(flags); if ((millis % 1000) == 0) { - Init(ExpirationMode.RelativeSeconds, millis / 1000, out _valueAndMode); + Init(ExpirationState.HasExpiration | extraFlags, millis / 1000, out _value, out _flags); } else { - Init(ExpirationMode.RelativeMilliseconds, millis, out _valueAndMode); + Init(ExpirationState.HasExpiration | ExpirationState.IsMillis | extraFlags, millis, out _value, out _flags); } } - private readonly ulong _valueAndMode; + private readonly long _value; + private readonly ExpirationState _flags; - private static void Init(ExpirationMode mode, long value, out ulong valueAndMode) + [Flags] + private enum ExpirationState : byte { - // 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)); + None = 0, + ExpireIfNotExists = (byte)ExpirationFlags.ExpireIfNotExists, + HasExpiration = 1 << 1, + IsMillis = 1 << 2, + IsAbsolute = 1 << 3, + KeepTtl = 1 << 4, + Persist = 1 << 5, } - private Expiration(ExpirationMode mode, long value) => Init(mode, value, out _valueAndMode); + private static ExpirationState ToStateFlags(ExpirationFlags flags) + { + const ExpirationFlags validFlags = ExpirationFlags.ExpireIfNotExists; + if ((flags & ~validFlags) != 0) Throw(); + return (ExpirationState)flags; - private enum ExpirationMode : byte + static void Throw() => throw new ArgumentOutOfRangeException(nameof(flags)); + } + + private static void Init(ExpirationState flags, long value, out long rawValue, out ExpirationState rawFlags) { - 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 + if (value < 0) Throw(); + rawValue = value; + rawFlags = flags; + static void Throw() => throw new ArgumentOutOfRangeException(nameof(value)); } - 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 + private Expiration(ExpirationState flags, long value) + { + _value = value; + _flags = flags; + } - 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 long Value => _value; - internal bool IsMilliseconds => - Mode is ExpirationMode.RelativeMilliseconds or ExpirationMode.AbsoluteMilliseconds; + internal bool IsKeepTtl => (_flags & ExpirationState.KeepTtl) != 0; + internal bool IsPersist => (_flags & ExpirationState.Persist) != 0; + internal bool IsExpireIfNotExists => (_flags & ExpirationState.ExpireIfNotExists) != 0; + internal bool IsNone => _flags == ExpirationState.None; + internal bool IsNoneOrKeepTtl => IsNone || IsKeepTtl; + internal bool IsAbsolute => (_flags & ExpirationState.IsAbsolute) != 0; + internal bool IsRelative => (_flags & ExpirationState.HasExpiration) != 0 && !IsAbsolute; - internal bool IsSeconds => Mode is ExpirationMode.RelativeSeconds or ExpirationMode.AbsoluteSeconds; + internal bool IsMilliseconds => (_flags & ExpirationState.IsMillis) != 0; - private static readonly Expiration s_Default = new(ExpirationMode.Default, 0); + internal bool IsSeconds => (_flags & (ExpirationState.HasExpiration | ExpirationState.IsMillis)) == ExpirationState.HasExpiration; - private static readonly Expiration s_KeepTtl = new(ExpirationMode.KeepTtl, 0), - s_Persist = new(ExpirationMode.Persist, 0); + private static readonly Expiration s_Default = new(ExpirationState.None, 0); + + private static readonly Expiration s_KeepTtl = new(ExpirationState.KeepTtl, 0), + s_Persist = new(ExpirationState.Persist, 0); private static void ThrowExpiryAndKeepTtl() => // ReSharper disable once NotResolvedInText @@ -206,68 +233,78 @@ internal static Expiration CreateOrKeepTtl(in DateTime? ttl, bool keepTtl) internal RedisValue GetOperand(out long value) { value = Value; - var mode = Mode; - return mode switch + if (IsKeepTtl) return RedisLiterals.KEEPTTL; + if (IsPersist) return RedisLiterals.PERSIST; + if ((_flags & ExpirationState.HasExpiration) == 0) return RedisValue.Null; + + return (IsAbsolute, IsMilliseconds) 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, + (false, false) => RedisLiterals.EX, + (false, true) => RedisLiterals.PX, + (true, false) => RedisLiterals.EXAT, + (true, true) => RedisLiterals.PXAT, }; } - private static void ThrowMode(ExpirationMode mode) => - throw new InvalidOperationException("Unknown mode: " + mode); - /// - public override string ToString() => Mode switch + public override string ToString() { - ExpirationMode.Default or ExpirationMode.NotUsed => "", - ExpirationMode.KeepTtl => "KEEPTTL", - ExpirationMode.Persist => "PERSIST", - _ => $"{Operand} {Value}", - }; + if (IsNone) return ""; + if (IsKeepTtl) return "KEEPTTL"; + if (IsPersist) return "PERSIST"; + return IsExpireIfNotExists ? $"{Operand} {Value} {RedisLiterals.ENX}" : $"{Operand} {Value}"; + } /// - public override int GetHashCode() => _valueAndMode.GetHashCode(); + public override int GetHashCode() + { + unchecked + { + return (_value.GetHashCode() * 397) ^ (int)_flags; + } + } /// - public override bool Equals(object? obj) => obj is Expiration other && _valueAndMode == other._valueAndMode; + public override bool Equals(object? obj) => obj is Expiration other && _value == other._value && _flags == other._flags; - internal int TokenCount => Mode switch + internal int GetTokenCount(bool allowEnx) { - ExpirationMode.Default or ExpirationMode.NotUsed => 0, - ExpirationMode.KeepTtl or ExpirationMode.Persist => 1, - _ => 2, - }; + if (!allowEnx && IsExpireIfNotExists) return ThrowEnxNotSupported(); + return IsNone ? 0 : (IsKeepTtl || IsPersist ? 1 : (IsExpireIfNotExists ? 3 : 2)); + + static int ThrowEnxNotSupported() => throw new NotSupportedException("ENX is not supported for this command."); + } internal void WriteTo(PhysicalConnection physical) { - var mode = Mode; - switch (Mode) + if (IsNone) + { + return; + } + + if (IsKeepTtl) + { + physical.WriteBulkString("KEEPTTL"u8); + return; + } + + if (IsPersist) + { + physical.WriteBulkString("PERSIST"u8); + return; + } + + physical.WriteBulkString((IsAbsolute, IsMilliseconds) switch + { + (false, false) => "EX"u8, + (false, true) => "PX"u8, + (true, false) => "EXAT"u8, + (true, true) => "PXAT"u8, + }); + physical.WriteBulkString(Value); + if (IsExpireIfNotExists) { - 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; + physical.WriteBulkString("ENX"u8); } } } diff --git a/src/StackExchange.Redis/Increx.IncrexMessage.cs b/src/StackExchange.Redis/Increx.IncrexMessage.cs new file mode 100644 index 000000000..f8cab9bc0 --- /dev/null +++ b/src/StackExchange.Redis/Increx.IncrexMessage.cs @@ -0,0 +1,122 @@ +using System; + +namespace StackExchange.Redis; + +internal partial class RedisDatabase +{ + internal abstract class IncrexMessageBase( + int database, + CommandFlags flags, + RedisKey key, + Expiration expiry, + IncrementOptions options) : Message(database, flags, RedisCommand.INCREX) + { + protected RedisKey Key => key; + protected Expiration Expiry => expiry; + protected IncrementOptions Options => options; + + public override int ArgCount + { + get + { + return 3 + BoundsArgCount + OptionsArgCount + Expiry.GetTokenCount(allowEnx: true); // key, BYINT/BYFLOAT, value, bounds, options, expiry + } + } + + private int OptionsArgCount => Options == IncrementOptions.Saturate ? 1 : 0; + + protected abstract int BoundsArgCount { get; } + protected abstract void WriteIncrementKindAndValue(PhysicalConnection physical); + protected abstract void WriteBounds(PhysicalConnection physical); + + protected override void WriteImpl(PhysicalConnection physical) + { + physical.WriteHeader(Command, ArgCount); + physical.WriteBulkString(Key); + WriteIncrementKindAndValue(physical); + WriteBounds(physical); + WriteOptions(physical); + Expiry.WriteTo(physical); + } + + private void WriteOptions(PhysicalConnection physical) + { + switch (Options) + { + case IncrementOptions.None: + break; + case IncrementOptions.Saturate: + physical.WriteRaw("$8\r\nSATURATE\r\n"u8); + break; + default: + throw new ArgumentOutOfRangeException(nameof(Options)); + } + } + } + + internal sealed class IncrexInt64Message( + int database, + CommandFlags flags, + RedisKey key, + long value, + long? lowerBound, + long? upperBound, + Expiration expiry, + IncrementOptions options) : IncrexMessageBase(database, flags, key, expiry, options) + { + protected override int BoundsArgCount => (lowerBound.HasValue ? 2 : 0) + (upperBound.HasValue ? 2 : 0); + + protected override void WriteIncrementKindAndValue(PhysicalConnection physical) + { + physical.WriteBulkString("BYINT"u8); + physical.WriteBulkString(value); + } + + protected override void WriteBounds(PhysicalConnection physical) + { + if (lowerBound.HasValue) + { + physical.WriteBulkString("LBOUND"u8); + physical.WriteBulkString(lowerBound.GetValueOrDefault()); + } + if (upperBound.HasValue) + { + physical.WriteBulkString("UBOUND"u8); + physical.WriteBulkString(upperBound.GetValueOrDefault()); + } + } + } + + internal sealed class IncrexDoubleMessage( + int database, + CommandFlags flags, + RedisKey key, + double value, + double? lowerBound, + double? upperBound, + Expiration expiry, + IncrementOptions options) : IncrexMessageBase(database, flags, key, expiry, options) + { + protected override int BoundsArgCount => (lowerBound.HasValue ? 2 : 0) + (upperBound.HasValue ? 2 : 0); + + protected override void WriteIncrementKindAndValue(PhysicalConnection physical) + { + physical.WriteBulkString("BYFLOAT"u8); + physical.WriteBulkString(value); + } + + protected override void WriteBounds(PhysicalConnection physical) + { + if (lowerBound.HasValue) + { + physical.WriteBulkString("LBOUND"u8); + physical.WriteBulkString(lowerBound.GetValueOrDefault()); + } + if (upperBound.HasValue) + { + physical.WriteBulkString("UBOUND"u8); + physical.WriteBulkString(upperBound.GetValueOrDefault()); + } + } + } +} diff --git a/src/StackExchange.Redis/Increx.ResultProcessor.cs b/src/StackExchange.Redis/Increx.ResultProcessor.cs new file mode 100644 index 000000000..dca1395ba --- /dev/null +++ b/src/StackExchange.Redis/Increx.ResultProcessor.cs @@ -0,0 +1,41 @@ +namespace StackExchange.Redis; + +internal static class IncrexResultProcessor +{ + internal static readonly ResultProcessor> Int64 = new Int64ResultProcessor(); + internal static readonly ResultProcessor> Double = new DoubleResultProcessor(); + + private sealed class Int64ResultProcessor : ResultProcessor> + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + { + if (result.Resp2TypeArray == ResultType.Array && result.ItemsCount >= 2) + { + var items = result.GetItems(); + if (items[0].TryGetInt64(out long value) && items[1].TryGetInt64(out long appliedIncrement)) + { + SetResult(message, new StringIncrementResult(value, appliedIncrement)); + return true; + } + } + return false; + } + } + + private sealed class DoubleResultProcessor : ResultProcessor> + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + { + if (result.Resp2TypeArray == ResultType.Array && result.ItemsCount >= 2) + { + var items = result.GetItems(); + if (items[0].TryGetDouble(out double value) && items[1].TryGetDouble(out double appliedIncrement)) + { + SetResult(message, new StringIncrementResult(value, appliedIncrement)); + return true; + } + } + return false; + } + } +} diff --git a/src/StackExchange.Redis/Increx.StringIncrementResult.cs b/src/StackExchange.Redis/Increx.StringIncrementResult.cs new file mode 100644 index 000000000..bba2d9017 --- /dev/null +++ b/src/StackExchange.Redis/Increx.StringIncrementResult.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Represents the result of an increment operation including the resulting value and the increment actually applied. +/// +/// The numeric type represented by the result. +[Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] +public readonly struct StringIncrementResult(T value, T appliedIncrement) +{ + /// + /// The resulting value after the increment operation. + /// + public T Value { get; } = value; + + /// + /// The increment that was actually applied. + /// + public T AppliedIncrement { get; } = appliedIncrement; +} diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index b7eb65d5c..c0761b9a7 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -3437,6 +3437,36 @@ IEnumerable SortedSetScan( /// double StringIncrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None); + /// + /// Atomically increments the integer value stored at key, optionally constraining the result and applying expiration semantics. + /// + /// The key of the string. + /// The amount to increment by. + /// The expiration to apply. Use to retain the existing TTL. + /// The optional lower bound for the resulting value. + /// The optional upper bound for the resulting value. + /// The options to use for this operation. + /// The flags to use for this operation. + /// The resulting value and the increment actually applied. +#pragma warning disable RS0026 // Public API with optional parameter(s) should have the most parameters amongst its public overloads + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + StringIncrementResult StringIncrement(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None); + + /// + /// Atomically increments the floating point value stored at key, optionally constraining the result and applying expiration semantics. + /// + /// The key of the string. + /// The amount to increment by. + /// The expiration to apply. Use to retain the existing TTL. + /// The optional lower bound for the resulting value. + /// The optional upper bound for the resulting value. + /// The options to use for this operation. + /// The flags to use for this operation. + /// The resulting value and the increment actually applied. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + StringIncrementResult StringIncrement(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0026 + /// /// Returns the length of the string value stored at key. /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 1c7555839..40db55cfd 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -844,6 +844,16 @@ IAsyncEnumerable SortedSetScanAsync( /// Task StringIncrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None); + /// +#pragma warning disable RS0026 // Public API with optional parameter(s) should have the most parameters amongst its public overloads + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + Task> StringIncrementAsync(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + Task> StringIncrementAsync(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0026 + /// Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index fb8560d3f..6efd8d68e 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -798,6 +798,12 @@ public Task StringIncrementAsync(RedisKey key, double value, CommandFlag public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) => Inner.StringIncrementAsync(ToInner(key), value, flags); + public Task> StringIncrementAsync(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) => + Inner.StringIncrementAsync(ToInner(key), value, expiry, lowerBound, upperBound, options, flags); + + public Task> StringIncrementAsync(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) => + Inner.StringIncrementAsync(ToInner(key), value, expiry, lowerBound, upperBound, options, flags); + public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLengthAsync(ToInner(key), flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index aa2eff75b..2ca7ca384 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -780,6 +780,12 @@ public double StringIncrement(RedisKey key, double value, CommandFlags flags = C public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) => Inner.StringIncrement(ToInner(key), value, flags); + public StringIncrementResult StringIncrement(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) => + Inner.StringIncrement(ToInner(key), value, expiry, lowerBound, upperBound, options, flags); + + public StringIncrementResult StringIncrement(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) => + Inner.StringIncrement(ToInner(key), value, expiry, lowerBound, upperBound, options, flags); + public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLength(ToInner(key), flags); diff --git a/src/StackExchange.Redis/Message.ValueCondition.cs b/src/StackExchange.Redis/Message.ValueCondition.cs index a9672d945..931553913 100644 --- a/src/StackExchange.Redis/Message.ValueCondition.cs +++ b/src/StackExchange.Redis/Message.ValueCondition.cs @@ -42,7 +42,7 @@ private sealed class KeyValueExpiryConditionMessage( private readonly ValueCondition _when = when; private readonly Expiration _expiry = expiry; - public override int ArgCount => 2 + _expiry.TokenCount + _when.TokenCount; + public override int ArgCount => 2 + _expiry.GetTokenCount(allowEnx: false) + _when.TokenCount; protected override void WriteImpl(PhysicalConnection physical) { diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 05dfc07c3..5a2f5026b 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -1720,7 +1720,7 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) // - 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.TokenCount + (when is When.Exists or When.NotExists ? 1 : 0)) + ? (1 + (2 * values.Length) + expiry.GetTokenCount(allowEnx: false) + (when is When.Exists or When.NotExists ? 1 : 0)) : (2 * values.Length); // MSET/MSETNX only support simple syntax protected override void WriteImpl(PhysicalConnection physical) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 6df19ab81..1d8566724 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -483,6 +483,14 @@ StackExchange.Redis.GeoUnit.Feet = 3 -> StackExchange.Redis.GeoUnit StackExchange.Redis.GeoUnit.Kilometers = 1 -> StackExchange.Redis.GeoUnit StackExchange.Redis.GeoUnit.Meters = 0 -> StackExchange.Redis.GeoUnit StackExchange.Redis.GeoUnit.Miles = 2 -> StackExchange.Redis.GeoUnit +[SER006]StackExchange.Redis.IncrementOptions +[SER006]StackExchange.Redis.IncrementOptions.None = 0 -> StackExchange.Redis.IncrementOptions +[SER006]StackExchange.Redis.IncrementOptions.Saturate = 1 -> StackExchange.Redis.IncrementOptions +[SER006]StackExchange.Redis.StringIncrementResult +[SER006]StackExchange.Redis.StringIncrementResult.AppliedIncrement.get -> T +[SER006]StackExchange.Redis.StringIncrementResult.StringIncrementResult() -> void +[SER006]StackExchange.Redis.StringIncrementResult.StringIncrementResult(T value, T appliedIncrement) -> void +[SER006]StackExchange.Redis.StringIncrementResult.Value.get -> T StackExchange.Redis.HashEntry StackExchange.Redis.HashEntry.Equals(StackExchange.Redis.HashEntry other) -> bool StackExchange.Redis.HashEntry.HashEntry() -> void @@ -779,7 +787,9 @@ StackExchange.Redis.IDatabase.StringGetSet(StackExchange.Redis.RedisKey key, Sta StackExchange.Redis.IDatabase.StringGetSetExpiry(StackExchange.Redis.RedisKey key, System.TimeSpan? expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue StackExchange.Redis.IDatabase.StringGetSetExpiry(StackExchange.Redis.RedisKey key, System.DateTime expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue StackExchange.Redis.IDatabase.StringGetWithExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValueWithExpiry +[SER006]StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.Expiration expiry, double? lowerBound = null, double? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.StringIncrementResult StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> double +[SER006]StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, long value, StackExchange.Redis.Expiration expiry, long? lowerBound = null, long? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.StringIncrementResult StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long StackExchange.Redis.IDatabase.StringLength(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long StackExchange.Redis.IDatabase.StringLongestCommonSubsequence(StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> string? @@ -1023,7 +1033,9 @@ StackExchange.Redis.IDatabaseAsync.StringGetSetAsync(StackExchange.Redis.RedisKe StackExchange.Redis.IDatabaseAsync.StringGetSetExpiryAsync(StackExchange.Redis.RedisKey key, System.TimeSpan? expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringGetSetExpiryAsync(StackExchange.Redis.RedisKey key, System.DateTime expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringGetWithExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER006]StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.Expiration expiry, double? lowerBound = null, double? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task>! StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER006]StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, long value, StackExchange.Redis.Expiration expiry, long? lowerBound = null, long? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task>! StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringLengthAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringLongestCommonSubsequenceAsync(StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! @@ -2071,7 +2083,9 @@ StackExchange.Redis.IDatabaseAsync.StringSetAsync(System.Collections.Generic.Key StackExchange.Redis.Expiration StackExchange.Redis.Expiration.Expiration() -> void StackExchange.Redis.Expiration.Expiration(System.DateTime when) -> void +StackExchange.Redis.Expiration.Expiration(System.DateTime when, StackExchange.Redis.ExpirationFlags flags) -> void StackExchange.Redis.Expiration.Expiration(System.TimeSpan ttl) -> void +StackExchange.Redis.Expiration.Expiration(System.TimeSpan ttl, StackExchange.Redis.ExpirationFlags flags) -> void override StackExchange.Redis.Expiration.Equals(object? obj) -> bool override StackExchange.Redis.Expiration.GetHashCode() -> int override StackExchange.Redis.Expiration.ToString() -> string! @@ -2080,6 +2094,9 @@ static StackExchange.Redis.Expiration.KeepTtl.get -> StackExchange.Redis.Expirat 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 +StackExchange.Redis.ExpirationFlags +StackExchange.Redis.ExpirationFlags.ExpireIfNotExists = 1 -> StackExchange.Redis.ExpirationFlags +StackExchange.Redis.ExpirationFlags.None = 0 -> StackExchange.Redis.ExpirationFlags override StackExchange.Redis.ValueCondition.Equals(object? obj) -> bool override StackExchange.Redis.ValueCondition.GetHashCode() -> int override StackExchange.Redis.ValueCondition.ToString() -> string! diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index dfe4c7259..c8153608d 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System; +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace StackExchange.Redis; @@ -47,6 +48,61 @@ private Message GetStringDeleteMessage(in RedisKey key, in ValueCondition when, return ExecuteAsync(msg, ResultProcessor.Digest); } + public StringIncrementResult StringIncrement(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + { + ValidateStringIncrementExpiry(expiry); + ValidateIncrementOptions(options); + var msg = new IncrexInt64Message(Database, flags, key, value, lowerBound, upperBound, expiry, options); + return ExecuteSync(msg, IncrexResultProcessor.Int64); + } + + public Task> StringIncrementAsync(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + { + ValidateStringIncrementExpiry(expiry); + ValidateIncrementOptions(options); + var msg = new IncrexInt64Message(Database, flags, key, value, lowerBound, upperBound, expiry, options); + return ExecuteAsync(msg, IncrexResultProcessor.Int64); + } + + public StringIncrementResult StringIncrement(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + { + ValidateStringIncrementExpiry(expiry); + ValidateIncrementOptions(options); + var msg = new IncrexDoubleMessage(Database, flags, key, value, lowerBound, upperBound, expiry, options); + return ExecuteSync(msg, IncrexResultProcessor.Double); + } + + public Task> StringIncrementAsync(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + { + ValidateStringIncrementExpiry(expiry); + ValidateIncrementOptions(options); + var msg = new IncrexDoubleMessage(Database, flags, key, value, lowerBound, upperBound, expiry, options); + return ExecuteAsync(msg, IncrexResultProcessor.Double); + } + + private static void ValidateStringIncrementExpiry(Expiration expiry) + { + if (expiry.IsKeepTtl) ThrowKeepTtl(); + if (expiry.IsPersist) ThrowPersist(); + if (expiry.IsExpireIfNotExists && !(expiry.IsAbsolute || expiry.IsRelative)) ThrowEnxWithoutExpiry(); + + static void ThrowKeepTtl() => throw new ArgumentException("KEEPTTL is not supported by this operation.", nameof(expiry)); + static void ThrowPersist() => throw new ArgumentException("PERSIST is not supported by this operation.", nameof(expiry)); + static void ThrowEnxWithoutExpiry() => throw new ArgumentException("ENX requires EX, PX, EXAT, or PXAT.", nameof(expiry)); + } + + private static void ValidateIncrementOptions(IncrementOptions options) + { + switch (options) + { + case IncrementOptions.None: + case IncrementOptions.Saturate: + break; + default: + throw new ArgumentOutOfRangeException(nameof(options)); + } + } + public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) { var msg = GetStringSetMessage(key, value, expiry, when, flags); diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index e33fd69b4..3ce895a9a 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -489,7 +489,7 @@ public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue[] } private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, in RedisValue hashField, Expiration expiry, CommandFlags flags) => - expiry.TokenCount switch + expiry.GetTokenCount(allowEnx: false) switch { // expiry, for example EX 10 2 => Message.Create(Database, flags, RedisCommand.HGETEX, key, expiry.Operand, expiry.Value, RedisLiterals.FIELDS, 1, hashField), @@ -508,12 +508,13 @@ private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, RedisValue[] ha } // precision, time, FIELDS, hashFields.Length, {N x fields} - int extraTokens = expiry.TokenCount + 2; + int expiryTokenCount = expiry.GetTokenCount(allowEnx: false); + int extraTokens = expiryTokenCount + 2; RedisValue[] values = new RedisValue[extraTokens + hashFields.Length]; int index = 0; // add PERSIST or expiry values - switch (expiry.TokenCount) + switch (expiryTokenCount) { case 2: values[index++] = expiry.Operand; @@ -617,9 +618,10 @@ public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, in RedisValue field, in RedisValue value, Expiration expiry, When when, CommandFlags flags) { + int expiryTokenCount = expiry.GetTokenCount(allowEnx: false); if (when == When.Always) { - return expiry.TokenCount switch + return expiryTokenCount switch { 2 => Message.Create(Database, flags, RedisCommand.HSETEX, key, expiry.Operand, expiry.Value, RedisLiterals.FIELDS, 1, field, value), 1 => Message.Create(Database, flags, RedisCommand.HSETEX, key, expiry.Operand, RedisLiterals.FIELDS, 1, field, value), @@ -636,7 +638,7 @@ private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, in RedisValue f _ => throw new ArgumentOutOfRangeException(nameof(when)), }; - return expiry.TokenCount switch + return expiryTokenCount switch { 2 => Message.Create(Database, flags, RedisCommand.HSETEX, key, existance, expiry.Operand, expiry.Value, RedisLiterals.FIELDS, 1, field, value), 1 => Message.Create(Database, flags, RedisCommand.HSETEX, key, existance, expiry.Operand, RedisLiterals.FIELDS, 1, field, value), @@ -653,7 +655,8 @@ private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, HashEntry[] has return HashFieldSetAndSetExpiryMessage(key, field.Name, field.Value, expiry, when, flags); } // Determine the base array size - var extraTokens = expiry.TokenCount + (when == When.Always ? 2 : 3); // [FXX|FNX] {expiry} FIELDS {length} + int expiryTokenCount = expiry.GetTokenCount(allowEnx: false); + var extraTokens = expiryTokenCount + (when == When.Always ? 2 : 3); // [FXX|FNX] {expiry} FIELDS {length} RedisValue[] values = new RedisValue[(hashFields.Length * 2) + extraTokens]; int index = 0; @@ -670,7 +673,7 @@ private Message HashFieldSetAndSetExpiryMessage(in RedisKey key, HashEntry[] has default: throw new ArgumentOutOfRangeException(nameof(when)); } - switch (expiry.TokenCount) + switch (expiryTokenCount) { case 2: values[index++] = expiry.Operand; @@ -5195,7 +5198,7 @@ private Message GetStringBitOperationMessage(Bitwise operation, RedisKey destina private Message GetStringGetExMessage(in RedisKey key, Expiration expiry, CommandFlags flags = CommandFlags.None) { - return expiry.TokenCount switch + return expiry.GetTokenCount(allowEnx: false) switch { 0 => Message.Create(Database, flags, RedisCommand.GETEX, key), 1 => Message.Create(Database, flags, RedisCommand.GETEX, key, expiry.Operand), @@ -5278,6 +5281,8 @@ private Message GetStringSetMessage( }; } + expiry.GetTokenCount(allowEnx: false); + if (when is When.Always & expiry.IsRelative) { // special case to SETEX/PSETEX diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index 7f147b358..de44f79dc 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -55,6 +55,8 @@ public static readonly RedisValue ASC = "ASC", BEFORE = "BEFORE", BIT = "BIT", + BYFLOAT = "BYFLOAT", + BYINT = "BYINT", BY = "BY", BYLEX = "BYLEX", BYSCORE = "BYSCORE", @@ -68,6 +70,7 @@ public static readonly RedisValue DIFF = "DIFF", DIFF1 = "DIFF1", DOCTOR = "DOCTOR", + ENX = "ENX", ENCODING = "ENCODING", EX = "EX", EXAT = "EXAT", @@ -101,6 +104,7 @@ public static readonly RedisValue LIMIT = "LIMIT", LIST = "LIST", LT = "LT", + LBOUND = "LBOUND", MATCH = "MATCH", MALLOC_STATS = "MALLOC-STATS", MAX = "MAX", @@ -145,6 +149,7 @@ public static readonly RedisValue STORE = "STORE", SUM = "SUM", TYPE = "TYPE", + UBOUND = "UBOUND", USERNAME = "USERNAME", USED = "USED", WEIGHTS = "WEIGHTS", diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 76d4030ee..b697ca6c7 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.8-rc1 +ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:custom-26235535976-debian FROM ${CLIENT_LIBS_TEST_IMAGE} COPY --from=configs ./Basic /data/Basic/ diff --git a/tests/RedisConfigs/docker-compose.yml b/tests/RedisConfigs/docker-compose.yml index e5b77344d..a20c08abb 100644 --- a/tests/RedisConfigs/docker-compose.yml +++ b/tests/RedisConfigs/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: .docker/Redis args: - CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-rc1} + CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:custom-26235535976-debian} additional_contexts: configs: . platform: linux diff --git a/tests/StackExchange.Redis.Tests/ExpiryTokenTests.cs b/tests/StackExchange.Redis.Tests/ExpirationUnitTests.cs similarity index 67% rename from tests/StackExchange.Redis.Tests/ExpiryTokenTests.cs rename to tests/StackExchange.Redis.Tests/ExpirationUnitTests.cs index 6012422ed..0d376e2ad 100644 --- a/tests/StackExchange.Redis.Tests/ExpiryTokenTests.cs +++ b/tests/StackExchange.Redis.Tests/ExpirationUnitTests.cs @@ -3,14 +3,33 @@ using static StackExchange.Redis.Expiration; namespace StackExchange.Redis.Tests; -public class ExpirationTests // pure tests, no DB +public class ExpirationUnitTests // pure tests, no DB { + [Fact] + public void ExpireIfNotExists_TimeSpan_Seconds() + { + var ex = new Expiration(TimeSpan.FromSeconds(5), ExpirationFlags.ExpireIfNotExists); + Assert.True(ex.IsExpireIfNotExists); + Assert.Equal(3, ex.GetTokenCount(allowEnx: true)); + Assert.Equal("EX 5 ENX", ex.ToString()); + } + + [Fact] + public void ExpireIfNotExists_DateTime_Milliseconds() + { + var when = new DateTime(2025, 7, 23, 10, 4, 14, DateTimeKind.Utc).AddMilliseconds(14); + var ex = new Expiration(when, ExpirationFlags.ExpireIfNotExists); + Assert.True(ex.IsExpireIfNotExists); + Assert.Equal(3, ex.GetTokenCount(allowEnx: true)); + Assert.Equal("PXAT 1753265054014 ENX", ex.ToString()); + } + [Fact] public void Persist_Seconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5000); var ex = CreateOrPersist(time, false); - Assert.Equal(2, ex.TokenCount); + Assert.Equal(2, ex.GetTokenCount(allowEnx: false)); Assert.Equal("EX 5", ex.ToString()); } @@ -19,7 +38,7 @@ public void Persist_Milliseconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5001); var ex = CreateOrPersist(time, false); - Assert.Equal(2, ex.TokenCount); + Assert.Equal(2, ex.GetTokenCount(allowEnx: false)); Assert.Equal("PX 5001", ex.ToString()); } @@ -28,7 +47,7 @@ public void Persist_None_False() { TimeSpan? time = null; var ex = CreateOrPersist(time, false); - Assert.Equal(0, ex.TokenCount); + Assert.Equal(0, ex.GetTokenCount(allowEnx: false)); Assert.Equal("", ex.ToString()); } @@ -37,7 +56,7 @@ public void Persist_None_True() { TimeSpan? time = null; var ex = CreateOrPersist(time, true); - Assert.Equal(1, ex.TokenCount); + Assert.Equal(1, ex.GetTokenCount(allowEnx: false)); Assert.Equal("PERSIST", ex.ToString()); } @@ -55,7 +74,7 @@ public void KeepTtl_Seconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5000); var ex = CreateOrKeepTtl(time, false); - Assert.Equal(2, ex.TokenCount); + Assert.Equal(2, ex.GetTokenCount(allowEnx: false)); Assert.Equal("EX 5", ex.ToString()); } @@ -64,7 +83,7 @@ public void KeepTtl_Milliseconds() { TimeSpan? time = TimeSpan.FromMilliseconds(5001); var ex = CreateOrKeepTtl(time, false); - Assert.Equal(2, ex.TokenCount); + Assert.Equal(2, ex.GetTokenCount(allowEnx: false)); Assert.Equal("PX 5001", ex.ToString()); } @@ -73,7 +92,7 @@ public void KeepTtl_None_False() { TimeSpan? time = null; var ex = CreateOrKeepTtl(time, false); - Assert.Equal(0, ex.TokenCount); + Assert.Equal(0, ex.GetTokenCount(allowEnx: false)); Assert.Equal("", ex.ToString()); } @@ -82,7 +101,7 @@ public void KeepTtl_None_True() { TimeSpan? time = null; var ex = CreateOrKeepTtl(time, true); - Assert.Equal(1, ex.TokenCount); + Assert.Equal(1, ex.GetTokenCount(allowEnx: false)); Assert.Equal("KEEPTTL", ex.ToString()); } @@ -100,7 +119,7 @@ public void DateTime_Seconds() { var when = new DateTime(2025, 7, 23, 10, 4, 14, DateTimeKind.Utc); var ex = new Expiration(when); - Assert.Equal(2, ex.TokenCount); + Assert.Equal(2, ex.GetTokenCount(allowEnx: false)); Assert.Equal("EXAT 1753265054", ex.ToString()); } @@ -110,7 +129,7 @@ public void DateTime_Milliseconds() var when = new DateTime(2025, 7, 23, 10, 4, 14, DateTimeKind.Utc); when = when.AddMilliseconds(14); var ex = new Expiration(when); - Assert.Equal(2, ex.TokenCount); + Assert.Equal(2, ex.GetTokenCount(allowEnx: false)); Assert.Equal("PXAT 1753265054014", ex.ToString()); } } diff --git a/tests/StackExchange.Redis.Tests/IncrexIntegrationTests.cs b/tests/StackExchange.Redis.Tests/IncrexIntegrationTests.cs new file mode 100644 index 000000000..467e6f611 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/IncrexIntegrationTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +[RunPerProtocol] +public class IncrexIntegrationTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + [Fact(Timeout = 5000)] + public async Task StringIncrementIncrex_Int64_WithBoundsAndExpiry() + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + db.StringSet(key, 10); + + var result = await db.StringIncrementAsync(key, 2L, TimeSpan.FromSeconds(5), lowerBound: 0, upperBound: 20); + + Assert.Equal(12, result.Value); + Assert.Equal(2, result.AppliedIncrement); + Assert.Equal(12, (long)db.StringGet(key)); + Assert.True((await db.KeyTimeToLiveAsync(key)) > TimeSpan.Zero); + } + + [Fact(Timeout = 5000)] + public async Task StringIncrementIncrex_Double_WithAbsoluteExpiryAndEnx() + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var key = Me(); + var when = DateTime.UtcNow.AddMinutes(30).AddMilliseconds(14); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.StringSet(key, 3.25, TimeSpan.FromMinutes(10)); + var beforeTtl = await db.KeyTimeToLiveAsync(key); + + var result = await db.StringIncrementAsync(key, 1.25, new Expiration(when, ExpirationFlags.ExpireIfNotExists), lowerBound: -1.5, upperBound: 9.5); + + Assert.Equal(4.5, result.Value); + Assert.Equal(1.25, result.AppliedIncrement); + Assert.Equal(4.5, (double)db.StringGet(key)); + var afterTtl = await db.KeyTimeToLiveAsync(key); + Assert.NotNull(beforeTtl); + Assert.NotNull(afterTtl); + Assert.True(afterTtl <= beforeTtl); + Assert.True(afterTtl > TimeSpan.FromMinutes(8)); + } + + [Fact(Timeout = 5000)] + public async Task StringIncrementIncrex_SyncVersion_ParsesResult() + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var intKey = (RedisKey)(Me() + ":int"); + var doubleKey = (RedisKey)(Me() + ":double"); + db.KeyDelete([intKey, doubleKey], CommandFlags.FireAndForget); + + var intResult = db.StringIncrement(intKey, 3L, Expiration.Default); + var doubleResult = db.StringIncrement(doubleKey, 1.5, Expiration.Default); + + Assert.Equal(3, intResult.Value); + Assert.Equal(3, intResult.AppliedIncrement); + Assert.Equal(1.5, doubleResult.Value); + Assert.Equal(1.5, doubleResult.AppliedIncrement); + } + + [Fact(Timeout = 5000)] + public async Task StringIncrementIncrex_DefaultRejectsWhenBoundExceeded() + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var intKey = (RedisKey)(Me() + ":int"); + var doubleKey = (RedisKey)(Me() + ":double"); + db.KeyDelete([intKey, doubleKey], CommandFlags.FireAndForget); + db.StringSet(intKey, 5); + db.StringSet(doubleKey, 5.5); + + var intResult = await db.StringIncrementAsync(intKey, 1L, TimeSpan.FromSeconds(5), lowerBound: 10); + var doubleResult = await db.StringIncrementAsync(doubleKey, 1.25, TimeSpan.FromSeconds(5), lowerBound: 10.25); + + Assert.Equal(5, intResult.Value); + Assert.Equal(0, intResult.AppliedIncrement); + Assert.Equal(5, (long)db.StringGet(intKey)); + Assert.Null(await db.KeyTimeToLiveAsync(intKey)); + + Assert.Equal(5.5, doubleResult.Value); + Assert.Equal(0, doubleResult.AppliedIncrement); + Assert.Equal(5.5, (double)db.StringGet(doubleKey)); + Assert.Null(await db.KeyTimeToLiveAsync(doubleKey)); + } + + [Fact(Timeout = 5000)] + public async Task StringIncrementIncrex_SaturateClampsToBound() + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var intKey = (RedisKey)(Me() + ":int"); + var doubleKey = (RedisKey)(Me() + ":double"); + db.KeyDelete([intKey, doubleKey], CommandFlags.FireAndForget); + db.StringSet(intKey, 8); + db.StringSet(doubleKey, 8.25); + + var intResult = await db.StringIncrementAsync(intKey, 5L, TimeSpan.FromSeconds(5), upperBound: 10, options: IncrementOptions.Saturate); + var doubleResult = await db.StringIncrementAsync(doubleKey, 5.5, TimeSpan.FromSeconds(5), upperBound: 10.5, options: IncrementOptions.Saturate); + + Assert.Equal(10, intResult.Value); + Assert.Equal(2, intResult.AppliedIncrement); + Assert.Equal(10, (long)db.StringGet(intKey)); + Assert.True((await db.KeyTimeToLiveAsync(intKey)) > TimeSpan.Zero); + + Assert.Equal(10.5, doubleResult.Value); + Assert.Equal(2.25, doubleResult.AppliedIncrement); + Assert.Equal(10.5, (double)db.StringGet(doubleKey)); + Assert.True((await db.KeyTimeToLiveAsync(doubleKey)) > TimeSpan.Zero); + } + + [Fact(Timeout = 5000)] + public async Task StringIncrementIncrex_DefaultRetainsExistingTtl() + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var intKey = (RedisKey)(Me() + ":int"); + var doubleKey = (RedisKey)(Me() + ":double"); + db.KeyDelete([intKey, doubleKey], CommandFlags.FireAndForget); + db.StringSet(intKey, 5, TimeSpan.FromMinutes(5)); + db.StringSet(doubleKey, 5.5, TimeSpan.FromMinutes(5)); + var beforeIntTtl = await db.KeyTimeToLiveAsync(intKey); + var beforeDoubleTtl = await db.KeyTimeToLiveAsync(doubleKey); + + var intResult = await db.StringIncrementAsync(intKey, 2L, Expiration.Default); + var doubleResult = await db.StringIncrementAsync(doubleKey, 2.25, Expiration.Default); + + Assert.Equal(7, intResult.Value); + Assert.Equal(2, intResult.AppliedIncrement); + var afterIntTtl = await db.KeyTimeToLiveAsync(intKey); + Assert.NotNull(beforeIntTtl); + Assert.NotNull(afterIntTtl); + Assert.True(afterIntTtl <= beforeIntTtl); + Assert.True(afterIntTtl > TimeSpan.FromMinutes(4)); + + Assert.Equal(7.75, doubleResult.Value); + Assert.Equal(2.25, doubleResult.AppliedIncrement); + var afterDoubleTtl = await db.KeyTimeToLiveAsync(doubleKey); + Assert.NotNull(beforeDoubleTtl); + Assert.NotNull(afterDoubleTtl); + Assert.True(afterDoubleTtl <= beforeDoubleTtl); + Assert.True(afterDoubleTtl > TimeSpan.FromMinutes(4)); + } + + [Theory(Timeout = 5000)] + [InlineData(5L, 2L, null, 10L, IncrementOptions.None, 7L, 2L, true)] + [InlineData(5L, 1L, 10L, null, IncrementOptions.None, 5L, 0L, false)] + [InlineData(5L, 2L, null, 10L, IncrementOptions.Saturate, 7L, 2L, true)] + [InlineData(8L, 5L, null, 10L, IncrementOptions.Saturate, 10L, 2L, true)] + // [InlineData(10L, 5L, null, 10L, IncrementOptions.Saturate, 10L, 0L, false)] + [InlineData(10L, 5L, null, 10L, IncrementOptions.Saturate, 10L, 0L, true)] + // [InlineData(11L, 1L, null, 10L, IncrementOptions.Saturate, 11L, 0L, false)] + [InlineData(11L, 1L, null, 10L, IncrementOptions.Saturate, 10L, -1L, true)] + public async Task StringIncrementIncrex_Int64_ExpirationSideEffects( + long initialValue, + long increment, + long? lowerBound, + long? upperBound, + IncrementOptions options, + long expectedValue, + long expectedAppliedIncrement, + bool expectExpiryChanged) + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var key = (RedisKey)Me(); + + db.KeyDelete(key, CommandFlags.FireAndForget); + db.StringSet(key, initialValue, ExistingExpiry); + var beforeTtl = await db.KeyTimeToLiveAsync(key); + + var result = await db.StringIncrementAsync(key, increment, NewExpiry, lowerBound, upperBound, options); + + Assert.Equal(expectedValue, result.Value); + Assert.Equal(expectedAppliedIncrement, result.AppliedIncrement); + Assert.Equal(expectedValue, (long)db.StringGet(key)); + await AssertExpiryAsync(db, key, beforeTtl, expectExpiryChanged); + } + + [Theory(Timeout = 5000)] + [InlineData(5.5, 1.25, null, 10.5, IncrementOptions.None, 6.75, 1.25, true)] + [InlineData(5.5, 1.25, 10.25, null, IncrementOptions.None, 5.5, 0D, false)] + [InlineData(5.5, 1.25, null, 10.5, IncrementOptions.Saturate, 6.75, 1.25, true)] + [InlineData(8.25, 5.5, null, 10.5, IncrementOptions.Saturate, 10.5, 2.25, true)] + // [InlineData(10.5, 5.5, null, 10.5, IncrementOptions.Saturate, 10.5, 0D, false)] + [InlineData(10.5, 5.5, null, 10.5, IncrementOptions.Saturate, 10.5, 0D, true)] + // [InlineData(11.5, 1.25, null, 10.5, IncrementOptions.Saturate, 11.5, 0D, false)] + [InlineData(11.5, 1.25, null, 10.5, IncrementOptions.Saturate, 10.5, -1D, true)] + public async Task StringIncrementIncrex_Double_ExpirationSideEffects( + double initialValue, + double increment, + double? lowerBound, + double? upperBound, + IncrementOptions options, + double expectedValue, + double expectedAppliedIncrement, + bool expectExpiryChanged) + { + await using var conn = Create(require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var key = (RedisKey)Me(); + + db.KeyDelete(key, CommandFlags.FireAndForget); + db.StringSet(key, initialValue, ExistingExpiry); + var beforeTtl = await db.KeyTimeToLiveAsync(key); + + var result = await db.StringIncrementAsync(key, increment, NewExpiry, lowerBound, upperBound, options); + + Assert.Equal(expectedValue, result.Value); + Assert.Equal(expectedAppliedIncrement, result.AppliedIncrement); + Assert.Equal(expectedValue, (double)db.StringGet(key)); + await AssertExpiryAsync(db, key, beforeTtl, expectExpiryChanged); + } + + private static async Task AssertExpiryAsync(IDatabase db, RedisKey key, TimeSpan? beforeTtl, bool expectExpiryChanged) + { + var afterTtl = await db.KeyTimeToLiveAsync(key); + Assert.NotNull(beforeTtl); + Assert.NotNull(afterTtl); + + if (expectExpiryChanged) + { + Assert.True(afterTtl <= ChangedExpiryUpperBound, $"Expected {key} TTL to use the new expiry, but was {afterTtl}."); + Assert.True(afterTtl > TimeSpan.Zero, $"Expected {key} TTL to be positive, but was {afterTtl}."); + } + else + { + Assert.True(afterTtl > UnchangedExpiryLowerBound, $"Expected {key} TTL to retain the original expiry, but was {afterTtl}."); + Assert.True(afterTtl <= beforeTtl, $"Expected {key} TTL not to grow, but went from {beforeTtl} to {afterTtl}."); + } + } + + private static readonly TimeSpan ExistingExpiry = TimeSpan.FromMinutes(20); + private static readonly TimeSpan NewExpiry = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ChangedExpiryUpperBound = TimeSpan.FromMinutes(1); + private static readonly TimeSpan UnchangedExpiryLowerBound = TimeSpan.FromMinutes(10); +} diff --git a/tests/StackExchange.Redis.Tests/IncrexTestServer.cs b/tests/StackExchange.Redis.Tests/IncrexTestServer.cs new file mode 100644 index 000000000..4a32e350c --- /dev/null +++ b/tests/StackExchange.Redis.Tests/IncrexTestServer.cs @@ -0,0 +1,304 @@ +extern alias respite; +using System; +using System.Globalization; +using respite::RESPite.Messages; +using StackExchange.Redis.Server; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class IncrexTestServer(ITestOutputHelper? log = null) : InProcessTestServer(log) +{ + public sealed class IncrexRequestSnapshot + { + public RedisKey Key { get; set; } + public bool IsFloat { get; set; } + public string Increment { get; set; } = ""; + public string? LowerBound { get; set; } + public string? UpperBound { get; set; } + public bool Saturate { get; set; } + public string? ExpiryMode { get; set; } + public string? ExpiryValue { get; set; } + public bool Enx { get; set; } + } + + public IncrexRequestSnapshot? LastRequest { get; private set; } + + [RedisCommand(-4, "INCREX")] + protected virtual TypedRedisValue Increx(RedisClient client, in RedisRequest request) + { + var snapshot = ParseRequest(in request); + LastRequest = snapshot; + + return snapshot.IsFloat + ? ExecuteDouble(client.Database, snapshot) + : ExecuteInt64(client.Database, snapshot); + } + + private IncrexRequestSnapshot ParseRequest(in RedisRequest request) + { + var snapshot = new IncrexRequestSnapshot { Key = request.GetKey(1) }; + int index = 2; + while (index < request.Count) + { + switch (request.GetString(index++)) + { + case "BYINT": + snapshot.IsFloat = false; + snapshot.Increment = request.GetString(index++); + break; + case "BYFLOAT": + snapshot.IsFloat = true; + snapshot.Increment = request.GetString(index++); + break; + case "LBOUND": + snapshot.LowerBound = request.GetString(index++); + break; + case "UBOUND": + snapshot.UpperBound = request.GetString(index++); + break; + case "SATURATE": + snapshot.Saturate = true; + break; + case "EX": + case "PX": + case "EXAT": + case "PXAT": + snapshot.ExpiryMode = request.GetString(index - 1); + snapshot.ExpiryValue = request.GetString(index++); + break; + case "ENX": + snapshot.Enx = true; + break; + default: + throw new InvalidOperationException("Unknown INCREX token: " + request.GetString(index - 1)); + } + } + return snapshot; + } + + private TypedRedisValue ExecuteInt64(int database, IncrexRequestSnapshot snapshot) + { + var raw = Get(database, snapshot.Key); + bool existed = !raw.IsNull; + long current = raw.IsNull ? 0 : (long)raw; + long delta = long.Parse(snapshot.Increment, CultureInfo.InvariantCulture); + long? lowerBound = snapshot.LowerBound is null ? null : long.Parse(snapshot.LowerBound, CultureInfo.InvariantCulture); + long? upperBound = snapshot.UpperBound is null ? null : long.Parse(snapshot.UpperBound, CultureInfo.InvariantCulture); + + GetInt64Result(current, delta, lowerBound, upperBound, snapshot.Saturate, out var next, out var applied, out var ignored); + + if (ignored) + { + return MakeResult(current, 0); + } + + ApplyValueAndExpiry(database, snapshot, existed, next); + return MakeResult(next, applied); + } + + private TypedRedisValue ExecuteDouble(int database, IncrexRequestSnapshot snapshot) + { + var raw = Get(database, snapshot.Key); + bool existed = !raw.IsNull; + double current = raw.IsNull ? 0D : (double)raw; + double delta = double.Parse(snapshot.Increment, CultureInfo.InvariantCulture); + double? lowerBound = snapshot.LowerBound is null ? null : double.Parse(snapshot.LowerBound, CultureInfo.InvariantCulture); + double? upperBound = snapshot.UpperBound is null ? null : double.Parse(snapshot.UpperBound, CultureInfo.InvariantCulture); + + GetDoubleResult(current, delta, lowerBound, upperBound, snapshot.Saturate, out var next, out var applied, out var ignored); + + if (ignored) + { + return MakeResult(current, 0); + } + + ApplyValueAndExpiry(database, snapshot, existed, next); + return MakeResult(next, applied); + } + + private void ApplyValueAndExpiry(int database, IncrexRequestSnapshot snapshot, bool existed, RedisValue value) + { + var priorTtl = existed ? Ttl(database, snapshot.Key) : null; + Set(database, snapshot.Key, value); + + if (snapshot.ExpiryMode is null) + { + if (priorTtl.HasValue && priorTtl.Value != TimeSpan.MaxValue) + { + _ = Expire(database, snapshot.Key, priorTtl.Value); + } + return; + } + + if (snapshot.Enx && priorTtl.HasValue && priorTtl.Value != TimeSpan.MaxValue) + { + _ = Expire(database, snapshot.Key, priorTtl.Value); + return; + } + + var ttl = snapshot.ExpiryMode switch + { + "EX" => TimeSpan.FromSeconds(long.Parse(snapshot.ExpiryValue!, CultureInfo.InvariantCulture)), + "PX" => TimeSpan.FromMilliseconds(long.Parse(snapshot.ExpiryValue!, CultureInfo.InvariantCulture)), + "EXAT" => DateTimeOffset.FromUnixTimeSeconds(long.Parse(snapshot.ExpiryValue!, CultureInfo.InvariantCulture)).UtcDateTime - Time(), + "PXAT" => DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(snapshot.ExpiryValue!, CultureInfo.InvariantCulture)).UtcDateTime - Time(), + _ => throw new InvalidOperationException("Unknown expiry mode: " + snapshot.ExpiryMode), + }; + _ = Expire(database, snapshot.Key, ttl); + } + + private static void GetInt64Result( + long current, + long delta, + long? lowerBound, + long? upperBound, + bool saturate, + out long next, + out long applied, + out bool ignored) + { + ignored = false; + + if (!TryAdd(current, delta, out var candidate)) + { + HandleInt64Overflow(current, delta, lowerBound, upperBound, saturate, out next, out applied, out ignored); + return; + } + + if (saturate) + { + next = Clamp(candidate, lowerBound, upperBound); + applied = next - current; + return; + } + + if (IsInBounds(candidate, lowerBound, upperBound)) + { + next = candidate; + applied = delta; + return; + } + + next = current; + applied = 0; + ignored = true; + } + + private static void HandleInt64Overflow( + long current, + long delta, + long? lowerBound, + long? upperBound, + bool saturate, + out long next, + out long applied, + out bool ignored) + { + if (saturate) + { + ignored = false; + next = delta >= 0 ? upperBound ?? long.MaxValue : lowerBound ?? long.MinValue; + applied = next - current; + return; + } + + next = current; + applied = 0; + ignored = true; + } + + private static void GetDoubleResult( + double current, + double delta, + double? lowerBound, + double? upperBound, + bool saturate, + out double next, + out double applied, + out bool ignored) + { + ignored = false; + var candidate = current + delta; + + if (saturate) + { + next = IsFinite(candidate) + ? Clamp(candidate, lowerBound, upperBound) + : delta >= 0 ? upperBound.GetValueOrDefault(double.MaxValue) : lowerBound.GetValueOrDefault(double.MinValue); + applied = next - current; + return; + } + + if (IsFinite(candidate) && IsInBounds(candidate, lowerBound, upperBound)) + { + next = candidate; + applied = delta; + return; + } + + next = current; + applied = 0; + ignored = true; + } + + private static bool TryAdd(long left, long right, out long value) + { + try + { + value = checked(left + right); + return true; + } + catch (OverflowException) + { + value = default; + return false; + } + } + + private static bool IsInBounds(long value, long? lowerBound, long? upperBound) + => (!lowerBound.HasValue || value >= lowerBound.GetValueOrDefault()) + && (!upperBound.HasValue || value <= upperBound.GetValueOrDefault()); + + private static bool IsInBounds(double value, double? lowerBound, double? upperBound) + => (!lowerBound.HasValue || value >= lowerBound.GetValueOrDefault()) + && (!upperBound.HasValue || value <= upperBound.GetValueOrDefault()); + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + + private static long Clamp(long value, long? lowerBound, long? upperBound) + { + if (lowerBound.HasValue && value < lowerBound.GetValueOrDefault()) return lowerBound.GetValueOrDefault(); + if (upperBound.HasValue && value > upperBound.GetValueOrDefault()) return upperBound.GetValueOrDefault(); + return value; + } + + private static double Clamp(double value, double? lowerBound, double? upperBound) + { + if (lowerBound.HasValue && value < lowerBound.GetValueOrDefault()) return lowerBound.GetValueOrDefault(); + if (upperBound.HasValue && value > upperBound.GetValueOrDefault()) return upperBound.GetValueOrDefault(); + return value; + } + + private static TypedRedisValue MakeResult(long value, long appliedIncrement) + { + var result = TypedRedisValue.Rent(2, out var span, RespPrefix.Array); + span[0] = TypedRedisValue.Integer(value); + span[1] = TypedRedisValue.Integer(appliedIncrement); + return result; + } + + private static TypedRedisValue MakeResult(double value, double appliedIncrement) + { + var result = TypedRedisValue.Rent(2, out var span, RespPrefix.Array); + span[0] = TypedRedisValue.Number(value); + span[1] = TypedRedisValue.Number(appliedIncrement); + return result; + } + + public override void ResetCounters() + { + LastRequest = null; + base.ResetCounters(); + } +} diff --git a/tests/StackExchange.Redis.Tests/IncrexUnitTests.cs b/tests/StackExchange.Redis.Tests/IncrexUnitTests.cs new file mode 100644 index 000000000..3eddb58a6 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/IncrexUnitTests.cs @@ -0,0 +1,286 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class IncrexUnitTests(ITestOutputHelper log) +{ + private RedisKey Me([CallerMemberName] string callerName = "") => callerName; + + [Fact] + public async Task StringIncrementIncrex_Int64_WithBoundsAndExpiry() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + + db.StringSet(key, 10); + + var result = await db.StringIncrementAsync(key, 2L, TimeSpan.FromSeconds(5), lowerBound: 0, upperBound: 20); + + Assert.Equal(12, result.Value); + Assert.Equal(2, result.AppliedIncrement); + Assert.Equal(12, (long)db.StringGet(key)); + Assert.True((await db.KeyTimeToLiveAsync(key)) > TimeSpan.Zero); + + var request = server.LastRequest!; + Assert.Equal(key, request.Key); + Assert.False(request.IsFloat); + Assert.Equal("2", request.Increment); + Assert.Equal("0", request.LowerBound); + Assert.Equal("20", request.UpperBound); + Assert.False(request.Saturate); + Assert.Equal("EX", request.ExpiryMode); + Assert.Equal("5", request.ExpiryValue); + Assert.False(request.Enx); + } + + [Fact] + public async Task StringIncrementIncrex_Double_WithAbsoluteExpiryAndEnx() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + var when = new DateTime(2025, 7, 23, 10, 4, 14, DateTimeKind.Utc).AddMilliseconds(14); + db.StringSet(key, 3.25, TimeSpan.FromMinutes(10)); + var beforeTtl = await db.KeyTimeToLiveAsync(key); + + var result = await db.StringIncrementAsync(key, 1.25, new Expiration(when, ExpirationFlags.ExpireIfNotExists), lowerBound: -1.5, upperBound: 9.5); + + Assert.Equal(4.5, result.Value); + Assert.Equal(1.25, result.AppliedIncrement); + Assert.Equal(4.5, (double)db.StringGet(key)); + var afterTtl = await db.KeyTimeToLiveAsync(key); + Assert.NotNull(beforeTtl); + Assert.NotNull(afterTtl); + Assert.True(afterTtl <= beforeTtl); + Assert.True(afterTtl > TimeSpan.FromMinutes(8)); + + var request = server.LastRequest!; + Assert.Equal(key, request.Key); + Assert.True(request.IsFloat); + Assert.Equal("1.25", request.Increment); + Assert.Equal("-1.5", request.LowerBound); + Assert.Equal("9.5", request.UpperBound); + Assert.False(request.Saturate); + Assert.Equal("PXAT", request.ExpiryMode); + Assert.Equal("1753265054014", request.ExpiryValue); + Assert.True(request.Enx); + } + + [Fact] + [RunPerProtocol] + public async Task StringIncrementIncrex_ExecuteUsesNumberResultTypes() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = nameof(StringIncrementIncrex_ExecuteUsesNumberResultTypes); + var expectedFractionalType = TestContext.Current.IsResp3() ? ResultType.Double : ResultType.BulkString; + + var fractional = await db.ExecuteAsync("INCREX", (RedisKey)(key + ":fractional"), "BYFLOAT", 1.5); + var fractionalItems = (RedisResult[])fractional!; + Assert.Equal(2, fractionalItems.Length); + Assert.Equal(expectedFractionalType, fractionalItems[0].Resp3Type); + Assert.Equal(expectedFractionalType, fractionalItems[1].Resp3Type); + Assert.Equal(1.5, (double)fractionalItems[0]); + Assert.Equal(1.5, (double)fractionalItems[1]); + + var integral = await db.ExecuteAsync("INCREX", (RedisKey)(key + ":integral"), "BYFLOAT", 2.0); + var integralItems = (RedisResult[])integral!; + Assert.Equal(2, integralItems.Length); + Assert.Equal(ResultType.Integer, integralItems[0].Resp3Type); + Assert.Equal(ResultType.Integer, integralItems[1].Resp3Type); + Assert.Equal(2, (long)integralItems[0]); + Assert.Equal(2, (long)integralItems[1]); + } + + [Fact] + public async Task StringIncrementIncrex_SyncVersion_ParsesResult() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + + var result = db.StringIncrement(Me(), 3L, Expiration.Default); + + Assert.Equal(3, result.Value); + Assert.Equal(3, result.AppliedIncrement); + } + + [Fact] + public async Task StringIncrementIncrex_DefaultRejectsWhenBoundExceeded() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + db.StringSet(key, 5); + + var result = await db.StringIncrementAsync(key, 1L, TimeSpan.FromSeconds(5), lowerBound: 10); + + Assert.Equal(5, result.Value); + Assert.Equal(0, result.AppliedIncrement); + Assert.Equal(5, (long)db.StringGet(key)); + Assert.Null(await db.KeyTimeToLiveAsync(key)); + Assert.False(server.LastRequest!.Saturate); + } + + [Fact] + public async Task StringIncrementIncrex_InvalidOptionsThrow() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + + var ex = Assert.Throws(() => db.StringIncrement(Me(), 1L, TimeSpan.FromSeconds(5), options: (IncrementOptions)2)); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public async Task StringIncrementIncrex_SaturateClampsToBound() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + db.StringSet(key, 8); + + var result = await db.StringIncrementAsync(key, 5L, TimeSpan.FromSeconds(5), upperBound: 10, options: IncrementOptions.Saturate); + + Assert.Equal(10, result.Value); + Assert.Equal(2, result.AppliedIncrement); + Assert.Equal(10, (long)db.StringGet(key)); + Assert.True((await db.KeyTimeToLiveAsync(key)) > TimeSpan.Zero); + Assert.True(server.LastRequest!.Saturate); + } + + [Fact] + public async Task StringIncrementIncrex_DefaultRetainsExistingTtl() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + db.StringSet(key, 5, TimeSpan.FromMinutes(5)); + var beforeTtl = await db.KeyTimeToLiveAsync(key); + + var result = await db.StringIncrementAsync(key, 2L, Expiration.Default); + + Assert.Equal(7, result.Value); + Assert.Equal(2, result.AppliedIncrement); + var afterTtl = await db.KeyTimeToLiveAsync(key); + Assert.NotNull(beforeTtl); + Assert.NotNull(afterTtl); + Assert.True(afterTtl <= beforeTtl); + Assert.True(afterTtl > TimeSpan.FromMinutes(4)); + } + + [Theory] + [InlineData(5L, 2L, null, 10L, IncrementOptions.None, 7L, 2L, true)] + [InlineData(5L, 1L, 10L, null, IncrementOptions.None, 5L, 0L, false)] + [InlineData(5L, 2L, null, 10L, IncrementOptions.Saturate, 7L, 2L, true)] + [InlineData(8L, 5L, null, 10L, IncrementOptions.Saturate, 10L, 2L, true)] + // [InlineData(10L, 5L, null, 10L, IncrementOptions.Saturate, 10L, 0L, false)] + [InlineData(10L, 5L, null, 10L, IncrementOptions.Saturate, 10L, 0L, true)] + // [InlineData(11L, 1L, null, 10L, IncrementOptions.Saturate, 11L, 0L, false)] + [InlineData(11L, 1L, null, 10L, IncrementOptions.Saturate, 10L, -1L, true)] + public async Task StringIncrementIncrex_Int64_ExpirationSideEffects( + long initialValue, + long increment, + long? lowerBound, + long? upperBound, + IncrementOptions options, + long expectedValue, + long expectedAppliedIncrement, + bool expectExpiryChanged) + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + + db.StringSet(key, initialValue, ExistingExpiry); + var beforeTtl = await db.KeyTimeToLiveAsync(key); + + var result = await db.StringIncrementAsync(key, increment, NewExpiry, lowerBound, upperBound, options); + + Assert.Equal(expectedValue, result.Value); + Assert.Equal(expectedAppliedIncrement, result.AppliedIncrement); + Assert.Equal(expectedValue, (long)db.StringGet(key)); + await AssertExpiryAsync(db, key, beforeTtl, expectExpiryChanged); + } + + [Theory] + [InlineData(5.5, 1.25, null, 10.5, IncrementOptions.None, 6.75, 1.25, true)] + [InlineData(5.5, 1.25, 10.25, null, IncrementOptions.None, 5.5, 0D, false)] + [InlineData(5.5, 1.25, null, 10.5, IncrementOptions.Saturate, 6.75, 1.25, true)] + [InlineData(8.25, 5.5, null, 10.5, IncrementOptions.Saturate, 10.5, 2.25, true)] + // [InlineData(10.5, 5.5, null, 10.5, IncrementOptions.Saturate, 10.5, 0D, false)] + [InlineData(10.5, 5.5, null, 10.5, IncrementOptions.Saturate, 10.5, 0D, true)] + // [InlineData(11.5, 1.25, null, 10.5, IncrementOptions.Saturate, 11.5, 0D, false)] + [InlineData(11.5, 1.25, null, 10.5, IncrementOptions.Saturate, 10.5, -1D, true)] + public async Task StringIncrementIncrex_Double_ExpirationSideEffects( + double initialValue, + double increment, + double? lowerBound, + double? upperBound, + IncrementOptions options, + double expectedValue, + double expectedAppliedIncrement, + bool expectExpiryChanged) + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + + db.StringSet(key, initialValue, ExistingExpiry); + var beforeTtl = await db.KeyTimeToLiveAsync(key); + + var result = await db.StringIncrementAsync(key, increment, NewExpiry, lowerBound, upperBound, options); + + Assert.Equal(expectedValue, result.Value); + Assert.Equal(expectedAppliedIncrement, result.AppliedIncrement); + Assert.Equal(expectedValue, (double)db.StringGet(key)); + await AssertExpiryAsync(db, key, beforeTtl, expectExpiryChanged); + } + + [Fact] + public async Task StringIncrementIncrex_RejectsKeepTtl() + { + using var server = new IncrexTestServer(log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + + var ex = Assert.Throws(() => db.StringIncrement(Me(), 1L, Expiration.KeepTtl)); + Assert.Equal("expiry", ex.ParamName); + } + + private static async Task AssertExpiryAsync(IDatabase db, RedisKey key, TimeSpan? beforeTtl, bool expectExpiryChanged) + { + var afterTtl = await db.KeyTimeToLiveAsync(key); + Assert.NotNull(beforeTtl); + Assert.NotNull(afterTtl); + + if (expectExpiryChanged) + { + Assert.True(afterTtl <= ChangedExpiryUpperBound, $"Expected {key} TTL to use the new expiry, but was {afterTtl}."); + Assert.True(afterTtl > TimeSpan.Zero, $"Expected {key} TTL to be positive, but was {afterTtl}."); + } + else + { + Assert.True(afterTtl > UnchangedExpiryLowerBound, $"Expected {key} TTL to retain the original expiry, but was {afterTtl}."); + Assert.True(afterTtl <= beforeTtl, $"Expected {key} TTL not to grow, but went from {beforeTtl} to {afterTtl}."); + } + } + + private static readonly TimeSpan ExistingExpiry = TimeSpan.FromMinutes(20); + private static readonly TimeSpan NewExpiry = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ChangedExpiryUpperBound = TimeSpan.FromMinutes(1); + private static readonly TimeSpan UnchangedExpiryLowerBound = TimeSpan.FromMinutes(10); +} diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs index cbdf55e4e..ac8d6b05d 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs @@ -1400,6 +1400,20 @@ public void StringIncrement_2() mock.Received().StringIncrement("prefix:key", 1.23, CommandFlags.None); } + [Fact] + public void StringIncrement_3() + { + prefixed.StringIncrement("key", 123L, TimeSpan.FromSeconds(5), lowerBound: 10, upperBound: 200, flags: CommandFlags.None, options: IncrementOptions.None); + mock.Received().StringIncrement("prefix:key", 123L, TimeSpan.FromSeconds(5), 10, 200, IncrementOptions.None, CommandFlags.None); + } + + [Fact] + public void StringIncrement_4() + { + prefixed.StringIncrement("key", 1.23, TimeSpan.FromSeconds(5), lowerBound: -1.0, upperBound: 2.0, flags: CommandFlags.None, options: IncrementOptions.Saturate); + mock.Received().StringIncrement("prefix:key", 1.23, TimeSpan.FromSeconds(5), -1.0, 2.0, IncrementOptions.Saturate, CommandFlags.None); + } + [Fact] public void StringLength() { diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs index dafadd836..58d333ec9 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs @@ -103,6 +103,20 @@ public async Task HashIncrementAsync_2() await mock.Received().HashIncrementAsync("prefix:key", "hashField", 1.23, CommandFlags.None); } + [Fact] + public async Task StringIncrementAsync_3() + { + await prefixed.StringIncrementAsync("key", 123L, TimeSpan.FromSeconds(5), lowerBound: 10, upperBound: 200, flags: CommandFlags.None, options: IncrementOptions.None); + await mock.Received().StringIncrementAsync("prefix:key", 123L, TimeSpan.FromSeconds(5), 10, 200, IncrementOptions.None, CommandFlags.None); + } + + [Fact] + public async Task StringIncrementAsync_4() + { + await prefixed.StringIncrementAsync("key", 1.23, TimeSpan.FromSeconds(5), lowerBound: -1.0, upperBound: 2.0, flags: CommandFlags.None, options: IncrementOptions.Saturate); + await mock.Received().StringIncrementAsync("prefix:key", 1.23, TimeSpan.FromSeconds(5), -1.0, 2.0, IncrementOptions.Saturate, CommandFlags.None); + } + [Fact] public async Task HashKeysAsync() { diff --git a/toys/StackExchange.Redis.Server/RedisClient.Output.cs b/toys/StackExchange.Redis.Server/RedisClient.Output.cs index c67302686..4e871b112 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.Output.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.Output.cs @@ -178,6 +178,9 @@ static void WritePrefix(IBufferWriter output, char prefix) case RespPrefix.Integer: PhysicalConnection.WriteInteger(output, (long)value.AsRedisValue()); break; + case RespPrefix.Double: + WriteDouble(output, (double)value.AsRedisValue()); + break; case RespPrefix.SimpleError: prefix = '-'; goto BasicMessage; @@ -230,6 +233,18 @@ static void WritePrefix(IBufferWriter output, char prefix) } } + static void WriteDouble(IBufferWriter output, double value) + { + Span valueSpan = stackalloc byte[Format.MaxDoubleTextLen]; + var len = Format.FormatDouble(value, valueSpan); + var span = output.GetSpan(3 + len); + span[0] = (byte)','; + valueSpan.Slice(0, len).CopyTo(span.Slice(1)); + span[1 + len] = (byte)'\r'; + span[2 + len] = (byte)'\n'; + output.Advance(3 + len); + } + static ResultType ToResultType(RespPrefix type) => type switch { @@ -264,6 +279,7 @@ private static RespPrefix ToResp2(RespPrefix type) case RespPrefix.Boolean: return RespPrefix.Integer; case RespPrefix.Double: + return RespPrefix.BulkString; case RespPrefix.BigInteger: return RespPrefix.SimpleString; case RespPrefix.BulkError: diff --git a/toys/StackExchange.Redis.Server/TypedRedisValue.cs b/toys/StackExchange.Redis.Server/TypedRedisValue.cs index a1f5606ce..d3264a1ec 100644 --- a/toys/StackExchange.Redis.Server/TypedRedisValue.cs +++ b/toys/StackExchange.Redis.Server/TypedRedisValue.cs @@ -134,6 +134,16 @@ public ReadOnlySpan Span public static TypedRedisValue Integer(long value) => new TypedRedisValue(value, RespPrefix.Integer); + /// + /// Initialize a that represents a number. + /// + /// The value to initialize from. + public static TypedRedisValue Number(double value) + { + var redisValue = (RedisValue)value; + return new TypedRedisValue(redisValue, redisValue.IsInteger ? RespPrefix.Integer : RespPrefix.Double); + } + /// /// Initialize a from a . /// @@ -228,6 +238,7 @@ public override string ToString() case RespPrefix.BulkString: case RespPrefix.SimpleString: case RespPrefix.Integer: + case RespPrefix.Double: case RespPrefix.SimpleError: return $"{Type}:{_value}"; default: