Skip to content

perf(durable-streams-rust): eliminate the write cardinality cliff — syncfs checkpoints, per-shard triggers, stream lanes#4697

Merged
balegas merged 13 commits into
mainfrom
perf/wal-checkpoint-syncfs
Jul 14, 2026
Merged

perf(durable-streams-rust): eliminate the write cardinality cliff — syncfs checkpoints, per-shard triggers, stream lanes#4697
balegas merged 13 commits into
mainfrom
perf/wal-checkpoint-syncfs

Conversation

@balegas

@balegas balegas commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Eliminates the WAL write cardinality cliff end-to-end. Formerly a 4-PR stack (#4704, #4705, #4707 folded in here). Full deployment guidance: WAL_TUNING.md (in this PR). Validated by the canonical benchmark campaign 2026-07-14 (ds-bench#6: results/REPORT.md + PROVENANCE.md) run against this PR's head.

The problem

WAL-durable write throughput collapsed with stream count: 10.4k ops/s @100k streams (vs ~110k @10k), degrading further under load. Two compounding causes:

  1. The checkpoint issued one fdatasync per touched stream — an O(N) barrier storm sharing the device with commit fsyncs.
  2. At extreme cardinality (~1M), checkpoint writeback amplification (~40× for small appends: dirty page + inode + extents + journal per file) saturated the single data device — one syncfs measured at 60–74 s.

The changes

commit what measured effect
syncfs checkpoint one syncfs barrier per stream lane instead of O(N-touched) fdatasync (unconditional on Linux) +51% at the 100k cliff
per-shard checkpoint triggers --wal-checkpoint-interval-ms + --wal-checkpoint-wal-bytes (retained-WAL/replay budget; shards self-stagger, in-flight guard, panic-safe) checkpoint cost ≈ 0
--stream-lanes N hash stream data files across per-device lane dirs; N parallel per-lane syncfs; lane count persisted + validated on open 1M wall broken: 68k → 212k (syncfs 60–74s → 6–11s)
cleanup dead knobs removed (--wal-fsync-parallel, meta gates, meta-sweep toggles, --tier local); memory×tier rejected at startup; flag-clear WAL records decode as torn smaller, safer surface

Results — canonical campaign 2026-07-14 (this PR's head, ideal configuration)

Write saturation (canonical-write, 256 B appends, c4d-standard-64-lssd, 3 stream lanes + 3 WAL lanes, pinned cores):

streams wal-ideal memory (no-fsync ceiling)
10k 417k 680k
100k 382k (−8%, no cliff) 632k

vs 10.4k @100k pre-campaign = 37×. At extreme cardinality (same config): 285k @500k, 212k @1m.

Reads (unchanged by this PR, revalidated): catch-up 2.27–2.74 GiB/s, cardinality-flat 10→100 streams; SSE tail p99 2–3 ms up to 2048 connections.

Mixed interference: 100k concurrent catch-up readers cost a pinned 50k ops/s write load nothing (303 MiB/s replay served alongside). The 2026-07-02 memory-mode delivery collapse no longer reproduces on this build: delivery tracks writes 1:1 to 127k/s (wal: keeps pace to ~40k, caps ~64k del/s at full write saturation).

Correctness

  • The recoverability contract is unchanged: ack = WAL group-commit fdatasync; a WAL segment is recycled only after its records' stream bytes are fsynced AND the durable-tail map is persisted. All knobs only change when/how that sequence runs.
  • 105/105 tests: syncfs crash-recovery e2e, 3-lane crash-recovery + layout-spread + lane-mismatch rejection, memory-mode sidecar-skip regression, randomized crash sim.
  • CI green including all four conformance configs (memory, wal-default, wal-read-offload-always, wal-resident-cache).
  • Defaults preserve behavior except syncfs-on-Linux (extensively measured); --stream-lanes defaults to 1 = byte-identical layout. Changeset: patch.

Follow-ups (tracked)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk

balegas and others added 3 commits July 8, 2026 23:09
…r telemetry

Skip the per-append .meta sidecar flush for plain appends (memory + wal),
eliminating the memory-mode cardinality cliff (+104% @ 50k local, +14-16% NVMe)
and reducing wal checkpoint work. Adds --server-stats (SRV_STATS: cpu/inflight/
svc/durwait) and --wal-fsync-parallel telemetry/knobs used to characterize the
wal bottleneck as device-fdatasync-bound (not shards/CPU). Findings in
CARDINALITY_CLIFF_FIX.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
…er vs O(N) per-stream fdatasync)

Cardinality-cliff #2. The checkpoint made each touched stream's data file
durable via one fdatasync per stream — O(N_touched) device barriers (measured
~29s at 200k streams), which saturate the disk and stall commit fsyncs (the
cliff). --wal-checkpoint-syncfs on replaces that loop with a single syncfs() on
the data fs: one barrier flushes every touched file. Linux-only (falls back to
the per-stream loop elsewhere); default off. Durability-before-recycle ordering
unchanged; covered by e2e_checkpoint_syncfs_recovers_acked_records.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: all changes are within the durable streams package.

…knob + retained-WAL size budget

Replace the hardcoded 3s all-shards checkpoint wave with a per-shard dual
trigger: --wal-checkpoint-interval-ms (time, default 3000 = old behavior) and
--wal-checkpoint-wal-bytes (size, 0 = off): checkpoint a shard as soon as its
retained WAL exceeds the budget. Rationale (wal-decomp-lane0, 2026-07-13):
acks never gate on checkpoint and reads never touch the WAL, so checkpoint
frequency is purely a replay-time budget — a size trigger makes that budget
explicit and lets shards self-stagger by their own write rates instead of
storming the device together. Each shard's clock restarts when its checkpoint
finishes, an in-flight guard prevents re-firing a slow shard, and due shards
still checkpoint concurrently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
@balegas

balegas commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Update: cliff eliminated — layout + syncfs validated end-to-end (2026-07-13)

Two follow-up benchmark campaigns (wal-decomp-lane0, wal-splitlane on c4d-standard-64-lssd raw-block NVMe) complete the picture:

1. A storage confound inflated the original cliff. In all earlier multilane runs the stream data files (<data-dir>/streams/) sat on the PD boot disk (raw-block node pools leave the base emptyDir on PD) while only WAL segments got NVMe. The checkpoint was hammering a network disk.

2. With correct layout, this PR's syncfs checkpoint is cardinality-flat:

config @100k streams peak ops/s
original (streams on PD) 10.4k
everything on ONE shared NVMe lane, syncfs @3s 46k
split-lane (streams on own lane, 5 WAL lanes), syncfs @3s 271.6k (286k @10k, −5%)
split-lane, checkpoint deferred 1h 306k
memory mode (no durability, ceiling) 512k
  • Commit fdatasync on dedicated lanes costs ~nothing (ckpt-off ≥ the no-fsync control — group commit amortizes better under fsync backpressure).
  • Checkpoint @3s costs 7–11% on the right layout; the cardinality cliff (−90%+) is gone (−5%).
  • Full decomposition + production recipe in ds-bench/results/wal-splitlane/report.md; layout guidance added to ds-bench AGENTS.md.

Follow-up branch perf/wal-checkpoint-sizetrigger (stacked on this PR): --wal-checkpoint-interval-ms + --wal-checkpoint-wal-bytes — per-shard dual trigger that turns checkpoint cadence into an explicit replay-time budget and self-staggers shards. 56/56 wal tests pass; cluster validation pending.

balegas and others added 3 commits July 13, 2026 18:49
…-path configuration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
…ed; note the separate ~1M-stream wall

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
…cross per-device lane dirs

The ~1M-stream wall (wal-1m-diag): with every stream file on ONE data device,
a checkpoint's syncfs writeback of ~200k dirty files took 60-74s per shard —
tiny appends to distinct files amplify to ~8-12KB of data+inode+journal
writeback each, saturating the single device and starving append staging
(batch_avg collapsed 53->2-6). --stream-lanes N hashes stream files across
streams/<0..N>/ subdirs, one per device: N× writeback capacity, N parallel
syncfs barriers (one per lane, threaded), and no single ext4 dir holds every
stream. Default 1 = byte-identical historical layout. Recovery walks all lanes;
e2e covers lanes=3 crash recovery + layout spread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
…n (Linux), drop dead fsync fan-out, refresh WAL_TUNING

- --wal-checkpoint-syncfs defaults ON for Linux (validated +51% at the cliff,
  neutral below; off = escape hatch). Non-Linux unchanged (no syncfs).
- Remove FSYNC_FANOUT / --wal-fsync-parallel: parallel per-file checkpoint
  fsync regressed in every controlled test (f8 -11% NVMe, f16 -19% constrained)
  and the syncfs barrier supersedes it. Flag accepted as a warning no-op so old
  deploy scripts don't crash. Checkpoint step 2 is now two modes: per-lane
  syncfs (Linux default) or the serial per-file fallback.
- e2e resets restore the platform default instead of hard 'false'.
- WAL_TUNING.md: stream-lanes results (1M wall broken), cardinality-based
  lane-split guidance, updated flag set, fd-ceiling note (#4706).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
@balegas balegas changed the title perf(durable-streams-rust): coalesce checkpoint fsync with syncfs (write cardinality cliff) perf(durable-streams-rust): eliminate the write cardinality cliff — syncfs checkpoints, per-shard triggers, stream lanes Jul 13, 2026
balegas and others added 2 commits July 13, 2026 23:06
Config-surface audit removals (all confirmed unexercised or A/B residue of
already-shipped defaults):
- --wal-fsync-parallel (was already a no-op stub) — gone entirely.
- --wal-checkpoint-syncfs — gone; the per-lane syncfs barrier is unconditional
  on Linux, the serial per-file loop is the non-Linux path.
- --wal-meta-gate / --mem-meta-gate — gone; the gated (default) behavior is now
  the only behavior, dead A/B baseline branches deleted.
- --meta-sweep-disable / --meta-sweep-stats — gone (bench-only diagnostics;
  sweep_meta_once simplified).
- --tier local + --tier-local-dir CLI — gone (--tier is off|s3); TierKind::Local
  stays crate-internal for unit tests.
- --durability memory + --tier is now rejected at startup (memory-mode acks are
  not durable; tiering presumes durable segments).
- WAL codec: PAYLOAD_CHECKSUMMED is mandatory — a flag-clear header decodes as
  Torn (the only writer that ever left it clear was the removed splice relay);
  dropped the legacy decode branch and its pinning test.
- Stale zero-copy-era comment on DurabilityMode::Memory corrected.

Kept deliberately (audit showed live use + CI coverage): --tail-cache-bytes
(fan-out dedup lever), --read-offload (cold-read isolation), --wal-stats /
--server-stats (bottleneck telemetry). Docs (README, WAL_TUNING) updated.

105/105 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
…unt guard, panic-safe checkpoint ticker

Self-review findings on #4697:
- Restore packages/durable-streams-rust/Dockerfile (deleted collaterally by a
  bench image-build flow; docker_multiarch_image.yml builds from this path).
- Persist the stream-lane count in streams/.lanes and REFUSE a mismatched
  --stream-lanes at open (mirrors the WAL shard count contract): a silent
  mismatch made every existing stream invisible. Also refuses lanes>1 over an
  existing flat layout; recovery skips the marker; e2e asserts the rejection.
- Checkpoint ticker: map task-id -> shard so a PANICKED checkpoint task still
  clears its in-flight guard (previously a panic silenced that shard's
  checkpoints forever = unbounded retained WAL).
- Drop unused AtomicBool import; mark TierKind::Local as the test-only backend
  (allow(dead_code)); merge the orphaned checkpoint doc comments; drop the
  removed-knob tombstone comment; whitespace nit.

105/105 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
@balegas

balegas commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Self-review pass (full-diff review) — findings and fixes, all pushed:

Blocking, fixed: the branch had collaterally deleted packages/durable-streams-rust/Dockerfile (bench image-build flow's copy/rm cycle); docker_multiarch_image.yml builds from that exact path. Restored verbatim from main.

Robustness, fixed:

  • --stream-lanes layout mismatch was a silent data-unavailability footgun (open a 3-lane dir with --stream-lanes 1 → every stream invisible). Now persisted in streams/.lanes and validated at open, mirroring the WAL shard-count contract; mismatch = clean startup error; enabling lanes over an existing flat layout is also refused. e2e-covered.
  • A panicking checkpoint task could permanently wedge its shard's in-flight guard (→ unbounded retained WAL). The ticker now maps task-id → shard and clears the guard on JoinError too.

Quality, fixed: unused import + never-constructed-variant warnings (0 warnings now), orphaned/tombstone doc comments, whitespace.

Noting explicitly (semantic change already in the diff): WAL records with PAYLOAD_CHECKSUMMED clear now decode as Torn (end of durable log) instead of "bytes present = complete". Safe — no released writer ever cleared the flag (only the long-removed splice relay did) — but it is a decode-behavior change for on-disk bytes.

105/105 tests pass.

balegas and others added 3 commits July 14, 2026 10:01
…docs, changeset

- srvstats module doc: 2-space list continuations (clippy doc_overindented_list_items, -D warnings on 1.97).
- README/WAL_TUNING through prettier (repo-wide markdown format check).
- .changeset/wal-cardinality-cliff.md: minor bump for @electric-ax/durable-streams-server-rust (the check counts only changesets ADDED vs main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
…ed data

Root cause of the flaky conformance test 'should wait for new data with
long-poll' (and a latent production data-skip present on main): when a
long-poll hits its deadline, the sleep arm read the FRESH tail and returned an
empty 204 advertising it as Stream-Next-Offset — without delivering the bytes
behind it. An append whose durable-tail publish lands inside the deadline
window is then jumped over by the client (which adopts the header offset from
every response) and never delivered; with the conformance suite's 500ms sleep
aligned to its 500ms --long-poll-timeout-ms, every run of that test sits on
this boundary. The wake path itself is race-free (canonical watch
subscribe/re-check); only the timeout arm violated the invariant.

Fix: the sleep arm now re-checks the tail exactly like the closed-channel arm
and serves the data when tail > from. Invariant: a long-poll response never
advances the client's offset beyond the bytes it delivered.

Regression test drives 200 appends racing the deadline (20ms window) and
asserts the invariant on every response; it reproduces the bug in <100
iterations without the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
@balegas

balegas commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

The conformance flake (should wait for new data with long-poll, 1500ms timeout on the wal-default row) was investigated and turned out to be a real latent bug, not CI jitter — present on main too.

Mechanism: the long-poll timeout arm read the fresh tail and returned an empty 204 advertising it as Stream-Next-Offset without delivering the bytes — so an append whose durable-tail publish lands inside the deadline window gets silently skipped by the client forever. The conformance test sits exactly on this boundary by construction (500ms pre-append sleep == the suite's --long-poll-timeout-ms 500). The wake path itself is provably race-free (watch subscribe → borrow_and_update re-check); only the timeout arm violated the delivery invariant. In production (30s timeout) the same interleaving = silent data skip for a long-poll consumer.

Fix (pushed): the sleep arm re-checks the tail like the closed-channel arm and serves observed data. New regression test races 200 appends against a 20ms deadline asserting no response advances the offset without delivering data — it reproduces the bug in <100 iterations without the fix, passes with it. 106/106 tests.

@balegas
balegas merged commit db4977d into main Jul 14, 2026
19 checks passed
@balegas
balegas deleted the perf/wal-checkpoint-syncfs branch July 14, 2026 11:02
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