Skip to content

Fix in-memory cache update races and memory accounting - #570

Open
niemyjski wants to merge 34 commits into
mainfrom
fix/in-memory-cache-immutable-entries
Open

niemyjski wants to merge 34 commits into
mainfrom
fix/in-memory-cache-immutable-entries

Conversation

@niemyjski

@niemyjski niemyjski commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Concurrent in-memory writes could overwrite newer values, lose list updates, or delete a replacement entry. Publish only against the entry observed, and preserve the previous value, expiration, and tracked size when an update is rejected.

Behavior/source changes that can break consumers:

  • Expired entries are missing on writes: numeric/list updates start fresh; replacement, expiration, and conditional operations miss. Genuine expiration raises ItemExpired when reclaimed; live conditional/list deletion is immediate and no longer raises that event.
  • Invalid or oversized replacements preserve the live entry. Items returns cached payloads with nullable annotations, which can affect warnings-as-errors builds.
  • List writes throw NotSupportedException when they cannot preserve an unsupported custom dictionary's comparer.

Validation: combined Release 2,448 passed / 22 skipped / 0 failed; current provider source workspace build, Redis 777 passed / 18 skipped, repository tests 152 passed, net8/net10 API compatibility, and changed-file formatting passed. All five stack heads have green hosted Build workflows.

Performance decision remains: existing concrete-dictionary custom calculators still copy whole lists. At 1,000 items with cloning off, the compatibility path costs 3.27× time / 37× allocation versus main. #587 adds an explicit read-only sizing opt-in: 0.95 µs / 7.80 KiB, versus 7.04 µs / 85.57 KiB without it and 2.16 µs / 2.31 KiB on main. This removes sizing copies, not all immutable-update allocation. Accept this migration/cost tradeoff before merging the cache stack.

Verification and implementation details

Validated heads: #570 351d2cba, #580 9760d3a8, #582 11488067, #586 e19ee7f3, #587 3f24b90b; baseline main be10785c. Final combined core: 2,470 total, 2,448 passed, 22 skipped. Redis selection: 795 total, 777 passed, 18 skipped.

The restore failures are resolved by the separate DataProtection update already on main (be10785c): 8.0.32 resolves Xml 8.0.5 and Pkcs 8.0.2. Current provider revisions also resolve the old xUnit pins and Parsers cryptography advisory. No workflow edits or audit suppression.

Correctness and compatibility

Verification limits

Ten current sibling repositories build with ReferenceFoundatioSource=true; two existing ASPIRE010 warnings remain. Redis 8.10.2 uses the exact combined core DLL, with the 14 lock cases from Redis #191 and six extra shared cache registrations in the temporary validation checkout. These registrations are not a claim that provider main/CI discovers them. Six Hybrid and three FastCloner cloning skips remain. Non-Redis service-backed suites and the documentation-site build were not run.

All five exact-head hosted workflows are green: #570, #580, #582, #586, #587.

dotnet build Foundatio.All.slnx -c Release --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:ReferenceFoundatioSource=true
dotnet tests/Foundatio.Tests/bin/Release/net10.0/Foundatio.Tests.dll -noColor
git diff --check

Performance and scope

See #582/#587 for paired measurements, including small lists, 10,000-item lists, cloning both ways, and the compatible/default path. Scalar, compaction, bulk, and retained-heap matrices were not rerun in this pass. Cold updates and mutable-element cloning still require whole-list work. No universal performance-neutrality claim.

File-size history: the original refactor was 1,875 → 1,484 lines; this parent currently has 1,832 lines. #582 already extracts snapshots; no further decomposition is proposed. Expired-entry semantics originally discussed in #571 are included here. Hybrid concurrency remains under #572.

Cache entries were mutable objects shared through the ConcurrentDictionary,
and ten code paths changed a published entry in place. Maintenance checked
IsExpired and then removed by key, so a concurrent refresh could make it
delete a live entry, throw "Removed item was not expired", skip memory
accounting and abort the expiration sweep.

Published entries are now immutable: every change publishes a copy, and
conditional removals use ConcurrentDictionary.TryRemove(KeyValuePair) so
only the exact entry that was checked can be removed. This also fixes:

