tidaldb/docs/planning/milestone-11/phase-3.md
jx12n 5ed2edb211 feat(m11): quorum-acked writes — ack=leader|quorum, commit index, durable frontier reports (m11p3)
ack=quorum gates replicated writes on a majority of the replica set durably
holding them: followers push their durably-applied frontier (ReportApplied,
once per apply round, decoupled from ship acks), the leader folds frontier
reports + ship-ack hints + heal resumes into a leadership-scoped CommitIndex
(k-th-largest durable mark), and handlers await it through an async
watch-channel bridge (zero parked threads per waiter). Honest timeouts:
retryable 503 naming the laggards; x-tidal-seq on every cluster write.
Follower blob applies are batched under group-commit fsyncs (22x seeding).
Exit gate: 167/167 leader-SIGKILL kill points, zero acked-write loss.

Seven-dimension review pass (all confirmed findings fixed):
- WAL blob drain now ABORTS on the first write failure instead of reusing
  the failed seqno mid-drain (a torn record buried mid-segment would
  truncate every later acked record on replay)
- apply_replicated_blobs waits every staged append even after a mid-batch
  failure, parses metadata once, and moves records into Arcs shared with
  the WAL writer (no deep clone per record on the follower apply path)
- CommitIndex: zero-peer fast path now respects demotion (active checked
  under lock before the single-replica return), k-th-largest uses
  select_nth over a reused scratch buffer
- await_quorum: re-reads the index once after the deadline fires (no false
  503 for a write that committed in the race window), warns when the
  commit-watch bridge dies outside shutdown, zero-peer path checks active
- notify_applied report failures: WARN on the first failure of a streak,
  INFO on recovery (a silently stalling frontier reads as unexplained
  quorum 503s); receiver skips re-notifying unadvanced frontiers
- x-tidal-deduplicated: 1 marks dedup-suppressed signal writes (relayed
  through forwards) so durability cursors can tell dedup from no-seqno
- docs: 167/167 kill-point record corrected in CHANGELOG; rolling-upgrade
  order (leader first — a pre-m11p3 leader silently downgrades quorum
  requests to leader-ack) in CHANGELOG + runbook §8; monitoring note for
  report-loss diagnosis on the quorum-timeout alert

Verified: workspace clippy -D warnings (incl. cluster-e2e targets), full
tidaldb/tidal-net/tidal-server/tidalctl suites green, tier-3 multi-process
quorum suite green (8/8 kill points, zero acked loss, partition gate/recover).
2026-06-11 13:28:08 -06:00

