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
14 KiB
Implementation Plan
-
1. Foundation: delaymgr ms-precision and netio on_connected callback
-
1.1 Add delay_task_ms() to delaymgr for millisecond-granularity one-shot tasks
- Add
struct timespec nextrun_tsfield todelay_task_t(alongside existingtime_t nextrun) - Add
delay_task_ms(mgr, delay_ms, runcount, fn, arg)in delay.c usingclock_gettime(CLOCK_MONOTONIC)for deadline; precision bounded bymgr->precision_ms - Update
delay_workerto check bothnextrun(seconds tasks) andnextrun_ts(ms tasks) without breaking existingdelay_taskcallers - Expose
delay_task_msin delay.h - Observable:
make testpasses existing delay tests; new unit test verifies a 500ms task fires within ±200ms - Requirements: 1.3, 4.4, 8.4
- Boundary: delay.c, delay.h
- Add
-
1.2 Add on_connected callback to endpoint_t in netio
- Add
void (*on_connected)(endpoint_t *ep)field toendpoint_tstruct - Add
endpoint_set_on_connected(endpoint_t *ep, void (*cb)(endpoint_t *))helper function - In
io_handler, afterssl_handshake_doneis set to 1, invokeep->on_connected(ep)then null the pointer (fire-once semantics) - Observable: unit test creates outgoing endpoint, sets callback, simulates handshake completion; callback fires exactly once
- Requirements: 2.1, 2.3
- Boundary: netio.c
- Add
-
2. Core: iomgr-native outgoing vote connection and reply handling
-
2.1 Add ha_state_t fields and CTRL_PRIMARY_COMMITTING to FSM enum
- Add to
ha_state_t:SSL_CTX *client_ssl_ctx,int phase2_done,_Atomic int repl_ack_count,int n_peers - Add to
ha_config_t:uint32_t committing_timeout_mswith defaultHA_DEFAULT_COMMITTING_TIMEOUT_MS = 10000 - Add
CTRL_PRIMARY_COMMITTINGtoctrl_role_tenum in ha.h - Initialize
phase2_done=0,repl_ack_count=0inha_init() - Observable: project builds without errors;
ha_initzero-initializes all new fields - Requirements: 5.2, 8.1
- Boundary: ha.h
- Add to
-
2.2 (P) Implement vote_ep_ctx_t and on_vote_connected callback
- Define
vote_ep_ctx_tstruct:ha_state_t *ha,cluster_t *c,ha_elect_ctx_t *ctx,int peer_idx,uint64_t my_term/my_tag/my_lsn,const char *my_name - Implement
on_vote_connected(endpoint_t *ep): castep->datatovote_ep_ctx_t, buildxce_request_t{opcode=OPCode_requestVote, body.requestvotemsg=...}, callxce_request(ep, &req), setep->msg_handler = vote_reply_msg_handler - Define
election_timeout_ctx_t:_Atomic int cancelled,ha_state_t *ha,cluster_t *c,ha_elect_ctx_t *ctx - Observable: unit test with mock endpoint verifies
xce_requestcalled with correct opcode and fields afteron_vote_connectedfires - Requirements: 1.2, 2.3, 3.1
- Boundary: ha.c (vote send path)
- Depends: 1.2, 2.1
- Define
-
2.3 (P) Implement vote_reply_msg_handler for iomgr-native reply decoding
- Implement
vote_reply_msg_handler(endpoint_t *ep): callxce_decode(pkt, &req), verifyreq.opcode == OPCode_reply, extractrep->data.requestvotereplymsg, callread_RequestVoteReply(&vr_raw, ...)BEFORE any capnp_free, callhandle_request_vote_reply(ha, ctx, peer_idx, vr_raw.term, vr_raw.voteGranted, ...) - Term fencing: if
vr_raw.term > ha->term, atomically updateha->termbefore recording vote - Observable: ASAN-clean unit test reads reply fields before free; no heap-use-after-free
- Requirements: 3.1, 3.2, 3.3, 3.4
- Boundary: ha.c (vote reply path)
- Depends: 2.1
- Implement
-
2.4 Implement ha_start_election_async() — non-blocking election initiator
- Implement
ha_start_election_async(ha, c, n_peers, peers, my_config_tag, my_lsn, my_name):- Store
n_peersinha->n_peers - For each peer: allocate
vote_ep_ctx_t, init endpoint withclient_ssl_ctx, seton_vote_connected+vote_ep_err_handler, callendpoint_connect+iomgr_monitor; skip peer on connect failure (counted as deny via error handler) - Allocate
election_timeout_ctx_twithcancelled=0; scheduledelay_task_ms(delaymgr, election_timeout_ms, 1, election_timeout_fn, ctx) - Single-node shortcircuit: if n_peers==1, call
ha_elect_evaluate_and_signaldirectly with PRIMARY result - Returns immediately; election result delivered via
ha_elect_evaluate_and_signal
- Store
- Observable: function returns in < 5ms; endpoints registered in iomgr; watchdog scheduled in delaymgr
- Requirements: 1.1, 1.2, 1.3, 2.1, 2.4, 4.3
- Boundary: ha.c (election orchestration)
- Depends: 1.1, 1.2, 2.2, 2.3
- Implement
-
3. Core: FSM gate extensions and role-based vote denial
-
3.1 (P) Extend ha_dispatch_gate for CTRL_PRIMARY_COMMITTING state
- Add
case CTRL_PRIMARY_COMMITTING:toha_dispatch_gatein ha.c: allowOPCode_heartbeat,OPCode_replConfig,OPCode_replConfigPush,OPCode_upgradeConfigAck,OPCode_hello,OPCode_walAck,OPCode_replEntry,OPCode_replAck,OPCode_replCatchUpDone; deny all others with-XCE_ERR_NOT_READY - Observable: unit test sends
OPCode_jobsubmitin COMMITTING state → returnsXCE_ERR_NOT_READY;OPCode_replCatchUpDone→ returns 0 - Requirements: 5.1, 5.2, 8.1
- Boundary: ha.c (dispatch gate)
- Depends: 2.1
- Add
-
3.2 (P) Extend handle_request_vote with role-based deny gate
- Before existing term/voted_for/freshness checks in
handle_request_vote: loadha->role; return 0 (deny) ifCTRL_PRIMARYorCTRL_PRIMARY_COMMITTING; return 0 (deny) ifCTRL_STANDBY && ha_heartbeat_is_fresh(ha) - Implement
ha_heartbeat_is_fresh(ha): compareclock_gettime(CLOCK_MONOTONIC)againstha->last_heartbeat; return 1 if elapsed <heartbeat_timeout_ms - Observable: unit test with mocked roles: PRIMARY→deny, COMMITTING→deny, STANDBY+fresh→deny, STANDBY+stale→evaluate, ELECTING→evaluate
- Requirements: 6.1, 6.2, 6.4
- Boundary: ha.c (vote gate)
- Depends: 2.1
- Before existing term/voted_for/freshness checks in
-
4. Core: committing phase — replication push and availability gate
-
4.1 Extend ha_elect_evaluate_and_signal with COMMITTING path
- Extend
ha_elect_evaluate_and_signal: after determining role=PRIMARY, check!ha->phase2_done; if first boot callcluster_init_phase2(c)then setha->phase2_done=1; setha->role = CTRL_PRIMARY_COMMITTING; resetrepl_ack_count=0; schedule committing timeout watchdog viadelay_task_ms - On STANDBY result: if first boot call
cluster_init_phase2(c)+ setphase2_done=1; set role=CTRL_STANDBY; election timeout callback setscancelled=1 - Observable: unit test verifies
cluster_init_phase2called exactly once on first election; second election (RDHUP) skips it; role enters COMMITTING not PRIMARY after winning - Requirements: 4.1, 5.2, 9.2
- Boundary: ha.c (election evaluation)
- Depends: 2.4, 3.1
- Extend
-
4.2 Implement config and WAL replication push to standbys
- In
ha_elect_evaluate_and_signalCOMMITTING path: callpush_config_to_standbys(c, ...)to push config to standbys with lowerconfig_tag - Implement
ha_send_ctrlhello_to_all_standbys(c): iterateha->standby_endpoints, sendCtrlHelloRequestto each viaxce_request— triggers existing catch-up replication inhandle_ctrlhello - Observable: 3-node startup log shows primary pushing config to ctrl-b/c immediately after COMMITTING; ctrl-b/c logs show config received
- Requirements: 7.1, 7.2, 7.3
- Boundary: ha.c + cluster.c (replication push)
- Depends: 4.1
- In
-
4.3 Implement ha_repl_ack_received and majority-ack → PRIMARY transition
- Implement
ha_repl_ack_received(ha, c): atomically incrementrepl_ack_count; compute quorum= (ha->n_peers + 2) / 2 - 1; ifrepl_ack_count >= quorum: set committing timeoutcancelled=1, callha_promote_to_primary(c)(sets role=CTRL_PRIMARY, enables hooks) - Wire
ha_repl_ack_receivedintoOPCode_replCatchUpDonehandler in cluster.c - Observable: unit test: 2 of 3 acks arrive → PRIMARY; 1 of 3 acks → stays COMMITTING
- Requirements: 7.4, 8.2, 8.3
- Boundary: ha.c + cluster.c (ack counting)
- Depends: 4.1
- Implement
-
4.4 Implement committing timeout watchdog (degraded-mode fallback)
- Define
committing_timeout_ctx_t:_Atomic int cancelled,ha_state_t *ha,cluster_t *c - Implement
committing_timeout_fn: if!cancelledandha->role == CTRL_PRIMARY_COMMITTING, log WARN "committing timeout — opening in degraded mode", callha_promote_to_primary(c) - Schedule via
delay_task_ms(delaymgr, committing_timeout_ms, 1, committing_timeout_fn, ctx)in the COMMITTING entry path - Observable: unit test with no standbys: after
committing_timeout_ms, role transitions to CTRL_PRIMARY with WARN log - Requirements: 8.4, 9.1
- Boundary: ha.c (committing watchdog)
- Depends: 1.1, 4.1
- Define
-
5. Integration: restructure myapp_run and wire all components
-
5.1 Restructure myapp_run to be fully non-blocking
- Move
delaymgr_init+delaymgr_startandpersist_init+persist_startfromcluster_startpath intomyapp_run(beforeha_start_election_async) - Create
client_ssl_ctx(TLS_client_method + SSL_VERIFY_NONE) inmyapp_run; store inc.ha.client_ssl_ctx - Call
load_primary_configto pre-load config_tag for freshness gate - Remove:
ha_wait_for_heartbeat_or_timeout,am_i_primary_quorum,for(;;)retry loop,cluster_init_phase2,cluster_start,bootstrap_config_if_neededcalls - Call
ha_start_election_asyncthencluster_rundirectly — no blocking between them - Observable:
myapp_runexits intocluster_runwithin 10ms of startup; no blocking calls visible in strace/callgraph - Requirements: 1.1, 9.3, 9.4
- Boundary: myapp.c
- Depends: 2.4, 3.1, 3.2, 4.1, 4.2, 4.3, 4.4
- Move
-
5.2 Wire heartbeat-timeout → CTRL_ELECTING → ha_start_election_async for RDHUP failover
- In
ha_on_primary_ctrl_fd_rdhup: after setting role=CTRL_ELECTING, callha_start_election_asyncwith the cluster's peer list (instead of the currentam_i_primary_quorumcall) - Verify
ha->phase2_done==1ensures RDHUP re-election skipscluster_init_phase2and goes directly to COMMITTING - Observable: kill primary with SIGKILL; surviving standby transitions ELECTING→COMMITTING→PRIMARY within
heartbeat_timeout_ms + election_timeout_ms + committing_timeout_ms - Requirements: 6.3, 6.5, 9.3
- Boundary: ha.c (RDHUP path)
- Depends: 5.1
- In
-
5.3 Remove bootstrap_config_if_needed and old election blocking path
- Delete
bootstrap_config_if_neededcall from myapp_run; delete the function body from config_repl.c if no other callers remain - Delete
ha_wait_for_heartbeat_or_timeoutcall from myapp_run (now internal to ha_start_election_async's watchdog) - Delete
am_i_primary_quorumblocking pthread loop from ha.c (keep unit-test stub in testsuite) - Delete
vote_request_threadandvote_thread_ctx_tfrom ha.c - Observable:
grep -r "bootstrap_config_if_needed\|vote_request_thread\|vote_thread_ctx_t" src/controller/ --include="*.c"returns no matches outside of test stubs - Requirements: 1.1, 9.2
- Boundary: ha.c, myapp.c, config_repl.c
- Depends: 5.1, 5.2
- Delete
-
6. Validation: tests, ASAN, and integration verification
-
6.1 Unit tests for election timeout and committing timeout watchdogs
- Test
election_timeout_fn: firesha_elect_evaluate_and_signalwhen not cancelled; no-op whencancelled=1 - Test
committing_timeout_fn: promotes to PRIMARY (degraded) when not cancelled; no-op when cancelled - Test
delay_task_msprecision: 500ms task fires within ±200ms (delaymgr precision_ms = 50ms for test) - Observable: all tests pass; no ASAN errors
- Requirements: 1.3, 4.3, 8.4
- Boundary: ha.c unit tests
- Depends: 1.1, 4.4
- Test
-
6.2 Unit tests for FSM gate and role-based vote denial
- Test
ha_dispatch_gate: COMMITTING allows replCatchUpDone, denies jobsubmit; ELECTING allows requestVote, denies jobsubmit - Test
handle_request_voterole gate: all 5 cases (PRIMARY, COMMITTING, STANDBY+fresh, STANDBY+stale, ELECTING) - Test
ha_repl_ack_received: quorum=1 of 2 peers acks → PRIMARY; 0 of 2 → stays COMMITTING - Observable: 15+ new unit test cases pass;
make testgreen - Requirements: 5.1, 5.2, 6.1, 6.2, 6.4, 8.2, 8.3
- Boundary: ha.c unit tests, cluster.c unit tests
- Depends: 3.1, 3.2, 4.3
- Test
-
6.3 Integration test: 3-node startup with full COMMITTING phase
- Start ctrl-a, ctrl-b, ctrl-c within 3 seconds; verify ctrl-a wins election and enters COMMITTING
- Verify ctrl-b/c receive config push (log: "config received from primary"); both send replCatchUpDone
- Verify ctrl-a transitions to CTRL_PRIMARY after 2 acks; verify job dispatch accepted after gate opens
- Verify ctrl-b/c deny vote requests while ctrl-a is COMMITTING or PRIMARY (role gate active)
- Observable: cluster serves a job within 30s of all nodes starting; no XCE_ERR_NOT_READY from primary after COMMITTING exits
- Requirements: 4.1, 7.1, 7.2, 7.3, 8.1, 8.2, 8.3
- Depends: 5.1, 5.2, 5.3
-
6.4 Integration test: primary crash and standby promotion (RDHUP failover)
- Start 3-node cluster; let ctrl-a become PRIMARY; SIGKILL ctrl-a
- Verify ctrl-b/c detect heartbeat timeout within
heartbeat_timeout_ms; one wins election → COMMITTING → PRIMARY - Verify promoted primary serves job traffic from last WAL state (no data loss)
- Verify ASAN-clean run: no heap-use-after-free, no stack-use-after-return
- Observable: cluster operational again within
heartbeat_timeout_ms + election_timeout_ms + committing_timeout_ms + 5s; ASAN output shows 0 errors - Requirements: 6.3, 6.5, 9.3
- Depends: 6.3
-
6.5 Regression: existing unit test suite green; ASAN clean
- Run full
meson test --suite controllerwithASAN_OPTIONS=detect_leaks=0; all 230+ existing tests pass - Verify single-node self-election still works without any network I/O
- Verify no compilation warnings introduced in modified files
- Observable:
meson test --suite controllerexits 0; test count ≥ prior baseline; no new ASAN errors - Requirements: 9.1, 9.4
- Depends: 6.4
- Run full
Implementation Notes
ha_elect_evaluate_and_signalis called from vtask context (via vote_reply_msg_handler) and from delaymgr context (via election_timeout_fn). Both are thread-safe becauseha_elect_ctx_toperations are mutex-protected andha->roleis atomic.cluster_init_phase2must be verified vtask-safe before task 4.1: check that all subsystems it touches (configmgr, jobmgr, etc.) were moved to phase1 in the prior refactor.- The committing timeout and election timeout contexts must outlive the callbacks. Allocate them on heap inside
ha_start_election_async; free inha_election_cleanupcalled after COMMITTING exits.