Skip to content

Commit 24ed30c

Browse files
authored
Support MSETEX (#2977)
* Support MSETEX * release notes * prefer WriteBulkString("WHATEVER"u8) over WriteBulkString(RedisLiterals.WHATEVER) * create Expiration as top-level concept * The method is no longer ambiguous, yay (also: typos) * Mark the MSETEX API as [Experimental], with docs * actually, we can't make that [SER002] because of overload resolution
1 parent e5120f7 commit 24ed30c

20 files changed

+725
-207
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
1111
<CodeAnalysisRuleset>$(MSBuildThisFileDirectory)Shared.ruleset</CodeAnalysisRuleset>
1212
<MSBuildWarningsAsMessages>NETSDK1069</MSBuildWarningsAsMessages>
13-
<NoWarn>$(NoWarn);NU5105;NU1507;SER001</NoWarn>
13+
<NoWarn>$(NoWarn);NU5105;NU1507;SER001;SER002</NoWarn>
1414
<PackageReleaseNotes>https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes</PackageReleaseNotes>
1515
<PackageProjectUrl>https://stackexchange.github.io/StackExchange.Redis/</PackageProjectUrl>
1616
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
22
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=OK/@EntryIndexedValue">OK</s:String>
33
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PONG/@EntryIndexedValue">PONG</s:String>
4+
<s:Boolean x:Key="/Default/UserDictionary/Words/=bitop/@EntryIndexedValue">True</s:Boolean>
5+
<s:Boolean x:Key="/Default/UserDictionary/Words/=evalsha/@EntryIndexedValue">True</s:Boolean>
6+
<s:Boolean x:Key="/Default/UserDictionary/Words/=geoadd/@EntryIndexedValue">True</s:Boolean>
7+
<s:Boolean x:Key="/Default/UserDictionary/Words/=getrange/@EntryIndexedValue">True</s:Boolean>
8+
<s:Boolean x:Key="/Default/UserDictionary/Words/=getset/@EntryIndexedValue">True</s:Boolean>
9+
<s:Boolean x:Key="/Default/UserDictionary/Words/=hincrby/@EntryIndexedValue">True</s:Boolean>
10+
<s:Boolean x:Key="/Default/UserDictionary/Words/=hmget/@EntryIndexedValue">True</s:Boolean>
11+
<s:Boolean x:Key="/Default/UserDictionary/Words/=hscan/@EntryIndexedValue">True</s:Boolean>
12+
<s:Boolean x:Key="/Default/UserDictionary/Words/=keepttl/@EntryIndexedValue">True</s:Boolean>
13+
<s:Boolean x:Key="/Default/UserDictionary/Words/=lpush/@EntryIndexedValue">True</s:Boolean>
14+
<s:Boolean x:Key="/Default/UserDictionary/Words/=lrange/@EntryIndexedValue">True</s:Boolean>
415
<s:Boolean x:Key="/Default/UserDictionary/Words/=pubsub/@EntryIndexedValue">True</s:Boolean>
5-
<s:Boolean x:Key="/Default/UserDictionary/Words/=vectorset/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
16+
<s:Boolean x:Key="/Default/UserDictionary/Words/=rpush/@EntryIndexedValue">True</s:Boolean>
17+
<s:Boolean x:Key="/Default/UserDictionary/Words/=sscan/@EntryIndexedValue">True</s:Boolean>
18+
<s:Boolean x:Key="/Default/UserDictionary/Words/=vectorset/@EntryIndexedValue">True</s:Boolean>
19+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xinfo/@EntryIndexedValue">True</s:Boolean>
20+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xpending/@EntryIndexedValue">True</s:Boolean>
21+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xrange/@EntryIndexedValue">True</s:Boolean>
22+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xread/@EntryIndexedValue">True</s:Boolean>
23+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xreadgroup/@EntryIndexedValue">True</s:Boolean>
24+
<s:Boolean x:Key="/Default/UserDictionary/Words/=xrevrange/@EntryIndexedValue">True</s:Boolean>
25+
<s:Boolean x:Key="/Default/UserDictionary/Words/=zcard/@EntryIndexedValue">True</s:Boolean>
26+
<s:Boolean x:Key="/Default/UserDictionary/Words/=zscan/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

docs/ReleaseNotes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Current package versions:
88

99
## Unreleased
1010

11+
- Support `MSETEX` (Redis 8.4.0) for multi-key operations with expiration ([#2977 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2977))
12+
1113
## 2.9.32
1214

1315
- Fix `SSUBSCRIBE` routing during slot migrations ([#2969 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2969))

docs/exp/SER002.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
Redis 8.4 is currently in preview and may be subject to change.
2+
3+
New features in Redis 8.4 include:
4+
5+
- [`MSETEX`](https://github.com/redis/redis/pull/14434) for setting multiple strings with expiry
6+
- [`XREADGROUP ... CLAIM`](https://github.com/redis/redis/pull/14402) for simplifed stream consumption
7+
- [`SET ... {IFEQ|IFNE|IFDEQ|IFDNE}`, `DELEX` and `DIGEST`](https://github.com/redis/redis/pull/14434) for checked (CAS/CAD) string operations
8+
9+
The corresponding library feature must also be considered subject to change:
10+
11+
1. Existing bindings may cease working correctly if the underlying server API changes.
12+
2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time
13+
or run-time breaks.
14+
15+
While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress
16+
this warning by adding the following to your `csproj` file:
17+
18+
```xml
19+
<NoWarn>$(NoWarn);SER002</NoWarn>
20+
```
21+
22+
or more granularly / locally in C#:
23+
24+
``` c#
25+
#pragma warning disable SER002
26+
```

src/StackExchange.Redis/CommandMap.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public sealed class CommandMap
2727
RedisCommand.KEYS, RedisCommand.MIGRATE, RedisCommand.MOVE, RedisCommand.OBJECT, RedisCommand.RANDOMKEY,
2828
RedisCommand.RENAME, RedisCommand.RENAMENX, RedisCommand.SCAN,
2929

30-
RedisCommand.BITOP, RedisCommand.MSETNX,
30+
RedisCommand.BITOP, RedisCommand.MSETEX, RedisCommand.MSETNX,
3131

3232
RedisCommand.BLPOP, RedisCommand.BRPOP, RedisCommand.BRPOPLPUSH, // yeah, me neither!
3333

@@ -53,7 +53,7 @@ public sealed class CommandMap
5353
RedisCommand.KEYS, RedisCommand.MIGRATE, RedisCommand.MOVE, RedisCommand.OBJECT, RedisCommand.RANDOMKEY,
5454
RedisCommand.RENAME, RedisCommand.RENAMENX, RedisCommand.SORT, RedisCommand.SCAN,
5555

56-
RedisCommand.BITOP, RedisCommand.MSETNX,
56+
RedisCommand.BITOP, RedisCommand.MSETEX, RedisCommand.MSETNX,
5757

5858
RedisCommand.BLPOP, RedisCommand.BRPOP, RedisCommand.BRPOPLPUSH, // yeah, me neither!
5959

src/StackExchange.Redis/Enums/RedisCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ internal enum RedisCommand
122122
MONITOR,
123123
MOVE,
124124
MSET,
125+
MSETEX,
125126
MSETNX,
126127
MULTI,
127128

@@ -336,6 +337,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
336337
case RedisCommand.MIGRATE:
337338
case RedisCommand.MOVE:
338339
case RedisCommand.MSET:
340+
case RedisCommand.MSETEX:
339341
case RedisCommand.MSETNX:
340342
case RedisCommand.PERSIST:
341343
case RedisCommand.PEXPIRE:

src/StackExchange.Redis/Experiments.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ internal static class Experiments
99
{
1010
public const string UrlFormat = "https://stackexchange.github.io/StackExchange.Redis/exp/";
1111
public const string VectorSets = "SER001";
12+
// ReSharper disable once InconsistentNaming
13+
public const string Server_8_4 = "SER002";
1214
}
1315
}
1416

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
using System;
2+
3+
namespace StackExchange.Redis;
4+
5+
/// <summary>
6+
/// Configures the expiration behaviour of a command.
7+
/// </summary>
8+
public readonly struct Expiration
9+
{
10+
/*
11+
Redis expiration supports different modes:
12+
- (nothing) - do nothing; implicit wipe for writes, nothing for reads
13+
- PERSIST - explicit wipe of expiry
14+
- KEEPTTL - sets no expiry, but leaves any existing expiry alone
15+
- EX {s} - relative expiry in seconds
16+
- PX {ms} - relative expiry in milliseconds
17+
- EXAT {s} - absolute expiry in seconds
18+
- PXAT {ms} - absolute expiry in milliseconds
19+
20+
We need to distinguish between these 6 scenarios, which we can logically do with 3 bits (8 options).
21+
So; we'll use a ulong for the value, reserving the top 3 bits for the mode.
22+
*/
23+
24+
/// <summary>
25+
/// Default expiration behaviour. For writes, this is typically no expiration. For reads, this is typically no action.
26+
/// </summary>
27+
public static Expiration Default => s_Default;
28+
29+
/// <summary>
30+
/// Explicitly retain the existing expiry, if one. This is valid in some (not all) write scenarios.
31+
/// </summary>
32+
public static Expiration KeepTtl => s_KeepTtl;
33+
34+
/// <summary>
35+
/// Explicitly remove the existing expiry, if one. This is valid in some (not all) read scenarios.
36+
/// </summary>
37+
public static Expiration Persist => s_Persist;
38+
39+
/// <summary>
40+
/// Expire at the specified absolute time.
41+
/// </summary>
42+
public Expiration(DateTime when)
43+
{
44+
if (when == DateTime.MaxValue)
45+
{
46+
_valueAndMode = s_Default._valueAndMode;
47+
return;
48+
}
49+
50+
long millis = GetUnixTimeMilliseconds(when);
51+
if ((millis % 1000) == 0)
52+
{
53+
Init(ExpirationMode.AbsoluteSeconds, millis / 1000, out _valueAndMode);
54+
}
55+
else
56+
{
57+
Init(ExpirationMode.AbsoluteMilliseconds, millis, out _valueAndMode);
58+
}
59+
}
60+
61+
/// <summary>
62+
/// Expire at the specified absolute time.
63+
/// </summary>
64+
public static implicit operator Expiration(DateTime when) => new(when);
65+
66+
/// <summary>
67+
/// Expire at the specified absolute time.
68+
/// </summary>
69+
public static implicit operator Expiration(TimeSpan ttl) => new(ttl);
70+
71+
/// <summary>
72+
/// Expire at the specified relative time.
73+
/// </summary>
74+
public Expiration(TimeSpan ttl)
75+
{
76+
if (ttl == TimeSpan.MaxValue)
77+
{
78+
_valueAndMode = s_Default._valueAndMode;
79+
return;
80+
}
81+
82+
var millis = ttl.Ticks / TimeSpan.TicksPerMillisecond;
83+
if ((millis % 1000) == 0)
84+
{
85+
Init(ExpirationMode.RelativeSeconds, millis / 1000, out _valueAndMode);
86+
}
87+
else
88+
{
89+
Init(ExpirationMode.RelativeMilliseconds, millis, out _valueAndMode);
90+
}
91+
}
92+
93+
private readonly ulong _valueAndMode;
94+
95+
private static void Init(ExpirationMode mode, long value, out ulong valueAndMode)
96+
{
97+
// check the caller isn't using the top 3 bits that we have reserved; this includes checking for -ve values
98+
ulong uValue = (ulong)value;
99+
if ((uValue & ~ValueMask) != 0) Throw();
100+
valueAndMode = (uValue & ValueMask) | ((ulong)mode << 61);
101+
static void Throw() => throw new ArgumentOutOfRangeException(nameof(value));
102+
}
103+
104+
private Expiration(ExpirationMode mode, long value) => Init(mode, value, out _valueAndMode);
105+
106+
private enum ExpirationMode : byte
107+
{
108+
Default = 0,
109+
RelativeSeconds = 1,
110+
RelativeMilliseconds = 2,
111+
AbsoluteSeconds = 3,
112+
AbsoluteMilliseconds = 4,
113+
KeepTtl = 5,
114+
Persist = 6,
115+
NotUsed = 7, // just to ensure all 8 possible values are covered
116+
}
117+
118+
private const ulong ValueMask = (~0UL) >> 3;
119+
internal long Value => unchecked((long)(_valueAndMode & ValueMask));
120+
private ExpirationMode Mode => (ExpirationMode)(_valueAndMode >> 61); // note unsigned, no need to mask
121+
122+
internal bool IsKeepTtl => Mode is ExpirationMode.KeepTtl;
123+
internal bool IsPersist => Mode is ExpirationMode.Persist;
124+
internal bool IsNone => Mode is ExpirationMode.Default;
125+
internal bool IsNoneOrKeepTtl => Mode is ExpirationMode.Default or ExpirationMode.KeepTtl;
126+
internal bool IsAbsolute => Mode is ExpirationMode.AbsoluteSeconds or ExpirationMode.AbsoluteMilliseconds;
127+
internal bool IsRelative => Mode is ExpirationMode.RelativeSeconds or ExpirationMode.RelativeMilliseconds;
128+
129+
internal bool IsMilliseconds =>
130+
Mode is ExpirationMode.RelativeMilliseconds or ExpirationMode.AbsoluteMilliseconds;
131+
132+
internal bool IsSeconds => Mode is ExpirationMode.RelativeSeconds or ExpirationMode.AbsoluteSeconds;
133+
134+
private static readonly Expiration s_Default = new(ExpirationMode.Default, 0);
135+
136+
private static readonly Expiration s_KeepTtl = new(ExpirationMode.KeepTtl, 0),
137+
s_Persist = new(ExpirationMode.Persist, 0);
138+
139+
private static void ThrowExpiryAndKeepTtl() =>
140+
// ReSharper disable once NotResolvedInText
141+
throw new ArgumentException(message: "Cannot specify both expiry and keepTtl.", paramName: "keepTtl");
142+
143+
private static void ThrowExpiryAndPersist() =>
144+
// ReSharper disable once NotResolvedInText
145+
throw new ArgumentException(message: "Cannot specify both expiry and persist.", paramName: "persist");
146+
147+
internal static Expiration CreateOrPersist(in TimeSpan? ttl, bool persist)
148+
{
149+
if (persist)
150+
{
151+
if (ttl.HasValue) ThrowExpiryAndPersist();
152+
return s_Persist;
153+
}
154+
155+
return ttl.HasValue ? new(ttl.GetValueOrDefault()) : s_Default;
156+
}
157+
158+
internal static Expiration CreateOrKeepTtl(in TimeSpan? ttl, bool keepTtl)
159+
{
160+
if (keepTtl)
161+
{
162+
if (ttl.HasValue) ThrowExpiryAndKeepTtl();
163+
return s_KeepTtl;
164+
}
165+
166+
return ttl.HasValue ? new(ttl.GetValueOrDefault()) : s_Default;
167+
}
168+
169+
internal static long GetUnixTimeMilliseconds(DateTime when)
170+
{
171+
return when.Kind switch
172+
{
173+
DateTimeKind.Local or DateTimeKind.Utc => (when.ToUniversalTime() - RedisBase.UnixEpoch).Ticks /
174+
TimeSpan.TicksPerMillisecond,
175+
_ => ThrowKind(),
176+
};
177+
178+
static long ThrowKind() =>
179+
throw new ArgumentException("Expiry time must be either Utc or Local", nameof(when));
180+
}
181+
182+
internal static Expiration CreateOrPersist(in DateTime? when, bool persist)
183+
{
184+
if (persist)
185+
{
186+
if (when.HasValue) ThrowExpiryAndPersist();
187+
return s_Persist;
188+
}
189+
190+
return when.HasValue ? new(when.GetValueOrDefault()) : s_Default;
191+
}
192+
193+
internal static Expiration CreateOrKeepTtl(in DateTime? ttl, bool keepTtl)
194+
{
195+
if (keepTtl)
196+
{
197+
if (ttl.HasValue) ThrowExpiryAndKeepTtl();
198+
return s_KeepTtl;
199+
}
200+
201+
return ttl.HasValue ? new(ttl.GetValueOrDefault()) : s_Default;
202+
}
203+
204+
internal RedisValue Operand => GetOperand(out _);
205+
206+
internal RedisValue GetOperand(out long value)
207+
{
208+
value = Value;
209+
var mode = Mode;
210+
return mode switch
211+
{
212+
ExpirationMode.KeepTtl => RedisLiterals.KEEPTTL,
213+
ExpirationMode.Persist => RedisLiterals.PERSIST,
214+
ExpirationMode.RelativeSeconds => RedisLiterals.EX,
215+
ExpirationMode.RelativeMilliseconds => RedisLiterals.PX,
216+
ExpirationMode.AbsoluteSeconds => RedisLiterals.EXAT,
217+
ExpirationMode.AbsoluteMilliseconds => RedisLiterals.PXAT,
218+
_ => RedisValue.Null,
219+
};
220+
}
221+
222+
private static void ThrowMode(ExpirationMode mode) =>
223+
throw new InvalidOperationException("Unknown mode: " + mode);
224+
225+
/// <inheritdoc/>
226+
public override string ToString() => Mode switch
227+
{
228+
ExpirationMode.Default or ExpirationMode.NotUsed => "",
229+
ExpirationMode.KeepTtl => "KEEPTTL",
230+
ExpirationMode.Persist => "PERSIST",
231+
_ => $"{Operand} {Value}",
232+
};
233+
234+
/// <inheritdoc/>
235+
public override int GetHashCode() => _valueAndMode.GetHashCode();
236+
237+
/// <inheritdoc/>
238+
public override bool Equals(object? obj) => obj is Expiration other && _valueAndMode == other._valueAndMode;
239+
240+
internal int Tokens => Mode switch
241+
{
242+
ExpirationMode.Default or ExpirationMode.NotUsed => 0,
243+
ExpirationMode.KeepTtl or ExpirationMode.Persist => 1,
244+
_ => 2,
245+
};
246+
247+
internal void WriteTo(PhysicalConnection physical)
248+
{
249+
var mode = Mode;
250+
switch (Mode)
251+
{
252+
case ExpirationMode.Default or ExpirationMode.NotUsed:
253+
break;
254+
case ExpirationMode.KeepTtl:
255+
physical.WriteBulkString("KEEPTTL"u8);
256+
break;
257+
case ExpirationMode.Persist:
258+
physical.WriteBulkString("PERSIST"u8);
259+
break;
260+
default:
261+
physical.WriteBulkString(mode switch
262+
{
263+
ExpirationMode.RelativeSeconds => "EX"u8,
264+
ExpirationMode.RelativeMilliseconds => "PX"u8,
265+
ExpirationMode.AbsoluteSeconds => "EXAT"u8,
266+
ExpirationMode.AbsoluteMilliseconds => "PXAT"u8,
267+
_ => default,
268+
});
269+
physical.WriteBulkString(Value);
270+
break;
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)