diff --git a/src/controller/cluster.c b/src/controller/cluster.c index 28db54bb..809954ca 100644 --- a/src/controller/cluster.c +++ b/src/controller/cluster.c @@ -508,12 +508,12 @@ static int myreq_handler(vtask_t* task) /* Distinguish the denial reason for operator diagnosis * (Req 7.2). raft_on_pre_vote_request denies on (a) stale term * — cand_term < raft.term — or (b) the shared eligibility gate - * (raft_cb_vote_eligible): PRIMARY-deny, fresh-heartbeat - * stickiness, or catch-up. We re-derive which fired WITHOUT - * calling the file-static heartbeat-fresh predicate: when the - * role is STANDBY and the node is neither catching up nor at a - * stale term, the only remaining eligibility denial is fresh - * heartbeat (a stale heartbeat would have passed the gate). */ + * (raft_cb_vote_eligible). Mirror that gate's exact predicate + * ORDER so the logged reason cannot disagree with the actual + * denial: stale-term, then PRIMARY, then fresh-heartbeat on a + * STANDBY, then catch-up. ha_heartbeat_is_fresh is a pure read + * exposed for this purpose, so the fresh-heartbeat-vs-catch-up + * overlap is reported exactly as the gate decided it. */ uint64_t my_raft_term = c->ha.raft.term; if (cand_term < my_raft_term) { deny_reason = "stale_term"; @@ -522,11 +522,12 @@ static int myreq_handler(vtask_t* task) &c->ha.role, memory_order_acquire); if (role == CTRL_PRIMARY) deny_reason = "primary"; + else if (role == CTRL_STANDBY && + ha_heartbeat_is_fresh(&c->ha)) + deny_reason = "fresh_heartbeat"; else if (atomic_load_explicit(&c->ha.in_catch_up, memory_order_acquire)) deny_reason = "catch_up"; - else if (role == CTRL_STANDBY) - deny_reason = "fresh_heartbeat"; else deny_reason = "eligibility"; } diff --git a/src/controller/ha.c b/src/controller/ha.c index 49f0d231..13e4cd04 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -87,7 +87,7 @@ static int vote_ep_err_handler(void *ep_raw); * PRIMARY registers the persist hooks and arms the own-term gate. */ static void ha_register_persist_hooks(cluster_t *c); static void ha_arm_own_term_gate(cluster_t *c); -static int ha_heartbeat_is_fresh(ha_state_t *ha); +int ha_heartbeat_is_fresh(ha_state_t *ha); /* exposed for PreVote denial-reason logging (cluster.c) */ static int primary_heartbeat_fn(void *arg); static void on_standby_connected(endpoint_t *ep); @@ -2093,7 +2093,7 @@ int ha_restore_term(ha_state_t *ha, const char *workdir) * Uses CLOCK_MONOTONIC seconds to avoid wall-clock skew; whole-second * granularity is sufficient for the 10 s default timeout. */ -static int ha_heartbeat_is_fresh(ha_state_t *ha) +int ha_heartbeat_is_fresh(ha_state_t *ha) { time_t last = ha->last_activity; /* time_t load is atomic on LP64 */ if (last == 0) diff --git a/src/controller/ha.h b/src/controller/ha.h index e751527c..0af6ecd0 100644 --- a/src/controller/ha.h +++ b/src/controller/ha.h @@ -763,6 +763,11 @@ void ha_discover_primary(ha_state_t *ha, cluster_t *c); /* ha_on_heartbeat_received: reset the heartbeat deadline (Req 6.3). */ void ha_on_heartbeat_received(ha_state_t *ha); +/* ha_heartbeat_is_fresh: 1 if a primary message arrived within + * heartbeat_timeout_ms (leader-stickiness predicate). Exposed for PreVote + * denial-reason logging in cluster.c; pure read, no state change. */ +int ha_heartbeat_is_fresh(ha_state_t *ha); + /* ha_disarm_heartbeat_timeout_vtask: stop the heartbeat watchdog thread. */ void ha_disarm_heartbeat_timeout_vtask(ha_state_t *ha);