- CompactAsync evicting a just-written entry (same remove-by-key flaw)
- lost concurrent updates in Increment/SetIfHigher/SetIfLower/List*
- RemoveIfEqualAsync/ListRemoveAsync faking deletes with a
  DateTime.MinValue tombstone and never releasing tracked memory
- Items returning the internal CacheEntry instead of the cached value
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

ListAddAsync/ListRemoveAsync modified the dictionary returned by the entry's Value getter, which is the stored instance when CloneValues is off (the default), so concurrent adds wrote to the same Dictionary. They now copy the stored payload once and publish the copy. WithValue no longer clones; ReplaceIfEqualAsync publishes an entry built by CreateEntry, like SetAsync, so the caller's value is cloned once and sized outside the retry loop.
…g on races

SetInternalAsync's AddOrUpdate value overload kept wasUpdated/oldSize from a discarded update-factory attempt when the key was removed and the add path won the retry. AddAsync then stored the key but returned false. Now that RemoveIfEqualAsync deletes immediately, a concurrent lock release makes this reachable, orphaning the lock key. Locals are now reset on every attempt. CompactAsync picks another candidate instead of stopping when its choice was replaced, bounded by maxRemovals.
Every read-modify-write now goes through UpdateEntry: read the entry, decide the next state, publish it with a compare-and-swap (add, replace or identity-conditional remove), and adjust memory size by the difference. Retries rerun a pure callback against the latest entry, so the per-attempt locals (wasUpdated, oldSize, difference, wasNewEntry) that caused the AddAsync bug are gone.

- Collapse double/long SetIfHigher, SetIfLower and Increment into generic INumber<T> implementations
- Collapse the string and generic ListAddAsync branches
- Remove UpdateMemorySizeForEntry and the separate memory-tracking paths in SetInternalAsync
- Move contract tests (concurrent add/release, increment, list add, SetIfHigher/SetIfLower) into CacheClientTestsBase so every provider runs them; skip the concurrent numeric ones on HybridCacheClient pending #572
- Restore the background maintenance path and comments in the time zone regression test
- Add deterministic tests that force each race interleaving through the UpdateEntry callback, no clock hooks needed
…d list values

- ReplaceIfEqualAsync builds the replacement only after the expected value matches, so a mismatch returns false instead of throwing MaxEntrySizeExceededCacheException (as on main)
- ListAddAsync merges from the new entry's stored values, which CreateEntry already cloned, so CloneValues keeps the caller's objects out of the cache
- Document that CacheEntry must keep reference equality for the compare-and-swap operations
Folds in the lock-ownership fixes from #573 on top of UpdateEntry:
- ReplaceIfEqualAsync/RemoveIfEqualAsync treat an expired entry as a miss, checked
  after the value comparison so a lease that expires mid-comparison is not revived
- CacheLockProvider.RenewAsync throws LockException when renewal fails instead of
  silently reporting success; documented on ILockProvider and ILock
- Multi-resource TryAcquireAsync releases what it holds and returns null when an
  earlier lock is lost during acquisition
- Clock-controlled throttle test, docs, and skill gotcha
@niemyjski niemyjski changed the title Fix InMemoryCacheClient removing live entries during maintenance Fix in-memory cache update races and reject lost-lock renewal Sep 24, 2026
@niemyjski
niemyjski marked this pull request as draft September 24, 2026 02:07
@niemyjski
niemyjski marked this pull request as ready for review September 26, 2026 02:53
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T04:12:27.826802Z 351d2cb New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 589d686e4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Foundatio/Caching/InMemoryCacheClient.cs Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@niemyjski

niemyjski commented Oct 5, 2026 •

Copy link
Copy Markdown
Member Author

Updated 2026-10-06: the stack owner has already published these repairs in #570 at 84ffb3641aec54363957194199dcfc7326de088c; #580 at 1859d15105e2ea45497969cfd325e3a28cf91fe0 inherits them. This supersedes this comment’s earlier “not pushed yet” status. This repair task did not publish the stack. Do not push the old local repair commit d8cf5747: its cache changes are already included and its combined scope predates the split.

Source comparison confirms the published cache implementation is byte-for-byte identical to our repaired version: OrderedDictionary comparer preservation, explicit rejection of unsupported dictionary copies, expiry checks before conversion/comparison, retained post-comparison checks, and expiry rechecking after replacement construction. Live comparison exceptions retain faulted-task behavior. All added regressions remain; the published tests additionally assert TTL and memory retention after unsupported-dictionary rejection. #580 changes only the six renewal-related files relative to #570.

