myxce/.kiro/specs/ha-config-bootstrap/election-trace.md
Rongsong Shen e31673dd88 chore: checkpoint all prior work for clean state
Captures all uncommitted work from ha-config-bootstrap,
ha-heartbeat-liveness, and associated spec/tooling updates:

- src: ha.c, ha.h, cluster.c, config_repl.c, myapp.c, raft.c —
  heartbeat liveness, RDHUP integration, committing phase fixes
- tests: test_ha_role.c, test_config_repl.c, test_ha_rdhup_integration.c,
  test_p1_integration.c, test_p3_hardening.c, scheduler test
- specs: election-iomgr-refactor, ha-heartbeat-liveness,
  ha-peer-connection-reuse, raft-library (new); ha-config-bootstrap
  and election-iomgr-refactor task/research updates
- tooling: .wolf/ state, ha/data cluster fixtures, raft/raft.tla,
  src/build symlink, xce-build skill update
2026-06-04 13:04:16 +00:00

12 KiB
Raw Permalink Blame History

Election Trace: 3-Node Cold Start (2026-06-04 00:15:58)

Parameters

Parameter Value
election_timeout_ms 5000
election_priority_step_ms 500
heartbeat_interval_ms 3000
committing_timeout_ms 10000
heartbeat_timeout_ms 10000
cluster size 3 (ctrl-1 idx=0, ctrl-2 idx=1, ctrl-3 idx=2)
initial_state ha-term.bin wiped, config_tag=4986624fd309142c on all 3

Startup Sequence (myapp_run)

For each node, the startup order is:

  1. cluster_init_phase1 (raft_init with n_peers=1 placeholder, term=0)
  2. SSL context
  3. iomgr_init + iomgr_start ← iomgr thread RUNNING
  4. cluster_register_listener ← listener in epoll
  5. delaymgr_init + start
  6. persist_init + start
  7. load_primary_config
  8. stagger nap + ha_start_election_async
  9. cluster_run

Key: iomgr is running (step 3) BEFORE the stagger nap (step 8). This means incoming vote requests are processed during the nap.

Detailed Trace

T+0.000s — ctrl-1 starts (pid 532871)

restored term=0 from ha-term.bin
ha->term=0, voted_for_name=""
raft STATE (from ha_init): n_peers=1, self_idx=0, term=0, role=FOLLOWER, voted_for=-1

myapp_run step 7: Pre-election config loaded: tag=4986624fd309142c

ha_start_election_async:
  ha->raft_c = c  ← SET to non-NULL
  rcfg.initial_term = atomic_load(&ha->term) = 0
  rcfg.initial_voted_for = -1 (voted_for_name="")
  raft_init: n_peers=3, self_idx=0, s->term=0, s->role=FOLLOWER
  raft_start_election:
    s->term: 0 → 1
    s->voted_for = 0 (self)
    s->voted_for_term = 1
    s->votes_granted = 1
    s->role = CANDIDATE
    on_persist(term=1, voted_for=0):
      ha->term = 1
      ha->voted_for_name = "ctrl-1" (c is non-NULL)
    on_send_vote_request to peer 1 (ctrl-2), term=1
    on_send_vote_request to peer 2 (ctrl-3), term=1
    on_schedule_timeout(5000ms)

  CMD: INFO "→ CTRL_ELECTING (async, n_peers=3)"
  CMD: INFO "returning (n_peers=3, raft election started)"

→ cluster_run() starts iomgr event loop
→ vote connections to ctrl-2 and ctrl-3 attempted
→ ctrl-2 and ctrl-3 NOT STARTED YET → connection refused → peer counted as DENY
→ ctrl-1 waits for election timeout (5000ms)
  votes_granted=1 (self only), quorum=2, not reached

T+0.301s — ctrl-2 starts (pid 532883)

restored term=0
ha->term=0, voted_for_name=""
raft STATE (from ha_init): n_peers=1, self_idx=0, term=0, role=FOLLOWER, voted_for=-1

myapp_run steps 1-7 complete. iomgr running. listener in epoll.

→ enters 500ms stagger nap (my_priority=1 × 500ms)

DURING NAP: iomgr is running, listener accepting connections.
  But ctrl-1's vote connections already failed (ctrl-2 wasn't up yet).
  No new vote requests arrive during this nap window.
  ha->term stays at 0.
  voted_for_name stays at "".

T+0.603s — ctrl-3 starts (pid 532895)

restored term=0
ha->term=0, voted_for_name=""
raft STATE (from ha_init): n_peers=1, self_idx=0, term=0, role=FOLLOWER, voted_for=-1

→ enters 1000ms stagger nap (my_priority=2 × 500ms)

T+0.801s — ctrl-2 nap ends, starts election

already_voted check: voted_for_name="" (EMPTY!) → NO → starts election

ha_start_election_async:
  ha->raft_c = c  ← SET to non-NULL
  rcfg.initial_term = ha->term = 0
  raft_init: n_peers=3, self_idx=1, s->term=0
  raft_start_election:
    s->term: 0 → 1
    s->voted_for = 1 (self)
    s->votes_granted = 1
    on_persist(term=1, voted_for=1):
      ha->term = 1
      ha->voted_for_name = "ctrl-2" (c non-NULL)
    on_send_vote_request to peer 0 (ctrl-1), term=1
    on_send_vote_request to peer 2 (ctrl-3), term=1

  CMD: INFO "→ CTRL_ELECTING"
  CMD: INFO "returning"
→ cluster_run

T+0.810s — ctrl-2's votes arrive

To ctrl-1 (index 0):

ctrl-1 raft state: s->term=1, s->role=CANDIDATE, s->voted_for=0(self)
raft_on_vote_request(term=1, from_idx=1):
  Step 1: term(1) < s->term(1)? NO (equal)
  Step 2: term(1) > s->term(1)? NO (equal)
  Step 3: role is CANDIDATE → passes (not LEADER)
  Step 4: s->voted_for(0) != from_idx(1) && s->voted_for_term(1) == term(1)
          → "already voted for different candidate" → DENY
Result: DENY (already voted for self at term=1)

To ctrl-3 (index 2) — ctrl-3 is in 1000ms nap, iomgr running:

ctrl-3 raft state: n_peers=1, self_idx=0, s->term=0, s->role=FOLLOWER, s->voted_for=-1
(placeholder from ha_init — n_peers/selx are WRONG for this context)

