Skip to content

Fix CAS and increment/decrement lost-update races in cache-core - #81

Merged
brayniac merged 4 commits into
mainfrom
brayniac/cas-concurrency-fixes
Jun 11, 2026
Merged

brayniac merged 4 commits into
mainfrom
brayniac/cas-concurrency-fixes

Conversation

@brayniac

Copy link
Copy Markdown
Owner

Summary

Two lost-update races in TieredCache, found while porting the CasToken design to pelikan-io/cache-rs (pelikan-io/cache-rs#24):

  • cas() published via update_if_present, which compare-exchanges against a re-read slot word rather than the location the token was validated against. A concurrent set or cas landing between the token check and the publish was silently overwritten — the exact race CAS exists to prevent. Now publishes via cas_location(key, checked_location, new_location), making the slot exchange the linearization point; any concurrent write, relocation, or delete fails the CAS closed with Ok(false). Spurious exchange failures from concurrent readers bumping the frequency bits are retried while the key still maps to the checked location (freq-neutral get_item_frequency).
  • increment()/decrement() were get → parse → set: two concurrent operations could both read N and write N+delta. Both are now with_value_cas + cas() retry loops, with add() (insert-if-absent) for the initial case. with_value_cas returning None is only treated as key-absent when the hashtable agrees, since it's also the transient mid-migration result. Semantics preserved: trim/parse, overflow checks, saturation at zero, initial handling, error variants.

Also adds a regression test pinning that try_reserve bumps the segment generation on every recycle — the property CasToken's ABA protection depends on (the bump already existed; it just wasn't pinned).

Testing

  • New test_increment_concurrent_no_lost_updates (4 threads × 250 increments): against the old code it loses 55–70% of updates (observed 314/1000, 439/1000); with the fix it lands exactly at 1000, repeatedly.
  • New test_cas_token_lifecycle and test_increment_decrement_semantics pin single-threaded behavior; test_segment_generation_bumps_on_recycle pins the recycle bump.
  • cargo test -p cache-core (413 tests), --features loom (44 models), and segcache/heap-cache/slab-cache suites all green. cargo fmt --check clean. (One pre-existing clippy manual_checked_ops hit in disk/config.rs from a newer toolchain lint, present on main; workspace io_uring crates don't build on macOS as usual.)

Follow-up

HeapCache::cas (cache/heap/src/lib.rs) has the same check-then-act publish shape on its slot storage and needs an analogous fix. Full race-freedom of cas() against the (astronomically narrow) recycle-during-publish window would additionally need a segment read guard held from the generation check through the slot exchange.

🤖 Generated with Claude Code

brayniac and others added 4 commits June 11, 2026 08:33
cas() validated the CasToken but then published via
update_if_present, which compare-exchanges against a re-read slot
word rather than the location the token was checked against. A
concurrent set or cas landing between the token check and the publish
was silently overwritten — a lost update, the exact race CAS exists
to prevent.

Publish via cas_location(key, checked_location, new_location) instead,
making the slot exchange the linearization point: any concurrent
write, relocation, or delete changes the slot and the CAS fails
closed with Ok(false). Spurious exchange failures caused by
concurrent readers bumping the frequency bits are retried while the
key still maps to the checked location (get_item_frequency is
freq-neutral).

Co-Authored-By: Claude Fable 5 <[email protected]>
increment() and decrement() were get -> parse -> set: two concurrent
operations could both read N and both write N+delta, losing an
update. Rewrite both as a retry loop over with_value_cas (zero-copy
read + CAS token under the item guard) and cas(), so a racing write
forces a re-read instead of being overwritten. The missing-key
initial case uses add() (insert-if-absent), retrying on a lost
creation race.

with_value_cas returns None both for an absent key and for an item
that is transiently unreadable (segment mid-migration); only treat
the key as absent when the hashtable agrees, otherwise retry.

The new concurrent stress test (4 threads x 250 increments) loses
~55-70% of updates against the old code and passes exactly with the
fix; behavior-preserving semantics (trim/parse, overflow, saturation,
initial handling, error variants) are pinned by unit tests.

Co-Authored-By: Claude Fable 5 <[email protected]>
CasToken ABA protection depends on try_reserve incrementing the
segment generation on every Free -> Reserved transition. That bump
exists (try_reserve resets stats and bumps generation) but no test
pinned it across a full recycle cycle; add one so a refactor of the
reserve path cannot silently drop it.

Co-Authored-By: Claude Fable 5 <[email protected]>
- Use checked_div in DiskConfig::segment_count to satisfy the new
  clippy::manual_checked_ops lint (behavior unchanged: 0 when
  segment_size is 0).
- Bump rustls-webpki 0.103.10 -> 0.103.13 for RUSTSEC-2026-0098,
  RUSTSEC-2026-0099, and RUSTSEC-2026-0104.

Both pre-date this branch; they gate CI as of the latest stable
clippy and advisory database.

Co-Authored-By: Claude Fable 5 <[email protected]>
@brayniac
brayniac merged commit 1a29a40 into main Jun 11, 2026
26 of 28 checks passed
@brayniac
brayniac deleted the brayniac/cas-concurrency-fixes branch June 11, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant