@@ -38,15 +38,13 @@ public HybridCacheClient(ICacheClient distributedCacheClient, IMessageBus messag
3838 _distributedCache = distributedCacheClient ;
3939 _messageBus = messageBus ;
4040 _messageBus . SubscribeAsync < InvalidateCache > ( OnRemoteCacheItemExpiredAsync ) . AnyContext ( ) . GetAwaiter ( ) . GetResult ( ) ;
41- if ( localCacheOptions is null )
42- localCacheOptions = new InMemoryCacheClientOptions
43- {
44- TimeProvider = _timeProvider ,
45- ResiliencePolicyProvider = _resiliencePolicyProvider ,
46- LoggerFactory = loggerFactory
47- } ;
41+ localCacheOptions ??= new InMemoryCacheClientOptions
42+ {
43+ TimeProvider = _timeProvider ,
44+ ResiliencePolicyProvider = _resiliencePolicyProvider ,
45+ LoggerFactory = loggerFactory
46+ } ;
4847 _localCache = new InMemoryCacheClient ( localCacheOptions ) ;
49- _localCache . ItemExpired . AddHandler ( OnLocalCacheItemExpiredAsync ) ;
5048 }
5149
5250 public InMemoryCacheClient LocalCache => _localCache ;
@@ -58,15 +56,6 @@ public HybridCacheClient(ICacheClient distributedCacheClient, IMessageBus messag
5856 TimeProvider IHaveTimeProvider . TimeProvider => _timeProvider ;
5957 IResiliencePolicyProvider IHaveResiliencePolicyProvider . ResiliencePolicyProvider => _resiliencePolicyProvider ;
6058
61- private Task OnLocalCacheItemExpiredAsync ( object sender , ItemExpiredEventArgs args )
62- {
63- if ( ! args . SendNotification )
64- return Task . CompletedTask ;
65-
66- _logger . LogTrace ( "Local cache expired event: key={Key}" , args . Key ) ;
67- return _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ args . Key ] , Expired = true } ) ;
68- }
69-
7059 private Task OnRemoteCacheItemExpiredAsync ( InvalidateCache message )
7160 {
7261 if ( ! String . IsNullOrEmpty ( message . CacheId ) && String . Equals ( _cacheId , message . CacheId ) )
@@ -88,7 +77,7 @@ private Task OnRemoteCacheItemExpiredAsync(InvalidateCache message)
8877 {
8978 if ( message . Expired )
9079 _localCache . RemoveExpiredKey ( key , false ) ;
91- else if ( key . EndsWith ( "*" ) )
80+ else if ( key . EndsWith ( '*' ) )
9281 tasks . Add ( _localCache . RemoveByPrefixAsync ( key . Substring ( 0 , key . Length - 1 ) ) ) ;
9382 else
9483 keysToRemove . Add ( key ) ;
@@ -138,7 +127,7 @@ public async Task<int> RemoveByPrefixAsync(string prefix)
138127 {
139128 int removed = await _distributedCache . RemoveByPrefixAsync ( prefix ) . AnyContext ( ) ;
140129 await _localCache . RemoveByPrefixAsync ( prefix ) . AnyContext ( ) ;
141- await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ prefix + " *"] } ) . AnyContext ( ) ;
130+ await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ $ " { prefix } *"] } ) . AnyContext ( ) ;
142131 return removed ;
143132 }
144133
@@ -244,12 +233,21 @@ public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiresIn = n
244233 {
245234 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
246235
247- _logger . LogTrace ( "Setting key {Key} to local cache with expiration: {Expiration}" , key , expiresIn ) ;
248- await _localCache . SetAsync ( key , value , expiresIn ) . AnyContext ( ) ;
249- bool set = await _distributedCache . SetAsync ( key , value , expiresIn ) . AnyContext ( ) ;
236+ _logger . LogTrace ( "Setting key {Key} with expiration: {Expiration}" , key , expiresIn ) ;
237+ bool updated = await _distributedCache . SetAsync ( key , value , expiresIn ) . AnyContext ( ) ;
238+ if ( updated )
239+ {
240+ await _localCache . SetAsync ( key , value , expiresIn ) . AnyContext ( ) ;
241+ }
242+ else
243+ {
244+ // Remove from local cache when set fails (e.g., past expiration removes the key)
245+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
246+ }
247+
250248 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
251249
252- return set ;
250+ return updated ;
253251 }
254252
255253 public async Task < int > SetAllAsync < T > ( IDictionary < string , T > values , TimeSpan ? expiresIn = null )
@@ -258,19 +256,38 @@ public async Task<int> SetAllAsync<T>(IDictionary<string, T> values, TimeSpan? e
258256 if ( values . Count is 0 )
259257 return 0 ;
260258
261- _logger . LogTrace ( "Adding keys {Keys} to local cache with expiration: {Expiration}" , values . Keys , expiresIn ) ;
262- await _localCache . SetAllAsync ( values , expiresIn ) . AnyContext ( ) ;
263- int set = await _distributedCache . SetAllAsync ( values , expiresIn ) . AnyContext ( ) ;
259+ _logger . LogTrace ( "Setting keys {Keys} with expiration: {Expiration}" , values . Keys , expiresIn ) ;
260+ int setCount = await _distributedCache . SetAllAsync ( values , expiresIn ) . AnyContext ( ) ;
261+ if ( setCount == values . Count )
262+ {
263+ await _localCache . SetAllAsync ( values , expiresIn ) . AnyContext ( ) ;
264+ }
265+ else
266+ {
267+ // Remove all keys from local cache when set fails or partially succeeds.
268+ // We don't know which specific keys succeeded, so remove all to force re-fetch.
269+ await _localCache . RemoveAllAsync ( values . Keys ) . AnyContext ( ) ;
270+ }
271+
264272 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = values . Keys . ToArray ( ) } ) . AnyContext ( ) ;
265- return set ;
273+ return setCount ;
266274 }
267275
268276 public async Task < bool > ReplaceAsync < T > ( string key , T value , TimeSpan ? expiresIn = null )
269277 {
270278 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
271279
272- await _localCache . ReplaceAsync ( key , value , expiresIn ) . AnyContext ( ) ;
273280 bool replaced = await _distributedCache . ReplaceAsync ( key , value , expiresIn ) . AnyContext ( ) ;
281+ if ( replaced )
282+ {
283+ await _localCache . SetAsync ( key , value , expiresIn ) . AnyContext ( ) ;
284+ }
285+ else
286+ {
287+ // Remove from local cache when replace fails (e.g., past expiration removes the key)
288+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
289+ }
290+
274291 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
275292 return replaced ;
276293 }
@@ -279,8 +296,19 @@ public async Task<bool> ReplaceIfEqualAsync<T>(string key, T value, T expected,
279296 {
280297 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
281298
282- await _localCache . ReplaceIfEqualAsync ( key , value , expected , expiresIn ) . AnyContext ( ) ;
283299 bool replaced = await _distributedCache . ReplaceIfEqualAsync ( key , value , expected , expiresIn ) . AnyContext ( ) ;
300+ if ( replaced )
301+ {
302+ // Use SetAsync instead of ReplaceIfEqualAsync for local cache because we know the
303+ // distributed cache now has this exact value, and we need local cache to be in sync.
304+ await _localCache . SetAsync ( key , value , expiresIn ) . AnyContext ( ) ;
305+ }
306+ else
307+ {
308+ // Remove from local cache when replace fails (e.g., past expiration removes the key)
309+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
310+ }
311+
284312 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
285313 return replaced ;
286314 }
@@ -289,20 +317,56 @@ public async Task<double> IncrementAsync(string key, double amount, TimeSpan? ex
289317 {
290318 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
291319
292- double incremented = await _distributedCache . IncrementAsync ( key , amount , expiresIn ) . AnyContext ( ) ;
293- await _localCache . ReplaceAsync ( key , incremented , expiresIn ) ;
320+ double newValue = await _distributedCache . IncrementAsync ( key , amount , expiresIn ) . AnyContext ( ) ;
321+
322+ if ( expiresIn . HasValue )
323+ {
324+ // When expiration is specified, we can safely cache the new value locally
325+ await _localCache . SetAsync ( key , newValue , expiresIn ) . AnyContext ( ) ;
326+ }
327+ else
328+ {
329+ // When expiresIn is null, IncrementAsync preserves existing TTL in L2 (distributed cache).
330+ // We cannot replicate TTL preservation in L1 without an extra network call to fetch the TTL.
331+ // Options considered:
332+ // 1. SetAsync(key, newValue, null) - L1 never expires, could serve stale data after L2 expires
333+ // 2. RemoveAsync(key) - Forces re-fetch on next read, guarantees consistency (current approach)
334+ // 3. Fetch TTL from L2 then SetAsync - Extra network call on every increment
335+ // We choose option 2 for correctness over performance. Users who want to avoid the re-fetch
336+ // overhead should pass an explicit expiration value to IncrementAsync.
337+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
338+ }
339+
294340 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
295- return incremented ;
341+ return newValue ;
296342 }
297343
298344 public async Task < long > IncrementAsync ( string key , long amount , TimeSpan ? expiresIn = null )
299345 {
300346 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
301347
302- long incremented = await _distributedCache . IncrementAsync ( key , amount , expiresIn ) . AnyContext ( ) ;
303- await _localCache . ReplaceAsync ( key , incremented , expiresIn ) ;
348+ long newValue = await _distributedCache . IncrementAsync ( key , amount , expiresIn ) . AnyContext ( ) ;
349+
350+ if ( expiresIn . HasValue )
351+ {
352+ // When expiration is specified, we can safely cache the new value locally
353+ await _localCache . SetAsync ( key , newValue , expiresIn ) . AnyContext ( ) ;
354+ }
355+ else
356+ {
357+ // When expiresIn is null, IncrementAsync preserves existing TTL in L2 (distributed cache).
358+ // We cannot replicate TTL preservation in L1 without an extra network call to fetch the TTL.
359+ // Options considered:
360+ // 1. SetAsync(key, newValue, null) - L1 never expires, could serve stale data after L2 expires
361+ // 2. RemoveAsync(key) - Forces re-fetch on next read, guarantees consistency (current approach)
362+ // 3. Fetch TTL from L2 then SetAsync - Extra network call on every increment
363+ // We choose option 2 for correctness over performance. Users who want to avoid the re-fetch
364+ // overhead should pass an explicit expiration value to IncrementAsync.
365+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
366+ }
367+
304368 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
305- return incremented ;
369+ return newValue ;
306370 }
307371
308372 public async Task < bool > ExistsAsync ( string key )
@@ -377,8 +441,8 @@ public async Task SetExpirationAsync(string key, TimeSpan expiresIn)
377441 {
378442 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
379443
380- await _localCache . SetExpirationAsync ( key , expiresIn ) . AnyContext ( ) ;
381444 await _distributedCache . SetExpirationAsync ( key , expiresIn ) . AnyContext ( ) ;
445+ await _localCache . SetExpirationAsync ( key , expiresIn ) . AnyContext ( ) ;
382446 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
383447 }
384448
@@ -389,17 +453,22 @@ public async Task SetAllExpirationAsync(IDictionary<string, TimeSpan?> expiratio
389453 if ( expirations . Count is 0 )
390454 return ;
391455
392- await _localCache . SetAllExpirationAsync ( expirations ) . AnyContext ( ) ;
393456 await _distributedCache . SetAllExpirationAsync ( expirations ) . AnyContext ( ) ;
457+ await _localCache . SetAllExpirationAsync ( expirations ) . AnyContext ( ) ;
394458 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = expirations . Keys . ToArray ( ) } ) . AnyContext ( ) ;
395459 }
396460
397461 public async Task < double > SetIfHigherAsync ( string key , double value , TimeSpan ? expiresIn = null )
398462 {
399463 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
400464
401- await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
402465 double difference = await _distributedCache . SetIfHigherAsync ( key , value , expiresIn ) . AnyContext ( ) ;
466+
467+ // Always remove from local cache. Even when difference == 0 (value wasn't changed because
468+ // the existing value was already higher), we don't know what the actual current value is.
469+ // We only know our value wasn't higher, not what the distributed cache actually contains.
470+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
471+
403472 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
404473 return difference ;
405474 }
@@ -408,8 +477,13 @@ public async Task<long> SetIfHigherAsync(string key, long value, TimeSpan? expir
408477 {
409478 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
410479
411- await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
412480 long difference = await _distributedCache . SetIfHigherAsync ( key , value , expiresIn ) . AnyContext ( ) ;
481+
482+ // Always remove from local cache. Even when difference == 0 (value wasn't changed because
483+ // the existing value was already higher), we don't know what the actual current value is.
484+ // We only know our value wasn't higher, not what the distributed cache actually contains.
485+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
486+
413487 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
414488 return difference ;
415489 }
@@ -418,8 +492,13 @@ public async Task<double> SetIfLowerAsync(string key, double value, TimeSpan? ex
418492 {
419493 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
420494
421- await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
422495 double difference = await _distributedCache . SetIfLowerAsync ( key , value , expiresIn ) . AnyContext ( ) ;
496+
497+ // Always remove from local cache. Even when difference == 0 (value wasn't changed because
498+ // the existing value was already lower), we don't know what the actual current value is.
499+ // We only know our value wasn't lower, not what the distributed cache actually contains.
500+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
501+
423502 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
424503 return difference ;
425504 }
@@ -428,8 +507,13 @@ public async Task<long> SetIfLowerAsync(string key, long value, TimeSpan? expire
428507 {
429508 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
430509
431- await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
432510 long difference = await _distributedCache . SetIfLowerAsync ( key , value , expiresIn ) . AnyContext ( ) ;
511+
512+ // Always remove from local cache. Even when difference == 0 (value wasn't changed because
513+ // the existing value was already lower), we don't know what the actual current value is.
514+ // We only know our value wasn't lower, not what the distributed cache actually contains.
515+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
516+
433517 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
434518 return difference ;
435519 }
@@ -439,43 +523,81 @@ public async Task<long> ListAddAsync<T>(string key, IEnumerable<T> values, TimeS
439523 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
440524 ArgumentNullException . ThrowIfNull ( values ) ;
441525
526+ // Handle string specially to avoid treating it as IEnumerable<char>
442527 if ( values is string stringValue )
443528 {
444- await _localCache . ListAddAsync ( key , stringValue , expiresIn ) . AnyContext ( ) ;
445- long set = await _distributedCache . ListAddAsync ( key , stringValue , expiresIn ) . AnyContext ( ) ;
529+ long added = await _distributedCache . ListAddAsync ( key , stringValue , expiresIn ) . AnyContext ( ) ;
530+ if ( added == 1 )
531+ {
532+ // String added successfully - update local cache
533+ await _localCache . ListAddAsync ( key , stringValue , expiresIn ) . AnyContext ( ) ;
534+ }
535+ else
536+ {
537+ // Failed - remove to force re-fetch
538+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
539+ }
540+
446541 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
447- return set ;
542+ return added ;
448543 }
449- else
544+
545+ var items = values . ToArray ( ) ;
546+ long addedCount = await _distributedCache . ListAddAsync ( key , items , expiresIn ) . AnyContext ( ) ;
547+ if ( addedCount == items . Length )
450548 {
451- var items = values . ToArray ( ) ;
549+ // All items added successfully - update local cache
452550 await _localCache . ListAddAsync ( key , items , expiresIn ) . AnyContext ( ) ;
453- long set = await _distributedCache . ListAddAsync ( key , items , expiresIn ) . AnyContext ( ) ;
454- await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
455- return set ;
456551 }
552+ else
553+ {
554+ // Partial success - remove to force re-fetch
555+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
556+ }
557+
558+ await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
559+ return addedCount ;
457560 }
458561
459562 public async Task < long > ListRemoveAsync < T > ( string key , IEnumerable < T > values , TimeSpan ? expiresIn = null )
460563 {
461564 ArgumentException . ThrowIfNullOrEmpty ( key ) ;
462565 ArgumentNullException . ThrowIfNull ( values ) ;
463566
567+ // Handle string specially to avoid treating it as IEnumerable<char>
464568 if ( values is string stringValue )
465569 {
466- await _localCache . ListRemoveAsync ( key , stringValue , expiresIn ) . AnyContext ( ) ;
467570 long removed = await _distributedCache . ListRemoveAsync ( key , stringValue , expiresIn ) . AnyContext ( ) ;
571+ if ( removed == 1 )
572+ {
573+ // String removed successfully - update local cache
574+ await _localCache . ListRemoveAsync ( key , stringValue , expiresIn ) . AnyContext ( ) ;
575+ }
576+ else
577+ {
578+ // Failed - remove to force re-fetch
579+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
580+ }
581+
468582 await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
469583 return removed ;
470584 }
471- else
585+
586+ var items = values . ToArray ( ) ;
587+ long removedCount = await _distributedCache . ListRemoveAsync ( key , items , expiresIn ) . AnyContext ( ) ;
588+ if ( removedCount == items . Length )
472589 {
473- var items = values . ToArray ( ) ;
590+ // All items removed successfully - update local cache
474591 await _localCache . ListRemoveAsync ( key , items , expiresIn ) . AnyContext ( ) ;
475- long removed = await _distributedCache . ListRemoveAsync ( key , items , expiresIn ) . AnyContext ( ) ;
476- await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
477- return removed ;
478592 }
593+ else
594+ {
595+ // Partial success - remove to force re-fetch
596+ await _localCache . RemoveAsync ( key ) . AnyContext ( ) ;
597+ }
598+
599+ await _messageBus . PublishAsync ( new InvalidateCache { CacheId = _cacheId , Keys = [ key ] } ) . AnyContext ( ) ;
600+ return removedCount ;
479601 }
480602
481603 public async Task < CacheValue < ICollection < T > > > GetListAsync < T > ( string key , int ? page = null , int pageSize = 100 )
@@ -508,7 +630,6 @@ public async Task<CacheValue<ICollection<T>>> GetListAsync<T>(string key, int? p
508630
509631 public virtual void Dispose ( )
510632 {
511- _localCache . ItemExpired . RemoveHandler ( OnLocalCacheItemExpiredAsync ) ;
512633 _localCache . Dispose ( ) ;
513634
514635 // TODO: unsubscribe handler from messagebus.
0 commit comments