@@ -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