Skip to content

fix(replication): follower keeps reading the stream while it applies a large graph - #391

Merged
jsam merged 1 commit into
mainfrom
fm/il-356-follower-reads
Oct 6, 2026
Merged

jsam merged 1 commit into
mainfrom
fm/il-356-follower-reads

Conversation

@jsam

@jsam jsam commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Fix GitHub issue #356 in inputlayer/inputlayer (production readiness, replication): "Warm standby: follower must keep reading the stream while it reconciles a large graph".

From the EN-12 part 1 review (the review of PR #283, warm-standby WAL streaming to a read-only follower): the follower reconciles a whole graph in spawn_blocking without reading the socket; the primary's send times out after max(timeout_ms, 10 s) and drops the connection, and the follower restarts the resync from scratch. Decouple socket reads from reconcile (bounded buffer with backpressure), so large graphs never cause a resync loop. Acceptance: resync of a graph 10x the measured 1M-fact case under steady commits completes on the first attempt.

Deliver it as a small PR; done means merged with CI green.

What Changed

  • Follower reads and applies on separate tasks. A reader task fills a new byte-bounded receive buffer (src/protocol/replication/buffer.rs, 64 MiB) that the session applies from, and a writer task sends acks plus a WebSocket ping whenever replication.heartbeat_ms passes with nothing to acknowledge. When the buffer is full the reader waits, so the primary's send blocks instead of anything being dropped; time spent waiting for room does not count towards the follower's timeout_ms silence check.
  • Primary tells a busy follower from a dead one. It records when it last heard from the follower (acks or pings) and fails a blocked send only when the follower has neither read nor been heard from for the send timeout (max(timeout_ms, 10 s)). It also sends heartbeats while it captures a checkpoint. A primary from before this change ignores the pings, so the replication guide now says to upgrade the primary first or both together, and its "Known limit" note is replaced by a description of the new behaviour.
  • Tests. Unit tests for the buffer, paused-time tests for the stalled-send and silence paths (tokio test-util added as a dev-dependency), and an integration test that a 60,000-fact resync under steady commits completes on the first attempt. The 10,000,000-fact acceptance run (large_resync_under_steady_commits) is added as an #[ignore]d lab test, so it does not run in CI.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The change decouples reading from applying with a byte-bounded, order-preserving buffer and a ping-based liveness check that stays compatible with older peers, and tracing the reader, writer, batch and primary send paths found no reachable defect; the only open point is that the 10M-fact acceptance run is an ignored lab test whose result is not verifiable from source.

Testing

Drove real inputlayer-server primary and follower processes through the repository's replication test harness, release build. On head, the committed acceptance run (10M facts, a commit every 5 ms) finished the resync in 31.2 s on the first attempt and converged, and the committed 60k-fact first-attempt test plus the retained-log-resync and partition-heal tests passed. To reproduce the reported failure I used a temporary harness knob that makes each steady commit carry 5,000 extra tuples: base failed (connection reset, resync restarted, never recovered within the run), head passed on the first attempt. An adversarial SIGSTOP of the follower for 25 s under bulk commits showed head recovering after thaw with one reconnect, no second resync and converged data. The temporary harness changes were reverted and saved as a patch in the evidence directory. No UI is involved, so there is no visual evidence.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A fresh follower resyncs a 10,000,000-fact graph (10x the 1M case) while the primary commits every 5 ms, and finishes on the first attempt with all data converged ✅ pass live r2-head-committed-tests.log: resync took 31.2 s with 3656 commits meanwhile; follower status at the end shows resyncs 1, resync_failures 0, reconnects 0, no last_error, lag_events 0
Same 10M-fact resync while each steady commit also carries 5,000 tuples, so the primary's send really blocks during the apply: head finishes on the first attempt ✅ pass live r2-head-10M-heavy-lab.log: resync took 42.4 s; follower status at the end shows resyncs 1, resync_failures 0, reconnects 0, lag_events 0
The reported failure reproduces on the base commit under the same heavy-commit 10M resync (connection dropped, resync starts over) ✅ pass live r2-base-10M-heavy-lab.log: follower status at the end shows last_error 'Connection reset by peer', reconnects 1, resync_failures 1, resyncs 2, state resyncing
A small resync under steady commits completes on the first attempt and both relations converge (the committed CI test) ✅ pass live r2-head-committed-tests.log: a_resync_under_steady_commits_completes_on_the_first_attempt ... ok
Adversarial: a follower frozen with SIGSTOP for 25 s under bulk commits recovers after thaw without a second resync and converges with the primary ✅ pass live r2-head-frozen-follower.log: after thaw the follower caught up in 26 s with reconnects 1, resync_failures 0, resyncs 1, lag_events 0; bulk rows converged. r2-base-frozen-follower.log shows base also r…
Existing stream-loss paths still work: a follower behind the retained log resyncs, and a partitioned follower notices the silence and catches up after the heal ✅ pass live r2-head-committed-tests.log: a_follower_behind_the_retained_log_resyncs ... ok; a_partitioned_follower_notices_the_silence_and_catches_up_after_the_heal ... ok
Adversarial: the primary drops a follower that has neither read nor been heard from for the send timeout (at least 10 s) ⏸️ untested no No live result was established. The frozen-follower runs (r2-head-frozen-follower.log, r2-base-frozen-follower.log) exercised the condition, but the drop was not observable: the primary's status kept…
Evidence: Head: committed acceptance run (10M facts, commit every 5 ms) plus committed replication tests

resync of 10000000 facts under a commit every 5ms: 31.2 s, 3656 commits meanwhile follower status at the end: {"applied_lsn":4663,"lag_events":0,"primary_head_lsn":4663,"reconnects":0,"resync_failures":0,"resyncs":1,"state":"streaming"} test result: ok. 4 passed; 0 failed

   Compiling inputlayer v0.1.0 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M4768V684HF0KVV7GYF2AHAV)
    Finished `release` profile [optimized] target(s) in 3.89s
     Running tests/replication_tests.rs (target/release/deps/replication_tests-0b7edb58f0fe5025)

running 4 tests
test a_follower_behind_the_retained_log_resyncs ... ok
test a_partitioned_follower_notices_the_silence_and_catches_up_after_the_heal ... ok
test a_resync_under_steady_commits_completes_on_the_first_attempt ... ok
test large_resync_under_steady_commits ... resync of 10000000 facts under a commit every 5ms: 31.2 s, 3656 commits meanwhile
follower status: {"applied_lsn":1009,"lag_events":3649,"last_contact_ms":1,"primary_head_lsn":4658,"primary_revision":1007,"reconnects":0,"resync_failures":0,"resyncs":1,"state":"streaming","stream_id":"18b76e3af431ef21"}
follower status at the end: {"applied_lsn":4663,"lag_events":0,"last_contact_ms":33,"primary_head_lsn":4663,"primary_revision":4661,"reconnects":0,"resync_failures":0,"resyncs":1,"state":"streaming","stream_id":"18b76e3af431ef21"}
primary status at the end: {"followers":[{"acked_lsn":4663,"addr":"127.0.0.1","connected_ms":32210,"lag_events":0,"lag_ms":0,"name":"follower","resynced":true,"sent_lsn":4663}],"head_lsn":4663,"lag_events":0,"lag_ms":0,"resync_pin_overflows":0,"stream_id":"18b76e3af431ef21","sync":{"confirmed_lsn":4663,"degrades":0,"mode":"async","on_follower_loss":"block","rearms":0,"state":"async","sync_timeout_ms":5000,"unconfirmed":0,"wait_ms_total":0,"waits":0}}
ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 10 filtered out; finished in 205.56s


real	3m29.501s
user	0m23.167s
sys	0m1.881s
Evidence: Head: 10M resync under heavy commits (5,000 tuples per commit) completes on the first attempt

resync of 10000000 facts under a commit every 5ms: 42.4 s, 154 commits meanwhile follower status at the end: {"applied_lsn":1161,"lag_events":0,"primary_head_lsn":1161,"reconnects":0,"resync_failures":0,"resyncs":1,"state":"streaming"} test large_resync_under_steady_commits ... ok

    Finished `release` profile [optimized] target(s) in 0.20s
     Running tests/replication_tests.rs (target/release/deps/replication_tests-0b7edb58f0fe5025)

