feat(ha-repl-hardening): per-standby convergence tracking from heartbeats (task 3.1)

ha_process_standby_state stores (tag, lsn) per standby, evaluates
convergence (tag>=committed && lsn>=committed), sets converged flag,
logs INFO only on transition. Wired into repl_ack_decode_and_dispatch.
5 tests: converged, lsn-behind, tag-behind, regression, multi-peer.
This commit is contained in:
Rongsong Shen 2026-06-04 16:40:44 +00:00
parent 7dc657b1bd
commit aab41fa609
4 changed files with 267 additions and 6 deletions

View file

@ -934,6 +934,46 @@ void ha_nop_reply_state_report(ha_state_t *ha, myconf_t *myconf,
reply_out->data.state_report = sr;
}
/*
* ha_process_standby_state — update per-standby convergence tracking.
*
* Called on each heartbeat reply from a standby (CTRL_PRIMARY_COMMITTING or
* CTRL_PRIMARY). Stores (reported_tag, reported_lsn) into the per-standby
* arrays, evaluates convergence, and logs INFO on the first
* not-converged → converged transition (Req 7.2). Regression resets the flag
* (Req 2.5).
*
* Convergence: reported_tag >= committed_tag AND reported_lsn >= committed_lsn.
*
* Requirements: 1.2, 2.2, 2.5 (ha-repl-hardening spec)
*/
void ha_process_standby_state(ha_state_t *ha, int peer_idx,
uint64_t reported_tag, uint64_t reported_lsn,
uint64_t committed_tag, uint64_t committed_lsn)
{
if (peer_idx < 0 || peer_idx >= MAX_HA_CANDIDATES)
return;
/* Store reported values */
ha->standby_reported_tag[peer_idx] = reported_tag;
ha->standby_reported_lsn[peer_idx] = reported_lsn;
/* Evaluate convergence */
bool was_converged = ha->standby_converged[peer_idx];
bool now_converged = (reported_tag >= committed_tag) &&
(reported_lsn >= committed_lsn);
ha->standby_converged[peer_idx] = now_converged;
/* Log INFO on first transition to converged (Req 7.2) */
if (now_converged && !was_converged) {
INFO("HA: standby[%d] converged: config_tag=%" PRIu64
" applied_lsn=%" PRIu64 " (committed_tag=%" PRIu64
" committed_lsn=%" PRIu64 ")",
peer_idx, reported_tag, reported_lsn,
committed_tag, committed_lsn);
}
}
/* ── Dispatch gate (req 1.3, 1.4) ──────────────────────────────────────────
*
* Called from myreq_handler() in cluster.c before the opcode switch.
@ -1961,8 +2001,11 @@ done:
/*
* repl_ack_decode_and_dispatch: decode an inbound packet on a promoted vote
* endpoint while role == CTRL_PRIMARY_COMMITTING or CTRL_PRIMARY.
* Expects OPCode_replCatchUpDone; calls ha_repl_ack_received on success.
* Drops unexpected opcodes with a WARN log.
*
* - OPCode_replCatchUpDone → ha_repl_ack_received
* - OPCode_reply with stateReportMsg → ha_process_standby_state (Task 3.1)
* - OPCode_nop or OPCode_reply without stateReport → silently ignored
* - anything else → WARN + drop
*/
static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
{
@ -1990,13 +2033,60 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
outer_rp.p = capn_getp(capn_root(&capn_ctx), 0, 1);
read_Request(&outer_rq, outer_rp);
int opcode = (int)outer_rq.opcode;
capn_free(&capn_ctx);
if (opcode == OPCode_replCatchUpDone) {
capn_free(&capn_ctx);
ha_repl_ack_received(ha, c);
} else if (opcode == OPCode_nop || opcode == OPCode_reply) {
/* Heartbeat NOP reply or generic reply — silently drop */
} else if (opcode == OPCode_reply &&
outer_rq.body_which == Request_body_replymsg &&
outer_rq.body.replymsg.p.data) {
/* Heartbeat NOP reply — decode inner RequestReply for stateReport */
struct RequestReply rep_s;
memset(&rep_s, 0, sizeof(rep_s));
read_RequestReply(&rep_s, outer_rq.body.replymsg);
if (rep_s.data_which == RequestReply_data_stateReportMsg) {
/* Decode stateReport and call convergence tracker (Task 3.1).
* read_StateReport reads scalar fields directly from the capnp
* pointer without heap allocation — safe to call before capn_free. */
struct StateReport sr_raw;
memset(&sr_raw, 0, sizeof(sr_raw));
read_StateReport(&sr_raw, rep_s.data.stateReportMsg);
state_report_t sr;
sr.applied_lsn = (uint64_t)sr_raw.appliedLsn;
sr.config_tag = (uint64_t)sr_raw.configTag;
capn_free(&capn_ctx);
/* peer_idx from standby_ep_ctx_t (layout-compatible with vote_ep_ctx_t) */
int peer_idx = vctx->peer_idx;
uint64_t committed_lsn = 0;
uint64_t committed_tag = 0;
#ifndef XCE_TESTING
if (c) {
committed_lsn = atomic_load_explicit(&c->persist.committed_lsn,
memory_order_acquire);
committed_tag = atomic_load_explicit(&ha->committed_config_tag,
memory_order_acquire);
}
#else
/* In test mode, c may be NULL; caller sets ha fields directly */
committed_lsn = atomic_load_explicit(&ha->last_known_committed_lsn,
memory_order_acquire);
committed_tag = atomic_load_explicit(&ha->committed_config_tag,
memory_order_acquire);
(void)c;
#endif
ha_process_standby_state(ha, peer_idx,
sr.config_tag, sr.applied_lsn,
committed_tag, committed_lsn);
} else {
capn_free(&capn_ctx);
}
} else if (opcode == OPCode_nop) {
/* Plain NOP with no body — silently ignore */
capn_free(&capn_ctx);
} else {
capn_free(&capn_ctx);
WARN("repl_ack_decode_and_dispatch: unexpected opcode %d on promoted ep",
opcode);
}