feat(ha-single-leader-role): remove CTRL_PRIMARY_COMMITTING enum + remaining decls — compiler-enforced (task 5.2)

This commit is contained in:
Rongsong Shen 2026-06-13 14:41:42 +00:00
parent 7f974e766b
commit 93dfc02717
10 changed files with 44 additions and 55 deletions

View file

@ -59,7 +59,7 @@
- Observable completion: full controller suite green; `grep -rln "COMMITTING" src/controller/testsuite/` returns zero files
- _Requirements: 1.1, 1.2_
- [ ] 5.2 Remove the enum value and remaining declarations (compiler-enforced completeness)
- [x] 5.2 Remove the enum value and remaining declarations (compiler-enforced completeness)
- Delete `CTRL_PRIMARY_COMMITTING = 4` from `ctrl_role_t` (ha.h:64); delete `HA_DEFAULT_COMMITTING_TIMEOUT_MS` (ha.h:131), `committing_timeout_ms` (ha.h:141), `repl_ack_count` (ha.h:180), `committing_timeout_ctx` (ha.h:187); delete the `.committing_timeout_ms` initializer (cluster.c:899); sweep remaining ha.h comments (214-230, 244-262, 373, 503-519, 607-614, 673-700, 791), the cluster.c ctrlhello comment (1402-1410), and the raft.c comment (src/lib/raft/raft.c:123)
- Fix everything the compiler then surfaces — this is the completeness gate for code references
- Observable completion: clean build with zero warnings about the removed symbols; full controller testsuite + ASAN green via `/xce-build controller`
@ -87,6 +87,7 @@
- **(3.1) Legacy path routes through BARE helpers, not `ha_promote_to_primary`**: `ha_elect_evaluate_and_signal`'s PRIMARY branch calls `ha_register_persist_hooks` + `ha_start_primary_heartbeat` + `ha_arm_own_term_gate` directly (matching the raft path), NOT `ha_promote_to_primary` — because the latter calls `ha_do_promote` (term increment) and the legacy branch historically has NO term store/increment (research §4 finding 2). This is a deliberate deviation from design §283's loose wording ("route through ha_promote_to_primary / ha_register_persist_hooks"); the bare-helper route preserves term semantics and is the correct reading.
- **(4.1) No shims — DELETE, don't stub** (design §Transitional-State Deletions / §Design Synthesis: "rewritten or DELETED, not stubbed"). Machinery-subject test cases (convergence checker, committing watchdog, ack-driven promotion, ack counting) were DELETED, not shimmed: test_ha_role.c (5 majority_convergence + 2 committing_timeout_promotes_degraded), test_ha_rdhup_integration.c (rdhup_committing_to_primary_on_ack, rdhup_3node_one_ack_promotes), test_ha_connection_reuse.c (dispatcher_committing_handles_repl_ack, promoted_ep_repl_ack_promotes_to_primary). Kept state-entry cases had their deleted-fn CALLS excised (not shimmed). `test_ha_connection_reuse.c` was overlooked by the spec's §3 census disposition for machinery cases — confirmed in census (line 97) but its ack cases needed DELETE, not UPDATE.
- **(4.2) Residual stale COMMITTING comments in ha.c** (~lines 443, 826, 1240): documentary prose on the legacy raft_cb_become_leader/convergence path — NOT live branches. Scrub in 5.2/6.x (they fail the task-6.2 repo-wide grep). After 4.2, ZERO live code branches on `CTRL_PRIMARY_COMMITTING` in ha.c; the enum value persists in ha.h:64 until 5.2.
- **(5.2) Code is COMMITTING-free; only DOCS remain for task 6.1**: after 5.2 the grep `CTRL_PRIMARY_COMMITTING|HA_DEFAULT_COMMITTING_TIMEOUT_MS|committing_timeout_ms|repl_ack_count|committing_timeout_ctx` is ZERO across `src/controller/` + `src/lib/`. Remaining tree references (task 6.1 docs-sync targets, then 6.2 verifies): `src/docs/cluster-config-split-analysis.md:34` (committing_timeout_ms) AND `src/controller/CLAUDE.md` (TLA+/role-mapping table still names PRIMARY_COMMITTING — 6.1 must scrub ALL its COMMITTING refs, not just the :40 line) plus the steering/skill docs already listed in 6.1. ctrl_role_t now has 4 values: CTRL_PRIMARY=0, CTRL_STANDBY=1, CTRL_INIT=2, CTRL_ELECTING=3.
- **(5.1) Meson 300s timeout is now routinely hit** — barrier tests' commit-deadline waits push controller-suite wall-time over the 300s meson cap under load (`Fail: 0, Timeout: 1`). The AUTHORITATIVE green check is the DIRECT binary run (`src/xce.build/controller/controller_unit_test`, EXIT=0, 167 Tests/0 Failures aggregated). For task 6.2's "suite + ASAN green" gate: run the direct binary OR re-run meson until under-cap; a `Timeout: 1` is a wrapper artifact, NOT a test failure. `test_committing_timeout_default_is_60s` was DELETED in 5.1 (not 5.2) because its body used the uppercase production macro `HA_DEFAULT_COMMITTING_TIMEOUT_MS` (caught by the grep gate); the knob it tested dies in 5.2 regardless.
- **(4.2) Test-leak / suite-timeout facts**: the controller test target sets `ASAN_OPTIONS=detect_leaks=0` (controller/meson.build:163), so a pre-existing suite-wide LSan leak (~153KB, present at baseline 678b7f8, this spec leaks slightly LESS) is suppressed under the meson gate — LSan exit=1 only on a RAW direct-binary run, not a regression. The controller suite runs ~268s against a 300s meson timeout — borderline; the final task-6.2 full regression may TIMEOUT under machine load — re-run if so (it is not a failure).
- **(4.1) Residual for task 6.2 final grep**: 3 DOCUMENTARY PROSE comments in `test_ha_role_lifecycle.c` (~lines 760/815/825) still mention `ha_repl_ack_received`/`committing_timeout_fn` by name (no live code). The 4.1 gate (`src/controller/*.c|*.h`) excludes testsuite so this passed; the task-6.2 repo-wide grep WILL catch them — scrub these comments in 5.x/6.x. Also a non-tracked editor backup `src/controller/.ha.c.~undo-tree~` holds stale refs (irrelevant; not a build input — do not commit).