running 1 test
test large_resync_under_steady_commits has been running for over 60 seconds
resync of 10000000 facts under a commit every 5ms: 42.4 s, 154 commits meanwhile
follower status: {"applied_lsn":1006,"lag_events":136,"last_contact_ms":59,"primary_head_lsn":1142,"primary_revision":1004,"reconnects":0,"resync_failures":0,"resyncs":1,"state":"streaming","stream_id":"9e57dc44c5eb3e8c"}
follower status at the end: {"applied_lsn":1161,"lag_events":0,"last_contact_ms":37,"primary_head_lsn":1161,"primary_revision":1159,"reconnects":0,"resync_failures":0,"resyncs":1,"state":"streaming","stream_id":"9e57dc44c5eb3e8c"}
primary status at the end: {"followers":[{"acked_lsn":1161,"addr":"127.0.0.1","connected_ms":51941,"lag_events":0,"lag_ms":0,"name":"follower","resynced":true,"sent_lsn":1161}],"head_lsn":1161,"lag_events":0,"lag_ms":0,"resync_pin_overflows":0,"stream_id":"9e57dc44c5eb3e8c","sync":{"confirmed_lsn":1161,"degrades":0,"mode":"async","on_follower_loss":"block","rearms":0,"state":"async","sync_timeout_ms":5000,"unconfirmed":0,"wait_ms_total":0,"waits":0}}
test large_resync_under_steady_commits ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 185.32s


real	3m5.565s
user	0m19.584s
sys	0m0.735s
Evidence: Base (527bacc): the same heavy run drops the connection and restarts the resync

follower status at the end: {"applied_lsn":0,"last_error":"IO error: Connection reset by peer (os error 104)","reconnects":1,"resync_failures":1,"resyncs":2,"state":"resyncing"} test large_resync_under_steady_commits ... FAILED

    Finished `release` profile [optimized] target(s) in 0.21s
     Running tests/replication_tests.rs (target/release/deps/replication_tests-f5824e4510d7e326)

running 1 test
test large_resync_under_steady_commits has been running for over 60 seconds
resync of 10000000 facts under a commit every 5ms: 35.5 s, 622 commits meanwhile
follower status: {"applied_lsn":1006,"lag_events":175,"last_contact_ms":58,"primary_head_lsn":1181,"primary_revision":1004,"reconnects":0,"resync_failures":0,"resyncs":1,"state":"streaming","stream_id":"fb840865cf57eb04"}
follower status at the end: {"applied_lsn":0,"lag_events":0,"last_contact_ms":33295,"last_error":"IO error: Connection reset by peer (os error 104)","primary_head_lsn":0,"primary_revision":0,"reconnects":1,"resync_failures":1,"resyncs":2,"state":"resyncing","stream_id":"0000000000000000"}
primary status at the end: {"followers":[{"acked_lsn":0,"addr":"127.0.0.1","connected_ms":33327,"lag_events":1629,"lag_ms":58026,"name":"follower","resynced":true,"sent_lsn":1629}],"head_lsn":1629,"lag_events":623,"lag_ms":58026,"resync_pin_overflows":0,"stream_id":"fb840865cf57eb04","sync":{"confirmed_lsn":1006,"degrades":0,"mode":"async","on_follower_loss":"block","rearms":0,"state":"async","sync_timeout_ms":5000,"unconfirmed":0,"wait_ms_total":0,"waits":0}}

thread 'large_resync_under_steady_commits' (1511145) panicked at tests/replication_tests.rs:514:5:
assertion `left == right` failed: {"follower":{"applied_lsn":0,"lag_events":0,"last_contact_ms":33295,"last_error":"IO error: Connection reset by peer (os error 104)","primary_head_lsn":0,"primary_revision":0,"reconnects":1,"resync_failures":1,"resyncs":2,"state":"resyncing","stream_id":"0000000000000000"},"role":"follower"}
  left: Number(2)
 right: 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
test large_resync_under_steady_commits ... FAILED

failures:

failures:
    large_resync_under_steady_commits

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 14 filtered out; finished in 255.17s

error: test failed, to rerun pass `--test replication_tests`

real	4m15.421s
user	1m47.811s
sys	0m15.111s
Evidence: Head: follower frozen 25 s with SIGSTOP, then thawed

still listed in primary status after 25 s: [{"acked_lsn":5,"lag_events":184,"lag_ms":25070,"sent_lsn":18}] after thaw (26.0 s, caught up: true), follower: {"applied_lsn":190,"lag_events":0,"reconnects":1,"resync_failures":0,"resyncs":1,"state":"streaming"}

    Blocking waiting for file lock on package cache
   Compiling inputlayer v0.1.0 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M4768V684HF0KVV7GYF2AHAV)
    Finished `release` profile [optimized] target(s) in 5.71s
     Running tests/replication_tests.rs (target/release/deps/replication_tests-0b7edb58f0fe5025)

