feat(ha-heartbeat-liveness): idle-triggered primary NOP per standby (task 2.2)

Extend primary_heartbeat_fn to read last_push_time per standby and send NOP
only when elapsed >= heartbeat_interval_s. on_standby_connected writes
last_push_time before ctrlhello send. Remove workaround xce_nop_req at end
of on_standby_connected. Add ha_nop_req_hook XCE_TESTING seam. 3 new tests.
This commit is contained in:
Rongsong Shen 2026-06-01 15:05:41 +00:00
parent 738859fb4e
commit 622302a55c
3 changed files with 298 additions and 18 deletions

View file

@ -16,12 +16,16 @@
#define _GNU_SOURCE
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
#include "ha.h"
#include "netio.h"
#include "xce/cluster_def.h"
/* ── XCE_TESTING seam ────────────────────────────────────────────────────── */
@ -288,6 +292,198 @@ static void test_ha_disarm_makes_subsequent_poll_noop(void)
printf("test_ha_disarm_makes_subsequent_poll_noop: PASS\n");
}
/* ── Task 2.2: primary idle-tracking tests ──────────────────────────────── */
/*
* standby_ep_ctx_t: internal ha.c struct; redeclared here for direct
* construction. Must match the layout in ha.c exactly.
*/
typedef struct {
ha_state_t *ha;
cluster_t *c;
void *ctx; /* ha_elect_ctx_t*, always NULL for standby eps */
int peer_idx;
time_t last_push_time;
} standby_ep_ctx_t_test; /* suffix _test to avoid redefinition; ha.c uses standby_ep_ctx_t */
/*
* ha_nop_req_hook: XCE_TESTING hook. When non-NULL, primary_heartbeat_fn
* calls this INSTEAD OF xce_nop_req so tests can count sends without a
* live endpoint. Declared in ha.c.
*/
extern int (*ha_nop_req_hook)(endpoint_t *ep);
/*
* primary_heartbeat_fn_for_test: test entry-point that exercises the primary
* heartbeat idle-tracking logic directly without a live delaymgr.
* Declared in ha.c under XCE_TESTING.
*/
extern int primary_heartbeat_fn_for_test(cluster_t *c);
/*
* standby_ep_ctx_build_for_test: allocate a standby_ep_ctx_t, wire ep->data.
* Declared in ha.c under XCE_TESTING.
*/
extern endpoint_t *standby_ep_ctx_build_for_test(endpoint_t *ep,
ha_state_t *ha,
cluster_t *c,
int peer_idx);
/* ── Helpers ─────────────────────────────────────────────────────────────── */
static int g_nop_calls = 0;
static int stub_nop_req(endpoint_t *ep)
{
(void)ep;
g_nop_calls++;
return 0;
}
static void init_ha_primary_with_standby_eps(ha_state_t *ha)
{
ha_init(ha, &g_cfg, 0);
ha->standby_endpoints = calloc(1, sizeof(zarray_t));
zarray_init(ha->standby_endpoints, 4, NULL);
/* Set role to PRIMARY so primary_heartbeat_fn does not exit early */
atomic_store_explicit(&ha->role, (int32_t)CTRL_PRIMARY, memory_order_release);
}
static void fini_ha_primary_with_standby_eps(ha_state_t *ha)
{
if (ha->standby_endpoints) {
zarray_fini(ha->standby_endpoints);
free(ha->standby_endpoints);
ha->standby_endpoints = NULL;
}
ha_fini(ha);
}
/* ── Test 8: primary_heartbeat_fn sends NOP when last_push_time is 0 ────── */
/*
* Req 3.1, 3.2: standby ep with last_push_time=0 (long ago) — heartbeat fn
* must send NOP and update last_push_time to a recent timestamp.
*/
static void test_primary_hb_sends_nop_when_idle(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
init_ha_primary_with_standby_eps(&c.ha);
/* Build a mock standby endpoint with last_push_time=0 (idle since epoch) */
endpoint_t ep;
memset(&ep, 0, sizeof(ep));
standby_ep_ctx_build_for_test(&ep, &c.ha, &c, 0);
standby_ep_ctx_t_test *sctx = (standby_ep_ctx_t_test *)ep.data;
sctx->last_push_time = 0; /* long ago — elapsed >> heartbeat_interval_ms */
zarray_store(c.ha.standby_endpoints, &ep);
/* Install NOP hook */
g_nop_calls = 0;
ha_nop_req_hook = stub_nop_req;
primary_heartbeat_fn_for_test(&c);
ha_nop_req_hook = NULL;
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_nop_calls,
"Req 3.2: primary_heartbeat_fn must send NOP when last_push_time is stale");
/* last_push_time must have been updated to a recent value */
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, (int)sctx->last_push_time,
"Req 3.3: last_push_time must be updated after NOP send");
free(ep.data);
fini_ha_primary_with_standby_eps(&c.ha);
printf("test_primary_hb_sends_nop_when_idle: PASS\n");
}
/* ── Test 9: primary_heartbeat_fn skips NOP when last_push_time is fresh ── */
/*
* Req 3.4: standby ep with last_push_time=now — heartbeat fn must NOT send NOP.
*/
static void test_primary_hb_skips_nop_when_fresh(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
init_ha_primary_with_standby_eps(&c.ha);
endpoint_t ep;
memset(&ep, 0, sizeof(ep));
standby_ep_ctx_build_for_test(&ep, &c.ha, &c, 0);
standby_ep_ctx_t_test *sctx = (standby_ep_ctx_t_test *)ep.data;
/* Set last_push_time = now so elapsed ≈ 0 < heartbeat_interval_ms/1000 */
sctx->last_push_time = mono_now();
zarray_store(c.ha.standby_endpoints, &ep);
g_nop_calls = 0;
ha_nop_req_hook = stub_nop_req;
primary_heartbeat_fn_for_test(&c);
ha_nop_req_hook = NULL;
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_nop_calls,
"Req 3.4: primary_heartbeat_fn must NOT send NOP when last_push_time is fresh");
free(ep.data);
fini_ha_primary_with_standby_eps(&c.ha);
printf("test_primary_hb_skips_nop_when_fresh: PASS\n");
}
/* ── Test 10: on_standby_connected sets last_push_time ──────────────────── */
/*
* Req 3.3: on_standby_connected must write last_push_time so that the first
* heartbeat tick after connect does not send a redundant NOP.
*
* We use the standby_ep_ctx_build_for_test to construct a context with
* last_push_time=0, then verify that after a fresh connect (simulated by
* calling on_standby_connected_for_test), last_push_time is non-zero.
*/
extern int on_standby_connected_for_test(endpoint_t *ep);
static void test_on_standby_connected_sets_last_push_time(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
ha_state_t *ha = &c.ha;
ha_init(ha, &g_cfg, 0);
ha->standby_endpoints = calloc(1, sizeof(zarray_t));
zarray_init(ha->standby_endpoints, 4, NULL);
/* Provide minimal myconf so on_standby_connected doesn't deref NULL */
myconf_t conf;
memset(&conf, 0, sizeof(conf));
c.myconf = &conf;
endpoint_t ep;
memset(&ep, 0, sizeof(ep));
standby_ep_ctx_build_for_test(&ep, ha, &c, 0);
standby_ep_ctx_t_test *sctx = (standby_ep_ctx_t_test *)ep.data;
/* last_push_time is 0 from calloc */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, (int)sctx->last_push_time,
"last_push_time must start at 0 before on_standby_connected");
/* Call on_standby_connected directly via test entry (xce_request will fail
* on a null ep but last_push_time should be written before it) */
on_standby_connected_for_test(&ep);
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, (int)sctx->last_push_time,
"Req 3.3: on_standby_connected must write last_push_time != 0");
free(ep.data);
zarray_fini(ha->standby_endpoints);
free(ha->standby_endpoints);
ha->standby_endpoints = NULL;
ha_fini(ha);
printf("test_on_standby_connected_sets_last_push_time: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────────── */
void test_ha_liveness(void)
@ -300,5 +496,8 @@ void test_ha_liveness(void)
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);
RUN_TEST(test_primary_hb_sends_nop_when_idle);
RUN_TEST(test_primary_hb_skips_nop_when_fresh);
RUN_TEST(test_on_standby_connected_sets_last_push_time);
UNITY_END();
}