Skip to content

fix(caching): stop losing and duplicating cache invalidation keys - #904

Merged
samtrion merged 6 commits into
mainfrom
fix/829-cache-invalidation
Sep 29, 2026
Merged

samtrion merged 6 commits into
mainfrom
fix/829-cache-invalidation

Conversation

@samtrion

@samtrion samtrion commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR fixes the cache key registry behind AddCacheInvalidation:

  • Invalidation no longer loses keys that were registered while it was evicting.
  • Repeated cache writes no longer add duplicate keys.

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 a HashSet<string> per query type behind a single lock, instead of ConcurrentDictionary<Type, ConcurrentBag<string>>. Registering the same key again keeps one entry.
  • ICacheKeyRegistry.RemoveType now atomically removes and returns the keys of a query type. Keys registered afterwards go into a new set.
  • GetKeysForType was removed from ICacheKeyRegistry. Production code no longer calls it, and evicting from a snapshot would reintroduce the lost-key race. It stays on InMemoryCacheKeyRegistry as a documented diagnostic/test snapshot.
    • A lock-free TryRemove of a ConcurrentDictionary value was not used on purpose. A concurrent Register could still add to the set after it was detached, which is the same bug with a smaller window.
  • CacheInvalidationInterceptor: evicts exactly the keys that RemoveType returned. If RemoveAsync throws (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 runtime IQuery<TResponse> check. The interface's own example (UpdateCustomerCommand : IInvalidatingCommand<CustomerUpdatedResult>, which invalidates a query with a different response type) shows that a same-TResponse check would reject valid usage. Types without recorded keys are ignored.
  • XML docs on AddCacheInvalidation, CacheInvalidationInterceptor and DistributedCacheQueryInterceptor, plus a new delimited "Cache Invalidation" section in src/NetEvolve.Pulse/README.md, describe:
    • the process-local scope;
    • the cache-aside race;
    • the growth bound (distinct keys; expired keys are pruned only when an invalidation runs).
  • ADR 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, HybridCache tags with RemoveByTagAsync, and pub/sub.

Bug hunt

Suspicion Confirmed? Test
A key registered while RemoveAsync awaits is dropped by RemoveType and never evicted Yes (red on main) CacheInvalidationInterceptorTests.HandleAsync_KeyRegisteredDuringEviction_IsEvictedBySubsequentInvalidation
ConcurrentBag stores the same key again on every refill Yes (red on main: 3 entries instead of 1) InMemoryCacheKeyRegistryTests.Register_SameKeyRepeatedly_KeepsSingleEntry
Concurrent registrations and invalidations leave keys cached but unregistered Same race as row 1. A timing-based stress test could not reproduce it on main (MemoryDistributedCache.RemoveAsync completes synchronously), so it was removed in review Covered deterministically by HandleAsync_KeyRegisteredDuringEviction_IsEvictedBySubsequentInvalidation
The take-first design drops keys that are not evicted yet when RemoveAsync fails Not on main (green); guards the new design CacheInvalidationInterceptorTests.HandleAsync_EvictionFails_KeepsKeysThatWereNotEvicted
IInvalidatingCommand documents a runtime check that does not exist Yes (docs only) Claim removed; no test

Impact

  • ICacheKeyRegistry is internal, so no public API changes.
  • The registry now holds each distinct key only once, instead of once per cache write.
  • Registration takes a single lock. Register runs only on cache-miss writes, so contention is not expected. A code comment names per-type locks as the upgrade path.
  • Not fixed, now documented: invalidation remains process-local.
    • Entries cached by other instances, or before a restart, are evicted only by expiry.
    • The cache-aside race is inherent to the design.
    • Expiry-aware pruning of the registry was not added. Under CacheExpirationMode.Sliding, a read on another instance can extend an entry, so pruning locally could drop a live key.
    • Please review the proposed ADR and accept it or choose a distributed alternative.

Test evidence

  • Red run (test commit only, net10.0): 2 of 23 failed, Register_SameKeyRepeatedly_KeepsSingleEntry ("collection has 3 items but expected 1") and HandleAsync_KeyRegisteredDuringEviction_IsEvictedBySubsequentInvalidation ("collection has 0 items but expected 1").
  • dotnet build Pulse.slnx -c Release after merging current main: 0 warnings, 0 errors.
  • Full unit run (tests/NetEvolve.Pulse.Tests.Unit, Release, after merging main): 7275 of 7275 passed. net8.0, net9.0 and net10.0 each passed.
  • csharpier check .: clean.
  • No cache invalidation integration tests exist, and no provider implementations changed, so no integration tests were run.

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
@samtrion
samtrion requested a review from a team as a code owner September 28, 2026 22:54
@samtrion
samtrion requested a review from Hnogared September 28, 2026 22:54
@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.15%. Comparing base (972fb55) to head (b91b1f3).
⚠️ Report is 1 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…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.
@samtrion
samtrion merged commit 5b886d4 into main Sep 29, 2026
14 checks passed
@samtrion
samtrion deleted the fix/829-cache-invalidation branch September 29, 2026 00:20
samtrion added a commit that referenced this pull request Sep 29, 2026
Accept the seven decisions merged in state proposed with #893, #903,
#909, #908, #902, #905 and #904.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant