Skip to content

Commit 640509c

Browse files
authored
perf(server): remove the WAL coordination ceiling + 1M-stream cliff; memory mode 4× faster; crash-sim + 3 recovery fixes (#4675)
1 parent 9e3af10 commit 640509c

23 files changed

Lines changed: 4910 additions & 813 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@electric-ax/durable-streams-server-rust": patch
3+
---
4+
5+
Write-path performance overhaul plus three crash-recovery correctness fixes.
6+
7+
Performance: removed the WAL group-commit coordination ceiling (+55–60% saturated write throughput, p99 41→12 ms), fixed the 200k–1M stream-cardinality write cliff (1M streams now sustains 1.11M ops/s on 16 vCPU), and made `--durability memory` the buffered append path (4× faster, no longer Linux-only). New flags: `--wal-stats <secs>` and `--worker-threads <n>`.
8+
9+
Fixes (found by the new seeded crash/fault simulation): multi-segment WAL recovery no longer drops acked records after the first segment; a torn, never-acked tail on a quiet stream is truncated instead of becoming reader-visible; an acked DELETE is now durable before the 204.

packages/durable-streams-rust/ARCHITECTURE.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ flowchart LR
3535
W2 --> W3["encode_wire<br/>(JSON flatten, append delimiter)"]
3636
W3 --> W4[["per-stream appender mutex"]]
3737
W4 --> W5["write_all → data file<br/>(lands in page cache)"]
38-
W5 --> W6["update tail + resident cache"]
39-
W6 --> W8["publish tail<br/>(watch channel)"]
40-
W8 --> W7["group-commit fsync<br/>(WAL shard committer)"]
41-
W7 --> W9["204 / 200 — only after durable"]
38+
W5 --> W6["advance writer tail<br/>+ stage into WAL shard"]
39+
W6 --> W7["group-commit fsync<br/>(WAL shard committer)"]
40+
W7 --> W8["publish durable tail + resident cache<br/>(watch channel)"]
41+
W8 --> W9["204 / 200 — only after durable"]
4242
end
4343
4444
subgraph READ["READ · GET"]
@@ -72,10 +72,10 @@ The dotted edges are the only coupling between writers and readers: publishing t
7272
1. **Parse idempotency headers**`Producer-Id` / `Producer-Epoch` / `Stream-Seq`. Duplicate `(producer, epoch, seq)` is acknowledged without re-appending (exactly-once producers).
7373
2. **`encode_wire`** — turn the request body into the contiguous wire representation. In JSON mode this flattens arrays and appends the `,` delimiter so the on-disk bytes are already a valid stream fragment.
7474
3. **Acquire the per-stream appender mutex** (`AsyncMutex<Appender>`). This is the _only_ serialization point, and it's per-stream — different streams never contend.
75-
4. **`write_wire`**`write_all` the bytes to the data file (they land in the OS page cache immediately), advance the tail under a short `RwLock` write, update the **resident tail cache**, then **publish the new tail** on the `watch` channel. Note the order: the cache is populated _before_ the wake, so a woken subscriber reliably hits it.
76-
5. **Durability** — in `wal` mode the append is staged into the stream's assigned WAL shard, and the response returns only after the shard's group-commit committer `fdatasync`s the segment covering this record. See [Durability](#durability) below. In `wal` mode everything above and the entire read path are identical regardless of workload. In `memory` mode binary appends take a separate socket→file splice path (zero-copy `splice(2)`, no WAL, no `fdatasync`) that bypasses `handle_append` / `encode_wire` via the engine zero-copy intercept and acks immediately after the page-cache write.
75+
4. **`write_wire`**`write_all` the bytes to the data file (they land in the OS page cache immediately) and advance the _writer_ tail (`Shared.tail`) under a short `RwLock` write. The reader-observable tail does **not** move yet.
76+
5. **Durability, then visibility** — in `wal` mode the append is staged into the stream's assigned WAL shard (under the appender mutex, so per-stream LSN order matches byte order) and the handler awaits the shard's group-commit `fdatasync`. Only after the record is durable does `publish_durable_tail` advance the **reader-observable `durable_tail`** (monotonically), populate the **resident tail cache**, and **publish on the `watch` channel** — cache before wake, so a woken subscriber reliably hits it. Then the 2xx is returned. See [Durability](#durability) below. In `memory` mode the same buffered path runs with the WAL stage/wait skipped: the page-cache write is the ack.
7777

78-
Visibility vs durability are deliberately decoupled: the bytes are in the page cache (and the tail is published) before durability resolves, so a live reader sees data with minimal latency, while the _appender_ doesn't get its 2xx until the data is durable.
78+
Visibility is gated on durability (PROTOCOL.md §4.1): a live reader never observes bytes (or an EOF) that a crash could roll back. The writer tail runs ahead in `Shared.tail`; readers see `durable_tail`, which follows it as group commits resolve.
7979

8080
## Durability
8181

@@ -85,7 +85,7 @@ The server supports two durability modes, chosen at startup via `--durability`.
8585

8686
**`wal` (default)** — durable, single-node no-loss durability via a sharded write-ahead log. An append acks only after its record is durable in the WAL (group-commit `fdatasync`). This is the safe default for any deployment where local disk loss must not cause data loss. See the `wal` mode section below for the design.
8787

88-
**`memory` (Linux-only)** — no WAL, no `fsync`: binary appends move `socket→file` via `splice(2)` (zero-copy in the kernel); JSON appends are buffered writes; ack fires on the page-cache write. The per-stream files are the only durable-enough record, and recovery is the existing sidecar pass (rebuild stream state from the per-stream files + `.meta` sidecars). **NOT locally crash-durable** — a power loss or kernel panic can lose any un-fsynced page. Durability is delegated to (future) replication. Exits with status 2 on non-Linux.
88+
**`memory`** — no WAL, no `fsync`: appends take the same buffered write path as `wal` mode with the WAL stage/wait skipped; ack fires on the page-cache write. The per-stream files are the only durable-enough record, and recovery is the existing sidecar pass (rebuild stream state from the per-stream files + `.meta` sidecars). **NOT locally crash-durable** — a power loss or kernel panic can lose any un-fsynced page. Durability is delegated to (future) replication. Refuses to start over a WAL left by a previous `wal` run (replay it with `--durability wal` first, or delete the `wal/` directory to discard it deliberately).
8989

9090
| Mode | ack after | fsync | WAL | crash-safe? |
9191
| -------- | ---------------- | ---------------------- | --- | -------------------- |
@@ -105,11 +105,11 @@ Every append is written to the per-stream data file (page cache, no hot fsync
105105

106106
Per-stream files are `fdatasync`'d off the ack path at a periodic **checkpoint**, after which the bounded WAL is recycled. On boot, recovery replays the WAL from its oldest retained segment, reconciles each stream's durable tail (torn-tail repair via truncation + `fdatasync`), then resets the WAL for fresh appends.
107107

108-
The invariant: **visibility is never gated on durability**. Bytes land in the page cache and the tail is published before the WAL `fdatasync`, so live readers see an append at memory latency while the appender's acknowledgement waits for the WAL commit.
108+
The invariant: **readers only ever observe durable bytes** (PROTOCOL.md §4.1). Bytes land in the page cache immediately, but the reader-observable `durable_tail` (and the `watch` wake) advances only after the WAL `fdatasync` covering the record — the same barrier that releases the appender's acknowledgement. A crash therefore never rolls back anything a reader has seen.
109109

110110
### `memory` mode
111111

112-
In `memory` mode no WAL is created or attached. Appends write directly to the per-stream file (buffered write for JSON; zero-copy `socket→file` splice for binary) and ack immediately after the page-cache write — no `fdatasync`, no WAL staging. The per-stream file is the data; the `.meta` sidecar records the stream configuration and tail. On restart, the server runs the same sidecar pass it runs in `wal` mode (rebuild each stream from its file + sidecar) — there is no WAL to replay. Durability is delegated to replication (not yet built).
112+
In `memory` mode no WAL is created or attached. Appends write directly to the per-stream file (the same buffered write as `wal` mode) and ack immediately after the page-cache write — no `fdatasync`, no WAL staging. The per-stream file is the data; the `.meta` sidecar records the stream configuration and tail. On restart, the server runs the same sidecar pass it runs in `wal` mode (rebuild each stream from its file + sidecar) — there is no WAL to replay. Durability is delegated to replication (not yet built).
113113

114114
## Read path in detail
115115

@@ -140,8 +140,8 @@ flowchart TB
140140
end
141141
142142
A3 ==> PC[("OS page cache<br/>— the hot tier")]
143-
A3 ==> RC["resident tail cache<br/>(last chunk, configurable via --tail-cache-bytes,<br/>in heap)"]
144-
A3 -.-> TW[/"tail watch channel<br/>(one notify per append — before fsync)"/]
143+
A4 ==> RC["resident tail cache<br/>(last chunk, configurable via --tail-cache-bytes,<br/>in heap)"]
144+
A4 -.-> TW[/"tail watch channel<br/>(one notify per append — after the group commit)"/]
145145
146146
subgraph FAN["② Fan-out"]
147147
direction TB
@@ -159,7 +159,7 @@ The techniques, each with its mechanism and payoff:
159159
1. **Contiguous wire-byte storage.** The file _is_ the response. Reads are byte ranges with no reframing and no per-message copy — and this is what makes `sendfile` zero-copy possible at all.
160160
2. **Group-commit coalesced fsync.** The durability contract ("return after fsync") is the expensive part of an append. Concurrent appenders share a single in-flight barrier fsync, so throughput scales with the _batch size_ per fsync rather than one fsync per message. This is why unbatched appends hit ~30k/s where a per-append-fsync server (Node) does ~130/s.
161161
3. **Per-stream single writer, lock-free reads.** One async mutex orders a stream's appends; there is no global lock (streams live in a `DashMap`). Reads take a brief tail snapshot and do positioned reads — they never block the writer and never wait on each other.
162-
4. **Pre-fsync visibility.** Appended bytes are in the page cache and the tail is published before the fsync resolves, so live readers see data at memory latency; only the appender's acknowledgement waits for durability.
162+
4. **Durable-gated visibility, group-commit-amortized.** The reader-observable tail is published only after the record's group-commit fsync (readers never see bytes a crash could roll back — PROTOCOL.md §4.1). Because N concurrent appends share one barrier fsync, the visibility latency cost is one amortized group commit, not one fsync per append.
163163
5. **`watch`-channel wakeups.** Live readers park on a per-stream `watch`; an append is one `send_replace` that wakes all of them. No polling loop, no timer churn.
164164
6. **Resident tail cache (fan-out de-duplication).** Without it, N caught-up SSE/long-poll subscribers each re-read (and re-encode) the _same_ just-appended bytes — N× duplicated work that grows with audience size. The cache keeps the last chunk in the heap so all N share one read (and SSE encodes once per subscriber off that shared buffer). For small hot reads it's also fewer syscalls than `sendfile` and skips the read-offload pool hop.
165165
7. **Zero-copy egress.** `FileRange` reads are served with `sendfile` (page cache → socket, no userspace copy → ~5× less CPU per byte than a buffered copy). The **`--read-offload`** strategy keeps a cold backfill's disk fault off the async workers so one slow read can't stall unrelated requests.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# 1M-stream cardinality fixes — findings + results (2026-07-02)
2+
3+
Follow-up to `WRITE_BOTTLENECKS_1M.md` (bottleneck #2: stream cardinality) and
4+
`CONTENTION_INVESTIGATION.md`. Server commit: `662b0c845` on
5+
`perf/combined-t1a-t1c-t2a`. **Outcome: 1M streams reaches 1,114,644 ops/s on a
6+
16 vCPU `c4d-standard-16-lssd` (ladder unsaturated), and the 500k→1M degradation at
7+
equal load is −17% (was a cliff).**
8+
9+
## Root causes (evidence-first, local Linux repro at 20k→400k streams)
10+
11+
The key mechanism: **at high cardinality, ops/stream/checkpoint-interval drops below
12+
1, so every "amortized once-per-stream-per-interval" cost becomes a per-op cost.**
13+
14+
| # | cost | evidence | fix |
15+
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
16+
| 1 | Checkpoint drain did O(touched) capture (`shared.read()` + Arc clone per stream) **while holding the shard `dirty` mutex** — the same mutex every append's epoch-transition takes; at 400k streams every append is a transition | `WAL_CKPT drain_us` 25–140 ms/tick; `dirty_wait_load` 0.01→0.30 cores as streams 20k→400k; p99 ≈ max drain | O(1) critical section: take Vec + bump epoch under the lock, capture after release |
17+
| 2 | Checkpoint capture / cumulative tails-file re-read+re-sort+rewrite / recycle ran **on async runtime threads**, serially across shards | `WAL_CKPT` capture 31 ms + tails 24 ms per tick per shard on runtime threads | whole checkpoint body in one `spawn_blocking`; tails map memory-resident; shards checkpoint concurrently (`JoinSet`) |
18+
| 3 | **Per-append meta sidecar flush**: with inter-append gap > the 100 ms debounce (always, at high cardinality) every producer append did JSON + `File::create(.meta.tmp)` + `rename` → all workers spin on the **data-dir inode rwsem** | perf: `osq_lock`+`rwsem_spin_on_owner` under `write_meta_sync` = **38–46% of ALL server CPU at every cardinality** | WAL-staged appends only mark `meta_dirty`; checkpoint writes sidecars for drained streams after recycle (memory-mode keeps the debounced flush). Producer/access staleness bound: 100 ms debounce → checkpoint cadence (contract already allows lag) |
19+
| 4 | Two registry lookups per append (`handle_append` metric label + `_inner`) — 2× SipHash + cold DashMap walk at 1M keys | code inspection | `_inner` returns `is_json` |
20+
21+
## Local A/B (Linux harness, 6 srv cores, conn=256, shards=6)
22+
23+
| streams | before | after | p99 |
24+
| ------- | ------------- | ---------- | ------------ |
25+
| 20k | ~43–46k ops/s | **80.4k** | 41 → 7.7 ms |
26+
| 200k | 32.3k | **50.6k** | 53 → 17.9 ms |
27+
| 400k | 16.0k | **36–44k** | 144 → ~28 ms |
28+
29+
Correctness: 95 crate tests + 326 conformance tests pass. New telemetry: `WAL_CKPT`
30+
per-shard checkpoint phase line (`--wal-stats`), and the repro script grew
31+
`--tmpfs` / `--wal-stats` knobs + WAL_CKPT summarizing.
32+
33+
## Remote validation (GKE, 16 vCPU, pool client, 256 B, batch 1)
34+
35+
Suite `ds-bench/suites/run-durable-cpu16-1m-card.json`, image
36+
`durable-streams:combined-card@sha256:d74840bd…`; full detail + caveats in
37+
`ds-bench/results/run-durable-cpu16-1m-card/FINDINGS.md`.
38+
39+
| streams | pods | ops/s | p50 / p99 / max |
40+
| ------- | ---- | ------------- | ------------------------------------------------------------ |
41+
| 1M | 32 | 898,582 | 3.5 / 30.5 / **149.6 ms** (baseline 862k, 3.3 / 32 / 405 ms) |
42+
| 1M | 64 | **1,114,644** | 3.4 / 60.3 / 211 ms — still climbing (+21%/+16 pods) |
43+
| 500k | 48 | 1,110,268 | 3.1 / 42.7 / 145 ms |
44+
45+
## Open follow-ups
46+
47+
1. True 16 vCPU ceiling at 1M (ladder past 64 pods) and a 32 vCPU 1M run.
48+
2. **Per-shard producer-state journal**: sidecar writes/s ≈ ops/s at full cardinality
49+
(off the hot path now, but it stretches checkpoint cadence — locally ~1.6 s/shard
50+
meta phase at 400k — and bounds producer-state staleness). One cumulative
51+
per-shard file per tick, recovery overlays producers by max(epoch, seq).
52+
3. Read+write mix at 1M streams (everything here is write-only).
53+
4. `--wal-stats` cell on NVMe (`run-durable-cpu16-1m-card-stats.json`, never ran) to
54+
confirm checkpoint fsync/meta phase behavior at 1M on real disks.

0 commit comments

Comments
 (0)