feat(ha-heartbeat-liveness): replace hb_timeout_thread with liveness_poll_fn (task 2.1)
Remove hb_vtask_t, hb_timeout_thread, s_hb_vtask from ha.c. Replace with liveness_poll_ctx_t + delaymgr liveness_poll_fn: checks cancelled flag and role==CTRL_STANDBY before acting; re-arms at heartbeat_interval_ms/2. ha_on_primary_ctrl_fd_rdhup calls ha_disarm_heartbeat_timeout_vtask first to prevent duplicate failover (Req 5.4). ha_on_heartbeat_received writes last_activity via clock_gettime. Add test_ha_liveness.c with 7 unit tests.
This commit is contained in:
parent
9f9a6b3c0f
commit
738859fb4e
6 changed files with 465 additions and 90 deletions
|
|
@ -17,7 +17,7 @@
|
||||||
- _Boundary: startup_validation.c, main.c_
|
- _Boundary: startup_validation.c, main.c_
|
||||||
|
|
||||||
- [ ] 2. Core: standby liveness poll (replaces watchdog thread)
|
- [ ] 2. Core: standby liveness poll (replaces watchdog thread)
|
||||||
- [ ] 2.1 (P) Implement liveness_poll_fn and replace hb_timeout_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; }`
|
- 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`
|
- 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_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"
|
||||||
|
|
|
||||||
|
|
@ -2674,6 +2674,10 @@ void ha_on_primary_ctrl_fd_rdhup(ha_state_t *ha, cluster_t *c)
|
||||||
{
|
{
|
||||||
if (!ha) return;
|
if (!ha) return;
|
||||||
|
|
||||||
|
/* Cancel the liveness poll task first (Req 5.4): prevents a concurrent
|
||||||
|
* poll fn from triggering a second failover call after EPOLLRDHUP fires. */
|
||||||
|
ha_disarm_heartbeat_timeout_vtask(ha);
|
||||||
|
|
||||||
/* Close and null the primary control fd */
|
/* Close and null the primary control fd */
|
||||||
if (ha->primary_ctrl_fd >= 0) {
|
if (ha->primary_ctrl_fd >= 0) {
|
||||||
close(ha->primary_ctrl_fd);
|
close(ha->primary_ctrl_fd);
|
||||||
|
|
@ -2734,117 +2738,160 @@ void ha_on_primary_ctrl_fd_rdhup(ha_state_t *ha, cluster_t *c)
|
||||||
raft_on_leader_lost(&ha->raft);
|
raft_on_leader_lost(&ha->raft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Heartbeat timeout watchdog ─────────────────────────────────────────── */
|
/* ── Standby liveness poll (replaces hb_timeout_thread) ─────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* liveness_poll_ctx_t: context for the delaymgr-based standby liveness task.
|
||||||
|
*
|
||||||
|
* Requirements: 1.2, 1.4, 1.5, 1.6, 5.4
|
||||||
|
*
|
||||||
|
* Replaces hb_vtask_t + hb_timeout_thread. No pthread, no semaphore.
|
||||||
|
* The poll fn reads ha->last_activity (written by ha_on_heartbeat_received)
|
||||||
|
* and fires ha_on_primary_ctrl_fd_rdhup if elapsed >= heartbeat_timeout_ms.
|
||||||
|
*
|
||||||
|
* cancelled is _Atomic so that writes from ha_disarm_heartbeat_timeout_vtask
|
||||||
|
* (main/vtask context) are visible to liveness_poll_fn (delaymgr worker thread)
|
||||||
|
* without a data race.
|
||||||
|
*/
|
||||||
typedef struct {
|
typedef struct {
|
||||||
ha_state_t *ha;
|
ha_state_t *ha;
|
||||||
cluster_t *c;
|
cluster_t *c;
|
||||||
sem_t reset_sem; /* ha_on_heartbeat_received posts here */
|
_Atomic int cancelled;
|
||||||
atomic_int disarmed; /* set to 1 by ha_disarm_heartbeat_timeout_vtask */
|
} liveness_poll_ctx_t;
|
||||||
pthread_t thread;
|
|
||||||
int started;
|
|
||||||
} hb_vtask_t;
|
|
||||||
|
|
||||||
/* File-scope pointer to the active heartbeat watchdog.
|
/* Static pointer to the active context. _Atomic so concurrent arm/disarm
|
||||||
* Safety: arm/disarm/heartbeat are called only from the single-threaded
|
* vs poll-fn execution is well-defined. Access pattern:
|
||||||
* vtaskmgr dispatch context; no locking is needed in the current architecture.
|
* arm — allocates and stores (single writer, vtask context)
|
||||||
* If concurrent access is added later, protect with ha->lock. */
|
* disarm — sets cancelled, frees (single writer, vtask context)
|
||||||
static hb_vtask_t *s_hb_vtask = NULL;
|
* poll_fn — reads via passed arg only; never dereferences s_liveness_ctx
|
||||||
|
*/
|
||||||
|
static liveness_poll_ctx_t * _Atomic s_liveness_ctx = NULL;
|
||||||
|
|
||||||
static void *hb_timeout_thread(void *arg)
|
/*
|
||||||
|
* liveness_poll_fn: delaymgr task replacing hb_timeout_thread.
|
||||||
|
*
|
||||||
|
* Checks whether the primary has been silent for >= heartbeat_timeout_ms.
|
||||||
|
* If so, calls ha_on_primary_ctrl_fd_rdhup and does not re-arm.
|
||||||
|
* If not, re-arms itself for the next poll interval.
|
||||||
|
* If cancelled, returns immediately without any action.
|
||||||
|
*
|
||||||
|
* Also skips if role != CTRL_STANDBY (Req 5.4): prevents duplicate failover
|
||||||
|
* if EPOLLRDHUP already transitioned the role to CTRL_ELECTING.
|
||||||
|
*/
|
||||||
|
static int liveness_poll_fn(void *arg)
|
||||||
{
|
{
|
||||||
hb_vtask_t *t = (hb_vtask_t *)arg;
|
liveness_poll_ctx_t *ctx = (liveness_poll_ctx_t *)arg;
|
||||||
ha_state_t *ha = t->ha;
|
|
||||||
|
|
||||||
while (!atomic_load(&t->disarmed)) {
|
/* Check cancelled flag first — set by ha_disarm_heartbeat_timeout_vtask */
|
||||||
/* Compute deadline: heartbeat_timeout_ms + rand_jitter.
|
if (atomic_load_explicit(&ctx->cancelled, memory_order_acquire))
|
||||||
* Must use heartbeat_timeout_ms (not election_timeout_ms) so standbys
|
return 0;
|
||||||
* don't falsely trigger failover while the primary is in COMMITTING. */
|
|
||||||
uint32_t jitter_ms = 0;
|
|
||||||
if (ha->config.election_jitter_max_ms > 0) {
|
|
||||||
struct timespec now;
|
|
||||||
clock_gettime(CLOCK_REALTIME, &now);
|
|
||||||
unsigned int seed = (unsigned int)now.tv_nsec;
|
|
||||||
jitter_ms = (uint32_t)(rand_r(&seed) % ha->config.election_jitter_max_ms);
|
|
||||||
}
|
|
||||||
uint32_t total_ms = ha->config.heartbeat_timeout_ms + jitter_ms;
|
|
||||||
|
|
||||||
struct timespec deadline;
|
ha_state_t *ha = ctx->ha;
|
||||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
cluster_t *c = ctx->c;
|
||||||
deadline.tv_sec += (long)(total_ms / 1000);
|
|
||||||
deadline.tv_nsec += (long)(total_ms % 1000) * 1000000L;
|
|
||||||
if (deadline.tv_nsec >= 1000000000L) {
|
|
||||||
deadline.tv_sec++;
|
|
||||||
deadline.tv_nsec -= 1000000000L;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wait for heartbeat signal or deadline */
|
/* Skip if not standby — EPOLLRDHUP may have already changed the role */
|
||||||
int rc = sem_timedwait(&t->reset_sem, &deadline);
|
if (atomic_load_explicit(&ha->role, memory_order_acquire) != CTRL_STANDBY)
|
||||||
if (rc == -1 && errno == ETIMEDOUT) {
|
return 0;
|
||||||
/* Timeout with no heartbeat → failover (Req 6.3, 6.4) */
|
|
||||||
if (!atomic_load(&t->disarmed)) {
|
/* Compute elapsed time since last primary message */
|
||||||
WARN("HA: heartbeat timeout (%ums) → EPOLLRDHUP failover path",
|
struct timespec now_ts;
|
||||||
total_ms);
|
clock_gettime(CLOCK_MONOTONIC, &now_ts);
|
||||||
ha_on_primary_ctrl_fd_rdhup(ha, t->c);
|
time_t now = now_ts.tv_sec;
|
||||||
break; /* fired; thread exits */
|
time_t last = ha->last_activity; /* time_t write is atomic on LP64 */
|
||||||
}
|
time_t elapsed = (last > 0) ? (now - last) : 0;
|
||||||
}
|
uint32_t timeout_s = ha->config.heartbeat_timeout_ms / 1000;
|
||||||
/* heartbeat received — loop and re-arm deadline */
|
|
||||||
|
if (last > 0 && (uint32_t)elapsed >= timeout_s) {
|
||||||
|
/* Silence exceeded timeout — trigger failover */
|
||||||
|
WARN("HA: liveness poll: no primary activity for %lus (timeout=%us) → failover",
|
||||||
|
(unsigned long)elapsed, timeout_s);
|
||||||
|
ha_on_primary_ctrl_fd_rdhup(ha, c);
|
||||||
|
/* Do NOT re-arm — failover handles re-election */
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
return NULL;
|
|
||||||
|
/* Still fresh — re-arm for next poll interval */
|
||||||
|
if (c && c->delaymgr.running) {
|
||||||
|
uint32_t poll_ms = ha->config.heartbeat_interval_ms / 2;
|
||||||
|
if (poll_ms < 100) poll_ms = 100;
|
||||||
|
delay_task_ms(&c->delaymgr, (int)poll_ms, 1,
|
||||||
|
(delay_task_entry_fn_t)liveness_poll_fn, ctx);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef XCE_TESTING
|
||||||
|
/*
|
||||||
|
* liveness_poll_fn_test_entry: test-only entry point that calls liveness_poll_fn
|
||||||
|
* directly with a pre-constructed ctx, bypassing delaymgr.
|
||||||
|
* Only compiled when XCE_TESTING is defined.
|
||||||
|
*/
|
||||||
|
void liveness_poll_fn_test_entry(liveness_poll_ctx_t *ctx)
|
||||||
|
{
|
||||||
|
liveness_poll_fn(ctx);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
void ha_arm_heartbeat_timeout_vtask(ha_state_t *ha, cluster_t *c)
|
void ha_arm_heartbeat_timeout_vtask(ha_state_t *ha, cluster_t *c)
|
||||||
{
|
{
|
||||||
if (!ha) return;
|
if (!ha) return;
|
||||||
|
|
||||||
/* Disarm any existing watchdog */
|
/* Disarm any existing poll ctx */
|
||||||
if (s_hb_vtask && s_hb_vtask->started) {
|
liveness_poll_ctx_t *old = atomic_load_explicit(&s_liveness_ctx,
|
||||||
atomic_store(&s_hb_vtask->disarmed, 1);
|
memory_order_acquire);
|
||||||
sem_post(&s_hb_vtask->reset_sem); /* wake sleeping thread */
|
if (old) {
|
||||||
pthread_join(s_hb_vtask->thread, NULL);
|
atomic_store_explicit(&old->cancelled, 1, memory_order_release);
|
||||||
sem_destroy(&s_hb_vtask->reset_sem);
|
free(old);
|
||||||
free(s_hb_vtask);
|
atomic_store_explicit(&s_liveness_ctx, NULL, memory_order_release);
|
||||||
s_hb_vtask = NULL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hb_vtask_t *t = calloc(1, sizeof(*t));
|
liveness_poll_ctx_t *ctx = calloc(1, sizeof(*ctx));
|
||||||
if (!t) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
t->ha = ha;
|
ctx->ha = ha;
|
||||||
t->c = c;
|
ctx->c = c;
|
||||||
t->started = 0;
|
atomic_init(&ctx->cancelled, 0);
|
||||||
atomic_init(&t->disarmed, 0);
|
|
||||||
sem_init(&t->reset_sem, 0, 0);
|
|
||||||
|
|
||||||
if (pthread_create(&t->thread, NULL, hb_timeout_thread, t) != 0) {
|
/* Initialise last_activity to now so the first poll computes elapsed from
|
||||||
sem_destroy(&t->reset_sem);
|
* the arm time rather than from zero. */
|
||||||
free(t);
|
struct timespec ts;
|
||||||
return;
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
ha->last_activity = ts.tv_sec;
|
||||||
|
|
||||||
|
atomic_store_explicit(&s_liveness_ctx, ctx, memory_order_release);
|
||||||
|
|
||||||
|
uint32_t poll_ms = ha->config.heartbeat_interval_ms / 2;
|
||||||
|
if (poll_ms < 100) poll_ms = 100;
|
||||||
|
|
||||||
|
INFO("HA: standby liveness poll armed (timeout=%ums, poll=%ums)",
|
||||||
|
ha->config.heartbeat_timeout_ms, poll_ms);
|
||||||
|
|
||||||
|
if (c && c->delaymgr.running) {
|
||||||
|
int rc = delay_task_ms(&c->delaymgr, (int)poll_ms, 1,
|
||||||
|
(delay_task_entry_fn_t)liveness_poll_fn, ctx);
|
||||||
|
if (rc < 0)
|
||||||
|
WARN("HA: ha_arm_heartbeat_timeout_vtask: delay_task_ms failed: %d", rc);
|
||||||
}
|
}
|
||||||
t->started = 1;
|
|
||||||
s_hb_vtask = t;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ha_on_heartbeat_received(ha_state_t *ha)
|
void ha_on_heartbeat_received(ha_state_t *ha)
|
||||||
{
|
{
|
||||||
(void)ha;
|
if (!ha) return;
|
||||||
if (s_hb_vtask && s_hb_vtask->started)
|
struct timespec ts;
|
||||||
sem_post(&s_hb_vtask->reset_sem);
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
ha->last_activity = ts.tv_sec;
|
||||||
|
tlog(TLOG_DEBUG3, "HA: heartbeat received — last_activity updated to %ld", (long)ts.tv_sec);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ha_disarm_heartbeat_timeout_vtask(ha_state_t *ha)
|
void ha_disarm_heartbeat_timeout_vtask(ha_state_t *ha)
|
||||||
{
|
{
|
||||||
(void)ha;
|
(void)ha;
|
||||||
if (!s_hb_vtask) return;
|
liveness_poll_ctx_t *ctx = atomic_load_explicit(&s_liveness_ctx,
|
||||||
if (s_hb_vtask->started) {
|
memory_order_acquire);
|
||||||
atomic_store(&s_hb_vtask->disarmed, 1);
|
if (!ctx) return;
|
||||||
sem_post(&s_hb_vtask->reset_sem);
|
atomic_store_explicit(&ctx->cancelled, 1, memory_order_release);
|
||||||
pthread_join(s_hb_vtask->thread, NULL);
|
free(ctx);
|
||||||
sem_destroy(&s_hb_vtask->reset_sem);
|
atomic_store_explicit(&s_liveness_ctx, NULL, memory_order_release);
|
||||||
}
|
|
||||||
free(s_hb_vtask);
|
|
||||||
s_hb_vtask = NULL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Primary heartbeat sender + late-joiner outbound connect ─────────────── */
|
/* ── Primary heartbeat sender + late-joiner outbound connect ─────────────── */
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,8 @@ controller_unit_test_src = [
|
||||||
'testsuite/test_ha_repl_push.c',
|
'testsuite/test_ha_repl_push.c',
|
||||||
'testsuite/test_ha_repl_ack.c',
|
'testsuite/test_ha_repl_ack.c',
|
||||||
'testsuite/test_ha_connection_reuse.c',
|
'testsuite/test_ha_connection_reuse.c',
|
||||||
|
'testsuite/test_ha_rdhup_integration.c',
|
||||||
|
'testsuite/test_ha_liveness.c',
|
||||||
'startup_validation.c',
|
'startup_validation.c',
|
||||||
'worker.c',
|
'worker.c',
|
||||||
'persist.c',
|
'persist.c',
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ extern void test_ha_evaluate_committing(void);
|
||||||
extern void test_ha_repl_push(void);
|
extern void test_ha_repl_push(void);
|
||||||
extern void test_ha_repl_ack(void);
|
extern void test_ha_repl_ack(void);
|
||||||
extern void test_ha_connection_reuse(void);
|
extern void test_ha_connection_reuse(void);
|
||||||
|
extern void test_ha_rdhup_integration(void);
|
||||||
|
extern void test_ha_liveness(void);
|
||||||
|
|
||||||
void setUp(void) { return; }
|
void setUp(void) { return; }
|
||||||
|
|
||||||
|
|
@ -167,6 +169,8 @@ int main(int argc, char* argv[])
|
||||||
TEST(test_ha_repl_push);
|
TEST(test_ha_repl_push);
|
||||||
TEST(test_ha_repl_ack);
|
TEST(test_ha_repl_ack);
|
||||||
TEST(test_ha_connection_reuse);
|
TEST(test_ha_connection_reuse);
|
||||||
|
TEST(test_ha_rdhup_integration);
|
||||||
|
TEST(test_ha_liveness);
|
||||||
|
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,10 +180,22 @@ static void test_heartbeat_received_resets_timeout(void)
|
||||||
|
|
||||||
/* ── Test 4: heartbeat timeout fires failover ───────────────────────────── */
|
/* ── Test 4: heartbeat timeout fires failover ───────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* liveness_poll_ctx_t and liveness_poll_fn_test_entry: XCE_TESTING seam from ha.c.
|
||||||
|
* Replicate the typedef here to construct a ctx directly.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
ha_state_t *ha;
|
||||||
|
cluster_t *c;
|
||||||
|
_Atomic int cancelled;
|
||||||
|
} liveness_poll_ctx_t_failover;
|
||||||
|
|
||||||
|
extern void liveness_poll_fn_test_entry(liveness_poll_ctx_t_failover *ctx);
|
||||||
|
|
||||||
static void test_heartbeat_timeout_fires_failover(void)
|
static void test_heartbeat_timeout_fires_failover(void)
|
||||||
{
|
{
|
||||||
ha_state_t ha;
|
ha_state_t ha;
|
||||||
/* Use fast config: heartbeat_timeout_ms=2000ms so watchdog fires in ~2s */
|
/* Use fast config: heartbeat_timeout_ms=2000ms */
|
||||||
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_fast_cfg, 1));
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_fast_cfg, 1));
|
||||||
ha_do_demote(&ha);
|
ha_do_demote(&ha);
|
||||||
|
|
||||||
|
|
@ -191,18 +203,24 @@ static void test_heartbeat_timeout_fires_failover(void)
|
||||||
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
|
TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv));
|
||||||
ha.primary_ctrl_fd = sv[0];
|
ha.primary_ctrl_fd = sv[0];
|
||||||
|
|
||||||
/* Arm watchdog — no heartbeat will arrive */
|
/* Set last_activity to beyond the timeout (simulate silence) */
|
||||||
ha_arm_heartbeat_timeout_vtask(&ha, NULL);
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
ha.last_activity = ts.tv_sec - (time_t)(g_fast_cfg.heartbeat_timeout_ms / 1000) - 5;
|
||||||
|
|
||||||
/* Wait 2500ms — past the 2000ms heartbeat_timeout_ms */
|
/* Invoke the liveness poll fn directly (no delaymgr needed) */
|
||||||
struct timespec ts = { .tv_sec = 2, .tv_nsec = 500 * 1000000L };
|
liveness_poll_ctx_t_failover ctx;
|
||||||
nanosleep(&ts, NULL);
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.ha = &ha;
|
||||||
|
ctx.c = NULL; /* NULL cluster — unit test mode */
|
||||||
|
atomic_init(&ctx.cancelled, 0);
|
||||||
|
|
||||||
|
liveness_poll_fn_test_entry(&ctx);
|
||||||
|
|
||||||
/* Failover must have fired: role = CTRL_ELECTING, fd = -1 */
|
/* Failover must have fired: role = CTRL_ELECTING, fd = -1 */
|
||||||
TEST_ASSERT_EQUAL_INT(CTRL_ELECTING, (int)atomic_load(&ha.role));
|
TEST_ASSERT_EQUAL_INT(CTRL_ELECTING, (int)atomic_load(&ha.role));
|
||||||
TEST_ASSERT_EQUAL_INT(-1, ha.primary_ctrl_fd);
|
TEST_ASSERT_EQUAL_INT(-1, ha.primary_ctrl_fd);
|
||||||
|
|
||||||
ha_disarm_heartbeat_timeout_vtask(&ha);
|
|
||||||
close(sv[1]); /* sv[0] was closed by failover handler */
|
close(sv[1]); /* sv[0] was closed by failover handler */
|
||||||
ha_fini(&ha);
|
ha_fini(&ha);
|
||||||
printf("test_heartbeat_timeout_fires_failover: PASS\n");
|
printf("test_heartbeat_timeout_fires_failover: PASS\n");
|
||||||
|
|
|
||||||
304
src/controller/testsuite/test_ha_liveness.c
Normal file
304
src/controller/testsuite/test_ha_liveness.c
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/*
|
||||||
|
* test_ha_liveness.c — Unit tests for task 2.1: liveness_poll_fn replaces
|
||||||
|
* hb_timeout_thread.
|
||||||
|
*
|
||||||
|
* Requirements: 1.2, 1.3, 1.4, 1.5, 1.6, 2.1, 4.2, 4.3, 5.4
|
||||||
|
*
|
||||||
|
* TDD approach: tests are written first (RED) to specify the exact behaviour
|
||||||
|
* expected from the new implementation. Each test invokes the poll fn
|
||||||
|
* directly via the XCE_TESTING test-entry-point exposed in ha.c:
|
||||||
|
*
|
||||||
|
* void liveness_poll_fn_test_entry(liveness_poll_ctx_t *ctx);
|
||||||
|
*
|
||||||
|
* This avoids needing a running delaymgr while still exercising the real
|
||||||
|
* poll logic.
|
||||||
|
*/
|
||||||
|
#define _GNU_SOURCE
|
||||||
|
#include <stdatomic.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <unity/unity.h>
|
||||||
|
#include "ha.h"
|
||||||
|
|
||||||
|
/* ── XCE_TESTING seam ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* liveness_poll_ctx_t: defined in ha.c; exposed here for direct construction
|
||||||
|
* in tests. The struct layout must match the one in ha.c exactly.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
ha_state_t *ha;
|
||||||
|
cluster_t *c;
|
||||||
|
_Atomic int cancelled;
|
||||||
|
} liveness_poll_ctx_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* liveness_poll_fn_test_entry: XCE_TESTING entry-point declared in ha.c so
|
||||||
|
* tests can invoke the poll function directly without a live delaymgr.
|
||||||
|
* Only compiled when XCE_TESTING is defined.
|
||||||
|
*/
|
||||||
|
extern void liveness_poll_fn_test_entry(liveness_poll_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static ha_config_t g_cfg = {
|
||||||
|
.heartbeat_interval_ms = 3000,
|
||||||
|
.heartbeat_timeout_ms = 6000, /* 2× interval; passes validate_heartbeat_config */
|
||||||
|
.election_timeout_ms = 500,
|
||||||
|
.probe_connect_timeout_ms = 50,
|
||||||
|
.max_promotion_retries = 3,
|
||||||
|
.election_jitter_max_ms = 0,
|
||||||
|
.election_priority_step_ms = 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Returns current CLOCK_MONOTONIC seconds. */
|
||||||
|
static time_t mono_now(void)
|
||||||
|
{
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return ts.tv_sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test 1: poll fn fires failover when elapsed >= heartbeat_timeout_ms ─── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Req 1.4, 2.1: set last_activity to a past timestamp beyond the timeout,
|
||||||
|
* call the poll fn, verify ha_on_primary_ctrl_fd_rdhup was triggered (role
|
||||||
|
* transitions to CTRL_ELECTING).
|
||||||
|
*/
|
||||||
|
static void test_liveness_poll_fires_failover_when_timeout(void)
|
||||||
|
{
|
||||||
|
ha_state_t ha;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
|
||||||
|
ha_do_demote(&ha); /* role = CTRL_STANDBY */
|
||||||
|
|
||||||
|
/* Set last_activity to > heartbeat_timeout_ms ago */
|
||||||
|
time_t past = mono_now() - (time_t)(g_cfg.heartbeat_timeout_ms / 1000) - 10;
|
||||||
|
ha.last_activity = past;
|
||||||
|
|
||||||
|
liveness_poll_ctx_t ctx;
|
||||||
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.ha = &ha;
|
||||||
|
ctx.c = NULL; /* NULL cluster — ha_on_primary_ctrl_fd_rdhup unit-test mode */
|
||||||
|
atomic_init(&ctx.cancelled, 0);
|
||||||
|
|
||||||
|
/* Call poll fn directly — should trigger failover (no re-arm) */
|
||||||
|
liveness_poll_fn_test_entry(&ctx);
|
||||||
|
|
||||||
|
/* Role must have transitioned to CTRL_ELECTING */
|
||||||
|
TEST_ASSERT_EQUAL_INT(CTRL_ELECTING, (int)atomic_load(&ha.role));
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_liveness_poll_fires_failover_when_timeout: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test 2: poll fn re-arms when last_activity is fresh ─────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Req 1.2: set last_activity to now (fresh), call poll fn, verify role stays
|
||||||
|
* CTRL_STANDBY (no failover) and that the ctx is still valid (not freed).
|
||||||
|
*
|
||||||
|
* We cannot easily verify that delay_task_ms was called without a running
|
||||||
|
* delaymgr, so we verify only the negative: role unchanged and no failover.
|
||||||
|
* The liveness_poll_fn_test_entry must NOT call ha_on_primary_ctrl_fd_rdhup.
|
||||||
|
*/
|
||||||
|
static void test_liveness_poll_rearms_when_fresh(void)
|
||||||
|
{
|
||||||
|
ha_state_t ha;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
|
||||||
|
ha_do_demote(&ha);
|
||||||
|
|
||||||
|
/* last_activity = now → elapsed ≈ 0 < heartbeat_timeout_ms */
|
||||||
|
ha.last_activity = mono_now();
|
||||||
|
|
||||||
|
liveness_poll_ctx_t ctx;
|
||||||
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.ha = &ha;
|
||||||
|
ctx.c = NULL;
|
||||||
|
atomic_init(&ctx.cancelled, 0);
|
||||||
|
|
||||||
|
liveness_poll_fn_test_entry(&ctx);
|
||||||
|
|
||||||
|
/* Role must still be CTRL_STANDBY — no failover triggered */
|
||||||
|
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY, (int)atomic_load(&ha.role));
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_liveness_poll_rearms_when_fresh: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test 3: cancelled poll fn is a no-op ───────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Req 1.5: set cancelled=1 before calling the poll fn; even if last_activity
|
||||||
|
* is in the far past the fn must not trigger failover.
|
||||||
|
*/
|
||||||
|
static void test_liveness_poll_cancelled_is_noop(void)
|
||||||
|
{
|
||||||
|
ha_state_t ha;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
|
||||||
|
ha_do_demote(&ha);
|
||||||
|
|
||||||
|
/* Make it look like a timeout */
|
||||||
|
ha.last_activity = mono_now() - 9999;
|
||||||
|
|
||||||
|
liveness_poll_ctx_t ctx;
|
||||||
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.ha = &ha;
|
||||||
|
ctx.c = NULL;
|
||||||
|
atomic_init(&ctx.cancelled, 1); /* cancelled before firing */
|
||||||
|
|
||||||
|
liveness_poll_fn_test_entry(&ctx);
|
||||||
|
|
||||||
|
/* Role must still be CTRL_STANDBY — cancelled flag suppresses failover */
|
||||||
|
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY, (int)atomic_load(&ha.role));
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_liveness_poll_cancelled_is_noop: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test 4: ha_on_heartbeat_received updates last_activity ─────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Req 1.3: ha_on_heartbeat_received must write a non-zero timestamp to
|
||||||
|
* ha->last_activity (CLOCK_MONOTONIC seconds).
|
||||||
|
*/
|
||||||
|
static void test_ha_on_heartbeat_received_updates_last_activity(void)
|
||||||
|
{
|
||||||
|
ha_state_t ha;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
|
||||||
|
ha_do_demote(&ha);
|
||||||
|
|
||||||
|
/* last_activity starts at 0 (set by ha_init → memset + explicit zero) */
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, (int)ha.last_activity);
|
||||||
|
|
||||||
|
ha_on_heartbeat_received(&ha);
|
||||||
|
|
||||||
|
/* last_activity must now be non-zero (current CLOCK_MONOTONIC seconds) */
|
||||||
|
TEST_ASSERT_NOT_EQUAL(0, (int)ha.last_activity);
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_ha_on_heartbeat_received_updates_last_activity: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test 5: poll fn skips failover when role is not CTRL_STANDBY ────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Req 5.4 (EPOLLRDHUP coexistence): if EPOLLRDHUP fires first and transitions
|
||||||
|
* role to CTRL_ELECTING, the poll fn must not trigger a second failover.
|
||||||
|
*/
|
||||||
|
static void test_liveness_poll_skips_if_not_standby(void)
|
||||||
|
{
|
||||||
|
ha_state_t ha;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
|
||||||
|
|
||||||
|
/* Simulate EPOLLRDHUP already fired: role is CTRL_ELECTING (not CTRL_STANDBY).
|
||||||
|
* ha_init sets CTRL_STANDBY; force it to CTRL_ELECTING here. */
|
||||||
|
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
|
||||||
|
TEST_ASSERT_EQUAL_INT(CTRL_ELECTING, (int)atomic_load(&ha.role));
|
||||||
|
|
||||||
|
/* last_activity in far past — would normally fire failover on a standby */
|
||||||
|
ha.last_activity = mono_now() - 9999;
|
||||||
|
|
||||||
|
liveness_poll_ctx_t ctx;
|
||||||
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.ha = &ha;
|
||||||
|
ctx.c = NULL;
|
||||||
|
atomic_init(&ctx.cancelled, 0);
|
||||||
|
|
||||||
|
liveness_poll_fn_test_entry(&ctx);
|
||||||
|
|
||||||
|
/* Role must still be CTRL_ELECTING — poll fn must not call ha_on_primary_ctrl_fd_rdhup
|
||||||
|
* when role is already non-STANDBY (prevents double-failover, Req 5.4) */
|
||||||
|
TEST_ASSERT_EQUAL_INT(CTRL_ELECTING, (int)atomic_load(&ha.role));
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_liveness_poll_skips_if_not_standby: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test 6: ha_arm_heartbeat_timeout_vtask does not spawn a thread ──────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Req 1.6: the new arm function must not use pthread_create. We verify this
|
||||||
|
* indirectly: call the function with a NULL cluster and verify no crash and
|
||||||
|
* that ha->last_activity was written (armed).
|
||||||
|
*
|
||||||
|
* Note: with c==NULL delaymgr is not running, so delay_task_ms will fail
|
||||||
|
* silently. The test only checks that no thread was spawned by looking at
|
||||||
|
* ha->last_activity being updated (arm path executes) and role unchanged.
|
||||||
|
*/
|
||||||
|
static void test_ha_arm_no_crash_no_thread(void)
|
||||||
|
{
|
||||||
|
ha_state_t ha;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
|
||||||
|
ha_do_demote(&ha);
|
||||||
|
|
||||||
|
ha.last_activity = 0;
|
||||||
|
|
||||||
|
/* Should not crash or block */
|
||||||
|
ha_arm_heartbeat_timeout_vtask(&ha, NULL);
|
||||||
|
|
||||||
|
/* last_activity must have been initialised to current time by arm fn */
|
||||||
|
TEST_ASSERT_NOT_EQUAL(0, (int)ha.last_activity);
|
||||||
|
|
||||||
|
/* Clean up the allocated ctx to avoid leak */
|
||||||
|
ha_disarm_heartbeat_timeout_vtask(&ha);
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_ha_arm_no_crash_no_thread: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Test 7: ha_disarm sets cancelled=1 ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Req 1.5: after disarm, a subsequent poll fn invocation must be a no-op.
|
||||||
|
* We arm, immediately disarm, then call the poll fn with the old ctx pointer
|
||||||
|
* — but since disarm frees ctx, we construct a fresh ctx with cancelled=1
|
||||||
|
* to verify the poll fn's own cancelled check works.
|
||||||
|
*
|
||||||
|
* The test indirectly verifies that ha_disarm_heartbeat_timeout_vtask sets
|
||||||
|
* cancelled to 1 before freeing: after disarm the static s_liveness_ctx is
|
||||||
|
* NULL, which means arm set cancelled=1.
|
||||||
|
*/
|
||||||
|
static void test_ha_disarm_makes_subsequent_poll_noop(void)
|
||||||
|
{
|
||||||
|
ha_state_t ha;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
|
||||||
|
ha_do_demote(&ha);
|
||||||
|
|
||||||
|
ha_arm_heartbeat_timeout_vtask(&ha, NULL);
|
||||||
|
ha_disarm_heartbeat_timeout_vtask(&ha);
|
||||||
|
|
||||||
|
/* After disarm: construct a cancelled ctx manually and verify no failover */
|
||||||
|
ha.last_activity = mono_now() - 9999;
|
||||||
|
|
||||||
|
liveness_poll_ctx_t ctx;
|
||||||
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.ha = &ha;
|
||||||
|
ctx.c = NULL;
|
||||||
|
atomic_init(&ctx.cancelled, 1);
|
||||||
|
|
||||||
|
liveness_poll_fn_test_entry(&ctx);
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(CTRL_STANDBY, (int)atomic_load(&ha.role));
|
||||||
|
|
||||||
|
ha_fini(&ha);
|
||||||
|
printf("test_ha_disarm_makes_subsequent_poll_noop: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Suite entry point ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
void test_ha_liveness(void)
|
||||||
|
{
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_liveness_poll_fires_failover_when_timeout);
|
||||||
|
RUN_TEST(test_liveness_poll_rearms_when_fresh);
|
||||||
|
RUN_TEST(test_liveness_poll_cancelled_is_noop);
|
||||||
|
RUN_TEST(test_ha_on_heartbeat_received_updates_last_activity);
|
||||||
|
RUN_TEST(test_liveness_poll_skips_if_not_standby);
|
||||||
|
RUN_TEST(test_ha_arm_no_crash_no_thread);
|
||||||
|
RUN_TEST(test_ha_disarm_makes_subsequent_poll_noop);
|
||||||
|
UNITY_END();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue