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
358 lines
12 KiB
Markdown
358 lines
12 KiB
Markdown
# 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`.
|