running 1 test
before freeze, primary: [{"acked_lsn":5,"addr":"127.0.0.1","connected_ms":134,"lag_events":0,"lag_ms":0,"name":"follower","resynced":true,"sent_lsn":5}]
still listed in primary status after 25 s: [{"acked_lsn":5,"addr":"127.0.0.1","connected_ms":25223,"lag_events":184,"lag_ms":25070,"name":"follower","resynced":true,"sent_lsn":18}]
primary dropped the frozen follower after 25.1 s (185 bulk commits)
after thaw (26.0 s, caught up: true), follower: {"applied_lsn":190,"lag_events":0,"last_contact_ms":52,"last_error":"no word from the primary for 800 ms","primary_head_lsn":190,"primary_revision":188,"reconnects":1,"resync_failures":0,"resyncs":1,"state":"streaming","stream_id":"4edd7f21f63b5c19"}
after thaw, primary: [{"acked_lsn":190,"addr":"127.0.0.1","connected_ms":26021,"lag_events":0,"lag_ms":0,"name":"follower","resynced":false,"sent_lsn":190}]
follower log: ^[[2m2026-10-06T00:04:51.126462Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::replication::follower^[[0m^[[2m:^[[0m replication_follower_disconnected ^[[3merror^[[0m^[[2m=^[[0mno word from the primary for 800 ms
follower log: ^[[2m2026-10-06T00:04:51.126462Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::replication::follower^[[0m^[[2m:^[[0m replication_follower_disconnected ^[[3merror^[[0m^[[2m=^[[0mno word from the primary for 800 ms
test nm_frozen_follower_is_dropped_then_recovers ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 57.68s
Evidence: Base: same frozen-follower run, for comparison
    Blocking waiting for file lock on package cache
   Compiling inputlayer v0.1.0 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M4768V684HF0KVV7GYF2AHAV/.nm-base)
    Finished `release` profile [optimized] target(s) in 5.71s
     Running tests/replication_tests.rs (target/release/deps/replication_tests-f5824e4510d7e326)

running 1 test
before freeze, primary: [{"acked_lsn":5,"addr":"127.0.0.1","connected_ms":136,"lag_events":0,"lag_ms":0,"name":"follower","resynced":true,"sent_lsn":5}]
still listed in primary status after 25 s: [{"acked_lsn":5,"addr":"127.0.0.1","connected_ms":25161,"lag_events":185,"lag_ms":25006,"name":"follower","resynced":true,"sent_lsn":18}]
primary dropped the frozen follower after 25.0 s (186 bulk commits)
after thaw (23.0 s, caught up: true), follower: {"applied_lsn":191,"lag_events":0,"last_contact_ms":55,"last_error":"IO error: Connection reset by peer (os error 104)","primary_head_lsn":191,"primary_revision":189,"reconnects":2,"resync_failures":0,"resyncs":1,"state":"streaming","stream_id":"9360ddf29e8599b4"}
after thaw, primary: [{"acked_lsn":191,"addr":"127.0.0.1","connected_ms":2892,"lag_events":0,"lag_ms":0,"name":"follower","resynced":false,"sent_lsn":191}]
follower log: ^[[2m2026-10-06T00:04:54.489169Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::replication::follower^[[0m^[[2m:^[[0m replication_follower_disconnected ^[[3merror^[[0m^[[2m=^[[0mno word from the primary for 800 ms
follower log: ^[[2m2026-10-06T00:05:14.661285Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::replication::follower^[[0m^[[2m:^[[0m replication_follower_disconnected ^[[3merror^[[0m^[[2m=^[[0mIO error: Connection reset by peer (os error 104)
follower log: ^[[2m2026-10-06T00:04:54.489169Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::replication::follower^[[0m^[[2m:^[[0m replication_follower_disconnected ^[[3merror^[[0m^[[2m=^[[0mno word from the primary for 800 ms
follower log: ^[[2m2026-10-06T00:05:14.661285Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::replication::follower^[[0m^[[2m:^[[0m replication_follower_disconnected ^[[3merror^[[0m^[[2m=^[[0mIO error: Connection reset by peer (os error 104)
test nm_frozen_follower_is_dropped_then_recovers ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 58.08s
Evidence: Temporary test-harness patch used for the heavy-commit and frozen-follower runs (reverted, not part of the change)
diff --git a/tests/replication_tests.rs b/tests/replication_tests.rs
index 5b5065af..1f0d3721 100644
--- a/tests/replication_tests.rs
+++ b/tests/replication_tests.rs
@@ -449,7 +449,7 @@ async fn resync_under_commits(
     within: Duration,
 ) -> (Engine, Engine, Value, Duration, u64) {
     const CHUNK: u64 = 10_000;
-    let primary = primary(None).await;
+    let primary = primary(Some(env_or("LAB_RETAIN_BYTES", 64 << 20) as usize)).await;
     let mut client = WsClient::connect(&primary, KG).await.expect("connect");
     for start in (0..facts).step_by(CHUNK as usize) {
         let tuples: Vec<String> = (start..facts.min(start + CHUNK))
@@ -469,10 +469,16 @@ async fn resync_under_commits(
         tokio::spawn(async move {
             let mut commits = 0u64;
             while !stop.load(Ordering::SeqCst) {
-                client
-                    .commit(&format!("+steady({commits})"))
-                    .await
-                    .expect("steady commit");
+                let width = env_or("LAB_COMMIT_TUPLES", 0);
+                let program = if width == 0 {
+                    format!("+steady({commits})")
+                } else {
+                    let tuples: Vec<String> = (0..width)
+                        .map(|j| format!("({commits}, {j})"))
+                        .collect();
+                    format!("+steady({commits})\n+bulk[{}]", tuples.join(", "))
+                };
+                client.commit(&program).await.expect("steady commit");
                 commits += 1;
                 tokio::time::sleep(every).await;
             }
@@ -1061,3 +1067,75 @@ async fn large_resync_under_steady_commits() {
     );
     assert_first_attempt(&end);
 }
+
+#[tokio::test(flavor = "multi_thread")]
+#[ignore = "nm temporary"]
+async fn nm_frozen_follower_is_dropped_then_recovers() {
+    let primary = primary(Some(8usize << 30)).await;
+    let follower = follower(primary.http_url(), TOKEN, primary.api_key()).await;
+    commit(&primary, "+edge(1, 2)").await;
+    caught_up(&follower).await;
+    println!("before freeze, primary: {}", status_of(&primary).await["primary"]["followers"]);
+
+    let pid = follower.pid().expect("pid").to_string();
+    assert!(std::process::Command::new("kill").args(["-STOP", &pid]).status().unwrap().success());
+    let frozen = std::time::Instant::now();
+
+    let stop = Arc::new(AtomicBool::new(false));
+    let writer = {
+        let stop = Arc::clone(&stop);
+        let mut client = WsClient::connect(&primary, KG).await.expect("connect");
+        tokio::spawn(async move {
+            let mut commits = 0u64;
+            while !stop.load(Ordering::SeqCst) {
+                let tuples: Vec<String> = (0..5000).map(|j| format!("({commits}, {j})")).collect();
+                client.commit(&format!("+bulk[{}]", tuples.join(", "))).await.expect("bulk");
+                commits += 1;
+                tokio::time::sleep(Duration::from_millis(5)).await;
+            }
+            client.close().await;
+            commits
+        })
+    };
+    let dropped_after = loop {
+        let s = status_of(&primary).await;
+        if s["primary"]["followers"].as_array().map_or(true, |f| f.is_empty()) {
+            break frozen.elapsed();
+        }
+        if frozen.elapsed() > Duration::from_secs(25) { println!("still listed in primary status after 25 s: {}", s["primary"]["followers"]); break frozen.elapsed(); }
+        tokio::time::sleep(Duration::from_millis(100)).await;
+    };
+    stop.store(true, Ordering::SeqCst);
+    let commits = writer.await.unwrap();
+    println!("primary dropped the frozen follower after {:.1} s ({commits} bulk commits)", dropped_after.as_secs_f64());
+    for line in std::fs::read_to_string(primary.log_path()).unwrap().lines() {
+        if line.contains("replication") { println!("primary log: {line}"); }
+    }
+    assert!(dropped_after >= Duration::from_secs(9), "{dropped_after:?}");
+
+    assert!(std::process::Command::new("kill").args(["-CONT", &pid]).status().unwrap().success());
+    let head = status_of(&primary).await["primary"]["head_lsn"].as_u64().unwrap();
+    let thawed = std::time::Instant::now();
+    let mut caught = false;
+    while thawed.elapsed() < Duration::from_secs(150) {
+        if let Some(s) = status(&follower).await {
+            if s["follower"]["state"] == "streaming" && s["follower"]["applied_lsn"].as_u64().unwrap_or(0) >= head { caught = true; break; }
+        }
+        tokio::time::sleep(Duration::from_millis(200)).await;
+    }
+    println!("after thaw ({:.1} s, caught up: {caught}), follower: {}", thawed.elapsed().as_secs_f64(), status_of(&follower).await["follower"]);
+    println!("after thaw, primary: {}", status_of(&primary).await["primary"]["followers"]);
+    for line in std::fs::read_to_string(primary.log_path()).unwrap().lines() {
+        if line.contains("replication") { println!("primary log (end): {line}"); }
+    }
+    for line in std::fs::read_to_string(follower.log_path()).unwrap().lines() {
+        if line.contains("replication") { println!("follower log: {line}"); }
+    }
+    assert!(caught, "never recovered");
+    for c in [0, commits - 1] {
+        assert_eq!(converge(&primary, &follower, &format!("?bulk({c}, Y)")).await.len(), 5000);
+    }
+    for line in std::fs::read_to_string(follower.log_path()).unwrap().lines() {
+        if line.contains("replication") { println!("follower log: {line}"); }
+    }
+}
- Outcome: 🔧 2 issues found → no changes applied ✅ across 2 runs (39m8s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ tests/replication_tests.rs:1029 - The intent's acceptance run ("resync of a graph 10x the measured 1M-fact case under steady commits completes on the first attempt") exists only as the #[ignore]d lab test large_resync_under_steady_commits, so CI never runs it and the source holds no record of its result. The CI-run version (a_resync_under_steady_commits_completes_on_the_first_attempt, line 515) uses 60,000 facts, which applies well inside the old 10 s send timeout and would also pass without the fix. The mechanism itself is covered by the paused-time tests in src/protocol/replication/follower.rs (a follower busy for 6x the send timeout keeps the stream and loses nothing). No code change needed; whoever merges should confirm the 10M lab run was done on the perf host.
  • ℹ️ src/protocol/replication/primary.rs:387 - Tradeoff, as designed: the primary now keeps a blocked send waiting for as long as the follower pings, and the follower's ping task (write, follower.rs:559) runs independently of apply. A follower whose apply is stuck forever is therefore never dropped by the primary, where before it was dropped after max(timeout_ms, 10 s). The cost is bounded: the resync pin is capped (pin_cap), sync-mode writes are still bounded by sync_timeout_ms, and a dead or partitioned follower stops pinging and is dropped after the send timeout. The follower-side ping cadence is the follower's own heartbeat_ms, so a follower configured with heartbeat_ms above the primary's send timeout would still be dropped while busy; the default (500 ms against 10 s) is safe.
🔧 **Test** - 2 issues found → no changes applied ✅
  • ⚠️ The Test agent did not finish within its invocation budget. Reported: agent run tests timed out after 30m0s: agent last produced output 643ms ago (87 observed); agent reported: claude parse events: context deadline exceeded. This is a budget or provider-slowness cut, not a code failure. Re-running the same request costs another full budget, so no further attempt is made automatically. If this repository's targeted tests or evidence gathering routinely approach the default 30m0s, raise test_agent_timeout in global config. Respond with fix to spend another budget: a repair turn runs only for selected findings other than this budget cut, then validation re-runs. Or abort and retry after raising the budget.
  • 🚨 Approval is refused: the run worktree at ~/.no-mistakes/worktrees/9eaf1dfa27af/01M4768V684HF0KVV7GYF2AHAV holds work no Test turn validated, and the steps after Test would commit and publish it. It holds uncommitted changes to tests/replication_tests.rs, .nm-base/ (inspect with git -C ~/.no-mistakes/worktrees/9eaf1dfa27af/01M4768V684HF0KVV7GYF2AHAV status and git -C ~/.no-mistakes/worktrees/9eaf1dfa27af/01M4768V684HF0KVV7GYF2AHAV diff). Respond with fix to validate it, or abort.

🔧 No changes applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A fresh follower resyncs a 10,000,000-fact graph (10x the 1M case) while the primary commits every 5 ms, and finishes on the first attempt with all data converged ✅ pass live r2-head-committed-tests.log: resync took 31.2 s with 3656 commits meanwhile; follower status at the end shows resyncs 1, resync_failures 0, reconnects 0, no last_error, lag_events 0
Same 10M-fact resync while each steady commit also carries 5,000 tuples, so the primary's send really blocks during the apply: head finishes on the first attempt ✅ pass live r2-head-10M-heavy-lab.log: resync took 42.4 s; follower status at the end shows resyncs 1, resync_failures 0, reconnects 0, lag_events 0
The reported failure reproduces on the base commit under the same heavy-commit 10M resync (connection dropped, resync starts over) ✅ pass live r2-base-10M-heavy-lab.log: follower status at the end shows last_error 'Connection reset by peer', reconnects 1, resync_failures 1, resyncs 2, state resyncing
A small resync under steady commits completes on the first attempt and both relations converge (the committed CI test) ✅ pass live r2-head-committed-tests.log: a_resync_under_steady_commits_completes_on_the_first_attempt ... ok
Adversarial: a follower frozen with SIGSTOP for 25 s under bulk commits recovers after thaw without a second resync and converges with the primary ✅ pass live r2-head-frozen-follower.log: after thaw the follower caught up in 26 s with reconnects 1, resync_failures 0, resyncs 1, lag_events 0; bulk rows converged. r2-base-frozen-follower.log shows base also r…
Existing stream-loss paths still work: a follower behind the retained log resyncs, and a partitioned follower notices the silence and catches up after the heal ✅ pass live r2-head-committed-tests.log: a_follower_behind_the_retained_log_resyncs ... ok; a_partitioned_follower_notices_the_silence_and_catches_up_after_the_heal ... ok
Adversarial: the primary drops a follower that has neither read nor been heard from for the send timeout (at least 10 s) ⏸️ untested no No live result was established. The frozen-follower runs (r2-head-frozen-follower.log, r2-base-frozen-follower.log) exercised the condition, but the drop was not observable: the primary's status kept…
  • cargo test --release --test replication_tests -- --include-ignored --nocapture --test-threads=1 a_resync_under_steady_commits_completes_on_the_first_attempt large_resync_under_steady_commits a_follower_behind_the_retained_log_resyncs a_partitioned_follower on head with the committed test file (4 passed)
  • LAB_COMMIT_TUPLES=5000 cargo test --release --test replication_tests large_resync -- --ignored --nocapture on head with the temporary knob (passed, first attempt)
  • The same LAB_COMMIT_TUPLES=5000 run on a copy of base commit 527bacc3 (failed: connection reset, resync_failures 1, resyncs 2)
  • INPUTLAYER_TRACE_LEVEL=info cargo test --release --test replication_tests nm_frozen -- --ignored --nocapture (temporary SIGSTOP/SIGCONT follower test) on head and on base
  • Reverted tests/replication_tests.rs to the committed version, removed .nm-base/, and confirmed git status --short is empty
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Acceptance run (benchmark host)

Resync of a graph 10x the 1M-fact case under steady commits, on the benchmark host under its shared lock (32 cores, release build, primary and follower on the one host). Measured on d343eb7c, this change before the pipeline rebased it onto main; no before/after pair against the old code was run.

LAB_FACTS=10000000 cargo test --release --test replication_tests large_resync_under_steady_commits -- --ignored --nocapture
Graph 10,000,000 facts in one graph
Writes during the resync one commit every 5 ms; 3,408 commits
Resync (follower start to streaming) 28.3 s
Attempts first: resyncs 1, resync_failures 0, reconnects 0
Primary resync_pin_overflows 0
Afterwards follower at lag 0 (LSN 4413 of 4413), spot-checked facts and every steady commit present

Perf gate on the same host and commit (make perf-gate-remote): PASS (policy status provisional).

…a large graph (#356)

The follower read the socket and applied on one task, so applying one
graph from a checkpoint stopped it reading; the primary's send timed out
and the resync started over.

Reading and applying now run apart. A reader task fills a buffer of at
most 64 MiB; when it is full the reader waits and the primary's send
blocks, so nothing is dropped. The follower pings every heartbeat_ms it
has nothing to acknowledge, and the primary keeps a blocked send waiting
while it hears from the follower: it drops one only when it has neither
read nor been heard from for the send timeout. The primary also sends
heartbeats while it captures a checkpoint.
@jsam
jsam merged commit 2b3620c into main Oct 6, 2026
25 checks passed
@jsam
jsam deleted the fm/il-356-follower-reads branch October 6, 2026 00:24
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