Skip to content

Commit 1595d50

Browse files
authored
Implement Redis distributed cache without Lua (#54689)
* attempt 1:1 map without Lua * simplify TTL * Revert "simplify TTL" This reverts commit 670a62e. * simplify sync batch wait (tiny race conditions) made the old approach brittle) * 1. stabilize tests - .NET 9 has different test concurrency 2. add async expiration tests (different code path needs exercise)
1 parent f4c2f14 commit 1595d50

File tree

3 files changed

+351
-90
lines changed

3 files changed

+351
-90
lines changed

src/Caching/StackExchangeRedis/src/RedisCache.cs

Lines changed: 46 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -24,52 +24,23 @@ public partial class RedisCache : IDistributedCache, IDisposable
2424
{
2525
// Note that the "force reconnect" pattern as described https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-connection#using-forcereconnect-with-stackexchangeredis
2626
// can be enabled via the "Microsoft.AspNetCore.Caching.StackExchangeRedis.UseForceReconnect" app-context switch
27-
//
28-
// -- Explanation of why two kinds of SetScript are used --
29-
// * Redis 2.0 had HSET key field value for setting individual hash fields,
30-
// and HMSET key field value [field value ...] for setting multiple hash fields (against the same key).
31-
// * Redis 4.0 added HSET key field value [field value ...] and deprecated HMSET.
32-
//
33-
// On Redis versions that don't have the newer HSET variant, we use SetScriptPreExtendedSetCommand
34-
// which uses the (now deprecated) HMSET.
35-
36-
// KEYS[1] = = key
37-
// ARGV[1] = absolute-expiration - ticks as long (-1 for none)
38-
// ARGV[2] = sliding-expiration - ticks as long (-1 for none)
39-
// ARGV[3] = relative-expiration (long, in seconds, -1 for none) - Min(absolute-expiration - Now, sliding-expiration)
40-
// ARGV[4] = data - byte[]
41-
// this order should not change LUA script depends on it
42-
private const string SetScript = (@"
43-
redis.call('HSET', KEYS[1], 'absexp', ARGV[1], 'sldexp', ARGV[2], 'data', ARGV[4])
44-
if ARGV[3] ~= '-1' then
45-
redis.call('EXPIRE', KEYS[1], ARGV[3])
46-
end
47-
return 1");
48-
private const string SetScriptPreExtendedSetCommand = (@"
49-
redis.call('HMSET', KEYS[1], 'absexp', ARGV[1], 'sldexp', ARGV[2], 'data', ARGV[4])
50-
if ARGV[3] ~= '-1' then
51-
redis.call('EXPIRE', KEYS[1], ARGV[3])
52-
end
53-
return 1");
5427

5528
private const string AbsoluteExpirationKey = "absexp";
5629
private const string SlidingExpirationKey = "sldexp";
5730
private const string DataKey = "data";
5831

5932
// combined keys - same hash keys fetched constantly; avoid allocating an array each time
60-
private static readonly RedisValue[] _hashMembersAbsoluteExpirationSlidingExpirationData = new RedisValue[] { AbsoluteExpirationKey, SlidingExpirationKey, DataKey };
61-
private static readonly RedisValue[] _hashMembersAbsoluteExpirationSlidingExpiration = new RedisValue[] { AbsoluteExpirationKey, SlidingExpirationKey };
33+
private static readonly RedisValue[] _hashMembersAbsoluteExpirationSlidingExpirationData = [AbsoluteExpirationKey, SlidingExpirationKey, DataKey];
34+
private static readonly RedisValue[] _hashMembersAbsoluteExpirationSlidingExpiration = [AbsoluteExpirationKey, SlidingExpirationKey];
6235

6336
private static RedisValue[] GetHashFields(bool getData) => getData
6437
? _hashMembersAbsoluteExpirationSlidingExpirationData
6538
: _hashMembersAbsoluteExpirationSlidingExpiration;
6639

6740
private const long NotPresent = -1;
68-
private static readonly Version ServerVersionWithExtendedSetCommand = new Version(4, 0, 0);
6941

7042
private volatile IDatabase? _cache;
7143
private bool _disposed;
72-
private string _setScript = SetScript;
7344

7445
private readonly RedisCacheOptions _options;
7546
private readonly RedisKey _instancePrefix;
@@ -169,14 +140,24 @@ public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
169140

170141
try
171142
{
172-
cache.ScriptEvaluate(_setScript, new RedisKey[] { _instancePrefix.Append(key) },
173-
new RedisValue[]
174-
{
175-
absoluteExpiration?.Ticks ?? NotPresent,
176-
options.SlidingExpiration?.Ticks ?? NotPresent,
177-
GetExpirationInSeconds(creationTime, absoluteExpiration, options) ?? NotPresent,
178-
value
179-
});
143+
var prefixedKey = _instancePrefix.Append(key);
144+
var ttl = GetExpirationInSeconds(creationTime, absoluteExpiration, options);
145+
var fields = GetHashFields(value, absoluteExpiration, options.SlidingExpiration);
146+
147+
if (ttl is null)
148+
{
149+
cache.HashSet(prefixedKey, fields);
150+
}
151+
else
152+
{
153+
// use the batch API to pipeline the two commands and wait synchronously;
154+
// SE.Redis reuses the async API shape for this scenario
155+
var batch = cache.CreateBatch();
156+
var setFields = batch.HashSetAsync(prefixedKey, fields);
157+
var setTtl = batch.KeyExpireAsync(prefixedKey, TimeSpan.FromSeconds(ttl.GetValueOrDefault()));
158+
batch.Execute(); // synchronous wait-for-all; the two tasks should be either complete or *literally about to* (race conditions)
159+
cache.WaitAll(setFields, setTtl); // note this applies usual SE.Redis timeouts etc
160+
}
180161
}
181162
catch (Exception ex)
182163
{
@@ -203,14 +184,21 @@ public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOption
203184

204185
try
205186
{
206-
await cache.ScriptEvaluateAsync(_setScript, new RedisKey[] { _instancePrefix.Append(key) },
207-
new RedisValue[]
208-
{
209-
absoluteExpiration?.Ticks ?? NotPresent,
210-
options.SlidingExpiration?.Ticks ?? NotPresent,
211-
GetExpirationInSeconds(creationTime, absoluteExpiration, options) ?? NotPresent,
212-
value
213-
}).ConfigureAwait(false);
187+
var prefixedKey = _instancePrefix.Append(key);
188+
var ttl = GetExpirationInSeconds(creationTime, absoluteExpiration, options);
189+
var fields = GetHashFields(value, absoluteExpiration, options.SlidingExpiration);
190+
191+
if (ttl is null)
192+
{
193+
await cache.HashSetAsync(prefixedKey, fields).ConfigureAwait(false);
194+
}
195+
else
196+
{
197+
await Task.WhenAll(
198+
cache.HashSetAsync(prefixedKey, fields),
199+
cache.KeyExpireAsync(prefixedKey, TimeSpan.FromSeconds(ttl.GetValueOrDefault()))
200+
).ConfigureAwait(false);
201+
}
214202
}
215203
catch (Exception ex)
216204
{
@@ -219,6 +207,13 @@ public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOption
219207
}
220208
}
221209

210+
private static HashEntry[] GetHashFields(RedisValue value, DateTimeOffset? absoluteExpiration, TimeSpan? slidingExpiration)
211+
=> [
212+
new HashEntry(AbsoluteExpirationKey, absoluteExpiration?.Ticks ?? NotPresent),
213+
new HashEntry(SlidingExpirationKey, slidingExpiration?.Ticks ?? NotPresent),
214+
new HashEntry(DataKey, value)
215+
];
216+
222217
/// <inheritdoc />
223218
public void Refresh(string key)
224219
{
@@ -323,36 +318,10 @@ private async ValueTask<IDatabase> ConnectSlowAsync(CancellationToken token)
323318
private void PrepareConnection(IConnectionMultiplexer connection)
324319
{
325320
WriteTimeTicks(ref _lastConnectTicks, DateTimeOffset.UtcNow);
326-
ValidateServerFeatures(connection);
327321
TryRegisterProfiler(connection);
328322
TryAddSuffix(connection);
329323
}
330324

331-
private void ValidateServerFeatures(IConnectionMultiplexer connection)
332-
{
333-
_ = connection ?? throw new InvalidOperationException($"{nameof(connection)} cannot be null.");
334-
335-
try
336-
{
337-
foreach (var endPoint in connection.GetEndPoints())
338-
{
339-
if (connection.GetServer(endPoint).Version < ServerVersionWithExtendedSetCommand)
340-
{
341-
_setScript = SetScriptPreExtendedSetCommand;
342-
return;
343-
}
344-
}
345-
}
346-
catch (NotSupportedException ex)
347-
{
348-
Log.CouldNotDetermineServerVersion(_logger, ex);
349-
350-
// The GetServer call may not be supported with some configurations, in which
351-
// case let's also fall back to using the older command.
352-
_setScript = SetScriptPreExtendedSetCommand;
353-
}
354-
}
355-
356325
private void TryRegisterProfiler(IConnectionMultiplexer connection)
357326
{
358327
_ = connection ?? throw new InvalidOperationException($"{nameof(connection)} cannot be null.");
@@ -372,7 +341,7 @@ private void TryAddSuffix(IConnectionMultiplexer connection)
372341
}
373342
catch (Exception ex)
374343
{
375-
Log.UnableToAddLibraryNameSuffix(_logger, ex);;
344+
Log.UnableToAddLibraryNameSuffix(_logger, ex); ;
376345
}
377346
}
378347

@@ -557,6 +526,9 @@ private async Task RefreshAsync(IDatabase cache, string key, DateTimeOffset? abs
557526
}
558527
}
559528

529+
// it is not an oversight that this returns seconds rather than TimeSpan (which SE.Redis can accept directly); by
530+
// leaving this as an integer, we use TTL rather than PTTL, which has better compatibility between servers
531+
// (it also takes a handful fewer bytes, but that isn't a motivating factor)
560532
private static long? GetExpirationInSeconds(DateTimeOffset creationTime, DateTimeOffset? absoluteExpiration, DistributedCacheEntryOptions options)
561533
{
562534
if (absoluteExpiration.HasValue && options.SlidingExpiration.HasValue)

0 commit comments

Comments
 (0)