Skip to content

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

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

niemyjski wants to merge 37 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 replacement entries. Updates now publish against the observed entry and preserve live values when rejected.

Compatibility and behavior changes:

  • Expired entries act as missing on writes. Explicit matching-value/list deletion is immediate and no longer raises ItemExpired; genuine expiration still does when reclaimed.
  • Invalid, oversized, or already-expired replacements preserve a still-live value, TTL and tracked size.
  • Items returns cached payloads with nullable annotations, which can affect warnings-as-errors builds. Unsupported list-dictionary comparers throw NotSupportedException.
  • The separate lock/job stack (Breaking: reject lost-lock renewal and incomplete lock acquisition #580/Reject expired acquisitions and cancel lost work-item leases #586) throws LockException for lost renewal, rejects durations below 5 ms before cache access, and cooperatively cancels/abandons work items when their exclusive work-item lock is lost.

Validation (October 28): all reported parent/child CI checks are green. Final combined candidate: 2,517 passed / 22 skips; Redis: 778 passed / 18 skips, including all 42 required contracts. Source-provider build and binary API checks passed with the qualification below.

Hold: #598 fixes the flaky test and reduces sizing overhead, but compatible custom sizing at 10,000 items still costs 3.28× time / about 300× allocation versus main with cloning disabled. Provider builds still require an unpublished RabbitMQ dependency pin; durable Redis registrations and non-Redis service runtime coverage remain outstanding.

Verification and implementation details

Reviewable changes and merge order

Final source and verification

Main remains be10785cfed4e1850350dfaa024e1c3f25b7fac7. Stack heads: #570 0aa9d680, #580 64a4fa6a, #582 439e0d32, #586 3a888094, #587 f891a9b0, and #598 988ab9ff. The isolated final combined source tree is baef740a8b2b4390b9f125d845b93c3887a6e9ae.

  • Reduce custom list sizing overhead and stabilize expiration tests #598 exact revision: Release build/format passed; 2,501 tests, 2,479 passed, 22 existing skips, zero failures. Push CI and PR CI passed.
  • Combined source: 2,539 tests, 2,517 passed, 22 existing skips, zero failures. Six Hybrid concurrency, two Hybrid expiration, and three FastCloner skips remain among those 22.
  • Foundatio.All.slnx Release build passed with two existing ASPIRE010 warnings after the isolated RabbitMQ pin described below. All 14 test-assembly outputs used the candidate core DLL.
  • Fresh Microsoft.DotNet.ApiCompat 10.0.402 checks against main passed for core net8/net10 and TestHarness net10, with parameter-name checks and no suppressions. Nullable annotations do not change the CLR signature.
  • Redis 8.10.3: all 796 discovered cache/lock cases executed, 778 passed and 18 existing skips. All 30 lock cases and 12 additional direct/scoped cache cases explicitly passed. Its core/TestHarness DLL hashes match the final combined binaries. All four Redis fixtures passed the revised expiration test.

The previous expiration failure was reproduced with an injected 200 ms pause in all seven core fixtures. #598 removes the upper timing deadline instead of relying on reruns. Twelve fake-time cases retain overlapping finite-expiration coverage. Allocation and unchanged-update regressions failed before their corresponding corrections.

Correctness and runtime contracts

The parent rejects conditional replacements that expire during preparation or retry, preserving a still-live lease instead of reporting false renewal success. Mismatching comparisons also recheck expiry before returning. Rejected writes can reclaim expired entries and notify earlier; live entries remain intact. Conditional removal retains task-based exception delivery.

The children enforce size limits on fallback estimates, reclaim sparse list storage, preserve mutable-struct cloning, and avoid eager allocation when sampling read-only keys/values. Snapshot collections can still share mutable elements with cloning disabled. Raw list expiration values may normalize to UTC when indexing; DateTime.Kind preservation is not promised. Concurrent clear/dispose/recount memory accounting remains approximate, as on main.

Lock renewal requires atomic conditional replacement from custom cache providers. Lost ownership requires protected work to stop; cooperative cancellation is not storage-enforced fencing. Default empty work-item locks provide no exclusive protection. Queue-only renewal errors remain logged. These lock/job behavior changes are isolated in #580/#586, not hidden inside the cache refactor.

Dependency and provider limits

#599 pins only the non-packable core AppHost sample to MessagePack 2.5.303. A fresh unpatched-main sample build emitted NU1902 as a warning; the patched sample builds with only ASPIRE010 and its transitive vulnerability audit reports no affected packages. The shipped serializer already uses 3.1.12. See GHSA-qhrr-8q5h-9q3h.

Unmodified RabbitMQ main 340357f1 fails the all-provider build because its test AppHost resolves affected MessagePack 2.5.302 and treats NU1902 as an error. A separate one-line 2.5.303 test-AppHost pin makes the workspace build pass. That provider correction remains unpublished; the overlay is not proof that unmodified provider main builds. No audit suppression was used.

Redis validation used main 06668733, the existing registration PR #191 patch, and six cache-contract overrides in each direct/scoped fixture. The additional registrations remain a test-only validation overlay, so provider-main/CI coverage is not yet durable. Non-Redis service runtime suites were not run.

Performance and compatibility decision

BenchmarkDotNet 0.15.8, .NET 10.0.14 arm64, Apple M4 Max; one launch, three warmups, seven measured iterations. One operation adds/removes one item after warmup with a fixed clock. Main, prior #587 and #598 loaded in separate assembly contexts with verified hashes and identical bound-delegate calls. Twenty-four cases cover 1,000/10,000 items, cloning on/off, and compatible/read-only custom sizing. Cold-write and contention timing remain unmeasured in this pass.

Items, cloning off Main Previous stack #598 compatible sizing #598 read-only sizing
1,000 3.857 µs / 2.38 KiB 10.240 µs / 85.53 KiB 9.177 µs / 80.32 KiB 1.094 µs / 7.70 KiB
10,000 33.390 µs / 2.38 KiB 130.500 µs / 735.27 KiB 109.351 µs / 713.39 KiB 2.762 µs / 24.38 KiB

#598 reduces 10,000-item compatible-sizing time by about 16% and allocation by about 3% relative to the previous stack in the same run. Concrete-dictionary compatibility still requires a private whole-list copy. Interface-based, non-mutating calculators can opt in with SizeCalculator(calculateSize, supportsReadOnlyCollections: true); built-in fixed/dynamic sizing already supports that path. Changing the custom-calculator default would break callbacks that cast to Dictionary. No universal performance-neutrality claim.

The original refactor was 1,875 → 1,484 lines; subsequent correctness work changed the count. No further decomposition is proposed. Hybrid concurrency remains tracked in #572. Vendored FastCloner and workflows remain unchanged.

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 🔄 Running since 2026-10-28T03:07:22.890863Z 0aa9d68 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.

…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 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 thread src/Foundatio/Caching/InMemoryCacheClient.cs Outdated

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