Skip to content

feat(durable-streams-rust): replicated durability — quorum-replication acks via Raft (openraft)#4686

Open
balegas wants to merge 25 commits into
mainfrom
feat/replicated-durability
Open

feat(durable-streams-rust): replicated durability — quorum-replication acks via Raft (openraft)#4686
balegas wants to merge 25 commits into
mainfrom
feat/replicated-durability

Conversation

@balegas

@balegas balegas commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a third durability mode to the Rust durable-streams server: --durability replicated — Kafka-style durability where an append acks once a quorum of replicas has committed and applied it (openraft), with no fsync anywhere on the hot path. A 3-node cluster survives loss of any minority with zero acked-data loss, any node accepts writes, every node serves reads, and failed nodes restart-rejoin or get replaced live.

Full design/ops doc: packages/durable-streams-rust/REPLICATION.md. Deploy kit under deploy/replicated/ (local cluster, docker compose, k8s, smoke).

Reviewer guidance

Approach — log-first apply. Every state-mutating op (PUT/POST/DELETE) is a LogOp proposed into a single Raft log; every node, including the leader, mutates its store only from committed entries, inside the state machine. That makes each replica a deterministic function of the log prefix — no divergence, no truncation/repair on fail-over. The ack is the state machine's apply outcome (client_write returns it), so HTTP responses are byte-identical to single-node modes, including producer dedup/fencing evaluated authoritatively in log order.

Key mechanics, in review order:

  • src/replication/sm.rs — the state machine. Applies are sharded by stream path within each batch (per-stream order preserved; fork-creates barrier): one slow fs write must not stall every other stream's ack — we measured 100–450 ms apply stalls without this. Snapshots are manifests (per-stream metadata, O(streams) to build); the installing node wipes and pulls each stream's [0, tail) prefix over the mesh, exact because prefixes are immutable under append-only.
  • src/replication/net.rs — TCP RPC mesh (correlation ids, auto-reconnect) carrying the three Raft RPCs plus Forward (proposal to leader) and FetchStream (snapshot byte pull). Clients are a dynamic registry fed by membership addresses, so added nodes need no restarts.
  • src/replication/core.rs — propose path. On a follower: forward to leader, then wait for the local apply to cover the entry (read-your-writes on the node you wrote to).
  • src/replication/log_store.rs — in-memory log (deliberate: durability comes from replication), durable vote (fsync at election time only; also serves as the initialized marker that makes restarts skip bootstrap).
  • handlers.rsapply_replicated_* are the semantic twins of the single-node paths (same check order: closed → producer → seq), kept next to the code they mirror; replicated branches in PUT/POST/DELETE; /_repl/status, /_repl/add-learner, /_repl/change-membership.

Key invariants

  1. An acked append is applied on a quorum and survives any minority loss; replicas are byte-identical at equal applied indexes (tests assert full-content equality).
  2. Client retries after 503 are safe: producer dedup is replicated state, evaluated at apply time.
  3. Trim/purge never outruns state transfer: manifests are cheap so snapshots always exist ahead of purge; a fetch of a deleted+recreated path fails loudly (stream-id check) rather than installing wrong bytes.
  4. The vote is durable before it is visible — restart cannot grant conflicting elections.

Trade-offs (accepted, documented)

  • Quorum-memory durability: simultaneous power loss of a majority loses unsynced data — the same trade as Kafka acks=all with OS-managed flush. Zone-spread replicas are the mitigation.
  • One consensus group (single ordering pipeline, batched); sharding across groups is roadmap.
  • Follower fast-fail pre-checks are sequentially consistent: an append racing its stream's create on a lagging follower can see a retryable 404 (~1 in 10⁵ under round-robin load).
  • Tiering (--tier) unsupported in this mode.

Non-goals here: ds-bench harness integration (single-server assumption), sharded consensus groups, large-payload copy reduction — all in the REPLICATION.md roadmap.

