Skip to content

Commit 7169944

Browse files
authored
Fixes hybrid cache consistency issues (#433)
Ensures data consistency between local and distributed caches. The local cache is now only updated when the distributed cache operations succeed. If a distributed cache operation fails, the corresponding key is removed from the local cache to force a re-fetch, preventing stale data. Specifically addresses scenarios where `Set`, `SetAll`, `Replace`, `ReplaceIfEqual`, `Increment`, `ListAdd`, `ListRemove`, `SetIfHigher`, `SetIfLower` may result in inconsistent state. Also refactors the local cache initialization and removes the local cache expired event handler, relying solely on the message bus for invalidation. Remove L1 Expiration Notification Current Code (HybridCacheClient.cs lines 61-68) private Task OnLocalCacheItemExpiredAsync(object sender, ItemExpiredEventArgs args) { if (!args.SendNotification) return Task.CompletedTask; _logger.LogTrace("Local cache expired event: key={Key}", args.Key); return _messageBus.PublishAsync(new InvalidateCache { CacheId = _cacheId, Keys = [args.Key], Expired = true }); } Why Remove It L1 expiration is a local concern - When L1 expires, L2 has either already expired (same TTL) or still has the value (TTL skew). Either way, notifying other instances is unnecessary. Industry standard - Microsoft HybridCache (.NET 9+) and EasyCaching do NOT publish L1 expiration events. They only publish on write operations. Unnecessary traffic - Every L1 expiration generates a message to ALL instances, even though they will naturally expire around the same time. Potential harm - If L1 expires before L2 (clock drift), this forces other instances to re-fetch even though L2 still has valid data.
1 parent 3022038 commit 7169944

1 file changed

Lines changed: 177 additions & 56 deletions

File tree

src/Foundatio/Caching/HybridCacheClient.cs

Lines changed: 177 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)