perf(server): remove the WAL coordination ceiling + 1M-stream cliff; memory mode 4× faster; crash-sim + 3 recovery fixes#4675
Open
balegas wants to merge 18 commits into
Open
perf(server): remove the WAL coordination ceiling + 1M-stream cliff; memory mode 4× faster; crash-sim + 3 recovery fixes#4675balegas wants to merge 18 commits into
balegas wants to merge 18 commits into
Conversation
Phase 0 of the write-saturation contention investigation. Adds always-on, dependency-free per-shard contention telemetry (independent of the OTLP `telemetry` feature) and a local reproduction harness, so candidate architecture changes can be judged on whether they lift the commit-path ceiling AND drop the contention they target. - ShardStats: inner/dirty lock-wait nanos + acquire counts, records staged, durability waiters woken (src/wal/telemetry.rs). - Instrument the append hot path: register_dirty, reserve_and_stage (both inner acquisitions), publish_durable wakeup fan-out (src/wal/shard.rs). - `--wal-stats <secs>`: runtime gate (one relaxed load when off, no clock reads in a default run) + stderr WAL_CONT emitter with per-interval rates. - scripts/contention-repro.sh: drive the server with the ds-bench pool client, report throughput + CPU + steady-state contention. - DS_BENCH_FAST_FSYNC (bench-only): plain fsync over F_FULLFSYNC on macOS so a RAM-disk data dir gives cheap fsync (the Linux+NVMe lock-bound regime). - CONTENTION_INVESTIGATION.md: hypothesis, method, candidate architectures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
contention-repro-linux.sh runs the server in a cpuset-pinned container with a tmpfs data dir (cheap fdatasync = the Linux+NVMe regime) driven by a separate client container, so the throughput ceiling is not confounded by client/server CPU co-location. Incremental in-container builds via a per-worktree target volume keep iteration fast. Baseline reproduces the findings' signature: a hard ~45-49k ops/s ceiling at ~80% CPU, barely helped by more shards — the gate is the commit + durability- wakeup coordination machinery, not compute or the lock (at 6 cores). Recorded in CONTENTION_INVESTIGATION.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ t2a (dedicated committer threads) Combine three orthogonal WAL performance changes that all overlap in src/wal/shard.rs into one build. Hand-integrated onto t2a's shard.rs (structural committer rewrite), then layered t1c and t1a back in; the single-owner files were taken verbatim from each source branch. - t1a (lock-free register_dirty): StreamState.dirty_epoch (store.rs), Shard.dirty_epoch + dirty: Mutex<Vec<Arc<StreamState>>>, epoch-CAS register_dirty, epoch-bump+drain in checkpoint's dirty-lock section. - t1c (coalesced durability wakeups): durable_lsn: AtomicU64 + waiters: Mutex<WaiterReg> (min-heap of oneshots) replacing the watch channel; fast-path/register/re-check wait_durable; prefix-drain publish_durable recording waiters_woken = fired; telemetry.rs doc updates. - t2a (dedicated committer OS threads): CommitSignal (Mutex+Condvar) replacing Notify, synchronous run_committer + commit_once (direct fdatasync, no spawn_blocking), spawn_committer/CommitterHandle, WalSet spawn/stop_committers, main.rs shutdown wiring, recovery/e2e adapters. Integration points: the Shard struct holds all of dirty_epoch+dirty Vec, durable_lsn+waiters, and commit_signal; the constructor inits all of them (watch channel + Notify fully removed). publish_durable does t1c's coalesced drain AND retains sealed_pending, and is called from t2a's synchronous committer thread (atomic store + oneshot fires are thread-safe, no tokio handle). checkpoint reads durable_lsn via the atomic and does t1a's epoch-bump+drain. t1a's post-drain test was adapted to the spawn_committer/stop API. cargo test: 95 passed; 0 failed. cargo build --release + cargo clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ds-bench pool write-saturation suites pass `--worker-threads 32` to size the tokio runtime independently of the cgroup cpu limit (available_parallelism reads cpu.max, so under a limit it would under-size the pool). Add the flag (defaults to available_parallelism); also used as the default WAL shard count. Required for the combined branch to boot under the canonical remote config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… + per-append meta flush) At high stream cardinality (200k-1M) nearly every append is its stream's first touch of the checkpoint interval, which turned three amortized costs into per-op costs. Local repro (6 cores, 256 conns, 400k streams): 16.0k -> 44.2k ops/s (+2.8x), p99 144ms -> 27ms. - checkpoint drain: O(1) dirty-lock critical section (take + epoch bump only); the O(touched) tail/file capture ran under the lock and stalled every appender on the shard for 25-140ms per tick. - checkpoint body: run capture + per-stream fdatasyncs + tails/ckpt persist + recycle in ONE spawn_blocking task (was: capture/tails/recycle on the async runtime); shards checkpoint concurrently (JoinSet) instead of serially. - tails map: resident in memory (seeded once from disk); stop re-reading, re-parsing and re-sorting the whole cumulative file every 3s. - meta sidecar: append path no longer debounce-schedules write_meta_sync (JSON + File::create + rename per append -> all workers spinning on the data-dir inode rwsem, ~40% of server CPU in perf at every cardinality). WAL-staged appends just mark meta_dirty; the checkpoint flushes sidecars for drained streams after recycle. Memory-mode appends keep the debounced flush. Producer/access lag bound moves from the 100ms debounce to the checkpoint cadence (already a documented non-durable, lagging flush). - handle_append: drop the redundant second registry lookup for the is_json metric label (2x cold DashMap walk per append at 1M keys). - telemetry: WAL_CKPT per-shard checkpoint phase line (touched/tails/meta counts, capture/fsync/tails/rest/meta timings) behind --wal-stats; repro script summarizes it, plus --tmpfs / --wal-stats flags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sults Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzV7avRUMhPqmm1SgU4z1J
…ery gap assert; add randomized crash/recovery simulation Two recovery-path bugs found by the new seeded crash/fault simulation (src/wal/sim_tests.rs), plus the harness itself: 1. Shard::open re-preallocated 1.wal unconditionally. A SEALED first segment (exactly-packed at roll) grew a zero tail that replay read as end-of-log, silently dropping every later segment's acked records and truncating the per-stream files to the stale frontier; a RECYCLED 1.wal was recreated as all-zeros, making replay recover NOTHING. Boot now opens the newest existing segment non-destructively (FileSegment::open_existing) and only preallocates on a fresh dir. Regression tests: e2e_multi_segment_acked_records_after_first_seal_ survive_crash, e2e_recycled_first_segment_acked_records_survive_crash. 2. recovery.rs debug_assert claimed a stream rebuilt purely from replay must start at file_base — false after a previous boot's recover+reset cycle (post-boot WAL records legitimately start at the recovered tail with no persisted-tails entry until the next checkpoint). Debug builds panicked on a healthy recovery. The assert now checks the real invariant: no hole between the pre-replay file end and the first replayed record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzV7avRUMhPqmm1SgU4z1J
… in ARCHITECTURE.md ARCHITECTURE.md still described pre-fsync tail publication; the code gates reader visibility on durability (publish_durable_tail after wait_durable_lsn, PROTOCOL.md §4.1). Also clippy nits in the new sim/recovery code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzV7avRUMhPqmm1SgU4z1J
…car durable_tail proof Confirmed by the crash sim (seed 20230): a stream created after the last checkpoint whose only in-flight append was torn by power loss had NO truncation proof — zero surviving WAL records, no checkpoint tails entry — so the sidecar pass trusted tail = file size and exposed the torn fragment to readers (the C1 shape the WAL exists to prevent). The .meta sidecar now persists durable_tail, captured from Shared.durable_tail (only ever advances post-fsync, so captured values are honest) and riding along on writes that already happen: fsynced at create/close, refreshed by the checkpoint meta flush and by recovery's reconcile — zero new hot-path fsyncs. Recovery seeds every stream's frontier with max(meta proof, checkpoint tails, replayed ends); a fast path skips streams already exactly at their frontier so 1M-stream boots add no I/O; the proof is durably re-persisted before reset_after_recovery wipes the WAL + tails file. Old sidecars without the field keep the previous trust-the-file-size behavior (serde default). Regression test: e2e_wal_quiet_stream_torn_unacked_tail_truncated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzV7avRUMhPqmm1SgU4z1J
Found by the crash sim (seed 20387, clean crash, no fault injection): handle_delete acked 204 while the file + sidecar unlinks ran on a detached spawn_blocking task with no parent-dir fsync — a crash after the ack left both files on disk and the stream resurrected with all its data on reboot. DELETE now awaits delete_or_soft_delete_durable (synchronous unlinks + one parent-dir fsync, or the durable soft-delete meta write) and acks 500 on failure. The expiry sweep keeps the detached non-durable variant (re-expiry on next access is harmless). Regression test: e2e_acked_delete_is_durable_no_resurrection_after_crash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzV7avRUMhPqmm1SgU4z1J
Codecov Report✅ All modified and coverable lines are covered by tests.
Additional details and impacted files@@ Coverage Diff @@
## main #4675 +/- ##
===========================================
- Coverage 60.15% 29.99% -30.16%
===========================================
Files 410 258 -152
Lines 44348 18172 -26176
Branches 12582 6308 -6274
===========================================
- Hits 26677 5451 -21226
+ Misses 17593 12656 -4937
+ Partials 78 65 -13
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…es per stream The 2026-07-02 ds-bench campaign showed --durability memory LOSING to wal on writes at every cardinality (575k vs 816k @10k streams; 1.33M vs 2.05M @500k on 4 CPUs) and its SSE delivery collapsing past ~16k writes/s (16-20k del/s, p50 200ms+, against 40-62k writes — same client sustained 65k del/s under wal). Two causes, both fixed here: 1. --durability memory silently routed every binary append through the splice intercept: a fresh open() per append, pipe2 + 2x splice(2) when the body split past the head buffer, socket-readiness parking under the per-stream appender lock, and the tail cache force-disabled. At sync-sized payloads (256B) that fixed overhead loses to the buffered path's single write(2) plus an amortized group-commit fdatasync share. Most workloads have small payloads — remove the splice path entirely rather than size-gate it: memory mode is now the buffered wal path with the WAL stage/wait skipped, the Linux-only restriction is gone, and the tail cache is orthogonal again. 2. Memory mode sent one reactor wake (mutex push + eventfd write) per append — wal's group commit had been acting as an implicit fan-out batcher, and without it the reactor drowned. StreamSubs gains a wake_pending latch: wake_stream skips queue+eventfd while a wake is in flight; the reactor clears the latch BEFORE reading the tail (clear-then-read, so a racing publish is either covered by the flush or re-queues); registration resets it so a gen-stale drop can never strand a fresh subscriber. Idle streams keep per-append wakes (no added latency); hot streams converge to one wake per reactor cycle. Benefits wal mode at saturation too. cargo test: 100 passed. Bench validation to follow (ds-bench mixed suites). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nch (#4679) ## What Adds `packages/durable-streams-rust/MIXED_WORKLOAD_VALIDATION.md` — the evidence record for running ds-bench's new **mixed read/write interference workload** (concurrent writers + paced catch-up readers + SSE subscribers on shared streams) against this branch's server build. Local kind validation (server pinned to 2 CPU); the remote GKE run comes when the workload is promoted into the full suite. ## Why Before adding the mixed workload to the standard benchmark matrix we validated its two premises against the perf branch, and the results are worth recording with the branch they measured: - **Bounded catch-up read load does not cost write throughput.** A 60%-of-ceiling pinned write load (17.75k ops/s) holds to within noise under up to 128 paced readers pulling 123 MiB/s of replay bandwidth; only p99 tails couple (6.5 → 53 ms at the top level). Unpaced hot-loop readers (adversarial mode) fair-share writes down instead — and in both modes the server never sheds load (zero 429/503), which is worth a deliberate decision at some point. - **Live SSE delivery latency tracks commit latency + 1–3 ms all the way to write saturation**, in both `wal` and `memory` durability. The "~14 ms delivery floor" seen in the first run was WAL fsync cost, **not** the SSE reactor — no server-side change came out of this validation. - Baseline: this branch's build is ~1.5× faster than the previous build at 50-stream write saturation on the same box (29.7k vs 19.6k appends/s). Harness, suites and full result grids: `electric-sql/ds-bench` @ `3bcc0f2` (`suites/mixed-{cal,writes,delivery}-local.json`, `results/mixed-*/FINDINGS.md`). ## No code changes Docs only — the validation found nothing on this branch needing a fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…to perf/combined-t1a-t1c-t2a
…angesets into one The splice append path removal (#4684) left ARCHITECTURE.md and README.md describing a zero-copy socket→file path and a Linux-only restriction that no longer exist. Also folds the four per-fix changesets into a single patch changeset covering the whole branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f54bc38 to
d80bb1b
Compare
…ettier-format docs The splice append path deletion removed the last Linux user of the engine_raw AtomicBool import; the cfg-gated import is invisible to native macOS checks but fails clippy -D warnings on Linux CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
samwillis
approved these changes
Jul 6, 2026
Co-Authored-By: Claude Fable 5 <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 & why
The write path stopped scaling well before the hardware did — for three independent
reasons, each found, fixed and validated by a ds-bench campaign on this branch. This PR
removes all three ceilings, plus a seeded crash/fault simulation and the three
correctness fixes it found.
saturation, capped by group-commit + durability-wakeup coordination, not compute or
fsync. Baseline:
ds-bench/results/run-durable-pool2/FINDINGS.md.amortized "once per stream per checkpoint interval" became per-append costs when
ops/stream/interval dropped below 1. Findings:
CARDINALITY_1M.md.--durability memorywas slowerthan wal at every cardinality, and live SSE delivery collapsed past ~16k writes/s.
Merged from perf(server): drop splice append path; per-stream SSE wake coalescing — memory mode 4x faster #4684; findings:
MIXED_WORKLOAD_VALIDATION.md.Changes
1. Remove the coordination ceiling (all in
src/wal/)Tokio runtime onto a dedicated OS thread and drop the per-commit
spawn_blockinghop.Biggest single win; the runtime round-trip — not fsync — was capping commit cadence.
register_dirty. Epoch-gated atomic dirty registration; a streamalready marked this interval does zero map work (no per-append
Mutex<HashMap>insert).broadcasting the shard's entire
watchwaiter set on every commit.Supporting:
--wal-stats <secs>(dependency-free, runtime-gated contentiontelemetry:
WAL_CONT/WAL_CKPTper-interval lines; one relaxed atomic load when off)and
--worker-threads <n>(pin the runtime pool size; defaults toavailable_parallelism(), which reads the cgroup cpu limit).2. Fix the high-cardinality write cliff
the same mutex every append's epoch transition takes).
spawn_blocking, shards concurrent viaJoinSet), with a memory-resident tails map instead of a re-read/re-sort/rewrite ofthe cumulative tails file per tick.
JSON +
create+renamehad all workers spinning on the data-dir inode rwsem(38–46% of ALL server CPU). WAL-staged appends now only mark
meta_dirty.3. Memory-mode append path + SSE wake coalescing (merged from #4684)
binary append through a fresh
open()per append,pipe2+ 2×splice(2), andsocket-readiness parking under the per-stream appender lock, with the tail cache
force-disabled. At sync-sized payloads that loses badly to the buffered path's single
write(2)— the campaign measured memory losing to wal at every cardinality. Memorymode is now simply the buffered wal path with the WAL stage/wait skipped (~250 lines of
subtle lock-held-await handling deleted); the Linux-only restriction and the forced
tail-cache-off go with it.
StreamSubs::wake_pendinglatch). Wal's groupcommit was acting as an implicit fan-out batcher; without it, one reactor wake (mutex
push + eventfd syscall) per append drowned the reactor.
wake_streamnow skipsqueue+eventfd while a wake is in flight; the reactor clears the latch before
reading the tail (a racing publish is either covered by the flush or re-queues), and
registration resets it so a gen-stale dropped wake can never strand a fresh subscriber.
Idle streams keep per-append wakes (no latency floor); hot streams converge to one wake
per reactor cycle. Helps wal mode at saturation too.
4. Crash/fault simulation + the three correctness fixes it found
Seeded randomized crash/recovery simulation for the WAL path (
src/wal/sim_tests.rs,design in
docs/superpowers/specs/2026-07-02-wal-crash-simulation-design.md, writeup inCRASH_SIM_FINDINGS.md). Each seed drives the real handler path with a random workload,crashes by dropping the runtime + committers, injects power-loss disk faults constrained
to the documented fault model, reboots through the real recovery sequence, and checks a
no-loss/no-torn/consistency oracle over 4 crash generations.
cargo testruns a 4-seeddeterministic smoke (~4s); long hunts scale via
DS_SIM_SEEDS/DS_SIM_GENS/DS_SIM_STEPS. No new dependencies; zero shipped-binary footprint.It found (each fixed here with a deterministic regression test):
Shard::openre-preallocated
1.walunconditionally, so a sealed first segment grew a zerotail that replay mis-read as end-of-log (dropping every later segment's acked records,
then truncating the per-stream files to the stale frontier), and a checkpoint-
recycled
1.walwas recreated empty. One 128 MiB segment roll between boots wasthe only trigger — plain process crash, no power loss needed. Fix: non-destructive
boot open (
FileSegment::open_existing).stream created after the last checkpoint had no truncation proof, so recovery exposed
a torn fragment of a never-acked record. Fix: the
.metasidecar persists adurable_tailproof (rides on existing fsynced meta writes — no new hot-path fsyncs).unlinks ran on a detached task; a crash after the ack resurrected the stream with all
its data. Fix: unlinks + parent-dir fsync (or the durable soft-delete meta flag) are
awaited before the 204.
Plus a wrong recovery
debug_assert(panicked debug builds on a healthy multi-bootrecovery) and stale ARCHITECTURE.md/README.md claims fixed (visibility gating, and the
removed splice path / Linux-only memory-mode restriction).
Measured impact
Coordination ceiling — pool saturation ladder vs the 2026-06-30 baseline:
Direct counters at 4 vCPU (
--wal-stats) localize what is removed — the win iscoordination, not lock waits:
waiters_woken/commit ~340–378 → ~16–18 (~21× fewer);fsync/s83–192 → 1021–1178 (~10× more).High cardinality — 1M-stream write scaling, 16 vCPU (GKE, 256 B, batch 1):
Memory mode — local kind, 2-CPU server, mixed suites, same binary A/B:
Read/write interference validation of the wal-mode changes:
MIXED_WORKLOAD_VALIDATION.md(PR #4679).
Changeset
One consolidated patch changeset for
@electric-ax/durable-streams-server-rust(
.changeset/wal-write-path-perf-and-recovery-fixes.md) covering the perf work and thethree user-facing fixes.
Reviewer notes
src/wal/shard.rsis the core and deserves an adversarial concurrency review(lock-free dirty set + wakeup coalescing + committer handoff + checkpoint drain).
The crate's unit/e2e/sim tests cover it; CI is the source of truth.
src/sse_reactor.rswake coalescing — the clear-then-read ordering is thecorrectness argument (see the comment at the latch); worth a careful look.
DS_BENCH_FAST_FSYNC(store.rs,segment.rs) is a bench-only, non-durable fsyncshortcut (plain
fsyncvsF_FULLFSYNCon macOS) — env-gated, never default on. Flagif we'd rather split it out.
CONTENTION_INVESTIGATION.md,CARDINALITY_1M.md,CRASH_SIM_FINDINGS.md,MIXED_WORKLOAD_VALIDATION.md,scripts/contention-repro*.sh.Can stay or move to a docs-only PR.
inner_wait_load ≈ 0at 4–16 vCPU,so it adds lock-restructuring risk for ~no gain. T3 (shared-nothing) — design-only,
confirmed unnecessary at ≤32 cores.
sweep clean with all fault types enabled; clippy clean. Read+write mix validated in
docs(bench): mixed read/write interference validation of the perf branch #4679; at the fully saturated memory-mode max level delivery ratio dips to 1.55
(suspected client-bound on the single-VM kind node) — a remote
mixed-deliveryrunwill confirm.
🤖 Generated with Claude Code