Pre-existing uncommitted work: ha-repl-hardening payload data plane (7.x), heartbeat liveness fix (Req 1.3), connection-reuse ASAN fixes, doc cleanup. New: ha-consistency-invariants umbrella (requirements/design/research) and its four implementation sub-specs (specs generated via batch, cross-spec review applied), steering roadmap + product.md Raft+CP decision.
8.7 KiB
Implementation Plan
-
1. Foundation: state fields and startup validation
-
1.1 Add liveness state fields to ha_state_t and standby_ep_ctx_t
- Add
time_t last_activitytoha_state_tin ha.h; zero-initialize inha_init() - Add
time_t last_push_timetostandby_ep_ctx_tstruct (ha.c internal); initialize to 0 in all allocation sites - Remove
hb_vtask_ttypedef declaration from ha.h (struct moved to ha.c or fully removed) - Observable: project builds;
ha_init()test verifieslast_activity == 0after init - Requirements: 1.1, 1.3, 3.1
- Add
-
1.2 Add heartbeat timing validation at startup
- Implement
validate_heartbeat_config(heartbeat_timeout_ms, heartbeat_interval_ms, err_buf, err_len)instartup_validation.c - Returns
-EINVALwhenheartbeat_timeout_ms < 2 * heartbeat_interval_ms; writes human-readable error intoerr_buf - Wire call into
main.cafterha_config_tis populated, beforeha_init() - Observable: starting controller with
heartbeat_timeout_ms = heartbeat_interval_msprints error and exits non-zero - Requirements: 2.4
- Boundary: startup_validation.c, main.c
- Implement
-
2. Core: standby liveness poll (replaces watchdog thread)
-
2.1 (P) Implement liveness_poll_fn and replace hb_timeout_thread
- Define
liveness_poll_ctx_tstruct in ha.c:{ ha_state_t *ha; cluster_t *c; _Atomic int cancelled; } - Implement
liveness_poll_fn(void *arg): checkcancelledfirst; checkatomic_load(&ha->role) == CTRL_STANDBYto guard against duplicate fires with EPOLLRDHUP path; readha->last_activity, compute elapsed; ifelapsed >= heartbeat_timeout_mscallha_on_primary_ctrl_fd_rdhup(ha, c); else re-arm viadelay_task_msatheartbeat_interval_ms / 2 - Replace
ha_arm_heartbeat_timeout_vtask: allocateliveness_poll_ctx_t, writeclock_gettimetoha->last_activity, calldelay_task_msto schedule first poll; log INFO "HA: standby liveness poll armed" - Replace
ha_disarm_heartbeat_timeout_vtask: setctx->cancelled = 1, free ctx, set static ptr to NULL; no pthread_join needed - Add
ha_disarm_heartbeat_timeout_vtaskcall at the top ofha_on_primary_ctrl_fd_rdhupso that EPOLLRDHUP cancels any pending poll task before transitioning to CTRL_ELECTING (Req 5.4) - Update
ha_on_heartbeat_received: writeclock_gettime(CLOCK_MONOTONIC)seconds toha->last_activity; log DEBUG3 on each reset (not INFO to avoid log spam) - Remove
hb_vtask_tstruct,hb_timeout_threadfunction,s_hb_vtaskstatic variable from ha.c - Observable:
grep -r hb_timeout_thread src/controller/*.creturns zero matches; build passes with no new warnings; SIGKILL primary → standbys show exactly oneha_on_primary_ctrl_fd_rdhuplog each (no duplicate fires) - Requirements: 1.2, 1.4, 1.5, 1.6, 2.1, 2.2, 2.3, 4.2, 4.3, 5.2, 5.3, 5.4, 5.6
- Boundary: ha.c (standby liveness path)
- Define
-
2.2 (P) Extend primary_heartbeat_fn with per-standby idle tracking
- In
primary_heartbeat_fn: for each ep inha->standby_endpoints, castep->datatostandby_ep_ctx_tand readsctx->last_push_time - Compute
elapsed = now - sctx->last_push_time; ifelapsed >= heartbeat_interval_mscallxce_nop_req(ep)and writesctx->last_push_time = now; else skip NOP for this standby - In
on_standby_connected: writesctx->last_push_time = clock_gettime(...)immediately after TLS handshake completes (before ctrlhello send); this counts the ctrlhello as the first push - Remove the workaround
xce_nop_req(ep)call added at the end ofon_standby_connected(no longer needed) - Observable: with 2 standbys active and no WAL traffic, primary sends NOP to each standby at most once per
heartbeat_interval_ms; verified via DEBUG3 log counting - Requirements: 3.1, 3.2, 3.3, 3.4, 3.5
- Boundary: ha.c (primary heartbeat path, standby_ep_ctx_t)
- In
-
3. Integration: wire liveness reset into opcode dispatch
-
3.1 Update cluster.c opcode handlers to reset standby liveness on any primary message
OPCode_nophandler: replaceif (ep->fd == c->ha.primary_ctrl_fd)guard withif (atomic_load(&c->ha.role) == CTRL_STANDBY)— callha_on_heartbeat_received(&c->ha)OPCode_hellohandler (ctrlhello path): after the existinghandle_ctrlhellocall, additionally callha_on_heartbeat_received(&c->ha)when role is CTRL_STANDBY (ctrlhello counts as primary activity per Req 4.1)OPCode_replEntryandOPCode_walCatchupEntryhandlers: addha_on_heartbeat_received(&c->ha)call when role is CTRL_STANDBY- Observable: in a running 3-node cluster, after primary sends ctrlhello,
ha->last_activitytimestamp is fresh and standby liveness poll does not fire withinheartbeat_timeout_ms - Requirements: 1.3, 4.1
- Depends: 2.1
- Boundary: cluster.c (opcode dispatch)
-
3.2 Verify config_repl.c ctrlhello integration and EPOLLRDHUP cancellation path
- Confirm
ha_arm_heartbeat_timeout_vtaskcall inhandle_ctrlhello(config_repl.c:1380) still correctly arms the delaymgr poll (not a thread) - Confirm
ha_on_heartbeat_receivedcall at config_repl.c:1379 writeslast_activity(new impl) - Confirm
ha_on_primary_ctrl_fd_rdhupnow callsha_disarm_heartbeat_timeout_vtaskas its first step (Req 5.4) - Observable: build passes;
handle_ctrlhellounit test (existing) still green;ha_arm_heartbeat_timeout_vtaskno longer spawns a pthread; SIGKILL primary test shows exactly one RDHUP log per standby (no duplicate from poll) - Requirements: 1.3, 4.1, 4.2, 5.2, 5.3, 5.4
- Depends: 2.1, 3.1
- Boundary: config_repl.c, ha.c (ha_on_primary_ctrl_fd_rdhup)
- Confirm
-
4. Validation: unit and integration tests
-
4.1 (P) Unit tests for standby liveness poll
- Test
liveness_poll_fnfiresha_on_primary_ctrl_fd_rdhupwhennow - last_activity >= heartbeat_timeout_ms - Test
liveness_poll_fnre-arms (no failover) whennow - last_activity < heartbeat_timeout_ms - Test
liveness_poll_fnwithcancelled = 1is a no-op (no failover, no re-arm) - Test
ha_on_heartbeat_receivedupdatesha->last_activityto a non-zero value - Test
ha_arm_heartbeat_timeout_vtaskdoes NOT spawn a pthread (thread count unchanged before/after call) - Test
validate_heartbeat_config:timeout = 2 × intervalpasses;timeout = interval - 1fails - Observable:
meson test --suite controllerexits 0 with all new test cases green; zero ASAN errors - Requirements: 1.2, 1.4, 1.5, 1.6, 2.1, 2.2, 2.3, 2.4
- Boundary: ha.c unit tests, startup_validation.c unit tests
- Depends: 2.1, 1.2
- Test
-
4.2 (P) Unit tests for primary idle NOP suppression
- Test
primary_heartbeat_fn: sends NOP to standby whenlast_push_timeelapsed ≥heartbeat_interval_ms - Test
primary_heartbeat_fn: skips NOP whenlast_push_timeelapsed <heartbeat_interval_ms - Test
on_standby_connectedwrites non-zerolast_push_timeimmediately - Observable: all 3 test cases pass;
meson test --suite controllerexits 0 - Requirements: 3.1, 3.2, 3.3, 3.4
- Boundary: ha.c unit tests
- Depends: 2.2
- Test
-
4.3 Integration test: 3-node cold start — no false RDHUP loops and race elimination
- Deploy 3-node cluster (ctrl-1 has config, ctrl-2/3 tag=0); verify ctrl-1 elects PRIMARY
- Verify ctrl-2/3 receive ctrlhello, set
last_activity, and do NOT trigger RDHUP beforeheartbeat_timeout_mselapses from ctrlhello - Verify cluster is stable for 60s with no
hb_timeout_threadlog entries (thread removed) and no RDHUP false-positive entries - Observable:
grep "hb_timeout_thread\|heartbeat timeout" /tmp/xce-deploy/logs/ctrl-{2,3}.logreturns 0 lines during steady state; all 3 processes alive after 60s - Requirements: 1.3, 1.4, 4.1, 4.2, 4.3, 5.1, 5.3
- Depends: 3.1, 3.2
-
4.4 Integration test: primary crash triggers failover within timeout window
- Start 3-node cluster; verify ctrl-1 PRIMARY and cluster stable
- SIGKILL ctrl-1; verify ctrl-2 or ctrl-3 detects silence within
heartbeat_timeout_ms; promotes to PRIMARY via RDHUP path - Verify promoted primary serves traffic; verify no infinite RDHUP loop
- Observable: promoted standby log shows
ha_on_primary_ctrl_fd_rdhup → CTRL_ELECTING → CTRL_PRIMARYexactly once; cluster operational withinheartbeat_timeout_ms + election_timeout_ms + committing_timeout_ms + 5s - Requirements: 1.4, 2.1, 2.2, 2.3, 5.2
- Depends: 4.3
-
* 4.5 Integration test: missed-heartbeat tolerance (SIGSTOP/SIGCONT)
- Start 3-node cluster; SIGSTOP ctrl-1 for
1 × heartbeat_interval_ms; SIGCONT; verify no failover fires - SIGSTOP ctrl-1 for
heartbeat_timeout_ms + 500ms; verify failover fires exactly once - Observable: first scenario — no RDHUP log on standbys; second scenario — exactly one
ha_on_primary_ctrl_fd_rdhuplog per standby - Requirements: 2.2, 2.3
- Depends: 4.4
- Start 3-node cluster; SIGSTOP ctrl-1 for