Skip to content

feat(replication): stream committed changes to a read-only warm-standby follower - #283

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

jsam merged 11 commits into
mainfrom
fm/en-en12

Conversation

@jsam

@jsam jsam commented Oct 4, 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.

(Context: plan ~/firstmate/data/nsdl-plan/report.md §6.1, GitHub milestone 7, issue #264. Item EN-12: warm standby: WAL streaming to a follower, promote on failover, fenced writer epoch — why: HA for the SLA; RPO set by synchronous or asynchronous shipping)

Issue #264 splits EN-12 into four PRs: WAL streaming, follower apply, promotion with writer epoch, and failover in the Kubernetes deployment; RPO and RTO measured. This change is EN-12 part 1: WAL streaming to a read-only follower that applies the stream and stays consistent.

What Changed

  • Adds a [replication] config section with three roles: standalone, primary and follower. A primary records every durable change (data, catalog, vector index, API keys) in an in-memory replication log, numbered by LSN. It streams that log over GET /v1/replication/stream, a WebSocket guarded by a shared token. A follower that has fallen out of the retained window gets a checkpoint resync first. GET /v1/replication/status reports lag and follower state.
  • Adds a follower client and storage-engine replica apply (apply_replicated, reconcile_graph). The follower applies each batch of received frames with one fsync, acks the primary, saves its position so it resumes after a restart, and refuses writes. It reconnects with backoff when the primary goes silent or the connection drops, and it refuses async durability.
  • Adds unit tests and an end-to-end tests/replication_tests.rs suite covering kill-primary, partition, restart, resync and concurrent multi-graph writers. Also adds a replication guide with measured lag, RPO and catch-up times, configuration docs, and config.example.toml entries.

Risk Assessment

✅ Low: The latest fix round is small and correct. The new resumed setter records the saved position without setting last_contact, and applied still sets it. The gitleaks entry is an exact-match placeholder, and the new test checks the primary-down restart case. The earlier fixes (replaying from every LSN, keeping the resync backoff until caught up) are present in the current code.

Testing

I built the server and client and ran a real primary and follower in throwaway temp directories, driving them with the CLI client and the status endpoint. The follower matched the primary through inserts, deletes, rules, graph creates and drops, and relation drops, and it refused a write. A follower restart resumed from its saved position with lag 0 and no resync. When I SIGKILLed the primary during 80 acknowledged writes, the follower held all 80 as a contiguous prefix. After a follower restart while the primary was down, last_contact_ms was absent and the follower kept its position. When the primary restarted, the follower resynced and converged. A follower with async durability or the wrong token was refused. For subscriptions, partitions and falling behind retain_bytes, I ran the end-to-end replication test file between real processes; all 8 tests passed. The targeted replica unit tests also passed. Everything passed, and I removed all temp servers and data.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Facts, rules and a new graph committed on the primary are queryable on the follower with the primary's API key ✅ pass live s1-replicate-read-refuse.txt
A write sent to the follower is refused with 'read-only replica' and the data is unchanged ✅ pass live s1-replicate-read-refuse.txt
Fact deletes, relation drops and graph create/drop on the primary are mirrored on the follower ✅ pass live s2-deletes-drops.txt
A SIGKILLed follower restarts from its saved position: no resync, applied_lsn=head, lag 0 on the follower and on the primary's entry ✅ pass live s3-follower-restart-resume.txt
Killing the primary under a steady writer loses no acknowledged write on the follower, which holds a contiguous prefix ✅ pass live s4-kill-primary-rpo.txt (80/80 acknowledged writes present)
A follower restarted while the primary is unreachable reports its resumed position and no last_contact_ms ✅ pass live s5-follower-restart-primary-down.txt
After the primary restarts, the follower resyncs to the new stream and converges, including post-restart writes ✅ pass live s6-primary-restart-resync.txt
A follower configured with durability_mode=async refuses to start with a clear error ✅ pass live s7-async-and-wrong-token-refused.txt
A follower with the wrong token gets 401 and never receives the primary's data ✅ pass live s7-async-and-wrong-token-refused.txt
Subscription on the follower, network partition heal, and falling behind retain_bytes all converge (end-to-end tests between real server processes) ✅ pass live replication_tests.txt (cargo test --test replication_tests, 8 passed)
Evidence: Replicate facts/rules/graphs, follower reads and refuses writes
\### primary: write facts, rules, graph
Connecting to server at http://127.0.0.1:18611...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> +edge[(1, 2), (2, 3), (3, 4)]
Inserted 3 fact(s) into 'edge'.
> +reach(X, Y) <- edge(X, Y)
Rule 'reach' registered.
> +reach(X, Z) <- reach(X, Y), edge(Y, Z)
Rule 'reach' registered.
> .kg create other
Knowledge graph 'other' created.
Switched to knowledge graph: other

\### primary: query
Connecting to server at http://127.0.0.1:18611...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> ?edge(X, Y)
┌───┬───┐
│ X │ Y │
├───┼───┤
│ 1 │ 2 │
│ 2 │ 3 │
│ 3 │ 4 │
└───┴───┘
3 rows
> ?reach(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
└───┴───┘
3 rows
> .kg list
Knowledge Graphs:
  default *
  other

\### follower (same API key as primary): query
Connecting to server at http://127.0.0.1:18612...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> ?edge(X, Y)
┌───┬───┐
│ X │ Y │
├───┼───┤
│ 1 │ 2 │
│ 2 │ 3 │
│ 3 │ 4 │
└───┴───┘
3 rows
> ?reach(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
└───┴───┘
3 rows
> .kg list
Knowledge Graphs:
  default *
  other

\### follower: attempt write +edge(9, 9)
Connecting to server at http://127.0.0.1:18612...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> +edge(9, 9)
read-only replica: this server is a replication follower; send writes to the primary
exit=0

\### follower: ?edge after refused write
Connecting to server at http://127.0.0.1:18612...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> ?edge(X, Y)
┌───┬───┐
│ X │ Y │
├───┼───┤
│ 1 │ 2 │
│ 2 │ 3 │
│ 3 │ 4 │
└───┴───┘
3 rows

\### GET follower /v1/replication/status
{
    "role": "follower",
    "follower": {
        "state": "streaming",
        "stream_id": "c98d011980503e91",
        "applied_lsn": 8,
        "primary_head_lsn": 8,
        "lag_events": 0,
        "primary_revision": 5,
        "last_contact_ms": 7,
        "resyncs": 1,
        "resync_failures": 0,
        "reconnects": 0
    }
}

\### GET primary /v1/replication/status
{
    "role": "primary",
    "primary": {
        "stream_id": "c98d011980503e91",
        "head_lsn": 8,
        "followers": [
            {
                "name": "follower",
                "addr": "127.0.0.1",
                "connected_ms": 14348,
                "sent_lsn": 8,
                "acked_lsn": 8,
                "lag_events": 0,
                "resynced": true
            }
        ],
        "resync_pin_overflows": 0
    }
}
Evidence: Deletes, relation drop, graph drop replicate
\### primary: graph create/drop, fact delete, relation drop
Connecting to server at http://127.0.0.1:18611...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> .kg create doomed
Knowledge graph 'doomed' created.
Switched to knowledge graph: doomed
> +pt[(1, 1), (2, 2)]
Inserted 2 fact(s) into 'pt'.
> .kg use default
Switched to knowledge graph: default
> -edge(3, 4)
Deleted 1 facts from 'edge'.
> +tmp[(7)]
Inserted 1 fact(s) into 'tmp'.
> .rel drop tmp
Relation 'tmp' dropped.
> .kg drop doomed
Knowledge graph 'doomed' dropped.
> +edge(5, 6)
Inserted 1 fact(s) into 'edge'.

\### primary state
Connecting to server at http://127.0.0.1:18611...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> ?edge(X, Y)
┌───┬───┐
│ X │ Y │
├───┼───┤
│ 1 │ 2 │
│ 2 │ 3 │
│ 5 │ 6 │
└───┴───┘
3 rows
> ?reach(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
└───┴───┘
2 rows
> .rel
Relations:
  edge (arity: 2, columns: [col0: any, col1: any], tuples: 3)
> .kg list
Knowledge Graphs:
  default *
  other

\### follower state
Connecting to server at http://127.0.0.1:18612...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> ?edge(X, Y)
┌───┬───┐
│ X │ Y │
├───┼───┤
│ 1 │ 2 │
│ 2 │ 3 │
│ 5 │ 6 │
└───┴───┘
3 rows
> ?reach(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
└───┴───┘
2 rows
> .rel
Relations:
  edge (arity: 2, columns: [col0: any, col1: any], tuples: 3)
> .kg list
Knowledge Graphs:
  default *
  other

\### follower status
{
    "role": "follower",
    "follower": {
        "state": "streaming",
        "stream_id": "c98d011980503e91",
        "applied_lsn": 15,
        "primary_head_lsn": 15,
        "lag_events": 0,
        "primary_revision": 10,
        "last_contact_ms": 9,
        "resyncs": 1,
        "resync_failures": 0,
        "reconnects": 0
    }
}
Evidence: Follower restart resumes with zero lag on both status endpoints
\### saved follower position before kill
{"stream_id":14523265582378270353,"lsn":15,"primary_revision":10}
\### follower SIGKILLed; restarting on same data dir
\### follower status after restart (primary idle, no new commits)
{
    "role": "follower",
    "follower": {
        "state": "streaming",
        "stream_id": "c98d011980503e91",
        "applied_lsn": 15,
        "primary_head_lsn": 15,
        "lag_events": 0,
        "primary_revision": 10,
        "last_contact_ms": 13,
        "resyncs": 0,
        "resync_failures": 0,
        "reconnects": 0
    }
}
\### primary status
{
    "role": "primary",
    "primary": {
        "stream_id": "c98d011980503e91",
        "head_lsn": 15,
        "followers": [
            {
                "name": "follower",
                "addr": "127.0.0.1",
                "connected_ms": 1762,
                "sent_lsn": 15,
                "acked_lsn": 15,
                "lag_events": 0,
                "resynced": false
            }
        ],
        "resync_pin_overflows": 0
    }
}
\### follower data after restart
Current knowledge graph: default
> ?edge(X, Y)
┌───┬───┐
│ X │ Y │
├───┼───┤
│ 1 │ 2 │
│ 2 │ 3 │
│ 5 │ 6 │
└───┴───┘
3 rows
Evidence: Kill primary under writer: no acknowledged write lost
Primary SIGKILLed ~4s into a steady single writer (+w(i), one commit per client call).
acked writes: 80, max acked: 80
follower w rows: 80, max: 80
acked but missing on follower: 0
follower holds contiguous prefix 1..max: yes
follower status after primary death:
{
    "role": "follower",
    "follower": {
        "state": "connecting",
        "stream_id": "c98d011980503e91",
        "applied_lsn": 95,
        "primary_head_lsn": 95,
        "lag_events": 0,
        "primary_revision": 90,
        "last_contact_ms": 7202,
        "resyncs": 0,
        "resync_failures": 0,
        "reconnects": 1,
        "last_error": "connecting to ws://127.0.0.1:18611/v1/replication/stream: IO error: Connection refused (os error 111)"
    }
}
Evidence: Follower restart with primary down: no last_contact_ms
\### primary down; follower SIGKILLed and restarted
\### follower status ~2s after restart, primary unreachable
{
    "role": "follower",
    "follower": {
        "state": "connecting",
        "stream_id": "c98d011980503e91",
        "applied_lsn": 95,
        "primary_head_lsn": 95,
        "lag_events": 0,
        "primary_revision": 90,
        "resyncs": 0,
        "resync_failures": 0,
        "reconnects": 0,
        "last_error": "connecting to ws://127.0.0.1:18611/v1/replication/stream: IO error: Connection refused (os error 111)"
    }
}
\### follower still serves reads of its copy
80 rows
Evidence: Primary restart triggers resync and convergence
\### primary restarted; committing +w(1000) and +edge(6, 7)
Inserted 1 fact(s) into 'w'.
Inserted 1 fact(s) into 'edge'.
\### follower status
{
    "role": "follower",
    "follower": {
        "state": "streaming",
        "stream_id": "dd4da6dacc47d25f",
        "applied_lsn": 2,
        "primary_head_lsn": 2,
        "lag_events": 0,
        "primary_revision": 94,
        "last_contact_ms": 2,
        "resyncs": 1,
        "resync_failures": 0,
        "reconnects": 0,
        "last_error": "connecting to ws://127.0.0.1:18611/v1/replication/stream: IO error: Connection refused (os error 111)"
    }
}
\### primary status
{
    "role": "primary",
    "primary": {
        "stream_id": "dd4da6dacc47d25f",
        "head_lsn": 2,
        "followers": [
            {
                "name": "follower",
                "addr": "127.0.0.1",
                "connected_ms": 2468,
                "sent_lsn": 2,
                "acked_lsn": 2,
                "lag_events": 0,
                "resynced": true
            }
        ],
        "resync_pin_overflows": 0
    }
}
\### 18611: ?w rows / ?reach(1,Y)
82 rows
│ 1 │ Y │
│ 1 │ 2 │
│ 1 │ 3 │
2 rows
\### 18612: ?w rows / ?reach(1,Y)
82 rows
│ 1 │ Y │
│ 1 │ 2 │
│ 1 │ 3 │
2 rows
\### follower log resync lines
2026-10-05T16:46:42.566007Z  INFO inputlayer::protocol::replication::follower: replication_resync_started stream_id=15946585314249134687 head=2
2026-10-05T16:46:42.574411Z  INFO inputlayer::protocol::replication::follower: replication_resync_finished stream_id=15946585314249134687 head=2 revision=94 graphs=3
Evidence: Async follower and wrong-token follower refused
\### follower with durability_mode = async

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

\### follower with durability_mode = batched (allowed) but WRONG token
follower log replication lines:
2026-10-05T16:47:00.513251Z  WARN inputlayer::protocol::replication::follower: replication_follower_disconnected error=connecting to ws://127.0.0.1:18611/v1/replication/stream: HTTP error: 401 Unauthorized
2026-10-05T16:47:01.315213Z  WARN inputlayer::protocol::replication::follower: replication_follower_disconnected error=connecting to ws://127.0.0.1:18611/v1/replication/stream: HTTP error: 401 Unauthorized
2026-10-05T16:47:02.917319Z  WARN inputlayer::protocol::replication::follower: replication_follower_disconnected error=connecting to ws://127.0.0.1:18611/v1/replication/stream: HTTP error: 401 Unauthorized
primary followers list:
['127.0.0.1:2']
follower data (should have none of the primary's graphs):
LOCK
default
metadata
persist
Evidence: End-to-end replication tests between real servers
test api_keys_issued_and_revoked_on_the_primary_apply_on_the_follower ... ok
test a_follower_holds_the_primary_state_serves_reads_and_refuses_writes ... ok
test a_restarted_follower_resumes_from_its_saved_position ... ok
test concurrent_writers_on_many_graphs_converge_on_the_follower ... ok
test a_follower_behind_the_retained_log_resyncs ... ok
test a_partitioned_follower_notices_the_silence_and_catches_up_after_the_heal ... ok
test killing_the_primary_leaves_a_consistent_prefix_and_its_restart_resyncs_the_follower ... ok
test a_follower_with_the_wrong_token_is_refused ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.30s
- Outcome: 🔧 2 issues found → auto-fixed ✅ across 3 runs (13m43s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (2) ✅
  • ⚠️ src/storage_engine/replica_tests.rs:263 - The round 2 fix was never applied, although the user chose to fix it in that round. replaying_events_already_applied_changes_nothing still replays only from [0, head / 2, head - 1]. Round 1 made CreateIndex/DropIndex a no-op on a missing graph (replica.rs:225-235) and added the trailing doomed index events to write_everything (replica_tests.rs:215-223). Neither change is exercised by this test.

Why no tested start reaches that path:

  • Replaying from 0 or head/2 re-runs the insert_tuples_into(&#34;doomed&#34;, ..) / register_schema_in(&#34;doomed&#34;, ..) commits. These re-create the graph through ensure_replica_graph, so the later CreateIndex/DropIndex find it.
  • Replaying from head-1 applies only DropGraph.

The crash sequence the round 1 finding describes needs a start inside the trailing create-index/drop-index/create-index run. With no such start, the test still passes if the old self.kg_handle(&amp;kg)? is put back. The fix is the one recorded in round 2: replay from every position (for from in 0..head), so a start at head-2 or head-3 runs CreateIndex/DropIndex against the dropped graph.

  • ⚠️ src/protocol/replication/follower.rs:274 - This is in round 1's resync-livelock fix. The follower stops backing off as soon as it applies its first tail batch after a checkpoint (self.resyncing = false at follower.rs:274). The primary, however, holds the pin and can still end the resync with End::PinOverflowed until the follower is up to date (primary.rs:234-236, 252).

Failing sequence:

  1. The checkpoint finishes and the pinned backlog is under 4 × retain_bytes.
  2. The follower applies tail batches more slowly than the primary commits.
  3. The pinned backlog passes the cap. read_after returns Unavailable, so the primary ends with PinOverflowed and sends Error.
  4. On the follower, resync_failed is false and streamed is true. resync_backoff resets to zero and the backoff falls back to MIN_BACKOFF (100 ms).
  5. The next hello's LSN has been evicted, so the follower resyncs at once, and every round costs the primary another capture_checkpoint_at_lsn.

The primary's own message says 'the resync is retried' after a backoff (End::PinOverflowed doc), and the user asked for backoff between consecutive resync attempts, reset only once a resync completes and streaming starts. The pin lifetime defines completion as reaching the primary's head.

Fix: clear resyncing only once the applied LSN reaches the head carried in the frame (lsn &gt;= head) or on a Heartbeat, both of which mean the primary is up to date. Do the same for the streamed-driven reset at follower.rs:96-97. Update the module doc (follower.rs:9-11, 'before their checkpoint is applied') and replication.mdx ('resets once a resync finishes and streaming starts') to match.

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

  • ℹ️ src/protocol/replication/follower.rs:74 - Commit ab0336f, a fixer commit from the previous run, makes the follower report its resumed position at startup by calling status.applied(loaded.stream_id, loaded.lsn, loaded.primary_revision). ReplicationStatus::applied (status.rs:201-212) also sets last_contact = Some(Instant::now()), so the follower records contact with the primary before it has connected to it.

Failing sequence: the follower restarts while the primary is unreachable. /v1/replication/status then reports last_contact_ms as the time since the follower process started. The field should be absent, because the primary has not been heard from in this run. The docs define the field as 'time since the primary was last heard', so a monitor reading it right after a restart sees a recent contact that never happened.

Fix: at startup, set only stream_id, applied_lsn, primary_head and primary_revision. Either add a small status setter that leaves last_contact alone, or let applied take a flag.

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

🔧 **Test** - 2 issues found → auto-fixed ✅
  • ⚠️ src/protocol/replication/follower.rs:52 - After a follower restarts and resumes from its saved position (replication/follower.json, lsn 22 in the live run), /v1/replication/status reports the wrong position on both servers. The follower shows stream_id 0000000000000000, applied_lsn 0, lag_events 22. The primary's entry for it shows acked_lsn 0, lag_events 22. This lasts as long as the primary stays idle, although the follower holds every change. The data was correct.

Cause: run() loads the Position but never calls replication_status().applied(..); only save_position does, and in Tail mode with nothing new to apply that never runs. On the primary, follower_acked is set only from Ack messages, not seeded from the hello's LSN. As a result, lag alerts fire falsely after every follower restart, and tooling that waits for lag_events == 0 waits forever on a quiet primary.

Fix: publish the loaded position to the status right after Position::load (or when the Start message arrives in Tail mode), and on the primary seed the follower's acked LSN from the hello's LSN when the stream ID matches. Add a test to a_restarted_follower_resumes_from_its_saved_position that checks lag 0 before any new commit.

  • 🚨 live validation verdict: no-go (9 of 10 scenarios were driven live against the product); failed: After a follower restart, /v1/replication/status reports the follower's real position and zero lag
  • Live validation: ❌ no-go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
A fresh follower copies the primary's facts, rules, second graph, vector index and API key, and answers the same queries ✅ pass live s1-follower-mirrors-primary.txt; status showed resyncs=1, streaming, lag 0
The follower refuses fact writes and graph creation with a 'read-only replica' error ✅ pass live s2-writes-refused-and-later-changes-stream.txt
An insert, delete, index drop, graph create and graph drop on the primary appear on the follower within 1 s; both servers report lag 0 ✅ pass live s2-writes-refused-and-later-changes-stream.txt, s3-index-then-graph-drop-and-follower-restart.txt
After an index is created on a graph that is then dropped, and the follower is SIGKILLed and restarted, it resumes from its saved position without a resync and still matches the primary ✅ pass live s3-index-then-graph-drop-and-follower-restart.txt (resyncs=0 after restart, reads match); the crash-replay case from every LSN is covered by replaying_events_already_applied_changes_nothing
After a follower restart, /v1/replication/status reports the follower's real position and zero lag ❌ fail live s3b-status-after-follower-restart.txt: with the primary idle, the follower shows stream_id 0, applied_lsn 0, lag_events 22, and the primary shows acked_lsn 0, lag 22; both correct themselves only afte…
SIGKILL of the primary while a client is committing leaves the follower holding exactly seq 1..k with k ≤ acknowledged; after the primary restarts the follower resyncs and matches it ✅ pass live s4-kill-primary-prefix-and-resync.txt: k=949 = 949 acknowledged, exactly 1..k; after restart primary 950 rows = follower 950 rows, resyncs incremented
A follower configured with durability_mode=async refuses to start; a primary with async still starts ✅ pass live s5-refusals.txt: follower exits with code 1 and prints 'a follower must be durable ... use "immediate" or "batched"'; the async primary kept running
A follower with the wrong token is refused with 401 and gets no data; a token shorter than 16 characters is refused at startup ✅ pass live s5-refusals.txt
A network partition is detected after timeout_ms and the follower catches up after the heal; a follower that falls behind retain_bytes resyncs ✅ pass live cargo test --test replication_tests a_partitioned_follower_notices_the_silence_and_catches_up_after_the_heal and a_follower_behind_the_retained_log_resyncs (both start real server processes through…
Writes during a checkpoint transfer that exceed 4×retain_bytes make the primary abandon the resync (resync_pin_overflows counter rises), and the follower backs off from 1 s, doubling up to 60 s ⏸️ untested no Triggering it live needs a checkpoint transfer slow enough for more than 4×retain_bytes of commits to land during it. With a test-sized state the transfer finishes in milliseconds. I did not build a m…
  • cargo test --test replication_tests (8 tests that start real server processes: happy path, kill primary, partition, follower restart, behind the retained log, wrong token, concurrent writers, API keys)
  • cargo test --lib replica and cargo test --lib config (replay from every LSN, pin cap, refusal of an async follower)
  • Live: built inputlayer-server and inputlayer-client, started a primary on :18101 and a follower on :18102 from TOML configs in a temp directory, seeded facts, rules, a second graph and an HNSW index, then compared reads on both servers
  • Live: tried +edge(9, 9) and .kg create rogue on the follower; inserted, deleted, dropped an index and dropped a graph on the primary, then compared reads and checked status on both servers
  • Live: created, dropped and re-created an index on a graph, dropped the graph, SIGKILLed the follower, restarted it, and checked follower.json, its status and its reads
  • Live: SIGKILLed the primary while a client was committing +run(i) (rate limit off); checked the follower held exactly 1..k with k ≤ acknowledged; restarted the primary and checked the resync and row counts
  • Live: started a follower with durability_mode = &#34;async&#34; (exits with code 1), a primary with async (starts), a follower with the wrong token (gets 401 and retries), and a follower with a short token (refused at startup)

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

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Facts, rules, graphs and vector data written on the primary are visible on the follower, and the follower reports lag 0 ✅ pass live r2-s1-mirror-and-refusal.txt: identical ?edge, ?reach, .kg list and ?docs output on :18201 and :18202; status shows applied_lsn 9 = head 9
A write sent to the follower is refused with a read-only error and changes nothing ✅ pass live r2-s1-mirror-and-refusal.txt: 'read-only replica: ... send writes to the primary'; ?edge(9, Y) on the follower returns no results
Index create/drop on a graph that is then dropped replicates, and a SIGKILLed follower restarts and resumes without a resync ✅ pass live r2-s3-index-graph-drop-follower-restart-status.txt: scratch is gone on the follower; after restart resyncs 0, the data is intact and the next commit streams (applied_lsn 17)
After a follower restart, /v1/replication/status on both servers shows the real position and lag 0 while the primary is idle (last round's regression) ✅ pass live r2-s3-...txt: at t+5s and t+10s the follower shows stream_id 07141ce50ed9ed08, applied_lsn 16, lag_events 0; the primary shows acked_lsn 16, lag_events 0; also the e2e test a_restarted_follower_resume…
SIGKILL the primary under a steady writer: the follower holds a contiguous prefix containing every acknowledged write, and resyncs to an identical copy when the primary restarts ✅ pass live r2-s4-kill-primary-prefix-resync.txt: 99 writes acknowledged, the follower holds n(1..99) contiguously, and after the restart resyncs=1 and the n sets are identical
Adversarial config: a follower with async durability is refused at startup, and a follower with the wrong token is rejected by the primary ✅ pass live r2-s5-refusals.txt: 'a follower must be durable ... use "immediate" or "batched"', exit 1; the primary logs replication_stream_unauthorized and the follower gets 401
Replaying from every LSN, including index events on an already dropped graph, is idempotent ⏸️ untested no The prior payload did not establish a live result for this scenario. A replay at an exact LSN needs a crash timed between the WAL sync and the position rename, and that cannot be triggered determinist…
  • cargo build --bin inputlayer-server --bin inputlayer-client, then a primary on :18201 and a follower on :18202 started from /tmp config dirs (immediate durability, shared token)
  • inputlayer-client on the primary: facts, recursive rules, a second graph and vector facts; the same reads on both servers; a +edge(9, 9) write on the follower
  • Primary: .kg create scratch, schema, .index create/.index drop/.index create, then .kg drop scratch; SIGKILL the follower and restart it; poll /v1/replication/status on both servers at 5 s and 10 s with no new commits; then one more commit
  • A 300-commit writer on the primary, SIGKILL of the primary mid-run, a contiguity check on the follower, a primary restart and a primary/follower comparison
  • Starting a follower with durability_mode=async; starting a follower with a wrong token
  • cargo test --test replication_tests (8 e2e tests, including a_restarted_follower_resumes_from_its_saved_position)
  • cargo test --lib replica (includes replaying_events_already_applied_changes_nothing, which replays from every LSN)

✅ No issues found.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Facts, rules and a new graph committed on the primary are queryable on the follower with the primary's API key ✅ pass live s1-replicate-read-refuse.txt
A write sent to the follower is refused with 'read-only replica' and the data is unchanged ✅ pass live s1-replicate-read-refuse.txt
Fact deletes, relation drops and graph create/drop on the primary are mirrored on the follower ✅ pass live s2-deletes-drops.txt
A SIGKILLed follower restarts from its saved position: no resync, applied_lsn=head, lag 0 on the follower and on the primary's entry ✅ pass live s3-follower-restart-resume.txt
Killing the primary under a steady writer loses no acknowledged write on the follower, which holds a contiguous prefix ✅ pass live s4-kill-primary-rpo.txt (80/80 acknowledged writes present)
A follower restarted while the primary is unreachable reports its resumed position and no last_contact_ms ✅ pass live s5-follower-restart-primary-down.txt
After the primary restarts, the follower resyncs to the new stream and converges, including post-restart writes ✅ pass live s6-primary-restart-resync.txt
A follower configured with durability_mode=async refuses to start with a clear error ✅ pass live s7-async-and-wrong-token-refused.txt
A follower with the wrong token gets 401 and never receives the primary's data ✅ pass live s7-async-and-wrong-token-refused.txt
Subscription on the follower, network partition heal, and falling behind retain_bytes all converge (end-to-end tests between real server processes) ✅ pass live replication_tests.txt (cargo test --test replication_tests, 8 passed)
  • cargo build --bin inputlayer-server --bin inputlayer-client, then a disposable primary (:18611) and follower (:18612) started from temp configs under /tmp
  • inputlayer-client scripts on the primary (inserts, rules, .kg create/.kg drop, -edge, .rel drop), then the same queries on the follower using the primary's API key
  • inputlayer-client write +edge(9, 9) sent to the follower (refused)
  • curl /v1/replication/status on both servers after each step
  • SIGKILL and restart of the follower while the primary is idle (resume from replication/follower.json, no resync)
  • SIGKILL of the primary under a steady writer, then a diff of acknowledged writes against the follower's ?w(X) rows
  • SIGKILL and restart of the follower while the primary is down (status: last_contact_ms absent, resumed applied_lsn)
  • Primary restart, then a check that the follower resyncs and converges with post-restart writes
  • Follower start with durability_mode = &#34;async&#34; (refused) and with a wrong token (401, never joins)
  • cargo test --test replication_tests (8 end-to-end tests between real server processes: subscription deltas, partition via proxy, behind retain_bytes, concurrent multi-graph writers, API keys)
  • cargo test --lib replica (includes replaying_events_already_applied_changes_nothing from every LSN)
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

🔧 **Lint** - 1 issue found ✅
  • ℹ️ src/ast/mod.rs:709 - make doc-check (cargo doc with -D warnings) fails on a broken intra-doc link to crate::params::bind_statements. No such function exists. This was already broken at the base commit, and this change does not touch it, so I left it alone. It needs a follow-up outside this change.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

@jsam

jsam commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Perf gate on sam-dev-benchmarks, candidate 3cb0b22 (the later commit is docs only) vs merge base f8a43f9, under the shared bench lock:

Performance gate: INCONCLUSIVE

Policy provisional: cost budget p50 +5%, p99 +10%, throughput -5%; 95% interval of the median paired per-round ratio. Budgets are the plan's proposed ceilings, pending captain decision D1; tightening is always allowed.

  • baseline: f8a43f96684ba92682274338f08f1e19f62ff12a (binary sha256 c2f741b80bb7)
  • candidate: 3cb0b222ea2d2657bcebd9fba46b0b060e4abe5b (binary sha256 8a670acfb3f1)
  • profile standard, 10 rounds, fixtures cheap_query, bound_query, insert_single, insert_batch, delta_single, delta_fanout, delta_first, interference
  • host computeinstance-e00nfsdgf9dzq987g3: AMD EPYC-Genoa Processor (32 CPUs, 125 GB), kernel 6.11.0-1016-nvidia, governor unknown, data on ext4 (/home/sam/bench/en12/repo/target/perf-gate/runs/20261005T070035Z/servers), server CPUs unpinned
  • load average: start 1.91 4.92 4.84 1/446 264729, end 0.90 1.55 3.10 1/474 272283
  • build: rustc 1.99.0 (b940084d7 2026-09-28); cargo build --release --all-features

Required metrics

metric baseline candidate cost ratio interval budget baseline spread status
bound_query.concurrent_latency_us.p99 12.454 ms 12.723 ms 1.014 0.967..1.080 1.10 9.3% PASS
bound_query.latency_us.p50 8.473 ms 8.475 ms 1.000 0.996..1.014 1.05 2.8% PASS
bound_query.latency_us.p99 8.739 ms 8.764 ms 1.004 0.992..1.017 1.10 10.7% PASS
bound_query.queries_per_sec 885/s 885/s 1.017 0.986..1.054 1.05 5.4% INCONCLUSIVE
cheap_query.latency_us.p50 0.370 ms 0.373 ms 1.020 0.973..1.042 1.05 6.5% PASS
cheap_query.latency_us.p99 0.413 ms 0.419 ms 1.017 0.935..1.051 1.10 17.2% PASS
cheap_query.queries_per_sec 13707/s 13239/s 1.020 0.964..1.086 1.05 9.1% INCONCLUSIVE
delta_fanout.delta_us.p50 7.996 ms 7.760 ms 0.968 0.916..1.021 1.05 11.9% PASS
delta_fanout.delta_us.p99 14.018 ms 13.560 ms 0.902 0.744..1.241 1.10 81.9% INCONCLUSIVE
delta_fanout.last_agent_us.p99 14.265 ms 13.752 ms 0.899 0.745..1.249 1.10 81.6% INCONCLUSIVE
delta_first.first_delta_us.p50 3.861 ms 3.845 ms 1.009 0.966..1.070 1.05 8.2% INCONCLUSIVE
delta_first.warm_delta_us.p50 4.723 ms 4.671 ms 0.996 0.950..1.058 1.05 10.9% INCONCLUSIVE
delta_single.delta_us.p50 7.024 ms 7.138 ms 1.013 0.951..1.081 1.05 11.7% INCONCLUSIVE
delta_single.delta_us.p99 13.109 ms 13.297 ms 0.984 0.897..1.277 1.10 38.4% INCONCLUSIVE
insert_batch.ack_us.p50 4.050 ms 3.793 ms 0.933 0.743..1.034 1.05 58.8% PASS
insert_batch.facts_per_sec 164099/s 178117/s 0.932 0.795..1.165 1.05 31.9% INCONCLUSIVE
insert_single.ack_us.p50 1.613 ms 1.544 ms 0.978 0.889..1.045 1.05 14.6% PASS
insert_single.ack_us.p99 6.351 ms 5.960 ms 0.911 0.692..1.097 1.10 41.2% PASS
insert_single.concurrent_facts_per_sec 572/s 607/s 0.934 0.913..1.040 1.05 20.8% PASS
insert_single.facts_per_sec 550/s 572/s 0.989 0.866..1.038 1.05 14.4% PASS
interference.delta_us.p50 7.768 ms 7.783 ms 0.987 0.918..1.061 1.05 12.7% INCONCLUSIVE
interference.delta_us.p99 14.470 ms 14.223 ms 0.986 0.806..1.297 1.10 51.3% INCONCLUSIVE

Diagnostic metrics (not gated)

metric baseline candidate cost ratio interval budget baseline spread status
bound_query.concurrent_latency_us.p50 8.478 ms 8.480 ms 1.004 0.986..1.021 1.05 3.4% PASS
cheap_query.concurrent_latency_us.p50 0.560 ms 0.573 ms 1.021 0.965..1.078 1.05 8.9% INCONCLUSIVE
cheap_query.concurrent_latency_us.p99 0.905 ms 0.908 ms 1.006 0.920..1.103 1.10 17.5% INCONCLUSIVE
delta_fanout.ack_us.p50 5.838 ms 5.686 ms 0.970 0.931..1.024 1.05 14.6% PASS
delta_fanout.ack_us.p99 11.131 ms 10.136 ms 0.918 0.720..1.177 1.10 65.7% INCONCLUSIVE
delta_fanout.last_agent_us.p50 8.123 ms 7.902 ms 0.971 0.914..1.026 1.05 12.1% PASS
delta_fanout.subscribe_us.p50 1.343 ms 1.343 ms 1.000 0.981..1.022 1.05 3.0% PASS
delta_fanout.subscribe_us.p99 - - - - 1.10 - INVALID: baseline round 1: 64 samples, need 100
delta_first.first_delta_us.p99 - - - - 1.10 - INVALID: baseline round 1: 40 samples, need 100
delta_first.subscribe_us.p50 3.372 ms 3.455 ms 1.031 0.962..1.081 1.05 7.9% INCONCLUSIVE
delta_first.subscribe_us.p99 - - - - 1.10 - INVALID: baseline round 1: 40 samples, need 100
delta_first.warm_delta_us.p99 - - - - 1.10 - INVALID: baseline round 1: 40 samples, need 100
delta_single.ack_us.p50 5.084 ms 5.274 ms 1.047 0.970..1.148 1.05 16.6% INCONCLUSIVE
delta_single.ack_us.p99 11.043 ms 11.521 ms 1.010 0.881..1.300 1.10 44.4% INCONCLUSIVE
delta_single.last_agent_us.p50 7.024 ms 7.138 ms 1.013 0.951..1.081 1.05 11.7% INCONCLUSIVE
delta_single.last_agent_us.p99 13.109 ms 13.297 ms 0.984 0.897..1.277 1.10 38.4% INCONCLUSIVE
delta_single.subscribe_us.p50 - - - - 1.05 - INVALID: baseline round 1: 1 samples, need 20
delta_single.subscribe_us.p99 - - - - 1.10 - INVALID: baseline round 1: 1 samples, need 100
insert_batch.ack_us.p99 - - - - 1.10 - INVALID: baseline round 1: 20 samples, need 100
insert_single.concurrent_ack_us.p50 6.339 ms 5.979 ms 0.946 0.923..1.014 1.05 11.9% PASS
insert_single.concurrent_ack_us.p99 14.949 ms 14.641 ms 0.997 0.841..1.147 1.10 79.7% INCONCLUSIVE
interference.ack_us.p50 5.239 ms 5.292 ms 0.969 0.900..1.070 1.05 18.6% INCONCLUSIVE
interference.ack_us.p99 12.370 ms 11.870 ms 0.973 0.787..1.386 1.10 58.7% INCONCLUSIVE
interference.long_request_us.p50 183.759 ms 185.513 ms 1.008 1.005..1.014 1.05 0.7% PASS
interference.long_request_us.p99 - - - - 1.10 - INVALID: baseline round 1: 20 samples, need 100

@jsam
jsam marked this pull request as ready for review October 5, 2026 15:03
@jsam jsam changed the title feat(replication): stream every committed change to a read-only follower that applies it (EN-12 part 1) feat(replication): stream committed changes to a read-only warm-standby follower Oct 5, 2026
@jsam
jsam merged commit 57ad2e9 into main Oct 5, 2026
21 checks passed
@jsam
jsam deleted the fm/en-en12 branch October 5, 2026 17:00
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