Skip to content

Fix in-memory cache update races and reject lost-lock renewal - #570

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

niemyjski wants to merge 15 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 cache writes could overwrite newer values, lose list updates, or remove a replacement entry. This fixes atomic publication and rejected-update accounting, and rejects renewal of lost cache-backed leases.

Compatibility changes:

  • Lost-lock renewal through CacheLockProvider throws LockException. Durations below 5 ms throw ArgumentOutOfRangeException without cache mutation.
  • Live conditional/list deletion is immediate and does not emit ItemExpired. Expired conditional operations return false; expired conditional removal reclaims memory and emits the genuine expiration event. Oversized replacements preserve value, TTL, and tracked size.
  • Items returns cached values with nullable annotations. Source warnings may change; CLR signatures remain compatible. RemoveIfEqualAsync retains task-based exception delivery.

Validation: Release core 2,183 passed, 22 skipped, 0 failed; aligned provider cache/lock selection 1,858 passed, 27 skipped, 0 failed; core/all-provider builds, docs, formatting, and net8/net10 core APICompat passed. Three existing cloner-crash cases and six Hybrid concurrency cases remain skipped. Provider tooling/registrations were adapted only in the isolated validation workspace; unrelated provider integration suites were not run. Both hosted builds and CLA passed at the new revision.

Performance needs reviewer acceptance. Historical 1,000-item default/fixed steady writes improve time but allocate 3.07×; opaque custom sizing still costs ~3× time/~40× allocation, dynamic sizing 1.40×/4.08×, first modification 4.58× time, and retained heap ~2.21×. Bulk/cloned contention also costs more. The secondary fix adds 64 B and ~12 ns to conditional-removal misses; matched set/remove cycles allocate 56 B less. This is not a blanket performance approval.

Suggested stack: cache correctness/performance, then dependent renewal behavior/docs/tests. Both remain here. FastCloner #58 is independent; its vendored source is unchanged.

Verification and implementation details