Why openraft (and not the original OmniPaxos implementation, visible mid-branch): a deterministic fault-injection simulation we built caught a consensus safety bug in the last published omnipaxos release (stale cached Promise after AcceptSync → decided-log divergence, acked entry lost on leader change; fixed upstream Jan 2024 but never published), and the project is unmaintained. openraft is active and production-proven; the swap cost only the consensus adapter layer and made large payloads 18–39% faster with 3–4× lower memory (entry-count purge vs time-based trim). The simulation harness lives in git history pending an openraft port (roadmap).

Verification

cd packages/durable-streams-rust
cargo test                                # 106 tests: unit + 3-node TCP cluster tests
                                          # (convergence, forwarding, dedup, close/delete,
                                          #  forks, learner-join-past-purge, vote durability)
PROFILE=release deploy/replicated/smoke.sh  # e2e: writes via all nodes, byte-identical reads,
                                            # leader kill + fail-over, restart-rejoin

Local 3-node benchmarks (one 10-core machine, so conservative; ds-bench multi-stream/sustained, details + sizing rules in REPLICATION.md): 256 B appends 36–52k ops/s (p99 2–7 ms) vs 96–100k single-node memory mode; 16 KB 14.9k ops/s (244 MB/s); 5-minute sustained runs hold RSS flat (20 MB @ 256 B; ≤143 MB @ 16 KB) with zero ack timeouts and all replicas byte-identical.

Files changed

  • packages/durable-streams-rust/src/replication/ — the new module: types (Raft type wiring), entry (LogOp/outcomes), sm (state machine, sharded apply, manifest snapshots), net (RPC mesh), core (propose/ack, stats), log_store (in-mem log + durable vote), tests
  • packages/durable-streams-rust/src/handlers.rs — replicated branches + apply twins + admin endpoints
  • packages/durable-streams-rust/src/{main,store}.rs--repl-* flags, boot wipe/resync, create_with_meta_durability, wipe_all
  • packages/durable-streams-rust/deploy/replicated/ — local-cluster.sh (up/status/down/restart), smoke.sh, monitor.sh, Dockerfile, docker-compose.yml, k8s.yaml
  • packages/durable-streams-rust/{REPLICATION,README}.md — design, ops, benchmarks
  • .changeset/replicated-durability.md — minor bump

🤖 Generated with Claude Code

balegas and others added 24 commits July 1, 2026 00:14
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>
…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
…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>
--durability replicated: Kafka-style acks — durability via quorum
replication (OmniPaxos / Sequence Paxos) instead of local fsync.
Log-first apply keeps every replica a deterministic function of the
decided log; any node accepts writes (proposal forwarding) and serves
reads. Documents guarantees, accepted trade-offs, config, deploy story.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xos)

Kafka-style durability: an append acks once a quorum of replicas has
decided it — no fsync on the hot path. Log-first apply: every node
(leader included) mutates its store only from decided entries, so
replicas are a deterministic function of the log and fail-over needs no
truncation/repair. Any node accepts writes (OmniPaxos forwards follower
proposals); every node serves reads (same publish_durable_tail path).

- src/replication/: entry types, vendored in-memory OmniPaxos storage,
  TCP mesh (len-prefixed bincode, reconnect, resend-covered drops),
  consensus core (10ms ticks, batch-drained proposals, periodic trim)
- handlers.rs: authoritative apply twins (closed -> producer dedup ->
  Stream-Seq, in log order) + replicated branches in PUT/POST/DELETE
  building identical responses from apply outcomes; /_repl/status
- main.rs: --repl-id/--repl-peers/--repl-listen/--repl-ack-timeout-ms/
  --repl-trim-secs; stale-WAL refusal extended to replicated mode
- tests: 3-node in-process clusters over loopback TCP — convergence,
  follower proposals, replicated producer dedup, close/delete, forks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…moke

- local-cluster.sh up|status|down: builds and runs 3 nodes on
  4437-4439 (http) / 5437-5439 (mesh), waits for leader election
