Skip to content

Add segment reader pinning (refcount + RAII item guards)#25

Open
brayniac wants to merge 4 commits into
pelikan-io:mainfrom
brayniac:brayniac/segment-reader-pinning
Open

Add segment reader pinning (refcount + RAII item guards)#25
brayniac wants to merge 4 commits into
pelikan-io:mainfrom
brayniac:brayniac/segment-reader-pinning

Conversation

@brayniac

Copy link
Copy Markdown
Contributor

Summary

First step of making segcache concurrent-safe (and a latent bug fix today): an Item holds a raw pointer into segment memory with no lifetime tie, so a held Item could be silently invalidated by eviction — in-place compact() memmoves, copy_into relocation, or segment recycling — triggered by subsequent cache calls. This ports crucible's reader-pinning machinery: a per-segment ref_count with a two-phase acquire protocol, and an RAII SegmentGuard carried by every Item, so a segment cannot be touched while readers hold items into it.

Archaeology: Segment::clear still carries the comment "skips over seg_wait_refcount … because no threading" — the original implementation had exactly this wait, dropped in the single-threaded port. This restores it in crucible's form.

Design

  • SegmentHeader gains ref_count: AtomicU32 (fits in padding, still 64 bytes) with crucible's two-phase acquisition: check state → increment → re-check → back out on failure. Items acquired via the four read paths (get, get_no_freq_incr, wrapping_add, saturating_sub) carry the guard.
  • can_evict() requires zero readers, which gates every eviction selection path (Random/Fifo/Cte/Util ranking, merge chains, S3-FIFO); clear_segment fails cleanly on a pinned segment (covers RandomFifo); push_free debug-asserts the invariant.
  • TtlBucket::{expire, clear} become cursor walks: every visited segment is drained from the hashtable, but pinned segments stay linked and are reclaimed by a later pass once readers drop — approximating crucible's AwaitingRelease handoff until the full segment state machine is ported (next step in the concurrency sequence). reserve no longer spins on an inaccessible tail (newly reachable state); it expands past it.
  • A new segment_pinned_skip metric counts deferred reclamations.

Eviction-side internals (which hold &mut Segments) intentionally keep using unpinned access; the writer-side CAS state transitions arrive with the state-machine port, as noted in the loom model comments.

Testing

  • Two loom models for the acquire protocol (two readers racing the eviction gate; acquisition fails in all interleavings once draining).
  • Behavioral tests: a pinned segment survives ~10× heap of Fifo eviction churn with byte-stable value and unchanged CAS token (location + generation); items held across clear() stay readable while the hashtable drains, and a later clear() reclaims; numeric-op items pin like any other.
  • All three behavioral tests verified to fail when ref_count() is neutralized to always return 0 (gates blind) and with the can_evict() gate removed.
  • cargo test -p segcache (53 tests), --features debug, loom suite (10 models), workspace incl. doctests, clippy --all-targets --all-features -- -D warnings, fmt --check all green.

Sequencing

Built on #24's generation tokens (rebased onto main post-merge). Next steps in the concurrency sequence: segment state machine (crucible's 9 states, replacing plain state stores with CAS transitions and adding the AwaitingRelease deferred-release handoff), then the lock-free free queue and concurrent reserve path.

🤖 Generated with Claude Code

brayniac and others added 4 commits June 11, 2026 10:11
Add a ref_count field (fits in existing padding; header stays 64
bytes) with a two-phase reader acquisition protocol ported from
crucible's SliceSegment: check the state is readable, increment, then
re-check and back out if a writer transitioned the segment in
between. Nothing acquires pins yet; eviction gating follows.

The original implementation waited on a segment refcount before
clearing (see the 'skips over seg_wait_refcount' note in
Segment::clear); this restores that machinery in crucible's form.

Two loom models cover the protocol: readers racing a writer that
mirrors the production eviction gate, and acquisition failing in
every interleaving once a segment is draining.

Co-Authored-By: Claude Fable 5 <[email protected]>
Add SegmentGuard, an RAII pin on a segment's reader count, and a
Segments::acquire_item_at that performs the two-phase acquisition
before handing out a RawItem. The four read paths that return or
mutate items (get, get_no_freq_incr, wrapping_add, saturating_sub)
now construct Items that carry the guard, so the segment backing an
Item is pinned for exactly as long as the Item is alive.

No behavior change yet: nothing consults the reader count when
selecting segments for eviction or recycling. That gating follows.

Co-Authored-By: Claude Fable 5 <[email protected]>
can_evict() now requires a zero reader count, which gates every
eviction selection path: Random, the Fifo/Cte/Util ranking and its
least_valuable_seg re-check, merge candidate chains (a pinned
destination zeroes the chain length; a pinned source stops the pass),
S3-FIFO selection, and the empty-segment fast-free in remove_at.
clear_segment() additionally fails cleanly on a pinned segment,
covering RandomFifo's head selection, and push_free() debug-asserts
the invariant.

A pinned segment is simply not chosen this pass and is reclaimed by a
later pass once its readers drop — the deferred-release handoff
(AwaitingRelease) arrives with the full state machine port.

The churn test pins one segment and pushes ~10x the heap through a
Fifo cache: the held value must stay byte-stable and the key's CAS
token (location + generation) unchanged. Verified the test fails
without the can_evict() gate.

Co-Authored-By: Claude Fable 5 <[email protected]>
TtlBucket::expire and clear were head-pop loops that always freed the
segment they drained — incompatible with reader pins. Both are now
cursor walks: every visited segment is drained from the hashtable
(Segment::clear is idempotent on revisit), but only unpinned segments
are freed. A drained-but-pinned segment stays linked in the chain and
is reclaimed by a later expire/clear pass once its readers drop —
this approximates crucible's AwaitingRelease handoff until the full
state machine is ported. Return values now count only segments
actually freed.

TtlBucket::reserve previously spun forever on an inaccessible tail
(impossible before; possible now that a drained-pinned segment can
remain as tail) — an inaccessible tail now falls through to
expansion, linking a fresh segment after it.

Verified all three behavioral tests fail when ref_count is
neutralized to zero (gates blind), and pass with it live.

Co-Authored-By: Claude Fable 5 <[email protected]>
@brayniac brayniac requested a review from thinkingfish June 11, 2026 17:21
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