8.2 KiB
| id | status | status_updated | status_note | component | severity | timestamp | last_seen | occurrences | files | tags | related | fix_commits | gitea_issue | gitea_synced_status | gitea_synced_hash | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bug-1920 | fixed | 2026-10-04 | controller | hang | 2026-10-04T07:25:24Z | 2026-10-04T07:25:24Z | 1 |
|
|
|
|
340 | fixed | 92bfd940b0d5 |
bug-1920: new primary's WAL catch-up stalls after log-mismatch NACK; standby never advances, commits stuck
Description
Observed:
# new primary ctrl-2 (term 21), its only reachable standby ctrl-1 (ctrl-3 killed):
07:06:45,520 ctrl-2 HA repl: standby[0] lsn=323 < stream_ceiling_lsn=342 (committed_lsn=341) → stream WAL batches
07:06:45,522 ctrl-2 ha_stream_wal_to_standby: streamed WAL [323+1..342] to standby
07:06:45,520 ctrl-1 ha_consume_append_entries: leader_idx=-1 term=21 prevIdx=341 n=1 -> success=0 match=0 (ae_reject_logmismatch)
07:06:45,523 ctrl-2 repl_ack_decode_and_dispatch: WAL NACK from standby lsn=342 success=0 — marking catch-up
07:06:48,603 ctrl-2 HA repl: standby[0] lsn=340 < stream_ceiling_lsn=342 (committed_lsn=341) → stream WAL batches
07:06:48,603 ctrl-2 ha_stream_wal_to_standby: streamed WAL [340+1..342] to standby
07:06:48,602 ctrl-1 ha_consume_append_entries: leader_idx=-1 term=21 prevIdx=323 n=0 -> success=1 match=323 (nop heartbeat)
... same pair every 3 s for >60 s; ctrl-1 never logs another walCatchupEntry ...
07:07:47,044 ctrl-2 commit: WAIT deadline expired without commit (lsn=343 committed_lsn=341 writable_lsn=342 timeout_ms=500)
worker: xcecli failover: cluster not ready after 21 attempts (last status=-10013)
[xce-test-ha] FAIL: round 19: worker-resync job ...: worker never logged a fresh rejoin after the failover (pre=18 post=18)
After the term-21 election the standby's Raft log matched only to 323. The first catch-up stream reached ctrl-1 only as a single entry (prevIdx=341, n=1), which was rejected. The bug-1479 NACK backoff then set the standby's catch-up join position to 340 (one below the rejected index) instead of 323. From then on the primary "streams [341..342]" every heartbeat, yet ctrl-1 receives only nop heartbeats (no walCatchupEntry), so no further NACK arrives to walk the position back. ctrl-1 stays at 323, the 2-of-3 cluster cannot commit (committed_lsn stuck at 341), and every client request gets -10013 not ready.
Expected: the primary walks its catch-up position back to the standby's real match index (323) and streams [324..], so the standby catches up and commits resume, as bug-1479 intended.
Impact: after a failover, the new primary is unavailable (no commits, clients get not-ready) for as long as the only other live member stays stalled. Observed for >60 s, unresolved until the soak tore the cluster down.
Found by: soak (/xce-test-ha sequential 20 rounds, validating bug-1919, 2026-10-04)
Environment
- OS: no /etc/os-release; Docker container; NixOS kernel; kernel Linux 6.18.33 x86_64
- libc: glibc 2.42-47 (interpreter /nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/ld-linux-x86-64.so.2)
- Toolchain: gcc (GCC) 15.2.0, meson 1.9.1
- Build: asan (xce.build)
- Code: scheduler-framework @ acf78f9a-dirty
- Deployment: local 3-node HA (
/xce-test-hasequential), one xcecd_virtual soak worker + scheduler; working tree also carries the uncommitted bug-1915 and bug-1919 changes
Reproduction
XCE_HA_ROUNDS=20 nix develop ./src --command bash .claude/skills/xce-test-ha/run-ha-soak.sh 20 <scratch dir>.- Needs a failover where the surviving standby's log is far behind the new primary's (here match 323 vs leader tip 342) when the new primary registers it.
- Round fails "worker never logged a fresh rejoin"; ctrl-2.log repeats
standby[0] lsn=340 < stream_ceiling_lsn=342every 3 s with no walCatchupEntry arriving at ctrl-1.
- Frequency: 1/4 soak runs, 2026-10-04 (round 19 of run 3)
- Evidence: session scratchpad stall-ctrl-1.log, stall-ctrl-2.log; soak log soak-seq3.log (deploy dir ha-seq3)
Root cause
Three layers; the first is the defect, the other two let it turn into a stall:
- Pre-recovery WAL writes corrupt the segment layout. A restarted standby accepts the primary's connection during its startup delay, before
cluster_init_phase2/persist_recoverruns (bug-849's "phase2 before cluster_run accepts replication" does not hold: ctrl-2 got the primary hello at 07:05:47.806, phase2 ran at 07:05:48.18). In that window the primary streams catch-up from the advertisedapplied_lsn=0, andpersist_wal_write_replicatedwrote into the un-recoveredpersist_t(current_lsn=1, no open segment):open_new_segmentO_TRUNC'd the existingseg-0000000000000001.waland rewrote LSNs 1..234 into it.persist_recoverthen setcurrent_lsn=306but left that fd open as the append segment, so LSNs 306..348 were filed underseg-...0001. Found on disk: ctrl-2's seg-1 = 1..234 then 306..348; noseg-...0132. The bug-1416recovery_completegate already closed this window for apply, but not for WAL writes. - The catch-up streamer silently sends nothing.
ha_stream_wal_to_standbyorders segments by filename start LSN and skips any segment whose successor starts at or below the standby's position, so it skipped seg-1 and found no entry above 305. It still logged "streamed WAL [..] to standby" with zero entries sent. - The catch-up start position runs above the standby's log. After a log-mismatch NACK (prevIdx=341),
ha_raft_on_repl_nackset the join position to next_index-1 = 340, andha_dispatch_repl_action_for_standbylet it override the standby's own report (323). From 340, even a correct stream needs one rejected round per heartbeat to walk back 17 entries.
Scan of the four 2026-10-04 soaks: 3 nodes in 2 runs had a misfiled seg-1. It stalls only when that node later becomes primary and a standby needs catch-up from the misfiled range.
Fix
persist_wal_write_replicatedreturns-EBUSYwhile!persist_recovery_complete(p)(the bug-1416 gate that myapp closes before any network traffic and thatpersist_recoverreopens). This is the backstop for every replicated-write path.ha_apply_wal_catchup_entryandha_handle_snapshot_chunkdrop replication traffic silently before recovery: no persist, no ACK, no NACK (a NACK would walk the primary's next_index back past entries this node really holds). One INFO line per process.ha_dispatch_repl_action_for_standbystreams from min(join position, standby report): a lower join position (a bug-1479 conflicted suffix) still wins.ha_stream_wal_to_standbyWARNs "found NO entries" when a non-empty range yields nothing, and logs the entry count on success.
Verification
- Test:
test_replicated_write_refused_before_recovery(src/controller/testsuite/test_commit_durability.c: a pre-recovery replicated write leaves seg-1 intact and the first post-recovery append opens seg-),test_intake_dropped_before_recovery(test_term_safety_adapter.c: neither ACK nor NACK, nothing persisted),test_dispatch_streams_from_min_of_join_and_report(test_convergence_verification.c: streams from 323 rather than 340; a lower join position still wins). All three RED before the fix (write returned 0 / ACK sent / streamed from 340), GREEN after. - Suites:
xce-build.sh allasan, 127/127 meson suites OK, Go tests ok (2026-10-04). - Live: 4x
/xce-test-hasequential 20 rounds, 2026-10-04: 4/4 PASS. The pre-recovery drop fired 14 times (3-5 per run), no "found NO entries" warning, and a scan of every node's WAL found every segment's first LSN equal to its filename with no LSN jumps. Before the fix, the same scan found misfiled seg-1 on 3 nodes in 2 of 4 runs. Longer runs, 2026-10-04: sequential 50 rounds PASS, chaotic 50 rounds PASS (zero committed divergence), sequential 30 rounds under 40 CPU spinners on 32 cores PASS. The drop fired 7/10/5 times, there was no "found NO entries" warning, the WAL layout was clean on every node, and there were no ASAN reports.
UPDATE (2026-10-08): Permanent gate (2026-10-08, 5cf92335): the sequential, chaotic and truncated-fence soak modes now run wal-layout-check.py before PASS; it flags the pre-fix runs' misfiled seg-...0001 and passed a sequential 20-round soak (25 segments, 0 problems).
References
- Related: none