- smoke.sh: end-to-end — create/append via different nodes, byte-equal
  reads everywhere, leader kill, post-failover append + convergence
- Dockerfile + docker-compose.yml: containerized 3-node cluster
- k8s.yaml: StatefulSet (ordinal-derived repl-id) + headless mesh
  service + client service; zone spread; Parallel pod management so
  quorum formation isn't readiness-deadlocked

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al-cluster state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…weep + REPL_STATS

Three changes driven by local 3-node benchmarking (ds-bench multi-stream,
200 streams / 32 conns / 256 B):

1. CORRECTNESS: a Compaction(Trim) drained in the same message batch as
   the Decides it covers could delete decided-but-unapplied entries
   (omnipaxos try_trim guards its own decided_idx, not our applied
   cursor) — silent replica divergence + leaked acks (12 ack timeouts /
   20 s run). The core now flushes the applier before handling any
   compaction message. Post-fix: 200/200 streams byte-identical.

2. PERF: applies ran inline in the core task, so one slow store write
   stalled consensus and every ack — measured 100-450 ms apply stalls
   (REPL_STATS apply_max_us) driving p99 110 ms and 5.5k ops/s. Decided
   entries now dispatch to 4 shard appliers (path-hashed; per-stream log
   order preserved; fork-creates barrier + apply inline). Per-append
   debounced meta rewrites (10 renames/s/stream — fs-journal churn, the
   stall source) replaced by a 3 s per-shard dirty sweep; replicated
   creates skip the meta fsync (the decided entry is the durability).
   Result: 39k ops/s (7.1x), p50 0.66 ms, p99 2.2 ms, zero errors.
   Single-node memory mode on the same box: 96k ops/s.

3. TELEMETRY: --repl-stats N emits REPL_STATS lines (proposed/decided/
   applied rates, log window, pending acks, timeouts, apply_max_us);
   /_repl/status gains log_window/pending/timeouts; deploy/replicated/
   monitor.sh samples RSS/CPU + status to CSV; local-cluster.sh grows
   MODE=memory|wal single-node baseline support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hes a real omnipaxos 0.2.2 safety bug; pin fixed upstream rev

sim_tests.rs drives 3 consensus instances over a seeded simulated network
(drops, partition/delay windows, crash-stop incl. the leader, elections,
resends, active trimming) against real stores through the real apply
functions. Invariants: acked appends survive on every live node exactly
once in producer-seq order; replicas byte-identical; bounded quiescence;
compaction actually ran. Fault model = the production mesh contract
(per-link FIFO with loss; no reorder/dup). 200 seeds in ~4 s.

The harness immediately found decided-log DIVERGENCE (an acked entry
vanishing on a new leader after a partition): crates.io omnipaxos 0.2.2
does not clear a follower's cached Promise message on AcceptSync, so a
stale Promise resend feeds the new leader pre-sync log state. Fixed
upstream in e6ddc52 (Jan 2024) but never published — dependency now pins
upstream main (bd234be2) which also carries election fixes #154/#156.
Bisect toggles: DS_REPL_SIM_NO_{DROP,PARTITION,CRASH}. Repro:
DS_REPL_SIM_SEED0=<seed>. On 0.2.2 the test fails within ~10 seeds; on
the pinned rev 200+ seeds pass. Storage vendored at the new trait
(usize idx, write_atomically); take_outgoing_messages buffer reuse.

Benchmarks re-validated on the pinned rev (40k ops/s @256B/32conn, zero
errors, 200/200 convergence); REPLICATION.md gains benchmark results,
sizing rules, validation story, and the pinned-rev rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Motivation: the DST harness caught an unpublished-fix safety bug in the
last released omnipaxos (REPLICATION.md 'History: why openraft') and the
project is unmaintained; openraft is active and production-proven
(Databend, CnosDB) and its snapshot/membership machinery matches our
roadmap (state transfer, node replacement).