173 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# m11p3 — Quorum-Acked Writes (COMPLETE — 2026-06-11)
Phase spec and exit gate: [docs/roadmap-to-cluster.md §4/m11p3](../../roadmap-to-cluster.md).
Closes ROADMAP gap **G4** ("no quorum durability — a leader-acked write can
die with the leader").
Predecessors: [phase-1.md](phase-1.md) (ship queue), [phase-2.md](phase-2.md)
(one replicated log; stream seqnos = WAL seqnos).
**Goal:** an opt-in durability contract a system of record can sit on:
`ack=quorum` writes succeed only once a **majority of the replica set
durably holds them**, with pipelined batch-level acks, honest timeout
semantics (retryable 503 naming the laggards), and a ledger checker proving
zero acknowledged loss under leader SIGKILL.
## Design (as adopted)
### 1. Durable frontier reports (the foundation)
m11p2's ship ack piggybacked the follower's applied seqno read **before**
enqueue — a monotonic floor. The key observation: that floor is already
**durable-true** (a follower's frontier provably advances only after storage
upserts AND its own WAL group-commit fsync — `apply_replicated_events` waits
every staged append before any in-memory fold; blob applies are
engine-WAL-first). What m11p3 adds is **freshness**, pushed the other way:
- The segment receiver calls the new `Transport::notify_applied(shard,
applied)` once per **apply round**; the gRPC transport fires a
`ReportApplied` RPC (fire-and-forget, deduped on unchanged frontiers) at
the stream's source. One report covers a whole pipelined window of
segments — batch-level, never per-write.
- The leader's report handler folds the mark into its hint map AND the
quorum commit index (via a late-bound `AppliedSink` — the ship queue that
owns the index is built after the transport).
- Ship acks stay instant (m11p2 semantics). Reports flow even when ships
DON'T: a follower converging by catch-up pull, a quiet leader, a healed
peer — all keep the commit index honest with zero leader work.
**The design that did NOT ship** (recorded because the failure is
instructive): holding each ship ack open until its segment's durable apply.
Functionally correct, and it passed every targeted test — but under
open-loop load it couples the leader's ship cadence to the follower's apply
latency: one gap-parked follower throttles its own feed (window × ack-wait),
falls further behind, and the cluster spirals into total quorum collapse
(measured: 100% write failure at 1,000 rps). The report push removes the
coupling entirely.
### 2. Commit index = k-th largest durable mark
`CommitIndex` (engine, `replication/commit.rs`) tracks per-peer durable
marks fed by frontier reports (`AppliedSink`), ship-ack hint folds
(`record_success`), and heal resumes (the follower's own status report) —
all three durable-true. With `n = peers + 1` replicas,
a seqno is **committed** once `floor(n/2)` peers report marks at or past it
(the leader is the remaining majority member; nothing unfsynced can ship by
construction). The index is leadership-scoped and owned by the ship queue:
`activate_from(baseline)` resets marks to the promote baseline,
`deactivate()`/`shutdown()` fail every waiter with `Demoted`, and an
epoch counter catches activate→deactivate→activate cycles mid-wait.
`PeerState.acked` (transport-accept, retry pruning) is deliberately NOT a
quorum input.
### 3. `ack=leader|quorum` on the write path
- **Deployment default**: topology `replication.ack` (default `leader`
the m0m11p2 contract, byte-for-byte). **Per-request override**:
`x-tidal-ack` header (invalid value → 400), forwarded verbatim by
follower gateways so the leader honors the CALLER's choice.
- `/signals`, `/items`, `/embeddings` (the replicated mutations) gate
`ack=quorum` AFTER leader durability by awaiting a **watch-channel mirror**
of the commit index (one bridge thread publishes; any number of handlers
await for free). Fully async by necessity, not taste: the first
implementation parked one blocking-pool thread per waiter, and at 1k rps
open-loop the 512-thread pool filled with parked waiters while the
completions that advance the index queued behind them — total collapse.
Budget: `replication.quorum_timeout_ms` (default 2000 — the cross-region
SLO).
- Every cluster write's response now carries **`x-tidal-seq`** (its
replicated-log seqno; relayed through forwards), giving clients an exact
durability cursor — and giving the ledger checker its ledger.
- Timeout → **retryable 503** naming the laggard regions, the commit index,
and the confirmed count. The write IS in the leader's log and MAY still
commit: retries are at-least-once (see the idempotency decision below).
Demotion mid-wait → the NotLeader 503 (never a false quorum claim).
- Hard negatives stay CRDT (per-user convergent data, `/cluster/reconcile`)
— the ack knob does not apply. The `/sharded/*` surface keeps leader-ack
semantics this phase (quorum × sharding lands with m11p6).
### 4. Follower blob applies are group-committed (found by the gate)
The quorum throughput gate exposed an m11p2 flaw invisible to convergence
tests: replicated items/embeddings applied ONE record at a time — each
paying a solo follower WAL fsync (`wal_blob_first` waits per record, the
writer synced per blob batch). Item apply capped at the fsync floor (~100/s
on macOS `F_FULLFSYNC`), so any item burst left followers with a
multi-second frontier stall that every subsequent `ack=quorum` write then
measured honestly as a 503. Two-sided fix, mirroring m11p1's signal
coalescing: `ReplicatedBlobApplier` became batched (`apply_blobs`: the
engine validates ALL records, stages ALL WAL appends, waits all — shared
group syncs — then upserts storage in order), and the WAL writer flushes
its queued blobs under ONE group fsync per drain window. Corpus seeding of
2,000 items + embeddings: 39.3s → **1.8s** (22×).
### 5. Observability
- `tidaldb_cluster_relay_durable_seq` is repurposed: it now reports the
**commit index** (the m11p2 value it replaced had become equal to
`relay_last_seq` by construction). `relay_last_seq relay_durable_seq`
is the cluster's quorum lag.
- New counter `tidaldb_cluster_quorum_timeouts_total`.
- `/cluster/status/local` gains `commit_index` and `ack` (the node's
deployment default).
### Idempotency open question — DECIDED (in-phase, per the roadmap)
**No new idempotency-key machinery in m11p3.** Findings: the WAL's
content-hash dedup window cannot dedup client retries (the server stamps
`Timestamp::now()` per request, so retried bodies hash differently), and
even when it fires it is log-only (the in-memory aggregate still folds).
Items/embeddings retries are idempotent upserts — retrying a quorum-timeout
503 on `/items`//`/embeddings` is always safe. Signal retries are
at-least-once: a retried 503 whose original DID commit double-counts that
signal's weight — bounded, decays, and self-corrects relative to exact
counting over time. Documented in runbook §8; client-supplied idempotency
keys ride on the session-write precedent (`WalCommand::SessionSignal`
already carries one) if a future phase needs exact-once accounting.
## Exit gate (from the roadmap)
- SIGKILL the leader under `ack=quorum` load → ledger checker proves zero
acknowledged loss across 100 random kill-points.
- Quorum throughput ≥ 50% of p1's leader-ack number (≥1,000/s Ref-A).
## Status
- [x] Durable frontier reports (`ReportApplied`, `notify_applied`, `AppliedSink`)
- [x] Follower blob group-commit (`apply_blobs` + writer group fsync)
- [x] `CommitIndex` (k-th-largest marks, leadership-scoped, laggard naming)
- [x] Ship-queue wiring (durable-mark folds, activate/deactivate/shutdown)
- [x] Seqno surfacing (`x-tidal-seq` through engine → handlers → forwards)
- [x] `ack=leader|quorum`: topology default + header override + 503 body
- [x] Metrics + status surfaces
- [x] In-process gRPC tests (gate/503/heal; forwarded quorum; blob writes)
- [x] Tier-3 suite: partition semantics + the ledger checker
- [x] 100-kill-point gate run + throughput run (recorded below)
- [x] Docs (runbook §8 rewrite, topology table, monitoring) + CHANGELOG
## Exit-gate evidence (local; release builds, real OS processes)
| Gate | Target | Measured |
|------|--------|----------|
| Zero acknowledged loss under leader SIGKILL | 100 random kill-points | **167/167 kill-points, zero acked loss** on the final design (batches of 64 + 91 + 12; each round = fresh 3-process cluster, concurrent `ack=quorum` item+signal writers via leader AND follower gateways, SIGKILL at timings spread 120598ms, then BOTH proofs on the promoted max-applied survivor: `max(acked seq) <= survivor applied_events` and per-item `/search` content probes). An earlier 100/100 run validated the superseded ack-holding design before it was replaced. |
| Quorum throughput ≥ 50% of p1's leader-ack number | ≥ 2,267/s local-equivalent | **3,600 quorum signal-writes/s within SLO** (writes mix, `wal.batch_timeout_ms: 2`, knee not reached; p50 ~45ms at 1.5k, ~370ms at 3.6k from concurrency-limit queueing; zero errors; replication lag ≤3 events at ramp end) = **79%** of p1's 4,534/s. Same-day leader-ack baseline on this machine: 5,971/s (10ms batch timeout). Ref-A (Linux) runs remain blocked on k3s access — same as p1/p2. |
Tier-3 partition semantics (real TCP severs): one follower down → quorum
commits via the other; both down → fast 503 naming the laggards while
`ack=leader` writes flow uninterrupted; healed → quorum recovers within the
breaker window. (`mp_quorum_writes_gate_and_recover_under_partition`.)
### Performance findings the gate forced (all shipped)
1. **Thread-per-quorum-wait collapses at 1k rps**: parked Condvar waiters
exhausted tokio's 512-thread blocking pool; the group-commit completions
that advance the index queued BEHIND them → 0 OK writes, 40s latencies.
Fix: async watch-channel bridge (one publisher thread, zero per-waiter
threads).
2. **Holding ship acks until durable apply death-spirals**: ack-wait ×
window throttles ships to a gap-parked follower → it lags further → all
quorum writes time out. Fix: follower-pushed `ReportApplied`, ship acks
stay instant.
3. **Per-record blob fsyncs cap item apply at ~100/s** (m11p2 flaw): fixed
with batched blob apply + writer group fsync → seeding 22× faster, item
bursts no longer stall the quorum frontier.