raft_on_vote_request(term=1, from_idx=1):
  Step 1: term(1) < s->term(0)? NO
  Step 2: term(1) > s->term(0)? YES
          s->term = 1
          s->voted_for = -1
          s->voted_for_term = 0
          s->role = FOLLOWER
          was_leader = false
  Step 3: role is FOLLOWER → passes
  Step 4: voted_for(-1) != from_idx(1)? NO (voted_for is -1 → doesn't match "!= -1" check)
  Step 5: on_vote_eligible → always returns 1
  Step 6: on_persist(term=1, from_idx=1):
    ha->term = 1  ← SET!
    c = ha->raft_c = NULL!  ← NOT YET SET (ha_start_election_async not called)
    voted_for(1) != my_priority(2) → memset(voted_for_name, 0, ...) ← CLEARED!
Result: GRANT ← VOTE GRANTED

SIDE EFFECT: ha->term=1, BUT voted_for_name is EMPTY (BUG)

T+0.865s (00:15:59.374) — ctrl-2 wins term=1

ctrl-2 receives ctrl-3's vote reply: vote_granted=1
votes_granted: self(1) + ctrl-3(1) = 2 = quorum (ceil(3/2)=2)

raft_on_vote_reply → quorum reached → on_become_leader(term=1):
  raft_cb_become_leader(term=1):
    is_first_boot = !phase2_done = true
    cluster_init_phase2(c)  ← first boot init
    phase2_done = 1
    ha->role = CTRL_PRIMARY_COMMITTING
    CMD: INFO "→ CTRL_PRIMARY_COMMITTING (term=1, first_boot=1)"
    
    Promote vote eps → standby channels
    CMD: INFO "promoted vote ep[0] → standby channel"
    CMD: INFO "promoted vote ep[1] → standby channel"
    
    push_config_to_standbys: config blob sent to ctrl-1 and ctrl-3
    ha_send_ctrlhello_to_all_standbys: ctrlhello sent
    
    ha_start_primary_heartbeat: armed every 3000ms
    
    Schedule committing_timeout(10000ms):
      deadline: T+10.865s (00:16:09.374)
    
    CMD: INFO "Config committed on majority (tag=4986624fd309142c, pushed=2/2)"
    CMD: INFO "primary heartbeat armed (interval=3000ms)"

T+0.915s (00:15:59.424) — ctrl-3 receives ctrlhello (still in nap)

During ctrl-3's nap (iomgr running):
  ctrl-2 sends ctrlhello on promoted vote ep → arrives at ctrl-3 listener
  
  handle_ctrlhello:
    primary_ctrl_fd = fd ← SET
    CMD: INFO "primary control channel established (fd=6)"
    CMD: INFO "standby ctrl-2 catch-up complete (applied_lsn=0 → committed_lsn=0)"
    
  handle_repl_config_push:
    config saved and loaded (tag=4986624fd309142c)
    
  ha_arm_heartbeat_timeout_vtask:
    liveness poll armed (timeout=10000ms, poll=1500ms)
    
  NOTE: ha->role stays at CTRL_STANDBY (set by ha_init).
  voted_for_name stays at "" (cleared by BUG).
  ha->term stays at 1 (set by raft_cb_persist).

T+1.603s (00:16:00.119) — ctrl-3 nap ends

already_voted check: voted_for_name="" (EMPTY!) → NO → starts election
  NOTE: primary_ctrl_fd != -1, raft.term = 1, but these aren't checked!

ha_start_election_async:
  ha->raft_c = c  ← NOW non-NULL
  rcfg.initial_term = ha->term = 1  ← SET TO 1!
  rcfg.initial_voted_for = -1 (voted_for_name is empty)
  raft_init: n_peers=3, self_idx=2, s->term=1
  raft_start_election:
    s->term: 1 → 2  ← TERM=2!
    s->voted_for = 2 (self)
    s->votes_granted = 1
    on_persist(term=2, voted_for=2):
      ha->term = 2
      ha->voted_for_name = "ctrl-3"

  CMD: INFO "→ CTRL_ELECTING"
  CMD: INFO "returning"
→ cluster_run
→ sends vote requests at TERM=2 to ctrl-1 and ctrl-2

T+1.610s — ctrl-3's votes at term=2 arrive

To ctrl-1:

ctrl-1: s->term=1, role=CANDIDATE, voted_for=0(self)
raft_on_vote_request(term=2, from_idx=2):
  term(2) > s->term(1) → term upgrade:
    s->term = 2, voted_for = -1, role = FOLLOWER
    was_leader = false (was CANDIDATE)
  role=FOLLOWER → passes
  voted_for=-1 → passes
  on_persist(term=2, from_idx=2):
    ha->term = 2
    ha->voted_for_name = "ctrl-3" (c is non-NULL for ctrl-1)
Result: GRANT

To ctrl-2:

ctrl-2: s->term=1, role=LEADER (in COMMITTING), voted_for=1(self)
raft_on_vote_request(term=2, from_idx=2):
  term(2) > s->term(1) → term upgrade:
    s->term = 2, voted_for = -1, role = FOLLOWER
    was_leader = true → on_become_follower!
      raft_cb_become_follower:
        cancel election timeout (none active)
        ha->role = CTRL_STANDBY  ← STEPS DOWN!
        CMD: INFO "→ CTRL_STANDBY"  (NOT IN LOG!)
        ha_arm_heartbeat_timeout_vtask
  role=FOLLOWER → passes
  on_persist(term=2, from_idx=2):
    result: ha->term=2, voted_for_name="ctrl-3"
Result: GRANT

NOTE: committing timeout from term=1 is NOT cancelled!
      It will fire at T+10.865s (00:16:09.374).

T+1.615s (00:16:00.170) — ctrl-3 wins term=2

ctrl-3: self(1) + ctrl-1(1) = 2 = quorum
raft_cb_become_leader(term=2):
  ha->role = CTRL_PRIMARY_COMMITTING
  CMD: INFO "→ CTRL_PRIMARY_COMMITTING (term=2, first_boot=1)"
  
  push_config_to_standbys: 
    repl_wait_majority_ack TIMEOUT (500ms)
    CMD: WARN "timeout waiting for majority quorum"
    CMD: WARN "Config push: timeout waiting for majority ACK"
  
  ha_start_primary_heartbeat(3000ms)
  
  Schedule committing_timeout(10000ms):
    deadline: T+11.615s (00:16:10.725)

T+5.200s (00:16:03.722) — ctrl-1 election timeout

ctrl-1's initial election timeout fires (5000ms from T+0.000s)
But ctrl-1 already stepped down to FOLLOWER at T+1.610s (saw term=2).
raft_cb_become_follower:
  cluster_init_phase2 (first_boot)
  ha->role = CTRL_STANDBY
  CMD: INFO "→ CTRL_STANDBY (term=2, first_boot=1)"
  ha_arm_heartbeat_timeout_vtask (10000ms)

T+10.865s (00:16:09.425) — ctrl-2 committing timeout FIRES

committing_timeout_fn checks t->ha->role:
  If raft.c fix worked: ha->role = CTRL_STANDBY → NO-OP (correct!)
  If raft.c fix didn't work: ha->role = CTRL_PRIMARY_COMMITTING → promotes

Actual log: ctrl-2 → CTRL_PRIMARY (term=2)
  CMD: WARN "committing timeout — majority ack not received..."
  CMD: INFO "→ CTRL_PRIMARY (term=2)"
  
CONCLUSION: raft.c fix did NOT work in this test run,
  or ctrl-2 never received ctrl-3's vote request at term=2.

T+11.615s (00:16:10.725) — ctrl-3 committing timeout fires

committing_timeout_fn: ha->role = CTRL_PRIMARY_COMMITTING → promotes
ha_do_promote: term 2→3
CMD: INFO "→ CTRL_PRIMARY (term=3)"

Root Causes Identified

Bug 1: ha->raft_c is NULL during vote processing in nap

During the stagger nap, other nodes' vote requests arrive and are processed. raft_cb_persist uses ha->raft_c to look up candidate names, but this is NULL until ha_start_election_async runs (after the nap).

Effect: ha->term is correctly set, but ha->voted_for_name is CLEARED. The already_voted check in myapp_run sees an empty voted_for_name and starts an unnecessary election.

Fix: Set ha->raft_c = c earlier, at startup, before iomgr starts.

Bug 2: Committing timeout not cancelled on step-down

When raft_on_vote_request (with my fix) calls on_become_follower for a node that was LEADER/COMMITTING, the committing timeout is not cancelled. The delaymgr task still fires later.

Fix: In raft_cb_become_follower, cancel any pending committing timeout.

Bug 3: already_voted check incomplete

Only checks ha->voted_for_name — misses the case where:

  • ha->raft.term > 0 (saw a higher term from another candidate)
  • ha->primary_ctrl_fd >= 0 (ctrlhello already received from winner)

Fix: Also check raft.term > 0 and primary_ctrl_fd >= 0.