myxce/.kiro/specs/ha-heartbeat-liveness/tasks.md
Rongsong Shen 06135954ff chore: baseline snapshot — prior-session work + ha-consistency-invariants spec family
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.
2026-06-11 13:21:50 +00:00

103 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Implementation Plan
- [x] 1. Foundation: state fields and startup validation
- [x] 1.1 Add liveness state fields to ha_state_t and standby_ep_ctx_t
- Add `time_t last_activity` to `ha_state_t` in ha.h; zero-initialize in `ha_init()`
- Add `time_t last_push_time` to `standby_ep_ctx_t` struct (ha.c internal); initialize to 0 in all allocation sites
- Remove `hb_vtask_t` typedef declaration from ha.h (struct moved to ha.c or fully removed)
- Observable: project builds; `ha_init()` test verifies `last_activity == 0` after init
- _Requirements: 1.1, 1.3, 3.1_
- [x] 1.2 Add heartbeat timing validation at startup
- Implement `validate_heartbeat_config(heartbeat_timeout_ms, heartbeat_interval_ms, err_buf, err_len)` in `startup_validation.c`
- Returns `-EINVAL` when `heartbeat_timeout_ms < 2 * heartbeat_interval_ms`; writes human-readable error into `err_buf`
- Wire call into `main.c` after `ha_config_t` is populated, before `ha_init()`
- Observable: starting controller with `heartbeat_timeout_ms = heartbeat_interval_ms` prints error and exits non-zero
- _Requirements: 2.4_
- _Boundary: startup_validation.c, main.c_
- [x] 2. Core: standby liveness poll (replaces watchdog thread)
- [x] 2.1 (P) Implement liveness_poll_fn and replace hb_timeout_thread
- Define `liveness_poll_ctx_t` struct in ha.c: `{ ha_state_t *ha; cluster_t *c; _Atomic int cancelled; }`
- Implement `liveness_poll_fn(void *arg)`: check `cancelled` first; check `atomic_load(&ha->role) == CTRL_STANDBY` to guard against duplicate fires with EPOLLRDHUP path; read `ha->last_activity`, compute elapsed; if `elapsed >= heartbeat_timeout_ms` call `ha_on_primary_ctrl_fd_rdhup(ha, c)`; else re-arm via `delay_task_ms` at `heartbeat_interval_ms / 2`
- Replace `ha_arm_heartbeat_timeout_vtask`: allocate `liveness_poll_ctx_t`, write `clock_gettime` to `ha->last_activity`, call `delay_task_ms` to schedule first poll; log INFO "HA: standby liveness poll armed"
- Replace `ha_disarm_heartbeat_timeout_vtask`: set `ctx->cancelled = 1`, free ctx, set static ptr to NULL; no pthread_join needed
- Add `ha_disarm_heartbeat_timeout_vtask` call at the top of `ha_on_primary_ctrl_fd_rdhup` so that EPOLLRDHUP cancels any pending poll task before transitioning to CTRL_ELECTING (Req 5.4)
- Update `ha_on_heartbeat_received`: write `clock_gettime(CLOCK_MONOTONIC)` seconds to `ha->last_activity`; log DEBUG3 on each reset (not INFO to avoid log spam)
- Remove `hb_vtask_t` struct, `hb_timeout_thread` function, `s_hb_vtask` static variable from ha.c
- Observable: `grep -r hb_timeout_thread src/controller/*.c` returns zero matches; build passes with no new warnings; SIGKILL primary → standbys show exactly one `ha_on_primary_ctrl_fd_rdhup` log 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)_
- [x] 2.2 (P) Extend primary_heartbeat_fn with per-standby idle tracking
- In `primary_heartbeat_fn`: for each ep in `ha->standby_endpoints`, cast `ep->data` to `standby_ep_ctx_t` and read `sctx->last_push_time`
- Compute `elapsed = now - sctx->last_push_time`; if `elapsed >= heartbeat_interval_ms` call `xce_nop_req(ep)` and write `sctx->last_push_time = now`; else skip NOP for this standby
- In `on_standby_connected`: write `sctx->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 of `on_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)_
- [x] 3. Integration: wire liveness reset into opcode dispatch
- [x] 3.1 Update cluster.c opcode handlers to reset standby liveness on any primary message
- `OPCode_nop` handler: replace `if (ep->fd == c->ha.primary_ctrl_fd)` guard with `if (atomic_load(&c->ha.role) == CTRL_STANDBY)` — call `ha_on_heartbeat_received(&c->ha)`
- `OPCode_hello` handler (ctrlhello path): after the existing `handle_ctrlhello` call, additionally call `ha_on_heartbeat_received(&c->ha)` when role is CTRL_STANDBY (ctrlhello counts as primary activity per Req 4.1)
- `OPCode_replEntry` and `OPCode_walCatchupEntry` handlers: add `ha_on_heartbeat_received(&c->ha)` call when role is CTRL_STANDBY
- Observable: in a running 3-node cluster, after primary sends ctrlhello, `ha->last_activity` timestamp is fresh and standby liveness poll does not fire within `heartbeat_timeout_ms`
- _Requirements: 1.3, 4.1_
- _Depends: 2.1_
- _Boundary: cluster.c (opcode dispatch)_
- [x] 3.2 Verify config_repl.c ctrlhello integration and EPOLLRDHUP cancellation path
- Confirm `ha_arm_heartbeat_timeout_vtask` call in `handle_ctrlhello` (config_repl.c:1380) still correctly arms the delaymgr poll (not a thread)
- Confirm `ha_on_heartbeat_received` call at config_repl.c:1379 writes `last_activity` (new impl)
- Confirm `ha_on_primary_ctrl_fd_rdhup` now calls `ha_disarm_heartbeat_timeout_vtask` as its first step (Req 5.4)
- Observable: build passes; `handle_ctrlhello` unit test (existing) still green; `ha_arm_heartbeat_timeout_vtask` no 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)_
- [x] 4. Validation: unit and integration tests
- [x] 4.1 (P) Unit tests for standby liveness poll
- Test `liveness_poll_fn` fires `ha_on_primary_ctrl_fd_rdhup` when `now - last_activity >= heartbeat_timeout_ms`
- Test `liveness_poll_fn` re-arms (no failover) when `now - last_activity < heartbeat_timeout_ms`
- Test `liveness_poll_fn` with `cancelled = 1` is a no-op (no failover, no re-arm)
- Test `ha_on_heartbeat_received` updates `ha->last_activity` to a non-zero value
- Test `ha_arm_heartbeat_timeout_vtask` does NOT spawn a pthread (thread count unchanged before/after call)
- Test `validate_heartbeat_config`: `timeout = 2 × interval` passes; `timeout = interval - 1` fails
- Observable: `meson test --suite controller` exits 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_
- [x] 4.2 (P) Unit tests for primary idle NOP suppression
- Test `primary_heartbeat_fn`: sends NOP to standby when `last_push_time` elapsed ≥ `heartbeat_interval_ms`
- Test `primary_heartbeat_fn`: skips NOP when `last_push_time` elapsed < `heartbeat_interval_ms`
- Test `on_standby_connected` writes non-zero `last_push_time` immediately
- Observable: all 3 test cases pass; `meson test --suite controller` exits 0
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- _Boundary: ha.c unit tests_
- _Depends: 2.2_
- [x] 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 before `heartbeat_timeout_ms` elapses from ctrlhello
- Verify cluster is stable for 60s with no `hb_timeout_thread` log entries (thread removed) and no RDHUP false-positive entries
- Observable: `grep "hb_timeout_thread\|heartbeat timeout" /tmp/xce-deploy/logs/ctrl-{2,3}.log` returns 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_
- [x] 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_PRIMARY` exactly once; cluster operational within `heartbeat_timeout_ms + election_timeout_ms + committing_timeout_ms + 5s`
- _Requirements: 1.4, 2.1, 2.2, 2.3, 5.2_
- _Depends: 4.3_
- [x]* 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_rdhup` log per standby
- _Requirements: 2.2, 2.3_
- _Depends: 4.4_