perf: per-slot Arc copy-on-write snapshot isolation, uncontended datapath during a save (#576 core)#588
Merged
Merged
Conversation
… frozen shard off-thread (#576) The concurrent-snapshot p99.9 catastrophe (3.5s vs a ~40ms baseline on c7g) was NOT the encode+fsync (#586 already moved those off-thread) but the O(N) `snapshot_chunk` COPY of the whole keyspace into owned records, which HAD to stay on the serving core (the store is `!Send`) and contended the datapath on memory bandwidth. #571 (yield) only interleaved it and #578 (throttle) only STRETCHED it over a longer window, both measured to help nothing / hurt. This removes the serving-side copy entirely via per-slot Arc copy-on-write: - STORE: `dbs` is now `Vec<Vec<Arc<HashTable<Entry>>>>`. GET reads through the Arc as a SHARED deref (`slot_table`) -- one pointer indirection, NO `Arc::get_mut`, NO atomic on the read path (keeps GET at +1-2% with #587's shared freq bump). A write takes `Arc::make_mut` (`slot_table_mut`): the common not-saving case is strong_count==1, an uncontended in-place mutate; while a save holds a frozen clone (strong_count>1) the first write to a slot deep-clones it. - DEEP-CLONE-ON-COW (the correctness crux): `Arc::make_mut` on a frozen slot clones `HashTable<Entry>`, which clones each `Entry`, and `Entry`'s `Clone` is a DEEP clone (a fresh pointee allocation -- Str blob copied, CollEntry re-boxed). The fresh table becomes the live slot; the frozen Arc retains the ORIGINAL entries, sole-owned by the persist thread. So a write during a save is NEVER visible in the dump and NEVER mutates/frees a pointee the persist thread reads: no data race, no use-after-free, no double-free. A shallow (pointer-copy) clone would be catastrophic here; `Entry` is verified NOT `Clone`-shallow. - FrozenSlot unsafe Send: `Arc<HashTable<Entry>>` is `!Send` (Entry is a `!Send` tagged NonNull). `struct FrozenSlot(Arc<..>)` with `unsafe impl Send` hands one slot to the persist thread. SOUND because a frozen slot is de-facto IMMUTABLE for the save: every datapath write COWs first (above), and the one shared-`&Entry` write -- the S3-FIFO freq bump (`bump_freq_shared` on GET/rmw) -- is GATED OFF via a per-shard `saving` flag for the save window, so no byte the persist thread reads is mutated. The Arc refcount is atomic, so the cross-thread move/drop is race-free; dropping a FrozenSlot frees pointees only when strong_count hit 1 (the slot was COW'd away, no live reader remains). This mirrors why the store is otherwise !Send (mutable aliasing on the owning core), which a frozen slot lacks. - SAVING FLAG fast path: a plain `bool` (single-threaded shard, ADR-0002/0005; the persist thread never touches it). `begin_save` Arc-clones every non-empty slot (O(slots), no key copy) and sets the flag; `end_save` clears it. The datapath fast path (not saving) is one predictable branch + the Arc deref. - SAVE PATH (coordinator `save_shard_local`): FREEZE via `begin_save`, hand the `Vec<FrozenSlot>` to the dedicated `ic-persist-<n>` thread (#586 pattern), which runs the whole O(N) encode+fsync (`dump_frozen_slots` -> `to_kvobj` + encode + CRC + `write_shard_dump`) OFF the serving core, drops the frozen Arcs, and signals a oneshot. The shard AWAITS that (a cross-thread wake, not a blocking join) and serves throughout. `end_save` runs on the normal-completion path only (all arms mean the persist thread's closure has fully exited); on task cancellation the flag safely stays set on an exiting process (never racing the still-running thread). - CRASH-SAFETY preserved (#530): the persist thread writes only the per-shard file atomically; the manifest is still written LAST on the home core. A panicked persist thread -> a failed save (record_save_failed), never a hang; frozen Arcs dropped on unwind. - CONSISTENCY: the dump is now a per-shard POINT-IN-TIME as of the freeze (STRONGER than the #571 chunked walk -- a mid-save write keeps its pre-freeze value in the dump, or is omitted if newly created). Cross-shard stays fuzzy (each shard freezes at its own instant), the accepted cache warm-start tradeoff. The #578 throttle knob stays settable but is inert for the tail (the copy it throttled is gone). TESTS (this is the correctness-critical PR): - store `cow_deep_clone_isolates_frozen_slots_from_overwrite_and_delete`: overwrite + delete every frozen key on the LIVE store while holding the FrozenSlots; the frozen view keeps its pre-freeze values byte-for-byte, the live store advances, and drop ordering is UAF/double-free-free. - store `freq_bump_skipped_while_saving_then_resumes`: the soundness gate. - store `point_in_time_holds_under_mass_writes_at_scale` (200k keys). - persist `dump_frozen_slots_is_point_in_time_across_later_writes`: dump + RELOAD shows the frozen point-in-time; post-freeze overwrites/deletes never corrupt it. - integration `bgsave_cow_serves_inplace_writes_and_reloads_consistently`: real APPEND/INCR/DEL/SET during a BGSAVE, each serviced with low latency, reload self-consistent, live writes present. - MIRI: `cargo +nightly miri test` ran BOTH focused store tests clean (no Undefined Behavior / UAF / double-free), including the deep-clone-on-COW + FrozenSlot drop ordering crux (91s). - clippy -D warnings clean, fmt clean, CI invariants + prior-art pass, io_uring build ok; existing bgsave/off-thread/backpressure/crash/torn/turnkey_restart tests all pass. Part of #576. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFWCZR47TDvsFPJ1waX85X Signed-off-by: Zeke <ezequiel.lares@outlook.com>
perf-gate (A5)Same-runner ratchet of HEAD against the merge-base (both rebuilt and measured in this job).
Overall: PASS
|
This was referenced Jul 9, 2026
ELares
added a commit
that referenced
this pull request
Jul 9, 2026
…e's encode stops stealing a serving core (#590) The per-slot Arc-COW snapshot (#588) cut the concurrent-snapshot p99.9 from 3.5s to 303ms on c7g by moving a save's O(N) encode+fsync onto a dedicated ic-persist-<shard> thread. But that thread still competes for a serving core: IronCache does NOT set per-thread CPU affinity today (the datapath is pinned only at the process level via taskset/cpuset), so at 16 shards on 8 pinned cores the persist thread is a 17th runnable thread the scheduler lands on one of those same serving cores, where its encode steals serving time and stretches the during-save tail. This adds a `persist_cpu` knob that pins the ic-persist thread(s) to a dedicated core off the datapath set, so the encode no longer competes for a serving core. The knob (boot config, restart-required; TOML `persist_cpu`, env `IRONCACHE_PERSIST_CPU`, CLI `--persist-cpu`): - `off` / empty (DEFAULT): no pin, the thread floats exactly as today. This is the safe default per the tunability tenet: dedicating a core is an env-dependent tradeoff (it costs a serving/spare core), so it is opt-in, never baked in. - `auto`: reserve the HIGHEST core of the process's current affinity mask. - explicit list: `8`, `6-7`, or `6-7,10`. Affinity mechanism: the syscall lives in the ironcache-runtime seam (`sched_setaffinity`/`sched_getaffinity` via libc, Linux-target-gated), because the ironcache binary is `#![forbid(unsafe_code)]`. It is one small, contained `unsafe` with a SAFETY note; a graceful no-op on non-Linux (a set value logs one warning and runs unpinned). The pure parse+select logic lives in ironcache-config (unit-tested, and `Config::validate` rejects a malformed value at boot); the binary's `crate::affinity` glues them and applies the pin at the top of the persist-thread closure. Because sched_setaffinity is bounded by the process cpuset (not the inherited taskset mask), the persist thread can escape a taskset-confined datapath onto a reserved core outside it -- the "give the server one extra core" deployment. Determinism (ADR-0003) is untouched: affinity is a scheduling concern off the engine decision path (no clock, no entropy, no effect on any stored value or ordering). The Arc-COW/FrozenSlot hot-path logic is unchanged; this only sets the persist thread's affinity. Measurement recipe (for the c7g re-measure): headtohead.sh/tail.sh gained a `PERSIST_CORE` env var that injects `IRONCACHE_PERSIST_CPU` on the IronCache launch. Give the server one extra core: SERVER_CORES=0-7 PERSIST_CORE=8 CLIENT_CORES=9-15 scripts/bench/tail.sh The datapath stays taskset-pinned to 0-7; the persist thread escapes to core 8. Or, standalone: `IRONCACHE_PERSIST_CPU=8 taskset -c 0-7 ironcache --shards 8 server`. Tests: unit tests of the core-selection logic (incl. "auto reserves the last core, shards get the rest") in ironcache-config; a Linux integration test that pins a thread and asserts sched_getaffinity reflects exactly the configured core (runs green in the Docker Linux build); and a non-Linux/unset graceful-no-op test. All existing bgsave/persist/Arc-COW/eviction/turnkey/server-config tests pass unchanged; clippy -D warnings, fmt, the io_uring build, and both CI invariant scripts are clean. Honest residual: pinning removes the persist thread's CPU STEAL but not its MEMORY-BANDWIDTH share -- the thread still reads the whole frozen keyspace to encode it, contending with the (memory-bound) datapath. So pinning is expected to close PART, not all, of the 303ms->~15ms gap. Bounding the off-core reader's bandwidth (lever 2 in the issue) and a cheaper encode read (lever 3) are the remaining levers; measure pinning first, then decide. Hence "Part of", not "Closes". Part of #589. Claude-Session: https://claude.ai/code/session_01VFWCZR47TDvsFPJ1waX85X Signed-off-by: Zeke <ezequiel.lares@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 9, 2026
ELares
added a commit
that referenced
this pull request
Jul 9, 2026
#591) Reposition the tail-latency docs from the (refuted) moat thesis to what was actually measured on c7g, so the docs stop overclaiming: - CONFIG.md (persist_cpu / "Dedicated persist core"): pinning the persist thread to a dedicated core makes the concurrent-snapshot tail WORSE (291ms float -> 1,125ms pinned, measured), not better -- it slows the encode and lengthens the memory-bandwidth-contention window. Default (float) is best for tail; the knob is retained only for CPU isolation for other reasons. Residual gap to ms-class is a fundamental bandwidth-headroom tradeoff. - PERSISTENCE.md (save-backpressure throttle #577): the throttle makes the tail WORSE (3.5s -> 16.75s at pct=10, measured), not the ~3-4x cut originally hypothesized. Corrected the "queue drains" claim and the fix reference: PR-B (#586) also failed; the per-slot Arc-COW (#588) is the actual fix (3.5s -> 291ms, 11.5x), with a fundamental residual. - TAIL_LATENCY.md: the "moat" framing was refuted by this very harness; state the honest result (baseline tail ties Dragonfly, qps/core + memory win, durable-save tail went catastrophic -> competitive/sub-second, NOT category-leading on the during-snapshot tail). No code change. Documents the #576/#588/#589 measured record so future work does not repeat the throttle/pinning dead ends (both worsen a bandwidth-duration-bound tail). Signed-off-by: Zeke <ezequiel.lares@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 9, 2026
ELares
added a commit
that referenced
this pull request
Jul 9, 2026
…meout-guarded, real-socket driver (#595) Land the OPT-IN wiring that connects the merged, data-safe transport core (upgrade/stream.rs: send_bulk/recv_bulk, send_cutover/recv_cutover, CutoverBarrier, the CRC'd envelope) to a REAL AF_UNIX socket rendezvous, a caller-side timeout, and the cross-shard flip decision, as a new module crates/ironcache/src/upgrade/drive.rs. OPT-IN + default byte-unchanged (paramount): HandoffPlan::from_config is Some ONLY when `handoff_socket` is configured; with no socket it is None, so the default #390 tmpfs/durable SAVE->reload path is never consulted and no boot/hot-path code changes. This is the single gate; a node without the knob behaves exactly as before this wiring. Wiring as-built (correct + tested): - Real-socket rendezvous: bind_handoff_listener (old/sender binds the node-local path, unlinking a stale socket file), accept_handoff, and connect_handoff (new/receiver connects, retrying while the sender is still binding). Real tokio UnixListener/UnixStream, the same transport two processes use, not the in-test socketpair the core unit tests drive. - Caller-owned TIMEOUT: every leg (send/recv bulk, cutover, shard, and the rendezvous) is wrapped in tokio::time::timeout, mapping an elapsed deadline to a new typed HandoffError::Timeout so a hung/wedged peer ABORTS instead of hanging. The core flagged the timeout as caller-owned; this is that caller. - Cross-shard atomic flip: barrier_from_results folds the per-shard outcomes into the ALL-OR-NOTHING CutoverState via the pure CutoverBarrier (Commit iff every shard handed off; Abort the instant any shard failed, sticky/fail-closed). Abort-safety (the #1 rule): The sender only READS its store (the core send_* never mutate/drop it), so on ANY failure (socket error, delta overflow, peer abort, OR a caller timeout) the OLD process keeps serving and the durable data_dir snapshot stays a valid fallback. A fired timeout DROPS (cancels) the in-flight future: the sender's dropped socket is an EOF to the peer (an abort), and the receiver's partial fresh store is never adopted. No path here can lose an acknowledged write or serve a half-loaded store. Live vs DEFERRED (why "Part of #391", not "Closes"): The final live-traffic zero-downtime serve-flip is deliberately deferred, because each remaining piece touches the live datapath / process lifecycle and cannot be made correct AND tested in-harness without two real sharded processes: - the receiver boot substitution (run_drain_loop pulling over the socket into its thread-local ShardStore instead of load_shard_on_boot); - the sender freeze (driving send_bulk from a #588 frozen Arc-COW view so the old shard keeps serving DURING the bulk; the core send_bulk holds a &ShardStore borrow for the whole scan today); - the dispatch -LOADING write-QUIESCE across shards + the cross-thread coordination that stops an old shard only after the barrier commits (no per-shard loading flag exists today, only node-wide CLIENT PAUSE); - the acceptor drain-and-final-accept for the non-socket-activated SO_REUSEPORT case (systemd socket-activation #389 is the supported no-RST path: the inherited listener fd is never closed across handoff); - the orchestrator spawning the sibling + old draining/exiting on Commit or resuming on Abort. Tests (data-safety first, over a REAL socket file, both ends on one current_thread runtime via tokio::join! since ShardStore/ReplRing are !Send): - real_socket_multi_shard_handoff_serves_every_key: populated old -> new over bind/accept/connect, barrier commits, every key served. - writes_during_transfer_present_after_cutover_over_real_socket: the delta (create/overwrite/delete during the window) converges via the timed phase wrappers. - receiver_crash_aborts_and_old_keeps_serving: abort-safety, old intact and nothing adopted. - hung_peer_times_out_and_old_keeps_serving + connect_to_unbound_socket_ times_out: a hung peer / unbound socket ABORTS via the timeout. - plan_is_none_without_socket_and_some_with_it: the opt-in gate keeps the default flow unchanged. Gates: cargo test -p ironcache (green), clippy --all-targets -D warnings, cargo build --features io_uring, cargo fmt, check-rust-invariants.sh, check-prior-art-claims.sh. No unsafe added. ADR-0002 (per-shard streams, the cutover barrier the one sync point) + ADR-0003 (ops-path, no engine clock/entropy). Part of #391. Claude-Session: https://claude.ai/code/session_01VFWCZR47TDvsFPJ1waX85X Signed-off-by: Zeke <ezequiel.lares@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 9, 2026
ELares
added a commit
that referenced
this pull request
Jul 10, 2026
…TOR, ACL) (#610) A doc-audit found stale claims. Each correction below uses only measured numbers (c7g, 2026-07-10): - README.md (perf/competitive): reframed from "Dragonfly leads GET / close the remaining gap" to the corrected thread-per-core re-bench. IronCache now LEADS GET by about 19% single-endpoint (2.43M vs Dragonfly 2.04M ops/sec) and by roughly 2x cluster-aware (4.32M vs 2.19M) once #517 zero-hop routing removes the cross-shard hop; the old "leads both" was a benchmark CONFIG artifact (shards oversubscribed relative to cores). Kept honest nuance: baseline p99.9 TIES Dragonfly (~15ms), the durable-save tail (291ms) is competitive not category-leading, and the decisive edges are GET throughput, memory, and determinism. Fixed the two-vCPU section's stale "Dragonfly pulls ahead at real core count" reading. - docs/PRODUCTION_READINESS.md: goal-completion verdict is now MET on GET (#507 CLOSED, #517 hop-elimination paid off) instead of "unproven pending a re-bench"; moved console web-surface hardening (#369, PR #607) from the "remain" list to "landed"; removed the stale within-3%/re-bench finish-line items; fixed the false "README.md:143 claims MONITOR redaction" citation in items 27 (README documents SLOWLOG/INFO/logs redaction, and MONITOR is intentionally unimplemented, so there is no doc mismatch). - docs/bench/TAIL_LATENCY.md: corrected the plain-GET "can trail Dragonfly" framing to "now LEADS Dragonfly under a proper thread-per-core config"; the durable-load tail nuance (already honest) is unchanged. - docs/design/ACL.md: documented per-subcommand ACL grants (Redis 7 `+cmd|sub`), specifically `+slowlog|get` for the least-privilege monitor user (read-only SLOWLOG without the `SLOWLOG RESET` wipe), and clarified that the CLUSTER_CONTRACT.md "no per-subcommand carve-out" note is narrow. - docs/research/dragonfly.md: reworded one phrase to remove a stale-deficit-grep false positive. - CHANGELOG.md: annotated the historical 2026-07-03 benchmark entry with the corrective re-bench note (record preserved). Not changed (already current): docs/design/CONFIG.md and docs/design/PERSISTENCE.md already point the save-throttle (#577) warning at the per-slot Arc-COW (#588) fix that cut the snapshot tail 3.5s -> 291ms (11.5x). Docs currency sweep (post competitive re-bench + console completion). Signed-off-by: Zeke <ezequiel.lares@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ELares
added a commit
that referenced
this pull request
Jul 11, 2026
The prior handoff bulk (send_bulk) scans the LIVE store: because the per-slot tables (#570/#576) can rehash under a concurrent write, a live chunked scan can TEAR (a pre-existing key rehashed behind the cursor is skipped, and if it was not itself re-written after the cut it lands in NEITHER the bulk NOR the delta, so it is LOST). It also forces the old shard to stop serving writes during bulk. PR-1 lands the riskiest primitive of the streamed live cutover (design of record: docs/design/UPGRADE_LIVE_CUTOVER.md): the ATOMIC CUT plus a tear-free frozen-view bulk, so the old shard keeps serving reads AND writes during the transfer. - freeze_cut (stream.rs): captures the #588 Arc-COW frozen slot view (begin_save) AND the delta floor F = ring.head() in ONE uninterrupted, non-await critical section on the shard thread. With the always-on observer ring (every mutator assigns its offset and appends in the same non-await step it applies), this makes bulk(frozen @ F) UNION delta(F, E] == EXACTLY the mutations with offset <= E, zero gaps, zero doubles. Any await or lock-drop between the two reads would let a write slip into the gap and be missed by both bulk and delta; there is none. - send_bulk_from_frozen (stream.rs): streams the immutable frozen slots (via the new store accessor) instead of the live store, so the scan is tear-free by construction (a concurrent write COWs a fresh live slot, leaving the frozen Arc untouched). A concurrent write rides the delta ring at offset > F. send_shard_from_frozen adds the no-quiesce convenience (ends the save on all paths so the freeze flag never leaks). - frozen_snapshot_chunk + FrozenCursor (ironcache-store): a minimal read-only accessor that yields bounded SnapshotEntry chunks from &[FrozenSlot], the frozen-view counterpart of snapshot_chunk. It only ADDS a read accessor over the existing FrozenSlot surface (the same shape the persist dump consumes); it does NOT touch the frozen four primitives (begin_save / end_save / the Arc::make_mut COW write path), so their soundness argument is unchanged. Tests (over UnixStream::pair, no sibling process): - HERO: a background writer HAMMERS the shard with a churny mix (create, overwrite crossing encodings, delete, absolute TTLs) THROUGHOUT the frozen bulk scan under real concurrent load, driving thousands of live mutations (forcing live-slot rehashes) across many concurrent segments of the scan. Reconstructs the receiver's post-cutover store and asserts bulk UNION delta equals EXACTLY the sender's acked keyspace as of the cut: every key present with its last-written value + encoding + absolute TTL, zero pre-existing keys lost to a mid-scan rehash, zero gaps, zero doubles. - ATOMICITY UNIT: after freeze_cut returns, F equals the ring head at the freeze; every subsequent write gets a strictly greater, contiguous offset; and the frozen view is unperturbed by those live writes (COW isolation). - The frozen convenience path streams every key and ends the save. The existing stream.rs socket_tests are unchanged and still pass. send_bulk is retained (documented) for the no-concurrent-write case. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ELares
added a commit
that referenced
this pull request
Jul 24, 2026
…ms -> 30ms), not a 40x loss (#768) The public benchmark docs still describe the PRE-#742 world and materially UNDERSELL the product. docs/bench/TAIL_LATENCY.md's measured record was committed 2026-07-23 14:20; PR #742 (the fix) merged 18:52 the SAME DAY, so every IronCache tail in that record is pre-fix. The post-fix c7g confirmation was run and posted on #676 but never made it into the docs. Corrected, using the numbers from that confirmation (same config: 8 server cores + a pinned persist core, tmpfs, 1M keys, BGSAVE/3s): - during-save p99.9: 794ms -> **30ms** (~26x); p99 640ms -> 12ms; the no-save baseline is 21ms, so the save is now essentially invisible to the datapath. - vs the same pinned competitors: Dragonfly 19ms, **IronCache 30ms**, Valkey 510ms, Redis 719ms -- i.e. from WORST-in-class into Dragonfly's class, ~17x/24x ahead of Valkey/Redis. Files: - TAIL_LATENCY.md: new CURRENT record up top; the old record retitled "Superseded: PRE-#742" with a warning that its analysis ATTRIBUTES TO SAVE COST what was actually a scheduling defect -- notably the dataset-scaling table and its "~4.3ms of datapath stall per MB" conclusion were measuring how long the HOL block lasted, not an inherent persistence cost. Competitor numbers there stay valid, and the save WALL-TIME characterization (deltas cut incremental cost; a base save is O(resident)) is still accurate, so the section is kept for provenance. The old "honest verdict" is marked superseded and its wrong causal conclusion called out explicitly. - README.md: the during-snapshot tail is no longer listed as "a known limitation at a memory-bandwidth floor" -- that was the most visible stale claim in the repo. - docs/PRODUCTION_READINESS.md: the "291ms, competitive but not category-leading" line updated. - docs/design/PERSISTENCE.md: the 291ms figure marked historical (it is the #588 Arc-COW change alone), and the "FUNDAMENTAL memory-bandwidth-headroom tradeoff" claim marked SUPERSEDED -- that theory was disproven twice by measurement (the #740 pacer was refuted on c7g and reverted; the 1-shard-vs-8-shard ablation showed the BIGGER save stalling ~60x LESS). No new measurement was taken for this: the numbers already existed on #676. This is purely making the shipped docs match the code that merged. Claude-Session: https://claude.ai/code/session_01FfFZ8gkkNhDBASuntB72HR Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The core of the #576 fix. Prior attempts BOTH failed on c7g (measured): PR-B (off-thread encode) left the p99.9 at ~3.9s; the throttle made it WORSE (16.75s at 10%). Root cause: the O(N) snapshot_chunk copy contends on memory bandwidth with the serving datapath. This eliminates that copy.
Design (per-slot Arc COW):
Vec<Vec<Arc<HashTable<Entry>>>>. GET reads through the Arc as a SHARED deref (one indirection, noArc::get_mut, no atomic on the read path -- enabled by perf: interior-mutable S3-FIFO freq bump so GET is a shared read (#576 COW prerequisite) #587's shared freq bump). A write takesArc::make_mut: not-saving isstrong_count==1(in-place, one atomic check); a frozen slot (>1) COWs on first write.saving: boolflag (single-threaded shard).begin_save()Arc-clones every non-empty slot (O(#slots), NO key copy) intoVec<FrozenSlot>and sets the flag.save_shard_localhands theFrozenSlots to the dedicatedic-persistthread, which does the whole O(N) encode + fsync OFF-core reading the frozen slots DIRECTLY (no copy), drops the frozen Arcs, and signals a oneshot; the shard serves throughout and clears the flag after.The correctness crux (deep-clone on COW):
Arc::make_muton a frozen slot clones theHashTable, which clones eachEntry, andEntry::cloneis ALREADY a DEEP clone (fresh pointee allocation for Str blobs + re-boxedCollEntry/CollValfor every collection variant). So a mid-save write's deep-clone becomes the live slot while the frozen Arc retains the ORIGINAL entries, sole-owned by the persist thread: a mid-save write is never in the dump and never mutates or frees a pointee the persist thread reads.FrozenSlot(Arc<HashTable<Entry>>)+unsafe impl Send: sound because a frozen slot is de-facto immutable for the save (every write COWs first; the one shared-&self write, the S3-FIFO freq bump, is GATED OFF whilesaving). A static assert pinsVec<FrozenSlot>: Send.Review + verification
make_mutwith no bypass;Entry::clonedeep for all variants;FrozenSlotSend sound with correct freeze/thaw ordering and no race window; no UAF/double-free/data-race.cow_deep_clone_isolates_frozen_slots_from_overwrite_and_delete,point_in_time_holds_under_mass_writes_at_scale(200k),dump_frozen_slots_is_point_in_time_across_later_writes,bgsave_cow_serves_inplace_writes_and_reloads_consistently(real APPEND/INCR/DEL/SET during a BGSAVE, low latency, self-consistent reload),freq_bump_skipped_while_saving_then_resumes; crash_mid_save + torn_crc + turnkey_restart + all bgsave/off-thread/eviction pass. 1154 ironcache+server tests pass; clippy-D warnings+ io_uring + invariant + prior-art + fmt clean; no dashes.Consistency is now per-shard point-in-time as of the freeze (stronger than #571). Manifest still written LAST (#530). The #578 throttle knob is now inert for the tail (the copy it throttled is gone); the dead
save_throttle_sleep_us/PERSIST_QUEUE_DEPTHwere removed.Part of #576. The real p99.9 win is re-measured on c7g next.
🤖 Generated with Claude Code