Correctness and compatibility

  • Candidate 1ca207a5a565c315a6e6aed16d54570a374cfa18 against merge-base 2b24e106b69af19514af9cc9b659127025a40525.

  • Conditional updates/removals compare entry identities. Numeric/list size rejection preserves the prior value, expiration, and accounting in both throwing modes; successful higher/lower replacements use the validated size.

  • Lists preserve previous snapshots, stored dictionary types and comparers, and default non-cloning semantics for exposed dictionaries. Clone-enabled writes reuse existing values internally and clone added values once. Small lists retain dictionary storage; large eligible lists copy affected blocks and the block directory, sharing immutable index nodes. Bulk changes use one dictionary copy when rebuilding index paths would cost more.

  • Built-in fixed sizing is identified by its library-owned constant calculator; replacing it with a custom calculator disables the optimization. Arbitrary callbacks still receive ordinary dictionaries and retain full rejection validation.

  • FastCloner regression tests and the conservative cycle-analysis fix now live in upstream PR Update README.md #58. No FastCloner or update-script diff remains here. Only the three clone-enabled ConcurrentDictionary comparer cases skip pending that fix; other comparer/type cases still run.

  • InMemoryCacheClient.cs remains one file: base 1,875 lines, original refactor 1,484, current revision 1,785. File splitting is out of scope.

  • Renewal documentation is scoped to CacheLockProvider; throttling/empty locks have no-op renewal. The example observes renewal failures, cancels cooperative protected work, and awaits renewal shutdown before release. Ownership checks are not storage-enforced fencing. Automatic job cancellation remains outside this PR (Automatic lock renewal in QueueProcessorJobBase #26); WorkItemJob logs renewal failures and continues.

  • Maintenance samples the clock once and conditionally removes observed entries. ConcurrentDictionary enumeration is safe during writes; taking an array snapshot would not make subsequent removals atomic. The previous clock/lookahead heuristics predate immutable entry publication. Concurrent disposal is not an operation barrier; global memory accounting remains approximate under concurrency.

  • Secondary review fixed expired conditional-removal cleanup, including expiry during comparison and within the 250 ms maintenance throttle. Only the successfully removed expired entry raises ItemExpired; a replacement owner remains intact. Seven new regressions failed before the correction.

  • RemoveIfEqualAsync retains its original async exception delivery: invalid arguments or comparison errors return a faulted task. Disabled maintenance trace logging no longer boxes its boolean or creates an argument array.

  • Portable released/missing/replaced/scoped-owner and invalid-duration regressions live in LockTestBase; concrete providers must register them. In-memory fake-clock tests retain deterministic expiry coverage. Newly acquired locks are disposed, renewal cases are alphabetical, and the extra blank line before disposal is removed. The multi-resource lost-lock warning now includes the exception message.

Verification

Both hosted builds passed at 1ca207a5: PR build and push build; CLA passed.

  • Candidate 1ca207a5: full Release core suite 2,205 total, 2,183 passed, 22 skipped, zero failed. Existing six Hybrid skips remain under HybridCacheClient: concurrent Increment/SetIfHigher/SetIfLower can leave a stale local value #572; three cloner comparer/cycle skips remain pending upstream Update README.md #58.
  • Core build: one existing ASPIRE010 sample warning, no errors. Documentation site build, changed-file formatting verification, and whitespace checks passed. No workflow, permissions, or dependency configuration changed.
  • Foundatio.All.slnx builds 65 projects against the candidate overlay: two existing ASPIRE010 sample warnings, no errors. The cache/lock selection executes 1,885 tests: 1,858 passed, 27 skipped, zero failed. Only MTP exit code 8 is ignored for assemblies with no matching tests. This does not claim unrelated cloud/queue/storage integration coverage.
  • Validation-only adaptations: Redis/Parsers xUnit 4.0.1 alignment, Redis SourceLink 10.0.401, current xUnit parallelization metadata, overrides for two pre-existing shared-test name collisions, and seven concrete Redis registrations for shared renewal cases. Provider checkouts are unchanged; these tooling and registration changes are not shipped by this core PR.
  • Core APICompat 10.0.401 passes against the exact merge-base for net8.0 and net10.0, including parameter-name checks. Nullable Items annotations still have the documented source-warning impact; APICompat does not prove behavioral compatibility.
  • Seven cleanup/task regressions fail before the correction and pass afterward. Focused cache/lock selection: 334 passed, one existing cloner skip, zero failed. Concurrent replacement cases include expiry during comparison; rejected numeric/list updates retain the earlier value/TTL/accounting coverage.
  • The actual automatic-renewal example ran six controlled scenarios: completed work, shutdown with renewal in flight, expired lease, replacement owner, cache failure, and work failure. Renewal faults were observed, cooperative work was cancelled, and renewal stopped before release.
  • Portable lock ownership tests are now shared. Redis executes them only through explicit fixture registrations; inheritance alone does not register provider tests. Clock-driven expiration boundaries remain in the in-memory fixture.
dotnet build Foundatio.slnx -c Release --disable-build-servers -m:1
dotnet tests/Foundatio.Tests/bin/Release/net10.0/Foundatio.Tests.dll -noColor
npm --prefix docs run build
dotnet run -c Release --project benchmarks -- --filter '*InMemoryListBenchmarks*' --job Short
git diff --check
# In the aligned sibling-provider workspace, with an isolated Redis connection configured:
dotnet build Foundatio.All.slnx -c Release -p:ReferenceFoundatioSource=true -m:1 -p:BuildInParallel=false
dotnet test --solution Foundatio.All.slnx -c Release --no-restore --no-build --filter-class '*CacheClientTests' --filter-class '*LockTests' --ignore-exit-code 8

Performance verification

The list table below is historical evidence from 589d686e after cloner separation: 26 Short-job base/candidate configurations, cloning on/off, six sizing/clone modes, and 10/1,000 items. The broader table is historical evidence from 6a77e97b. These matrices were not rerun for the secondary-review commits; fresh conditional-removal measurements follow them.

BenchmarkDotNet 0.15.8, MemoryDiagnoser, Apple M4 Max, macOS 27, .NET 10.0.12. Separate base/candidate builds; timing did not overlap task builds/tests. Benchmark process-priority elevation was unavailable, so measurements ran at normal priority. The earlier review included a 118-case Short comparison plus Default confirmation. The cloner-separation pass ran the 26 list configurations described above. Short figures are directional measurements, not production latency predictions.

Workload Base → candidate time Base → candidate allocated
1,000-item add/remove, default 2.066 → 0.745 μs 2.02 → 6.18 KB
Same, built-in fixed sizing 2.101 → 0.756 μs 2.02 → 6.18 KB
Same, cloning enabled 6.975 → 6.378 μs 158.08 → 80.45 KB
Same, opaque constant custom calculator 2.079 → 6.245 μs 2.02 → 80.18 KB
Same, custom calculator with cloning 7.136 → 7.065 μs 158.08 → 80.45 KB
Same, fixed sizing with cloning 7.381 → 6.424 μs 158.08 → 80.45 KB
Same, actual dynamic sizing 10.29 → 14.45 μs 25.38 → 103.55 KB

Historical broader workload measurements (6a77e97b, before removing the cloner patch):

Workload Base → candidate time Base → candidate allocated
Read 1,000-item list, cloning disabled 1.428 → 0.720 μs 4.35 → 4.05 KB
List write then read 4.755 → 1.491 μs 6.37 → 10.23 KB
Initial bulk insertion, 1,000 items 10.829 → 13.518 μs 96.94 → 130.04 KB
Fresh list plus first small modification 11.970 → 54.850 μs 98.23 → 188.05 KB
Remove/restore 500 of 1,000 items 13.330 → 21.390 μs 74.02 → 219.83 KB
Four contended list writers, cloning enabled 32.987 → 55.387 μs 563.23 → 811.95 KB
Maintenance, 1,000 expiring entries/system clock 13.650 → 7.611 μs 16,024 → 0 B
Scalar set, no cloning/sizing 83.94 → 75.58 ns 224 → 208 B
Scalar get, no cloning/sizing 18.93 → 19.47 ns 104 → 104 B
Conditional replacement, no cloning/sizing 112.22 → 110.43 ns 248 → 384 B
Increment, no cloning/sizing 177.23 → 171.30 ns 432 → 560 B
Expiration update, no cloning/sizing 41.34 → 54.81 ns 56 → 176 B
Compaction insertion, no cloning/sizing 6.047 → 5.945 μs 952 → 1,024 B
Four contended numeric writers, no cloning/sizing 5.207 → 5.536 μs 3.02 → 3.76 KB

The base non-cloning contended-list case throws a collection-modified exception, so no valid ratio is reported. Candidate contention includes retry and scheduling costs; retry counts were not separately instrumented. Cloned base operations can still lose updates, so that baseline is not correctness-equivalent.

Approximate retained-heap probe (100 caches × 1,000 integers, full GC): base 83,032 bytes/cache initially and 83,158 after modification; candidate 150,875 and 183,437. This includes whole-cache overhead and is not a BenchmarkDotNet allocation measurement. The first small modification builds the index; this cost recurs after bulk rebuilding. Reads of raw dictionaries force copying on the next write.

The checked-in InMemoryListBenchmarks covers eight operations, two list sizes, cloning on/off, and none/fixed/custom/dynamic sizing. Reproduce representative measurements with the command above; compare separate base/candidate builds using identical runtime and inputs. Short-job numbers should not be treated as universal guarantees.

Fresh secondary-review comparison: prior PR head 589d686e versus the candidate, separate before/after builds, BenchmarkDotNet 0.15.8/MemoryDiagnoser, .NET 10.0.12, Apple M4 Max. Frozen UTC keeps maintenance inside its throttle window to isolate hot-path costs; matched rows include setting then removing the value. Cloning uses the same record payload. Timing did not overlap builds/tests. Short runs are directional; the missing-entry case also ran a full Default confirmation.

Conditional-removal workload Before → candidate time Before → candidate allocated
Matched set/remove, cloning off, Short 240.64 → 223.22 ns 456 → 400 B
Live mismatch, cloning off, Short 60.06 → 73.01 ns 136 → 200 B
Missing, cloning off, Default 51.60 → 63.78 ns 136 → 200 B
Matched set/remove, cloning on, Short 327.40 → 325.34 ns 568 → 512 B
Live mismatch, cloning on, Short 99.23 → 115.05 ns 192 → 256 B
Missing, cloning on, Short 51.61 → 64.90 ns 136 → 200 B

The Default missing-entry case is 1.24× slower and allocates 64 B more. Guarding disabled maintenance trace logging saves 56 B per call, benefiting matched set/remove allocation. These microbenchmarks do not measure provider I/O or production contention and do not establish a timing improvement for the Short matched cases.

Proposed stack boundary

Keep immutable cache publication, expiration comparisons, size accounting, list performance, cache tests, caching docs, and benchmarks together in #570. A dependent lock PR would contain CacheLockProvider renewal rejection, ILockProvider/ILock documentation and multi-resource handling, LockTestBase, InMemoryLockTests, the locks guide, and the renewal skill gotcha. The lease PR depends on atomic expired-entry conditional behavior. This review split does not require source-file decomposition. Automatic job cancellation remains outside the PR (#26); WorkItemJob still logs renewal failures and continues.

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-09-26T02:58:25.931250Z 589d686 Draft marked ready
ℹ️ 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".

return existingEntry;
// Check expiry after the comparison: a lease that expired while it was being compared no longer
// belongs to the caller. Maintenance removes the entry and raises ItemExpired.
return current.IsExpired ? (current, false) : (null, true);

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 Schedule cleanup after rejecting an expired conditional remove

When a matching entry expires before or during comparison, this branch deliberately retains it, but the method no longer calls StartMaintenanceAsync. Since maintenance is only started by later writes rather than by a persistent timer, expired locks conditionally released after their TTL can remain in the backing dictionary indefinitely, keep CurrentMemorySize inflated, and never raise ItemExpired if no unrelated write occurs. Schedule maintenance here or conditionally remove and notify for the observed expired entry while still returning false.

AGENTS.md reference: AGENTS.md:L322-L322

Useful? React with 👍 / 👎.

@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.

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