Skip to content

feat(replication): synchronous shipping so acknowledged writes survive losing the primary - #367

Merged
jsam merged 7 commits into
mainfrom
fm/en-en12-p2
Oct 5, 2026
Merged

jsam merged 7 commits into
mainfrom
fm/en-en12-p2

Conversation

@jsam

@jsam jsam commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Intent

I want you to really hyper focus on making the symbolic engine, you know, crisp and really truly production ready. ... just ensure that both machines are propelry utilized now (Context: implement #284 exactly as written.)

Issue #284, "Engine: warm standby part 2, synchronous shipping with RPO 0 (EN-12)", part 2 of 4 of EN-12 (#264); part 1 (WAL streaming to a follower that applies and stays consistent, asynchronous shipping) is PR #283. Goal: synchronous shipping: a commit is acknowledged only after a follower has applied it durably, so the RPO is zero for every acknowledged write while a sync follower is attached. Scope: replication.mode = "async" | "sync" on the primary; in sync mode a commit reply waits until a follower acks its LSN (followers already ack each applied frame after one WAL fsync). replication.sync_timeout_ms and on_follower_loss = "block" | "degrade": block = writes wait, then fail with a timeout, so RPO stays 0 and availability drops; degrade = fall back to async with an alert, and re-arm once a follower catches up. The commit is already durable on the primary when the wait times out, so a timeout is reported as "not confirmed on a replica", never as a failed commit. Replication lag and the sync state on /metrics and /metrics/prometheus. Measure the commit latency cost of sync mode, and RPO in both modes, on the perf gate host. Done when: a kill-the-primary test in sync mode loses no acknowledged write; the latency cost is measured and documented; async mode latency is unchanged.

What Changed

  • Adds replication.mode = "async" | "sync", sync_timeout_ms (default 5000) and on_follower_loss = "block" | "degrade" to the primary. In sync mode a write's reply waits until a follower acks an LSN at least as high as both the write's own LSN and the log head, so this also covers no-op writes. With block, a write that no follower confirms in time fails with the new replica_unconfirmed error code: it is committed on the primary, just not confirmed on a replica. With degrade, the primary falls back to async with an alert and switches back to sync once a follower catches up. The new code is added to the ws-protocol, JS/Python clients, GUI types and AsyncAPI/error specs.
  • Reports replication state and lag on /metrics (the stats DTO gets a replication field) and on /metrics/prometheus as inputlayer_replication_* metrics. These include lag in events and ms, the sync state, waits, wait time, unconfirmed writes, degrades and rearms.
  • Adds tests: handler tests for sync replication, plus integration tests in tests/replication_tests.rs. One kills the primary in sync mode and checks that no acknowledged write is lost; another measures latency and RPO in both modes. The replication and configuration guides now document the new settings, the failure behaviour and the measured cost. For one writer, mean/p99 commit latency is 1.46 ms / 5.6 ms in async mode and 2.69 ms / 8.5 ms in sync mode; async stays at about the same latency as before.

Risk Assessment

✅ Low: Sync shipping is opt-in (async stays the default and its path only adds a cheap staged/note lookup). Every client write entry point (execute_program_with_params, query_program, query_program_with_session, with nested meta commands tracked by the outer request) goes through the single Handler::replicated boundary. The confirmed-LSN, block/degrade and re-arm logic traced correctly against follower acks sent after its WAL fsync. Earlier rounds' decisions (head-of-log wait, durable-follower refusal, keeping the extra metrics) are implemented as decided.

Testing

I built release binaries of the change and of the base commit, ran the 3 sync-replication integration tests that start the real server (all passed), and drove primary/follower process pairs over WebSocket and HTTP. In 8 sync-mode SIGKILL trials (about 1,000–2,400 acknowledged writes in most) no acknowledged write was lost, even with the follower frozen. With the follower frozen, async mode lost 633–739 acknowledged writes per trial. Block mode returns replica_unconfirmed after the timeout (no-op writes included) and recovers when a follower returns. Degrade mode falls back to async and re-arms. Metrics report lag and sync state. Invalid configurations are refused. Async latency matches the base build, and sync costs about +1.3 ms per single-writer commit (median). Everything passed, and the temp data dirs, server processes and base build were removed.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
SIGKILL the sync-mode primary under 4 concurrent writers: every acknowledged write is on the follower ✅ pass live kill-the-primary-trials.jsonl (sync trials 1–6: lost_acked 0, about 1,100–2,400 acked in 5 of the 6, one early kill with 17); integration test in_sync_mode_killing_the_primary_loses_no_acknowledged_wr…
Adversarial: freeze the follower (SIGSTOP), then SIGKILL the primary; sync loses no acknowledged write while async does ✅ pass live kill-the-primary-trials.jsonl: sync frozen-follower trials 10–12 lost 0; async frozen-follower trials 13–15 lost 739/642/633 acknowledged writes
Block mode with no follower: a write fails with replica_unconfirmed after sync_timeout_ms but is committed and readable on the primary ✅ pass live block-no-follower.txt: +x(1) returned replica_unconfirmed after 1508 ms; ?x(X) then returned [[1]] at once
Adversarial: no-op writes (re-insert of a present fact, delete of an absent fact) after an unconfirmed write must not be acknowledged at once ✅ pass live block-no-follower.txt: both returned replica_unconfirmed after about 1500 ms; the read before any write returned in 2 ms without waiting
Block mode recovers: once a follower attaches, writes succeed quickly and the state returns to sync ✅ pass live block-follower-returns.txt: +x(2) succeeded in 5 ms; the follower holds [1],[2]; status state=sync, confirmed_lsn=head_lsn=6; a write on the follower is refused with store_read_only
Degrade mode: a frozen follower causes one timed wait and then async fallback with an alert; the primary re-arms once the follower catches up ✅ pass live degrade-rearm.txt: +d(2) succeeded after 1512 ms, later writes took 2–7 ms, state=degraded, log replication_sync_degraded; after SIGCONT state=sync, rearms=1, log replication_sync_rearmed; the followe…
Replication lag and sync state are exported on /metrics and /metrics/prometheus ✅ pass live metrics-stalled.txt (lag_events, lag_seconds, confirmed_lsn, sync_state{state=stalled} 1, unconfirmed_total 3); degrade-rearm.txt (sync_state{sync} 1, degrades_total 1, rearms_total 1)
Config guards: a follower with async durability is refused; invalid mode, on_follower_loss and a zero sync_timeout_ms are refused ✅ pass live config-guards.txt: each case exits at startup with a clear error
Async-mode commit latency is unchanged versus the base commit, and the sync cost is measured ✅ pass live latency.txt: 1-writer median, base async 1.68/1.41 ms vs new async 1.41/1.59 ms; 4 writers, base 616/590 vs new 580/608 commits/s (within noise). New sync: 1-writer median 2.82/2.77 ms, 4 writers abou…
Evidence: Kill-the-primary trials (sync vs async, plain and frozen-follower)

sync plain x5 (plus trial 1): lost_acked 0 | sync with follower frozen x3: lost_acked 0 | async plain x3: lost 1, 0, 0 | async with follower frozen x3: lost_acked 739, 642, 633

{"variant":"plain","mode":"sync","trial":2,"acked":2318,"on_follower":2319,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"plain","mode":"sync","trial":3,"acked":2206,"on_follower":2207,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"plain","mode":"sync","trial":4,"acked":1156,"on_follower":1157,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"plain","mode":"sync","trial":5,"acked":2392,"on_follower":2393,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"plain","mode":"sync","trial":6,"acked":17,"on_follower":18,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"plain","mode":"async","trial":7,"acked":4,"on_follower":3,"lost_acked":1,"unconfirmed_extra_on_follower":0,"failed":{}}
{"variant":"plain","mode":"async","trial":8,"acked":85,"on_follower":85,"lost_acked":0,"unconfirmed_extra_on_follower":0,"failed":{}}
{"variant":"plain","mode":"async","trial":9,"acked":1139,"on_follower":1139,"lost_acked":0,"unconfirmed_extra_on_follower":0,"failed":{}}
{"variant":"stop","mode":"sync","trial":10,"acked":983,"on_follower":984,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"stop","mode":"sync","trial":11,"acked":1299,"on_follower":1300,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"stop","mode":"sync","trial":12,"acked":1716,"on_follower":1717,"lost_acked":0,"unconfirmed_extra_on_follower":1,"failed":{}}
{"variant":"stop","mode":"async","trial":13,"acked":1814,"on_follower":1075,"lost_acked":739,"unconfirmed_extra_on_follower":0,"failed":{}}
{"variant":"stop","mode":"async","trial":14,"acked":2712,"on_follower":2070,"lost_acked":642,"unconfirmed_extra_on_follower":0,"failed":{}}
{"variant":"stop","mode":"async","trial":15,"acked":1676,"on_follower":1043,"lost_acked":633,"unconfirmed_extra_on_follower":0,"failed":{}}
Evidence: Block mode, no follower: replica_unconfirmed incl. no-op writes
read (no write) ?x(X)                            2 ms  {"ok":true,"rows":[]}
insert +x(1)                                  1508 ms  {"ok":false,"code":"replica_unconfirmed","message":"committed on the primary, but no replica confirmed it within 1500 ms: it is applied here and could be lost only with this server. Do not retry it as a failed write."}
read ?x(X) after unconfirmed insert              1 ms  {"ok":true,"rows":[[1]]}
re-insert already-present +x(1) (no-op)       1503 ms  {"ok":false,"code":"replica_unconfirmed","message":"committed on the primary, but no replica confirmed it within 1501 ms: it is applied here and could be lost only with this server. Do not retry it as a failed write."}
delete absent -x(999) (no-op)                 1502 ms  {"ok":false,"code":"replica_unconfirmed","message":"committed on the primary, but no replica confirmed it within 1501 ms: it is applied here and could be lost only with this server. Do not retry it as a failed write."}
Evidence: Block mode recovers once a follower attaches; follower refuses writes
insert +x(2) with follower attached              5 ms  {"ok":true,"rows":[["Inserted 1 fact(s) into 'x'."]]}
re-insert +x(1) with follower attached           1 ms  {"ok":true,"rows":[["Inserted 0 fact(s) into 'x'."]]}
read ?x(X)                                       1 ms  {"ok":true,"rows":[[1],[2]]}
== follower ?x(X)
{"ok":true,"rows":[[1],[2]]}
write on follower: {"ok":false,"code":"store_read_only","message":"read-only replica: this server is a replication follower; send writes to the primary"}
== primary status
{"role":"primary","primary":{"stream_id":"fd3ff74011a7ec5c","head_lsn":6,"lag_events":0,"lag_ms":0,"sync":{"mode":"sync","state":"sync","on_follower_loss":"block","sync_timeout_ms":1500,"confirmed_lsn":6,"waits":5,"wait_ms_total":4505,"unconfirmed":3,"degrades":0,"rearms":0},"followers":[{"name":"follower","addr":"127.0.0.1","connected_ms":8389,"sent_lsn":6,"acked_lsn":6,"lag_events":0,"lag_ms":0,"resynced":true}],"resync_pin_overflows":0}}
inputlayer_replication_head_lsn 6
inputlayer_replication_confirmed_lsn 6
inputlayer_replication_lag_events 0
inputlayer_replication_sync_state{state="async"} 0
inputlayer_replication_sync_state{state="sync"} 1
inputlayer_replication_sync_state{state="stalled"} 0
inputlayer_replication_sync_state{state="degraded"} 0
Evidence: Stalled-state /metrics and /metrics/prometheus
== /metrics/prometheus (primary, block, no follower)
# HELP inputlayer_replication_head_lsn Newest replication event on this primary.
# TYPE inputlayer_replication_head_lsn gauge
inputlayer_replication_head_lsn 5
# HELP inputlayer_replication_confirmed_lsn Newest event a follower confirmed applying durably.
# TYPE inputlayer_replication_confirmed_lsn gauge
inputlayer_replication_confirmed_lsn 0
# HELP inputlayer_replication_lag_events Events committed here that no follower has confirmed.
# TYPE inputlayer_replication_lag_events gauge
inputlayer_replication_lag_events 5
# HELP inputlayer_replication_lag_seconds Age of the oldest event no follower has confirmed.
# TYPE inputlayer_replication_lag_seconds gauge
inputlayer_replication_lag_seconds 18.873
# HELP inputlayer_replication_followers Followers connected to this primary.
# TYPE inputlayer_replication_followers gauge
inputlayer_replication_followers 0
# HELP inputlayer_replication_sync_state Shipping state: async, sync, stalled (block, timing out) or degraded.
# TYPE inputlayer_replication_sync_state gauge
inputlayer_replication_sync_state{state="async"} 0
inputlayer_replication_sync_state{state="sync"} 0
inputlayer_replication_sync_state{state="stalled"} 1
inputlayer_replication_sync_state{state="degraded"} 0
# HELP inputlayer_replication_sync_waits_total Replies that waited for a follower to confirm their commit.
# TYPE inputlayer_replication_sync_waits_total counter
inputlayer_replication_sync_waits_total 3
# HELP inputlayer_replication_sync_wait_seconds_total Total time replies waited for a follower.
# TYPE inputlayer_replication_sync_wait_seconds_total counter
inputlayer_replication_sync_wait_seconds_total 4.503
# HELP inputlayer_replication_sync_unconfirmed_total Replies failed as not confirmed on a replica.
# TYPE inputlayer_replication_sync_unconfirmed_total counter
inputlayer_replication_sync_unconfirmed_total 3
# HELP inputlayer_replication_sync_degrades_total Falls back from synchronous to asynchronous shipping.
# TYPE inputlayer_replication_sync_degrades_total counter
inputlayer_replication_sync_degrades_total 0
# HELP inputlayer_replication_sync_rearms_total Returns to synchronous shipping after a fall back.
# TYPE inputlayer_replication_sync_rearms_total counter
inputlayer_replication_sync_rearms_total 0
== /metrics .replication
{
 "role": "primary",
 "primary": {
  "stream_id": "fd3ff74011a7ec5c",
  "head_lsn": 5,
  "lag_events": 5,
  "lag_ms": 18881,
  "sync": {
   "mode": "sync",
   "state": "stalled",
   "on_follower_loss": "block",
   "sync_timeout_ms": 1500,
   "confirmed_lsn": 0,
   "waits": 3,
   "wait_ms_total": 4503,
   "unconfirmed": 3,
   "degrades": 0,
   "rearms": 0
  },
  "followers": [],
  "resync_pin_overflows": 0
 }
}
Evidence: Degrade then re-arm transcript
== follower attached
+d(1)         23 ms {"ok":true,"rows":[["Inserted 1 fact(s) into 'd'."]]}
   sync: {'state': 'sync', 'confirmed_lsn': 5, 'degrades': 0, 'rearms': 0, 'unconfirmed': 0}
== follower frozen (SIGSTOP)
+d(2)       1512 ms {"ok":true,"rows":[["Inserted 1 fact(s) into 'd'."]]}
+d(3)          7 ms {"ok":true,"rows":[["Inserted 1 fact(s) into 'd'."]]}
+d(4)          2 ms {"ok":true,"rows":[["Inserted 1 fact(s) into 'd'."]]}
   sync: {'state': 'degraded', 'confirmed_lsn': 5, 'degrades': 1, 'rearms': 0, 'unconfirmed': 0}
== primary log
replication_sync_degraded: no follower confirmed a commit in time; shipping asynchronously until a follower catches up �[3mhead�[0m�[2m=�[0m6 �[3mconfirmed_lsn�[0m�[2m=�[0m5 �[3mtimeout_ms�[0m�[2m=�[0
== follower resumed (SIGCONT)
   sync: {'state': 'sync', 'confirmed_lsn': 8, 'degrades': 1, 'rearms': 1, 'unconfirmed': 0}
replication_sync_rearmed: a follower caught up; shipping synchronously again �[3mconfirmed_lsn�[0m�[2m=�[0m8
+d(5)          5 ms {"ok":true,"rows":[["Inserted 1 fact(s) into 'd'."]]}
   sync: {'state': 'sync', 'confirmed_lsn': 9, 'degrades': 1, 'rearms': 1, 'unconfirmed': 0}
== /metrics/prometheus sync_state
inputlayer_replication_sync_state{state="async"} 0
inputlayer_replication_sync_state{state="sync"} 1
inputlayer_replication_sync_state{state="stalled"} 0
inputlayer_replication_sync_state{state="degraded"} 0
inputlayer_replication_sync_degrades_total 1
inputlayer_replication_sync_rearms_total 1
== follower ?d(X)
?d(X)          2 ms {"ok":true,"rows":[[1],[2],[3],[4],[5]]}
Evidence: Config guard refusals
== follower with durability_mode=async (and mode=sync in its section)
WARNING: Durability mode is Async (not Immediate). Recent writes may be lost on crash.
ERROR: Failed to initialize InputLayer: a follower must be durable: storage.persist.durability_mode = "async" writes no WAL, so a restarted follower would resume past changes it lost; use "immediate" or "batched"
Error: "a follower must be durable: storage.persist.durability_mode = \"async\" writes no WAL, so a restarted follower would resume past changes it lost; use \"immediate\" or \"batched\""
exit=1
== primary with mode = "semi"
ERROR: Failed to parse config from /tmp/ilsync.VBqQ/c2/engine.toml: unknown variant: found `semi`, expected ``async` or `sync`` for key "default.replication.mode" in /tmp/ilsync.VBqQ/c2/engine.toml TOML file
== primary with on_follower_loss = "ignore"
ERROR: Failed to parse config from /tmp/ilsync.VBqQ/c3/engine.toml: unknown variant: found `ignore`, expected ``block` or `degrade`` for key "default.replication.on_follower_loss" in /tmp/ilsync.VBqQ/c3/engine.toml TOML file
== primary with sync_timeout_ms = 0
ERROR: Failed to initialize InputLayer: replication.sync_timeout_ms must be greater than 0
Error: "replication.sync_timeout_ms must be greater than 0"
Evidence: Commit latency: standalone / base async / new async / new sync
new standalone                 1 writer: median 1.39 ms p99 5.34 ms | 4 writers: median 4.87 ms p99 13.42 ms, 743 commits/s
base async primary+follower    1 writer: median 1.68 ms p99 7.07 ms | 4 writers: median 5.76 ms p99 14.92 ms, 616 commits/s
new async primary+follower     1 writer: median 1.41 ms p99 7.63 ms | 4 writers: median 6.08 ms p99 15.83 ms, 580 commits/s
new sync primary+follower      1 writer: median 2.82 ms p99 10.54 ms | 4 writers: median 6.05 ms p99 14.49 ms, 594 commits/s
new standalone                 1 writer: median 1.49 ms p99 6.31 ms | 4 writers: median 5.21 ms p99 12.89 ms, 711 commits/s
base async primary+follower    1 writer: median 1.41 ms p99 7.45 ms | 4 writers: median 6.07 ms p99 15.67 ms, 590 commits/s
new async primary+follower     1 writer: median 1.59 ms p99 6.24 ms | 4 writers: median 5.87 ms p99 14.54 ms, 608 commits/s
new sync primary+follower      1 writer: median 2.77 ms p99 10.80 ms | 4 writers: median 5.88 ms p99 14.92 ms, 606 commits/s
Evidence: Integration tests run
   Compiling arrow-json v53.4.1
   Compiling parquet v53.4.1
   Compiling arrow v53.4.1
   Compiling hyper v1.11.1
   Compiling hyper-util v0.1.21
   Compiling hyper-tls v0.6.0
   Compiling axum v0.7.9
   Compiling reqwest v0.12.28
   Compiling inputlayer-ontology-client v0.1.0 (~/.no-mistakes/worktrees/9eea230c3aae/01M46Y6EZ1569VKBG30F04MPTD/ontology-client)
   Compiling inputlayer v0.1.0 (~/.no-mistakes/worktrees/9eea230c3aae/01M46Y6EZ1569VKBG30F04MPTD)
    Finished `release` profile [optimized] target(s) in 2m 02s
     Running tests/replication_tests.rs (target/release/deps/replication_tests-04ac3cb8f0fdf1f3)

running 3 tests
test degrade_falls_back_to_async_and_rearms_once_the_follower_catches_up ... ok
test block_fails_writes_no_follower_confirms_and_recovers_when_one_returns ... ok
test in_sync_mode_killing_the_primary_loses_no_acknowledged_write ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 2.74s

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 1 issue found → auto-fixed ✅
  • ⚠️ src/storage_engine/mod.rs - merge conflict rebasing onto origin/main

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
SIGKILL the sync-mode primary under 4 concurrent writers: every acknowledged write is on the follower ✅ pass live kill-the-primary-trials.jsonl (sync trials 1–6: lost_acked 0, about 1,100–2,400 acked in 5 of the 6, one early kill with 17); integration test in_sync_mode_killing_the_primary_loses_no_acknowledged_wr…
Adversarial: freeze the follower (SIGSTOP), then SIGKILL the primary; sync loses no acknowledged write while async does ✅ pass live kill-the-primary-trials.jsonl: sync frozen-follower trials 10–12 lost 0; async frozen-follower trials 13–15 lost 739/642/633 acknowledged writes
Block mode with no follower: a write fails with replica_unconfirmed after sync_timeout_ms but is committed and readable on the primary ✅ pass live block-no-follower.txt: +x(1) returned replica_unconfirmed after 1508 ms; ?x(X) then returned [[1]] at once
Adversarial: no-op writes (re-insert of a present fact, delete of an absent fact) after an unconfirmed write must not be acknowledged at once ✅ pass live block-no-follower.txt: both returned replica_unconfirmed after about 1500 ms; the read before any write returned in 2 ms without waiting
Block mode recovers: once a follower attaches, writes succeed quickly and the state returns to sync ✅ pass live block-follower-returns.txt: +x(2) succeeded in 5 ms; the follower holds [1],[2]; status state=sync, confirmed_lsn=head_lsn=6; a write on the follower is refused with store_read_only
Degrade mode: a frozen follower causes one timed wait and then async fallback with an alert; the primary re-arms once the follower catches up ✅ pass live degrade-rearm.txt: +d(2) succeeded after 1512 ms, later writes took 2–7 ms, state=degraded, log replication_sync_degraded; after SIGCONT state=sync, rearms=1, log replication_sync_rearmed; the followe…
Replication lag and sync state are exported on /metrics and /metrics/prometheus ✅ pass live metrics-stalled.txt (lag_events, lag_seconds, confirmed_lsn, sync_state{state=stalled} 1, unconfirmed_total 3); degrade-rearm.txt (sync_state{sync} 1, degrades_total 1, rearms_total 1)
Config guards: a follower with async durability is refused; invalid mode, on_follower_loss and a zero sync_timeout_ms are refused ✅ pass live config-guards.txt: each case exits at startup with a clear error
Async-mode commit latency is unchanged versus the base commit, and the sync cost is measured ✅ pass live latency.txt: 1-writer median, base async 1.68/1.41 ms vs new async 1.41/1.59 ms; 4 writers, base 616/590 vs new 580/608 commits/s (within noise). New sync: 1-writer median 2.82/2.77 ms, 4 writers abou…
  • cargo build --release --bin inputlayer-server (change) and the same build of base commit 32b56c62 in a /tmp copy, used only for the latency comparison
  • cargo test --release --test replication_tests -- in_sync_mode_killing_the_primary block_fails_writes degrade_falls_back: 3 passed (these tests start the real server binary)
  • Manual: sync primary, block mode, no follower; over WebSocket: read, insert, read, re-insert of a present fact (no-op), delete of an absent fact (no-op) (block.mjs nofollower)
  • Manual: /metrics/prometheus and /metrics on the stalled primary
  • Manual: started a follower, then wrote to the primary, read from the follower, tried a write on the follower, and read the primary's /v1/replication/status
  • Manual: 15 kill-the-primary trials (killtrial.sh): 4 concurrent WebSocket writers, then SIGKILL of the primary; every acknowledged write checked against the follower's rows. Ran sync ×5, async ×3, and a frozen-follower (SIGSTOP) variant sync ×3 and async ×3
  • Manual: sync primary in degrade mode; SIGSTOP the follower, write, SIGCONT it, write again; checked the status counters, log lines replication_sync_degraded/replication_sync_rearmed, and Prometheus sync_state
  • Manual: config guards: follower with durability_mode = "async" (and mode = "sync" in its section), mode = "semi", on_follower_loss = "ignore", sync_timeout_ms = 0
  • Manual: latency (latpair.sh / latency.mjs): 600 commits from 1 writer, then 5 s of 4 writers; arms were standalone, base async pair, new async pair and new sync pair, each run twice
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@jsam jsam changed the title feat(replication): synchronous shipping with RPO 0 (EN-12 part 2) feat(replication): synchronous shipping so acknowledged writes survive losing the primary Oct 5, 2026
@jsam jsam changed the title feat(replication): synchronous shipping so acknowledged writes survive losing the primary feat(replication): synchronous shipping with RPO 0 (EN-12 part 2) Oct 5, 2026
@jsam
jsam marked this pull request as ready for review October 5, 2026 20:52
jsam added 7 commits October 5, 2026 21:07
…e a follower applied it (EN-12 part 2)

replication.mode = "sync" makes a primary reply to a request that changed
state only once a follower acked durable apply of the newest event it
appended. replication.sync_timeout_ms bounds the wait; on_follower_loss
decides what follows: block (the reply fails with the new wire code
replica_unconfirmed, the commit stands on the primary) or degrade (fall
back to async with an alert, re-arm once a follower caught up).

Requests note their LSNs through a task-local tracker handed to blocking
pool threads, so every write path (programs, DDL, users, keys, ACLs) waits
and reads never do. Async mode tracks nothing. Replication lag (events and
age of the oldest unconfirmed event) and the sync state are on /metrics,
/metrics/prometheus and /v1/replication/status.
…ttings and metrics; measured cost and RPO

Measured on the perf gate host (release, primary and follower on one 32-core
host, 5 interleaved rounds): 1 writer p50 1.46 ms async vs 2.69 ms sync;
4 writers 766 vs 770 commits/s. Async unchanged against 57ad2e9
(1.44 ms, 763/s). 10 kill-the-primary trials per mode: 0 acknowledged
writes lost in sync mode.
@jsam jsam changed the title feat(replication): synchronous shipping with RPO 0 (EN-12 part 2) feat(replication): synchronous shipping so acknowledged writes survive losing the primary Oct 5, 2026
@jsam
jsam merged commit efa87aa into main Oct 5, 2026
24 checks passed
@jsam
jsam deleted the fm/en-en12-p2 branch October 5, 2026 21:45
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