View file

@ -372,7 +372,7 @@ static int myreq_handler(vtask_t* task)
* raft_on_vote_request handles:
* - stale-term deny (term < current_term)
* - term upgrade (term > current_term → clear voted_for)
* - role gate: RAFT_LEADER denies; COMMITTING gate in on_vote_eligible
* - role gate: RAFT_LEADER denies (via on_vote_eligible)
* - one-vote-per-term invariant (voted_for != -1 && voted_for != from_idx)
* - on_persist before granting (durability guarantee)
*
@ -925,7 +925,6 @@ int cluster_init_phase1(cluster_t* c)
.max_promotion_retries = HA_DEFAULT_MAX_PROMOTION_RETRIES,
.election_jitter_max_ms = HA_DEFAULT_ELECTION_JITTER_MAX_MS,
.election_priority_step_ms = HA_DEFAULT_ELECTION_PRIORITY_STEP_MS,
.committing_timeout_ms = HA_DEFAULT_COMMITTING_TIMEOUT_MS,
};
int my_priority = 0;
@ -1464,8 +1463,8 @@ int cluster_run(cluster_t* c)
* This triggers handle_ctrlhello on the receiving standby, which runs the
* existing catch-up replication sequence (WAL streaming, config push).
*
* Must be called from the COMMITTING path in ha_elect_evaluate_and_signal
* after ha->role is set to CTRL_PRIMARY_COMMITTING (Req 7.2).
* Must be called from the post-election path in ha_elect_evaluate_and_signal
* after ha->role is set to CTRL_PRIMARY (Req 7.2).
*
* Thread safety: acquires ha->lock around standby_endpoints iteration.
* Best-effort: failures per-endpoint are logged but do not abort the loop.

View file

