fix(ha-repl-hardening): snapshot fallback off-by-one — empty standby must stream, not snapshot

After reserve-lsn-0 (oldest retained WAL entry = lsn 1 on a fresh cluster), a
freshly-joined empty standby (applied_lsn=0) hit the snapshot-fallback branch
'standby_lsn < oldest_wal_lsn' (0 < 1) and was sent a non-existent snapshot
instead of streaming — so the own-term NOOP (lsn 1) never replicated, committed_lsn
stayed 0, the own-term gate (writable_lsn=1) stayed closed, and standbys kept
empty WALs. Live evidence:
  ha_dispatch_repl_action_for_standby: standby[0] lsn=0 < oldest_wal_lsn=1 -> requesting snapshot transfer

The snapshot fallback should fire only when the standby's NEXT-needed entry
(standby_lsn+1) has been GC'd below the oldest retained entry. Fix the boundary
to 'standby_lsn + 1 < oldest_wal_lsn' in ha_evaluate_repl_action (and the parallel
dead repl_needs_snapshot predicate). An empty standby (next-needed=1, oldest=1)
now STREAMs lsn 1 -> acks -> committed_lsn 0->1 -> own-term gate opens -> standbys
receive the WAL. Snapshot still fires for genuinely GC'd-behind standbys
(standby_lsn+1 < oldest). No underflow (oldest=0 -> 1<0 false). Pre-reserve-lsn-0
this off-by-one was masked by oldest_wal_lsn=0 (0<0 false).

TDD: test_empty_standby_fresh_cluster_streams_not_snapshot (RED->GREEN);
snapshot + heartbeat pins strengthened to assert SEND_SNAPSHOT only for genuinely
GC'd standbys and STREAM_WAL at the empty/boundary case. Full controller suite
green under ASAN; commit engine / streamer / apply path / wire untouched.
This commit is contained in:
Rongsong Shen 2026-06-14 12:10:40 +00:00
parent 7519fa96dd
commit 007bf0c403
4 changed files with 120 additions and 29 deletions

View file

@ -190,11 +190,26 @@ static void test_needs_snapshot_boundary(void)
replicator_t r;
repl_init(&r);
/* Exact boundary: standby_lsn == oldest_wal_lsn — no snapshot needed */
/* fix-snapshot-boundary: snapshot fires iff the standby's NEXT-needed entry
* (standby_lsn + 1) is older than the oldest retained WAL entry — i.e.
* standby_lsn + 1 < oldest_wal_lsn (genuinely GC'd behind). */
/* At/above oldest: standby_lsn == oldest_wal_lsn — no snapshot. */
TEST_ASSERT_EQUAL_INT(0, repl_needs_snapshot(&r, 10, 10));
/* One behind: standby_lsn == oldest_wal_lsn - 1 — snapshot required */
TEST_ASSERT_EQUAL_INT(1, repl_needs_snapshot(&r, 9, 10));
/* Streamable edge: standby_lsn == oldest_wal_lsn - 1 — the next-needed entry
* (10) IS the oldest retained entry → streamable, NO snapshot.
* (9 + 1 = 10 < 10 is false.) */
TEST_ASSERT_EQUAL_INT(0, repl_needs_snapshot(&r, 9, 10));
/* Empty standby on a fresh cluster: next-needed entry (1) retained → no
* snapshot. (0 + 1 = 1 < 1 is false.) */
TEST_ASSERT_EQUAL_INT(0, repl_needs_snapshot(&r, 0, 1));
/* Genuinely GC'd behind: standby_lsn + 1 < oldest_wal_lsn — next-needed
* entry (9) was GC'd (oldest retained is 10) → snapshot required.
* (8 + 1 = 9 < 10 is true.) */
TEST_ASSERT_EQUAL_INT(1, repl_needs_snapshot(&r, 8, 10));
repl_fini(&r);
printf("test_needs_snapshot_boundary: PASS\n");