The architecture survives intact — log-first apply, apply-outcome acks,
sharded appliers (now inside RaftStateMachine::apply batches), 3s dirty
meta sweep, TCP mesh, any-node writes. What changed:

- types.rs: declare_raft_types (D=LogOp, R=OpOutcome) — client_write
  resolves with the apply outcome; the hand-rolled pending-ack map and
  the trim-vs-apply guard are gone (openraft owns commit/apply/purge
  ordering)
- net.rs: request/response RPC mesh (correlation ids, auto-reconnect)
  carrying AppendEntries/Vote/InstallSnapshot + Forward(LogOp); a
  follower forwards to the leader and then waits for its OWN apply to
  cover the entry (read-your-writes preserved on the origin node)
- sm.rs: state machine with path-sharded batch apply, fork-create
  barriers; snapshots are metadata-only markers (cheap to build => log
  purges and memory stays bounded WITHOUT needing every peer up —
  improvement over omnipaxos trim; installing one is refused loudly =
  fail-stop for laggards, v1 parity)
- log_store.rs: vendored openraft reference memstore (log + vote in
  memory, deliberate)
- --repl-trim-secs replaced by --repl-snapshot-logs N (purge cadence in
  entries, default 5000)
- sim_tests.rs removed (drove omnipaxos sans-io; port via simulated
  AsyncRuntime is on the roadmap — design lives in git history)

Verified: 104 tests green incl. the 4 cluster tests (frequent purge,
snapshot_logs=64); smoke passes incl. leader kill-over; first benchmarks
on par or better (256B/32conn 36.3k ops/s vs 40.3k; 128conn 51.5k vs
52.5k; 16KB 14.9k vs 10.7k (+39%), p99 5.7ms vs 18ms), 200/200
convergence, zero backpressure errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g rules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n, membership change, durable vote

The three roadmap items that turn a failed node from 'restart the whole
cluster' into a routine operation:

1. REAL SNAPSHOTS as manifests: a snapshot carries per-stream metadata
   (path, id, config, tail, producer/closed state) — O(streams) to
   build, so the every-5000-entries purge cadence is unaffected (bench
   sanity: 36.2k ops/s, unchanged). The installing node wipes its store
   and pulls each stream's [0, tail) prefix over the mesh (new
   FetchStream RPC, 4 MiB chunks) — exact because prefixes are
   immutable under append-only; a stream id check catches
   delete+recreate races.

2. DURABLE VOTE: fsynced to <data-dir>/repl.vote on change (election
   time only — never the append path) and reloaded at boot. Makes
   restart-rejoin election-safe, and doubles as the initialized marker
   so a restarted node skips bootstrap automatically. Replicated boots
   wipe stale stream files (the log/snapshot is the source of truth).

3. MEMBERSHIP CHANGE: POST /_repl/add-learner {id,addr} (blocks until
   caught up) + POST /_repl/change-membership {members} on the leader;
   --repl-join boots a node waiting to be added. Mesh clients are now a
   dynamic registry fed by membership addresses, so added nodes get
   links without restarts.

Verified: 106 tests green, incl. learner_joins_after_purge (600 appends
past the purge horizon, fresh node catches up via manifest+fetch only,
promotes to voter, proposes writes; byte-identical) and vote-file
persistence; smoke.sh extended with a process-level restart-rejoin leg
(killed leader restarts, resyncs, serves identical bytes, takes writes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from perf/memory-mode-drop-splice to perf/combined-t1a-t1c-t2a July 3, 2026 11:10
- engine_raw: drop the linux-gated AtomicBool import left behind by the
  splice removal (base-branch lint, denied by clippy -D warnings)
- replication: RpcClient visibility to match Clients' pub API; drop the
  unused ClientWriteResponse alias; enumerate instead of a manual loop
  counter in the state machine batch walk
- prettier --write CONTENTION_INVESTIGATION.md (pre-existing, failed the
  repo-wide format check)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from perf/combined-t1a-t1c-t2a to main July 6, 2026 16:01
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