@ -437,13 +437,12 @@ void ha_compute_safe_committed_lsn(ha_elect_ctx_t *ctx,
}
/*
* raft_cb_become_leader: node won the election — enter COMMITTING state.
* raft_cb_become_leader: node won the election — enter CTRL_PRIMARY directly.
*
* Equivalent of ha_elect_evaluate_and_signal CTRL_PRIMARY path:
* - enter CTRL_PRIMARY_COMMITTING
* - enter CTRL_PRIMARY (no transitional state)
* - call cluster_init_phase2 on first boot
* - reset repl_ack_count, push config, send ctrlhello
* - arm committing timeout watchdog
* - push config, send ctrlhello
*
* ha->raft_c must be non-NULL (production path only; test mode uses noop stubs).
*/
@ -469,7 +468,7 @@ static void raft_cb_become_leader(void *ctx, uint64_t term)
uint64_t safe_lsn = 0;
uint64_t safe_tag = 0;
ha_compute_safe_committed_lsn(ectx, &safe_lsn, &safe_tag);
/* Store results for use in WAL truncation and COMMITTING phase.
/* Store results for use in WAL truncation and post-election commit.
* safe_lsn is the highest LSN on a majority; entries above it
* may not have been committed on all nodes and must be truncated. */
if (c) {
@ -486,7 +485,7 @@ static void raft_cb_become_leader(void *ctx, uint64_t term)
* Req 2.3 (ha-repl-hardening). */
atomic_store_explicit(&ha->last_known_committed_lsn, safe_lsn,
memory_order_release);
/* Update committed config tag baseline for the COMMITTING decision loop */
/* Update committed config tag baseline for the post-election decision loop */
atomic_store_explicit(&ha->committed_config_tag, safe_tag,
memory_order_release);
}
@ -520,7 +519,7 @@ static void raft_cb_become_leader(void *ctx, uint64_t term)
", first_boot=%d)", term, is_first_boot);
/* Req 2.1 (ha-repl-hardening): re-initialize per-standby convergence
* tracking for THIS committing round. Stale converged flags from a
* tracking for THIS primary term. Stale converged flags from a
* previous term (demote → re-elect cycle) must not satisfy the
* majority-convergence gate before standbys re-report their state. */
memset(ha->standby_reported_tag, 0, sizeof(ha->standby_reported_tag));
@ -823,7 +822,7 @@ int ha_init(ha_state_t *ha, const ha_config_t *cfg, int my_priority)
/* ha-repl-hardening Task 1.4: per-standby convergence tracking arrays.
* memset(ha, 0, sizeof(*ha)) above already zeroes these arrays; the explicit
* note documents the invariant: on entry to CTRL_PRIMARY_COMMITTING every
* note documents the invariant: on promotion to CTRL_PRIMARY every
* standby starts as not-converged (tag=0, lsn=0, converged=false). */
/* heartbeat_seen starts at 0: no heartbeat seen yet.
@ -1237,8 +1236,8 @@ ha_repl_action_t ha_evaluate_repl_action(uint64_t standby_tag,
/*
* 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
* Called on each heartbeat reply from a standby (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).
@ -2071,14 +2070,14 @@ int (*ha_phase2_hook)(void *c) = NULL;
/*
* ha_send_ctrlhello_hook: test-only seam for ha_send_ctrlhello_to_all_standbys.
* When non-NULL, the COMMITTING path calls this INSTEAD OF the real function
* When non-NULL, the post-election path calls this INSTEAD OF the real function
* (cluster.c is not linked in unit-test binary). Req 7.2.
*/
int (*ha_send_ctrlhello_hook)(void *c) = NULL;
/*
* ha_push_config_hook: test-only seam for push_config_transport_only.
* When non-NULL, the COMMITTING path calls this INSTEAD OF the real function
* When non-NULL, the post-election path calls this INSTEAD OF the real function
* (config_repl.c's push_config_transport_only needs a real cluster). Req 7.1.
*/
int (*ha_push_config_hook)(void *c) = NULL;
@ -2976,7 +2975,7 @@ int election_timeout_fn_for_test(ha_state_t *ha, ha_elect_ctx_t *ctx,
/*
* repl_catchup_done_pkt_for_test: build a minimal io_packet_t encoding
* Request{opcode=replCatchUpDone, body_which=nodata}.
* Used by peer_ctrl_msg_handler tests to verify the COMMITTING dispatch path.
* Used by peer_ctrl_msg_handler tests to verify the repl-ack dispatch path.
*/
io_packet_t *repl_catchup_done_pkt_for_test(void)
{
@ -3273,7 +3272,7 @@ void ha_start_election_async(ha_state_t *ha, cluster_t *c,
INFO("ha_start_election_async: no local config (tag=0, n_peers=%d) — "
"participating as voter only, not starting own election", n_peers);
/* Transition to CTRL_STANDBY so myapp_run schedules fetch_config_from_primary.
* The primary will push config via ctrlhello once it enters COMMITTING. */
* The primary will push config via ctrlhello once it becomes CTRL_PRIMARY. */
atomic_store_explicit(&ha->role, (int32_t)CTRL_STANDBY, memory_order_release);
/* Free ctx — no vote sends will happen from this node this round. */
atomic_store_explicit(&ha->elect_ctx, NULL, memory_order_release);
@ -3285,8 +3284,8 @@ void ha_start_election_async(ha_state_t *ha, cluster_t *c,
raft_start_election(&ha->raft);
/* For single-node, raft_start_election fires on_become_leader synchronously.
* on_become_leader (raft_cb_become_leader) handles role → COMMITTING,
* phase2, and committing timeout. Free the ctx here since no raft callbacks
* on_become_leader (raft_cb_become_leader) handles role -> CTRL_PRIMARY and
* phase2. Free the ctx here since no raft callbacks
* will reference it after raft_start_election returns for single-node. */
if (n_peers == 1) {
atomic_store_explicit(&ha->elect_ctx, NULL, memory_order_release);

View file

@ -60,8 +60,7 @@ typedef enum {
CTRL_PRIMARY = 0,
CTRL_STANDBY = 1,
CTRL_INIT = 2, /* Startup: listener open, awaiting heartbeat or election timeout */
CTRL_ELECTING = 3, /* Running quorum election via OP_requestVote */
CTRL_PRIMARY_COMMITTING = 4 /* Primary waiting for quorum ACK on config commit */
CTRL_ELECTING = 3 /* Running quorum election via OP_requestVote */
} ctrl_role_t;
/* ── Election types ────────────────────────────────────────── */
@ -105,8 +104,8 @@ typedef struct {
* watchdog. Set by ha_start_election_async after scheduling the timeout. */
void *timeout_ctx; /* election_timeout_ctx_t*; NULL until watchdog scheduled */
/* Task 4.1: cluster_t* so ha_elect_evaluate_and_signal can call
* cluster_init_phase2(c) and arm the committing timeout watchdog.
* NULL in unit tests (c=NULL bypasses phase2 and watchdog arming). */
* cluster_init_phase2(c).
* NULL in unit tests (c=NULL bypasses phase2). */
cluster_t *c;
/* Task 6.3: candidate's own LSN/tag, stored unconditionally so that
* ha_compute_safe_committed_lsn can use them even for single-node elections
@ -128,7 +127,6 @@ typedef struct {
#define HA_DEFAULT_MAX_PROMOTION_RETRIES 5
#define HA_DEFAULT_ELECTION_JITTER_MAX_MS 200
#define HA_DEFAULT_ELECTION_PRIORITY_STEP_MS 500
#define HA_DEFAULT_COMMITTING_TIMEOUT_MS 60000
typedef struct {
uint32_t heartbeat_interval_ms; /* Primary heartbeat send interval (min 2000 ms) */
@ -138,7 +136,6 @@ typedef struct {
uint32_t max_promotion_retries; /* Max consecutive failed promotion attempts */
uint32_t election_jitter_max_ms; /* random jitter added to election timeout (default 200) */
uint32_t election_priority_step_ms; /* extra ms per priority level (default 500) */
uint32_t committing_timeout_ms; /* Quorum-ACK deadline for CTRL_PRIMARY_COMMITTING (default 60000) */
} ha_config_t;
/* probe_ctx_t deleted — only used by am_i_primary() (election-iomgr-refactor) */
@ -177,14 +174,12 @@ typedef struct {
/* ── New fields added by election-iomgr-refactor spec (Task 2.1) ── */
void *client_ssl_ctx; /* SSL_CTX* (TLS_client_method); created in myapp_run */
int phase2_done; /* 1 after cluster_init_phase2 runs */
_Atomic int repl_ack_count; /* count of standbys that ACKed in COMMITTING state */
int n_peers; /* total cluster size (including self), matches raft convention */
/* ── New fields added by raft-library spec (Task 6.1) ──────── */
raft_state_t raft; /* Raft consensus state (wired in task 6.2) */
/* ── New fields added by raft-library spec (Task 6.2) ──────── */
cluster_t *raft_c; /* cluster_t* set by ha_start_election_async; used by raft callbacks */
void *raft_election_timeout_ctx; /* election_timeout_ctx_t* for cancel (task 6.2) */
void *committing_timeout_ctx; /* committing_timeout_ctx_t* for cancel on step-down */
/* ── New fields added by ha-heartbeat-liveness spec (Task 1.1) ── */
time_t last_activity; /* CLOCK_MONOTONIC seconds of last primary message; 0 = none yet */
/* ── New fields added by ha-repl-hardening spec (Task 1.4) ──────── */
@ -376,7 +371,7 @@ ha_repl_action_t ha_evaluate_repl_action(uint64_t standby_tag,
* ha_process_standby_state: update per-standby convergence tracking.
*
* Called on each heartbeat reply from a standby while the primary is in
* CTRL_PRIMARY_COMMITTING or CTRL_PRIMARY. Stores the standby's reported
* CTRL_PRIMARY. Stores the standby's reported
* (config_tag, applied_lsn) and evaluates whether the standby has converged
* with the primary's committed state.
*
@ -650,7 +645,7 @@ int ha_dispatch_repl_action_for_standby(ha_state_t *ha,
* an OP_hello CtrlHelloRequest to each connected standby.
* This triggers the existing catch-up replication path in handle_ctrlhello
* (Req 7.2). Defined in cluster.c; called from ha_elect_evaluate_and_signal
* in the COMMITTING path.
* in the post-election path.
*/
void ha_send_ctrlhello_to_all_standbys(cluster_t *c);
@ -668,7 +663,7 @@ extern int (*ha_phase2_hook)(void *c);
/*
* ha_send_ctrlhello_hook: test-only seam for ha_send_ctrlhello_to_all_standbys.
* When non-NULL, ha_elect_evaluate_and_signal() calls this in the COMMITTING
* When non-NULL, ha_elect_evaluate_and_signal() calls this in the post-election
* path instead of the real function (cluster.c not linked in test binary).
* Requirements: 7.2.
*/
@ -676,7 +671,7 @@ extern int (*ha_send_ctrlhello_hook)(void *c);
/*
* ha_push_config_hook: test-only seam for push_config_to_standbys.
* When non-NULL, ha_elect_evaluate_and_signal() calls this in the COMMITTING
* When non-NULL, ha_elect_evaluate_and_signal() calls this in the post-election
* path instead of the real function. Requirements: 7.1.
*/
extern int (*ha_push_config_hook)(void *c);

View file

@ -820,7 +820,6 @@ static ha_config_t g_fg_cfg = {
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
/* Arrange a CTRL_PRIMARY ha_state_t with n_endpoints connected standby

View file

@ -840,7 +840,6 @@ static ha_config_t g_gate_cfg = {
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
/* Arrange a CTRL_PRIMARY ha_state_t with n_endpoints connected standby

View file

@ -175,11 +175,11 @@ static void test_dispatcher_electing_calls_vote_reply_hook(void)
printf("test_dispatcher_electing_calls_vote_reply_hook: PASS\n");
}
/* ── Test 2 (dispatcher transitional-leader → repl_ack count) DELETED ─────────
/* ── Test 2 (dispatcher transitional-leader ack count) DELETED ─────────
* ha-single-leader-role task 4.1. Subject was the deleted ack-counting
* promotion machinery (replCatchUpDone incrementing repl_ack_count to drive a
* transitional leader state to PRIMARY). That path is gone — nothing left to
* verify. No shim (design §"Transitional-State Deletions").
* promotion machinery (replCatchUpDone incrementing the per-term ACK tally to
* drive a transitional leader state to PRIMARY). That path is gone — nothing
* left to verify. No shim (design §"Transitional-State Deletions").
*/
/* ── Test 3: dispatcher drops packet for CTRL_STANDBY ───────────────────── */
@ -477,10 +477,10 @@ static void test_follower_passive_no_outbound_connect(void)
printf("test_follower_passive_no_outbound_connect: PASS\n");
}
/* ── Test 10 (Task 7.3: promoted ep repl_ack promotes to PRIMARY) DELETED ─────
/* ── Test 10 (Task 7.3: promoted ep ack promotes to PRIMARY) DELETED ─────
* ha-single-leader-role task 4.1. Subject was the deleted ack-counting
* promotion machinery (replCatchUpDone incrementing repl_ack_count until the
* majority threshold promoted a transitional leader state to CTRL_PRIMARY via
* promotion machinery (replCatchUpDone incrementing the per-term ACK tally until
* the majority threshold promoted a transitional leader state to CTRL_PRIMARY via
* ha_promote_to_primary).
* The mechanism is gone — promotion is now the heartbeat decision loop +
* per-entry majority ack. Nothing left to verify; no shim (design

View file

@ -150,7 +150,7 @@ static void test_demote_sets_standby_role(void)
/*
* Verify that ha_init zeroes the new fields added for election-iomgr-refactor:
* phase2_done, repl_ack_count, n_peers, client_ssl_ctx
* phase2_done, n_peers, client_ssl_ctx
* and that the surviving leader role values are distinct in ctrl_role_t.
*
* Requirements: 5.2, 8.1 (election-iomgr-refactor spec); 1.2 (ha-single-leader-role:
@ -171,9 +171,6 @@ static void test_ha_init_zeroes_new_fields(void)
/* phase2_done must be explicitly zero after ha_init */
TEST_ASSERT_EQUAL_INT(0, ha.phase2_done);
/* repl_ack_count is _Atomic int; must be zero after ha_init */
TEST_ASSERT_EQUAL_INT(0, atomic_load_explicit(&ha.repl_ack_count, memory_order_relaxed));
/* n_peers must be zero after ha_init */
TEST_ASSERT_EQUAL_INT(0, ha.n_peers);

View file

@ -755,7 +755,7 @@ static void *lifecycle_wait_thread(void *p)
*
* SHARED-SEQUENCE CONTRACT PIN (GREEN now, MUST STAY GREEN — not a behavioral
* RED carrier). After task 2.2 BOTH roads to PRIMARY (the raft become-leader
* callback and ha_repl_ack_received's quorum path) route through this same
* callback and the legacy quorum-ack path) route through this same
* ha_promote_to_primary sequence, which task 2.2 augments with the noop+gate
* tail while KEEPING hook registration. This case is therefore a CONTRACT/
* REGRESSION pin proving the shared promotion sequence registers the persist
@ -809,8 +809,8 @@ static void test_promotion_registers_persist_hooks(void)
/* ── Case 4: NO WATCHDOG — promotion arms no delaymgr task (Req 1.5, 2.3).
*
* The shared promotion sequence must never arm a wall-clock watchdog (the
* deleted committing_timeout_fn). Asserted via the delaymgr task-count seam:
* The shared promotion sequence must never arm a wall-clock promotion watchdog
* (deleted in task 4.x). Asserted via the delaymgr task-count seam:
* the count is unchanged across ha_promote_to_primary.
*
* Status note (RED vs already-GREEN): the force-promotion watchdog is armed
@ -820,8 +820,8 @@ static void test_promotion_registers_persist_hooks(void)
* exercises — never armed a watchdog, so this case is ALREADY GREEN today and
* stands as a regression-pin guaranteeing task 2.2's shared sequence does not
* introduce one. The watchdog REMOVAL from raft_cb_become_leader is covered
* at integration scope (task 4.x deletes committing_timeout_fn and the
* test_ha_evaluate_committing committing-timeout cases). */
* at integration scope (task 4.x deletes the promotion-watchdog arm and the
* transitional-state evaluation cases). */
static void test_promotion_arms_no_delaymgr_watchdog(void)
{
#ifdef XCE_TESTING
@ -1140,11 +1140,11 @@ static void test_legacy_and_raft_promotion_are_symmetric(void)
"legacy road must arm the own-term gate at noop_lsn (Req 1.4, 2.1) — "
"identical to the raft road's shared tail");
/* Symmetry axis 4: NO committing-timeout watchdog armed (Req 1.5). */
/* Symmetry axis 4: NO promotion watchdog armed (Req 1.5). */
int after = delaymgr_task_count(&g_lc.delaymgr);
TEST_ASSERT_EQUAL_INT_MESSAGE(before, after,
"legacy road must arm NO delaymgr watchdog (Req 1.5) — symmetric with "
"the raft road; the committing-timeout arm is deleted from this path");
"the raft road; the promotion-watchdog arm is deleted from this path");
pthread_mutex_destroy(&ctx->lock);
sem_destroy(&ctx->done);

View file

@ -120,8 +120,8 @@ int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
/* Step 2: term upgrade — update term, clear vote, step down to FOLLOWER.
* If we were LEADER, notify the HA FSM so it updates ha->role.
* Without this, the HA FSM stays in CTRL_PRIMARY_COMMITTING and
* eventually promotes to PRIMARY via the committing timeout → split-brain. */
* Without this, the HA FSM stays in CTRL_PRIMARY after losing the term,
* never stepping down to STANDBY → split-brain. */
if (term > s->term) {
int was_leader = (s->role == RAFT_LEADER);
s->term = term;