fix(caching): stop losing and duplicating cache invalidation keys - #904
Merged
Merged
Conversation
Registering the same key repeatedly must keep one entry, and a key registered while an invalidation is evicting must be evicted by the next invalidation. Also guards key retention on eviction failure and eviction of every key under concurrent registrations and invalidations.
…plicate them The registry now stores keys per query type as a set under one lock, and RemoveType atomically takes and returns the keys. The invalidation interceptor evicts exactly the taken keys, so keys registered while it evicts stay registered for the next invalidation instead of being dropped. Keys not yet evicted when an eviction fails are registered again. Refs #829
…and registry growth Describe the limitations on AddCacheInvalidation, the invalidation and query caching interceptors and in the README, remove the claim of a runtime query type check from IInvalidatingCommand, and record the process-local registry as a proposed ADR. Refs #829
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #904 +/- ##
==========================================
+ Coverage 96.09% 96.15% +0.05%
==========================================
Files 274 274
Lines 11511 11523 +12
Branches 1069 1071 +2
==========================================
+ Hits 11062 11080 +18
+ Misses 232 227 -5
+ Partials 217 216 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…face and the timing-based stress test GetKeysForType is only used by tests; keeping it on ICacheKeyRegistry invited evicting from a snapshot, which reintroduces the lost-key race. It now lives on InMemoryCacheKeyRegistry as a documented diagnostic snapshot. The concurrent stress test could not detect the regression and is covered deterministically by HandleAsync_KeyRegisteredDuringEviction_IsEvictedBySubsequentInvalidation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR fixes the cache key registry behind
AddCacheInvalidation:The cross-process limitation, the cache-aside race and the growth bound are now documented. A proposed ADR records why invalidation stays process-local for now.
Closes #829
Changes
InMemoryCacheKeyRegistry: stores keys as aHashSet<string>per query type behind a single lock, instead ofConcurrentDictionary<Type, ConcurrentBag<string>>. Registering the same key again keeps one entry.ICacheKeyRegistry.RemoveTypenow atomically removes and returns the keys of a query type. Keys registered afterwards go into a new set.GetKeysForTypewas removed fromICacheKeyRegistry. Production code no longer calls it, and evicting from a snapshot would reintroduce the lost-key race. It stays onInMemoryCacheKeyRegistryas a documented diagnostic/test snapshot.TryRemoveof aConcurrentDictionaryvalue was not used on purpose. A concurrentRegistercould still add to the set after it was detached, which is the same bug with a smaller window.CacheInvalidationInterceptor: evicts exactly the keys thatRemoveTypereturned. IfRemoveAsyncthrows (including on cancellation), it registers the keys it has not evicted yet again, then rethrows. The previous code kept these keys implicitly, so this keeps that behavior.IInvalidatingCommand<TResponse>: removed the claim about a runtimeIQuery<TResponse>check. The interface's own example (UpdateCustomerCommand : IInvalidatingCommand<CustomerUpdatedResult>, which invalidates a query with a different response type) shows that a same-TResponsecheck would reject valid usage. Types without recorded keys are ignored.AddCacheInvalidation,CacheInvalidationInterceptorandDistributedCacheQueryInterceptor, plus a new delimited "Cache Invalidation" section insrc/NetEvolve.Pulse/README.md, describe:decisions/2026-09-29-process-local-cache-invalidation.md(state: proposed). It keeps the in-memory registry and records the following alternatives for cross-process invalidation: per-type generation token,HybridCachetags withRemoveByTagAsync, and pub/sub.Bug hunt
RemoveAsyncawaits is dropped byRemoveTypeand never evictedmain)CacheInvalidationInterceptorTests.HandleAsync_KeyRegisteredDuringEviction_IsEvictedBySubsequentInvalidationConcurrentBagstores the same key again on every refillmain: 3 entries instead of 1)InMemoryCacheKeyRegistryTests.Register_SameKeyRepeatedly_KeepsSingleEntrymain(MemoryDistributedCache.RemoveAsynccompletes synchronously), so it was removed in reviewHandleAsync_KeyRegisteredDuringEviction_IsEvictedBySubsequentInvalidationRemoveAsyncfailsmain(green); guards the new designCacheInvalidationInterceptorTests.HandleAsync_EvictionFails_KeepsKeysThatWereNotEvictedIInvalidatingCommanddocuments a runtime check that does not existImpact
ICacheKeyRegistryis internal, so no public API changes.Registerruns only on cache-miss writes, so contention is not expected. A code comment names per-type locks as the upgrade path.CacheExpirationMode.Sliding, a read on another instance can extend an entry, so pruning locally could drop a live key.Test evidence
Register_SameKeyRepeatedly_KeepsSingleEntry("collection has 3 items but expected 1") andHandleAsync_KeyRegisteredDuringEviction_IsEvictedBySubsequentInvalidation("collection has 0 items but expected 1").dotnet build Pulse.slnx -c Releaseafter merging currentmain: 0 warnings, 0 errors.tests/NetEvolve.Pulse.Tests.Unit, Release, after mergingmain): 7275 of 7275 passed. net8.0, net9.0 and net10.0 each passed.csharpier check .: clean.