Both build checks and CLA pass on each exact head: #570 build 1 / build 2; #580 build 1 / build 2. This update verifies source/test retention and hosted check results; it does not represent a new local test run or a new independent review of the published stack.

Historical local validation (2026-10-05, d8cf5747): new comparer, expired-value, and expiry-during-sizing regressions failed before repair; final focused run 20 passed, 1 existing FastCloner skip; final full Release suite 2,222 passed, 22 skipped, zero failures; net8.0/net10.0 builds had zero warnings/errors. An earlier full run hit the existing 5ms hybrid expiry assertion; its targeted rerun and final full run passed. Independent Astra high review found the additional sizing-expiry gap, then confirmed its fix and found no remaining concrete blocker in that local review.

Performance acceptance remains open, including the disclosed custom-sized large-list costs. Unsupported IDictionary mutation explicitly throws NotSupportedException when its comparer cannot be preserved. Existing skips and provider/performance coverage limits remain; green checks and source equality are not a zero-defect guarantee. This correction changes no workflow and does not resolve the separate pending publishing-guard approval.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48f5404ac6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Foundatio/Caching/InMemoryCacheClient.cs
…st compaction races spending the removal budget

- UpdateEntry hides expired entries from callbacks, matching reads and Redis: Increment/SetIfHigher/SetIfLower/ListAdd
  start fresh, Replace/SetExpiration/ListRemove/RemoveIfEqual/ReplaceIfEqual miss, and SetExpirationAsync no longer
  revives an expired key. The displaced expired entry is reclaimed and raises ItemExpired once.
- UpdateStoredEntry keeps the raw view for maintenance, compaction, RemoveAsync and RemoveExpiredKey
- Add/Set/Replace share one write path; ReplaceAsync is now atomic instead of ContainsKey-then-Set
- Compaction no longer charges a lost compare-and-swap to its removal budget (conflicts have their own cap)
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

…k when nobody subscribes

- UpdateEntry runs its own compare-and-swap loop instead of wrapping UpdateStoredEntry in a capturing lambda, which
  added 96 B to every write. Both loops share TryPublish.
- OnItemExpired returns before queueing a task when there are no handlers (the old null check could never be true)
- Add InMemoryWriteBenchmarks for the scalar write paths
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

DoMaintenanceAsync_WithMaxTimeSpanExpiration_ShouldNotThrowException and
DoMaintenanceAsync_WithPositiveTimezoneOffset_ShouldNotThrowOnDateTimeMinValue had been rewritten or replaced. Every test
from main now exists unchanged; the stronger rewrite keeps its own name
(DoMaintenanceAsync_WithMaxTimeSpanExpiration_KeepsEntryAfterTimeAdvances).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@niemyjski
niemyjski added this pull request to stack #583 October 7, 2026 19:59
niemyjski added a commit that referenced this pull request Oct 7, 2026
…reference-holding structs, keep snapshot values owned

Moves the conflict lock onto the single remaining update loop (eager stripes, mask index, System.Threading.Lock on net9+). Custom SizeCalculators receive a Dictionary copy again; only the built-in calculators read the read-only view. Struct items holding references are cloned under CloneValues. GetSnapshot always hands out a copy, so reads use the owned dictionary without layout checks. Skips publishing an unchanged list, avoids a discarded clone for new shareable lists, and adds Utc suffixes to new expiration names.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 351d2cba33

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +397 to +398
if (current is null || update(current) is not { } decision)
return (current, missResult);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck expiry after a rejected lease comparison

When an entry crosses its TTL during a mismatching comparison in RemoveIfEqualAsync or ReplaceIfEqualAsync, update(current) returns null, so this early return skips the post-comparison IsExpired check. If maintenance ran less than 250 ms earlier, the subsequent maintenance request is throttled; with no later writes, the expired entry remains in the dictionary, keeps tracked memory inflated, and never raises ItemExpired. The fresh evidence beyond the earlier matching-expiration report is this distinct mismatch path, which returns before the new reclaim logic; recheck expiration before returning the miss.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant