fix(ha): close permanent quorum-loss deadlock on log-conflicted standby (bug-1479)
Chaotic election churn (config-upgrade-snapshot task 8.6's force_elect_node rotation) can leave a standby log-conflicted after a rejoin. Fixing this needed three compounding layers, each unit-green but still failing the identical live scenario until the next layer landed: 1. ha_raft_on_repl_nack (ha.c/ha.h): routes a WAL-ACK NACK into the raft library's nextIndex backoff and immediately repl_start_catchup's the standby at the corrected position, instead of the 16-strike silent eviction with no catch-up driver after it. Correct from the start, but live soak still failed identically: nothing ever produced the NACK this code was waiting for. 2. Standby-side NACK sending: ha_apply_term_entry_via_library's logOk-reject path now calls a new ha_send_wal_ack_reply_ex(..., success=0) instead of silently dropping the reject. ha_send_wal_ack_reply had hardcoded success=1 unconditionally -- the only WalAckReply sender in the controller -- making (1)'s NACK-routing correct but unreachable dead code. New ha_wal_ack_send_hook (XCE_TESTING) lets a unit test observe the NACK without a live endpoint. Live-confirmed the NACK now reaches the primary -- but the scenario still failed for a third reason. 3. ha_compute_repl_stream_ceiling_lsn (ha.c/ha.h): ha_stream_wal_to_standby's catch-up range was hard-bounded at committed_lsn, which can never advance in this exact scenario because advancing it requires an ACK from the very standby that's stuck catching up (2-of-3 alive, this standby is the only path to a new majority) -- a self-referential deadlock. Widens the STREAM_WAL ceiling (and the convergence check gating it) to the leader's own log tail, matching raft.tla's real AppendEntries semantics (leaderCommit is advisory, never a send-cap), while reusing bug-1478's exact guard so this doesn't reopen that trap (never widen onto a trailing local-only checkpoint's own unstreamable LSN). Verification: controller_unit_test 1053/1053 PASS, clean twice, including new regression tests for all three layers (test_config_repl.c, test_term_safety_adapter.c, test_ha_role.c), each RED-confirmed against its pre-fix behavior before the real fix was restored. Three successive live run-ha-soak.sh --config-upgrade re-runs tracked each layer landing; the final run passed end-to-end -- all 3 force_elect_node rounds succeeded (ctrl-2: 3/3 jobs acked term=6; ctrl-3: 3/3 jobs acked term=7, never previously reached), zero materialized-state divergence, zero job loss. Unblocks config-upgrade-snapshot task 8.6. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
aecd8766f4
commit
34ab78d72d
14 changed files with 1256 additions and 377 deletions
|
|
@ -3081,19 +3081,36 @@ static int ha_apply_single_entry(persist_t *p, wal_catchup_entry_t *e)
|
|||
return 0;
|
||||
}
|
||||
|
||||
/* ha_send_wal_ack_reply: send WalAckReply {term, lsn, success=1} back to
|
||||
* the primary over ep. Skipped when ep or c is NULL (test/offline path).
|
||||
* Task 3.1 (ha-wal-replication): ACK each successfully applied WAL entry. */
|
||||
static void ha_send_wal_ack_reply(ha_state_t *ha, struct endpoint_t *ep,
|
||||
cluster_t *c, uint64_t lsn)
|
||||
#ifdef XCE_TESTING
|
||||
void (*ha_wal_ack_send_hook)(uint64_t term, uint64_t lsn, int success) = NULL;
|
||||
#endif
|
||||
|
||||
/* ha_send_wal_ack_reply_ex: send WalAckReply {term, lsn, success} back to
|
||||
* the primary over ep. Skipped when ep or c is NULL (test/offline path),
|
||||
* unless ha_wal_ack_send_hook is set (XCE_TESTING), in which case the hook is
|
||||
* called with the built {term, lsn, success} INSTEAD of sending over ep --
|
||||
* lets a unit test observe a NACK (success=0) without a live endpoint
|
||||
* (bug-1479). Task 3.1 (ha-wal-replication): ACK/NACK each applied/rejected
|
||||
* WAL entry. */
|
||||
static void ha_send_wal_ack_reply_ex(ha_state_t *ha, struct endpoint_t *ep,
|
||||
cluster_t *c, uint64_t lsn, int success)
|
||||
{
|
||||
uint64_t term = (uint64_t)atomic_load_explicit(&ha->term, memory_order_acquire);
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
if (ha_wal_ack_send_hook) {
|
||||
ha_wal_ack_send_hook(term, lsn, success);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!ep || !c)
|
||||
return;
|
||||
|
||||
wal_ack_reply_t ack;
|
||||
ack.term = (uint64_t)atomic_load_explicit(&ha->term, memory_order_acquire);
|
||||
ack.term = term;
|
||||
ack.lsn = lsn;
|
||||
ack.success = 1;
|
||||
ack.success = success ? 1 : 0;
|
||||
|
||||
xce_reply_t reply;
|
||||
memset(&reply, 0, sizeof(reply));
|
||||
|
|
@ -3105,6 +3122,14 @@ static void ha_send_wal_ack_reply(ha_state_t *ha, struct endpoint_t *ep,
|
|||
xce_reply(ep, c->myhost, &reply);
|
||||
}
|
||||
|
||||
/* ha_send_wal_ack_reply: send a successful WalAckReply {term, lsn,
|
||||
* success=1} -- the common case, every existing call site. */
|
||||
static void ha_send_wal_ack_reply(ha_state_t *ha, struct endpoint_t *ep,
|
||||
cluster_t *c, uint64_t lsn)
|
||||
{
|
||||
ha_send_wal_ack_reply_ex(ha, ep, c, lsn, /*success*/1);
|
||||
}
|
||||
|
||||
/* ha_apply_term_entry_via_library: route ONE term-carrying replicated entry
|
||||
* (a live broadcast or a catch-up sub-entry) through the raft library
|
||||
* (raft_on_append_entries_request via ha_consume_append_entries) so the LIBRARY
|
||||
|
|
@ -3227,8 +3252,18 @@ static int ha_apply_term_entry_via_library(ha_state_t *ha, persist_t *p,
|
|||
ha_send_wal_ack_reply(ha, ep, c, e->lsn);
|
||||
return 1;
|
||||
}
|
||||
/* logOk reject (e.g. forward gap before the prefix arrives): no ACK; the
|
||||
* ACK-driven catch-up continuation re-streams from current_lsn in order. */
|
||||
/* logOk reject (e.g. forward gap before the prefix arrives, or a genuine
|
||||
* (term,lsn) conflict): bug-1479 -- send a NACK (success=0) so the
|
||||
* primary's ha_raft_on_repl_nack can back off nextIndex and immediately
|
||||
* mark this standby for catch-up, matching raft.tla's AppendEntries
|
||||
* action (the receiver ALWAYS replies, success = logOk). Before this fix
|
||||
* NOTHING was sent on reject: the primary's NACK-handling branch
|
||||
* (repl_ack_decode_and_dispatch) was correct but unreachable, since no
|
||||
* production path ever produced the success=0 reply it was waiting for
|
||||
* -- the standby would loop rejecting the identical re-broadcast entry
|
||||
* forever, discovered live via the config-upgrade soak's force-elect
|
||||
* rotation. */
|
||||
ha_send_wal_ack_reply_ex(ha, ep, c, e->lsn, /*success*/0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -3843,6 +3878,20 @@ uint64_t ha_compute_repl_target_lsn(uint64_t committed_lsn,
|
|||
return committed_lsn;
|
||||
}
|
||||
|
||||
/* bug-1479 (gap 3): see full doc comment in ha.h. Distinct from
|
||||
* ha_compute_repl_target_lsn above (the CONVERGENCE target) -- this is the
|
||||
* STREAM ceiling: how far a leader may actually push WAL to a standby, which
|
||||
* must extend past committed_lsn or a standby whose own ack is required to
|
||||
* advance committed_lsn can never receive the entries it needs to ack. */
|
||||
uint64_t ha_compute_repl_stream_ceiling_lsn(uint64_t committed_lsn,
|
||||
uint64_t last_written,
|
||||
uint64_t checkpoint_lsn)
|
||||
{
|
||||
if (last_written == checkpoint_lsn && last_written > committed_lsn)
|
||||
last_written = committed_lsn;
|
||||
return (last_written > committed_lsn) ? last_written : committed_lsn;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────────────
|
||||
* raft-drive-async Task 3.2 — SnapshotSend slow-lane offload (bug-1198).
|
||||
*
|
||||
|
|
@ -4233,8 +4282,38 @@ int ha_dispatch_repl_action_for_standby(ha_state_t *ha,
|
|||
uint64_t standby_tag = ha->standby_reported_tag[peer_idx];
|
||||
uint64_t standby_lsn = ha->standby_reported_lsn[peer_idx];
|
||||
|
||||
/* bug-1479: a standby marked in_catch_up (via ha_raft_on_repl_nack's
|
||||
* immediate trigger on log-conflict rejection, or repl_broadcast's
|
||||
* MAX_PENDING_ACKS eviction) can never correct its OWN self-reported
|
||||
* standby_reported_lsn — it's stuck rejecting every AppendEntries, so it
|
||||
* never sends a stateReport reflecting genuine progress. Use the
|
||||
* corrected join_applied_lsn instead: this heartbeat-driven cycle (runs
|
||||
* every heartbeat_interval_ms regardless of ACK/reject outcome) becomes
|
||||
* the actual catch-up driver for this case — the ACK-driven continuation
|
||||
* (ha_walack_on_ack, config_repl.c) can only fire on a REAL ack, which a
|
||||
* log-conflicted standby can never produce until streamed from the
|
||||
* corrected position at least once. */
|
||||
if (c) {
|
||||
uint64_t join_lsn;
|
||||
if (repl_get_catchup_join_lsn(&c->persist.repl, ep, &join_lsn))
|
||||
standby_lsn = join_lsn;
|
||||
}
|
||||
|
||||
/* bug-1479 (gap 3): stream (and evaluate convergence against) the
|
||||
* leader's own log tail, not just committed_lsn -- see
|
||||
* ha_compute_repl_stream_ceiling_lsn's doc comment (ha.h) for why capping
|
||||
* at committed_lsn is a permanent deadlock whenever this exact standby's
|
||||
* own ack is required to advance committed_lsn in the first place. */
|
||||
uint64_t stream_ceiling_lsn = committed_lsn;
|
||||
if (c) {
|
||||
uint64_t last_written = (c->persist.current_lsn > 0)
|
||||
? c->persist.current_lsn - 1 : 0;
|
||||
stream_ceiling_lsn = ha_compute_repl_stream_ceiling_lsn(
|
||||
committed_lsn, last_written, c->persist.checkpoint_lsn);
|
||||
}
|
||||
|
||||
ha_repl_action_t action = ha_evaluate_repl_action(standby_tag, standby_lsn,
|
||||
committed_tag, committed_lsn,
|
||||
committed_tag, stream_ceiling_lsn,
|
||||
oldest_wal_lsn);
|
||||
|
||||
switch (action) {
|
||||
|
|
@ -4283,16 +4362,19 @@ int ha_dispatch_repl_action_for_standby(ha_state_t *ha,
|
|||
case HA_REPL_STREAM_WAL:
|
||||
/* Task 3.4: batch WAL streaming.
|
||||
* Enqueues all batch OPCode_walCatchupEntry messages for the range
|
||||
* [standby_lsn+1, committed_lsn] via ha_stream_wal_to_standby().
|
||||
* [standby_lsn+1, stream_ceiling_lsn] via ha_stream_wal_to_standby()
|
||||
* (bug-1479 gap 3: the ceiling extends past committed_lsn to the
|
||||
* leader's own log tail — see ha_compute_repl_stream_ceiling_lsn).
|
||||
* All xce_request() calls are non-blocking (enqueue-only). */
|
||||
INFO("HA repl: standby[%d] lsn=%" PRIu64
|
||||
" < committed_lsn=%" PRIu64 " → stream WAL batches",
|
||||
peer_idx, standby_lsn, committed_lsn);
|
||||
" < stream_ceiling_lsn=%" PRIu64 " (committed_lsn=%" PRIu64
|
||||
") → stream WAL batches",
|
||||
peer_idx, standby_lsn, stream_ceiling_lsn, committed_lsn);
|
||||
#ifdef XCE_TESTING
|
||||
if (ha_repl_stream_wal_hook != NULL) {
|
||||
const char *wal_path = (c && c->persist.wal_path[0])
|
||||
? c->persist.wal_path : "";
|
||||
ha_repl_stream_wal_hook(ep, wal_path, standby_lsn, committed_lsn);
|
||||
ha_repl_stream_wal_hook(ep, wal_path, standby_lsn, stream_ceiling_lsn);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
|
|
@ -4311,7 +4393,7 @@ int ha_dispatch_repl_action_for_standby(ha_state_t *ha,
|
|||
uint64_t leader_term =
|
||||
atomic_load_explicit(&c->ha.term, memory_order_acquire);
|
||||
ha_stream_wal_to_standby(ep, c->persist.wal_path, &c->persist,
|
||||
leader_term, standby_lsn, committed_lsn);
|
||||
leader_term, standby_lsn, stream_ceiling_lsn);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -4820,6 +4902,38 @@ int ha_raft_on_repl_ack(cluster_t *c, int abs_peer_idx, uint64_t acked_lsn,
|
|||
return 0;
|
||||
}
|
||||
|
||||
int ha_raft_on_repl_nack(cluster_t *c, int abs_peer_idx, uint64_t reply_term,
|
||||
uint64_t *corrected_lsn_out)
|
||||
{
|
||||
if (!c)
|
||||
return -EINVAL;
|
||||
|
||||
/* Same defense-in-depth bounds/self check as ha_raft_on_repl_ack. */
|
||||
int self_idx = c->ha.my_priority;
|
||||
int n_peers = c->ha.raft.n_peers;
|
||||
if (abs_peer_idx < 0 || abs_peer_idx >= n_peers || abs_peer_idx == self_idx) {
|
||||
ERROR("ha_raft_on_repl_nack: rejecting out-of-range peer index %d "
|
||||
"(n_peers=%d self_idx=%d) — skipping library NACK accounting",
|
||||
abs_peer_idx, n_peers, self_idx);
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
/* A rejected AppendEntries reports a log conflict at the sent
|
||||
* prevLogIndex/prevLogTerm. match_index is unused on the failure branch
|
||||
* (raft.c: "failure: next_index[peer] = Max(next_index[peer]-1, 1). Does
|
||||
* NOT advance commit.") -- pass 0. The library applies the backoff only
|
||||
* when reply_term == s->term (stale-term drop), same as the ack path. */
|
||||
(void)raft_on_append_entries_reply(&c->ha.raft, abs_peer_idx, /*success*/0,
|
||||
/*match_index unused*/0, reply_term);
|
||||
|
||||
if (corrected_lsn_out) {
|
||||
uint64_t next = c->ha.raft.next_index[abs_peer_idx];
|
||||
*corrected_lsn_out = (next > 0) ? next - 1 : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* ha_arm_own_term_gate: append the own-term WAL_NOOP and arm the commit
|
||||
* engine's writable_lsn gate (ha-single-leader-role task 2.2, Req 1.4/2.1/2.2).
|
||||
|
|
@ -6638,8 +6752,36 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx,
|
|||
pthread_mutex_lock(&ha->raft_lock);
|
||||
(void)raft_on_higher_term_observed(&ha->raft, reply_term);
|
||||
pthread_mutex_unlock(&ha->raft_lock);
|
||||
|
||||
/* bug-1479: a same-or-lower-term NACK means an ordinary Raft
|
||||
* log conflict (ae_reject_logmismatch), NOT a stale-term
|
||||
* primary — the case above doesn't cover it. Before this fix
|
||||
* the reject was silently dropped: the library's own,
|
||||
* already-correct nextIndex backoff (raft_on_append_entries_
|
||||
* reply) was never invoked, so the primary kept re-
|
||||
* broadcasting NEW entries assuming agreement at an index
|
||||
* the standby had already rejected, forever — the standby
|
||||
* would eventually get silently evicted via
|
||||
* MAX_PENDING_ACKS, but nothing would ever stream it back
|
||||
* into sync (repl_start_catchup's own driver, the ACK-driven
|
||||
* continuation, needs a real ACK to fire, and a log-
|
||||
* conflicted standby can never send one while stuck).
|
||||
* Immediately (not after 16 silent failures) back off via
|
||||
* the library and mark catch-up at the corrected position —
|
||||
* ha_dispatch_repl_action_for_standby's heartbeat-driven
|
||||
* decision (below) is what actually streams to it. */
|
||||
if (c) {
|
||||
int ack_abs_idx = ha_repl_ack_abs_peer_idx(
|
||||
&c->ha, /*ep_is_vote*/ vctx->ctx != NULL, vctx->peer_idx);
|
||||
uint64_t corrected_lsn = 0;
|
||||
if (ha_raft_on_repl_nack(c, ack_abs_idx, reply_term,
|
||||
&corrected_lsn) == 0) {
|
||||
repl_start_catchup(&c->persist.repl, ep, corrected_lsn);
|
||||
}
|
||||
}
|
||||
WARN("repl_ack_decode_and_dispatch: WAL NACK from standby"
|
||||
" lsn=%lu success=0 — ignoring", (unsigned long)acked_lsn);
|
||||
" lsn=%lu success=0 — marking catch-up",
|
||||
(unsigned long)acked_lsn);
|
||||
}
|
||||
} else if (rep_s.data_which == RequestReply_data_replconfigpushreplymsg) {
|
||||
/* Config-push ACK from standby (ha-commit-barrier-rollout task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue