feat(ha-term-safety): retire term-0 repl_broadcast live send; carry leader term on replication (task 3.4)

Closes bug-990: the live replicator repl_broadcast wire-encoded term=0
(entry.term was never set), so standbys persisted hdr.term=0 and took the
legacy direct-apply path instead of the library (term,lsn) intake. Now the
live broadcast and catch-up streamer carry the leader's real term + AE prev
tuple; ha_apply_wal_catchup_entry routes every term-carrying entry through
raft_on_append_entries_request -> on_persist_entry (leader term stamped),
retiring the term-0 direct-apply. Standbys now persist the leader's real term
and reconcile by (term,lsn); the live commit chain is end-to-end.

848/849/850 + persist-before-ACK + commit-barrier + bug-960 preserved.
Proven at integration level; the definitive live STRICT_TERM failover run is
env-blocked here (bug-924 overlayfs) and recommended on a real fs.
This commit is contained in:
Rongsong Shen 2026-06-22 15:45:14 +00:00
parent 87242aa8e9
commit efe621c124
11 changed files with 307 additions and 45 deletions

View file

@ -440,6 +440,24 @@ static void record_entry_term(persist_t* p, uint64_t lsn, uint64_t term)
p->term_index_count = lsn;
}
/* entry_term_locked: the lock-free core of persist_entry_term — read the
* recorded Raft term for `lsn` from the in-memory term index, applying the
* legacy-0 rule (R1.8: a present term-0 entry maps to WAL_LEGACY_TERM; an absent
* LSN returns 0). Caller MUST hold p->lock. Used by the append path
* (persist_wal_async) to source the AppendEntries prev_log_term while already
* under the lock — calling persist_entry_term there would re-acquire p->lock
* and deadlock. */
static uint64_t entry_term_locked(const persist_t* p, uint64_t lsn)
{
if (!p || lsn == 0)
return 0;
if (lsn <= p->term_index_count && p->term_index != NULL) {
uint64_t raw = p->term_index[lsn - 1];
return (raw == 0) ? WAL_LEGACY_TERM : raw;
}
return 0;
}
/* Shrink the in-memory per-LSN term index so that every LSN > `keep_lsn` reads
* back as absent (ha-term-safety task 2.2). Called from the truncate path after
* the on-disk entries above keep_lsn are removed, keeping the term index
@ -477,15 +495,11 @@ uint64_t persist_entry_term(const persist_t* p, uint64_t lsn)
persist_t* mp = (persist_t*)p;
pthread_mutex_lock(&mp->lock);
uint64_t result = 0;
if (lsn <= mp->term_index_count && mp->term_index != NULL) {
uint64_t raw = mp->term_index[lsn - 1];
/* Legacy-zero rule (R1.8): a present entry carrying term 0 maps to the
* single lowest-known real Raft term, applied uniformly so two legacy-0
* entries at the same LSN match. Absent LSNs are caught by the bound
* above and return 0. */
result = (raw == 0) ? WAL_LEGACY_TERM : raw;
}
/* Legacy-zero rule (R1.8) lives in entry_term_locked: a present entry
* carrying term 0 maps to the single lowest-known real Raft term, applied
* uniformly so two legacy-0 entries at the same LSN match; absent LSNs
* return 0. */
uint64_t result = entry_term_locked(mp, lsn);
pthread_mutex_unlock(&mp->lock);
return result;
@ -744,7 +758,13 @@ int persist_wal_async(persist_t* p, uint32_t type,
* Note: WAL_CHECKPOINT is blocked by the assert at function entry, so
* every entry that reaches here is a replicable entry. */
p->last_replicated_crc = hdr.crc;
repl_broadcast(&p->repl, &hdr, payload, len);
/* ha-term-safety task 3.4: source the AppendEntries prev_log_term (term of
* the entry at hdr.lsn-1) under p->lock via the lock-free accessor, so the
* live broadcast carries the leader's term + prev tuple and the standby
* routes it through the library (raft_on_append_entries_request). 0 when
* hdr.lsn == 1 (no predecessor). */
uint64_t prev_log_term = entry_term_locked(p, hdr.lsn - 1);
repl_broadcast(&p->repl, &hdr, payload, len, prev_log_term);
/* Rotate segment when full */
if (p->seg_offset >= WAL_SEGMENT_SIZE_BYTES) {