feat(raft-drive-async): offload delaymgr peer reconnect to slow lane (task 2.1)
Heartbeat timer detects a disconnected peer, CAS-acquires a per-peer single-flight flag, slowlane_submits the bounded connect, and returns — the delaymgr never blocks on connect. Lane worker (reconnect_lane_fn) does the bounded endpoint_connect + lock-guarded iomgr_monitor; flag cleared on all terminal paths (incl. -EEXIST race fix). Default-ON kill-switch retains the inline path for TDD-RED/fallback. Never self-terminates on failure. 2.2 adds the inbound/outbound race + failure hardening (documented seam). 196/0 green.
This commit is contained in:
parent
a36234a103
commit
e07667ff9b
4 changed files with 569 additions and 17 deletions
|
|
@ -22,9 +22,12 @@
|
|||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <pthread.h>
|
||||
|
||||
#include <unity/unity.h>
|
||||
#include "ha.h"
|
||||
#include "netio.h"
|
||||
#include "slowlane.h"
|
||||
#include "xce/cluster_def.h"
|
||||
|
||||
/* ── XCE_TESTING seam ────────────────────────────────────────────────────── */
|
||||
|
|
@ -842,6 +845,279 @@ static void test_ha_arm_does_not_increase_thread_count(void)
|
|||
printf("test_ha_arm_does_not_increase_thread_count: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Tests 15–17: raft-drive-async Task 2.1 — reconnect offload ──────────────
|
||||
*
|
||||
* Acceptance criteria (Req 2.1, 2.2, 2.5; design ReconnectOffload trigger):
|
||||
* - the delaymgr heartbeat tick detects a disconnected peer and submits the
|
||||
* reconnect to the slow lane, then returns immediately — it NEVER performs
|
||||
* the blocking connect inline (flag OFF = old inline path, which stalls);
|
||||
* - per-peer dedup: while a reconnect to peer X is in flight, a second tick
|
||||
* enqueues no duplicate;
|
||||
* - heartbeats to reachable peers keep flowing while one peer is unreachable.
|
||||
*
|
||||
* Determinism: the blocking connect half is replaced by ha_reconnect_connect_hook,
|
||||
* which parks on a test-owned gate to simulate an unreachable peer. The test
|
||||
* controls exactly when the "connect" completes, so no real socket/timing is
|
||||
* involved. primary_heartbeat_fn_for_test is driven on a worker thread so the
|
||||
* test can assert it returns promptly (offload) vs stalls (inline). */
|
||||
|
||||
extern int (*ha_reconnect_connect_hook)(cluster_t *c, int peer_idx);
|
||||
|
||||
/* Gate that the reconnect hook parks on (simulates an unreachable peer). */
|
||||
typedef struct {
|
||||
pthread_mutex_t mu;
|
||||
pthread_cond_t release_cv; /* test broadcasts to release the parked hook */
|
||||
pthread_cond_t entered_cv; /* hook broadcasts when it has entered/parked */
|
||||
int gate_open;
|
||||
_Atomic int entered; /* hook invocations that reached the gate */
|
||||
_Atomic int calls; /* total hook invocations (dedup oracle) */
|
||||
} rc_gate_t;
|
||||
|
||||
static rc_gate_t g_rc_gate;
|
||||
|
||||
static void rc_gate_init(rc_gate_t *g)
|
||||
{
|
||||
pthread_mutex_init(&g->mu, NULL);
|
||||
pthread_cond_init(&g->release_cv, NULL);
|
||||
pthread_cond_init(&g->entered_cv, NULL);
|
||||
g->gate_open = 0;
|
||||
atomic_store(&g->entered, 0);
|
||||
atomic_store(&g->calls, 0);
|
||||
}
|
||||
|
||||
static void rc_gate_destroy(rc_gate_t *g)
|
||||
{
|
||||
pthread_mutex_destroy(&g->mu);
|
||||
pthread_cond_destroy(&g->release_cv);
|
||||
pthread_cond_destroy(&g->entered_cv);
|
||||
}
|
||||
|
||||
/* The hook stand-in for the blocking connect: count, signal entry, park until
|
||||
* the test opens the gate, then return success. Runs on a slow-lane worker
|
||||
* (flag ON) or inline on the caller (flag OFF). */
|
||||
static int rc_gated_connect_hook(cluster_t *c, int peer_idx)
|
||||
{
|
||||
(void)c; (void)peer_idx;
|
||||
atomic_fetch_add(&g_rc_gate.calls, 1);
|
||||
|
||||
pthread_mutex_lock(&g_rc_gate.mu);
|
||||
atomic_fetch_add(&g_rc_gate.entered, 1);
|
||||
pthread_cond_broadcast(&g_rc_gate.entered_cv);
|
||||
while (!g_rc_gate.gate_open)
|
||||
pthread_cond_wait(&g_rc_gate.release_cv, &g_rc_gate.mu);
|
||||
pthread_mutex_unlock(&g_rc_gate.mu);
|
||||
return 0; /* simulate a successful connect */
|
||||
}
|
||||
|
||||
static void rc_gate_open(rc_gate_t *g)
|
||||
{
|
||||
pthread_mutex_lock(&g->mu);
|
||||
g->gate_open = 1;
|
||||
pthread_cond_broadcast(&g->release_cv);
|
||||
pthread_mutex_unlock(&g->mu);
|
||||
}
|
||||
|
||||
/* Wait (bounded) until at least n hook invocations have parked at the gate. */
|
||||
static int rc_gate_wait_entered(rc_gate_t *g, int n, int timeout_ms)
|
||||
{
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_sec += timeout_ms / 1000;
|
||||
deadline.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
|
||||
if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; }
|
||||
|
||||
pthread_mutex_lock(&g->mu);
|
||||
int rc = 0;
|
||||
while (atomic_load(&g->entered) < n) {
|
||||
rc = pthread_cond_timedwait(&g->entered_cv, &g->mu, &deadline);
|
||||
if (rc != 0) break;
|
||||
}
|
||||
int entered = atomic_load(&g->entered);
|
||||
pthread_mutex_unlock(&g->mu);
|
||||
return entered >= n ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Build a primary cluster_t with: peers_cfg of 3 candidates (self at priority
|
||||
* 0), one reachable standby already in standby_endpoints (peer 1), and peer 2
|
||||
* absent (so the reconnect scan targets it). The slow lane is initialised. */
|
||||
static void rc_setup_primary(cluster_t *c, myconf_t *conf, endpoint_t *standby_ep)
|
||||
{
|
||||
memset(c, 0, sizeof(*c));
|
||||
memset(conf, 0, sizeof(*conf));
|
||||
|
||||
init_ha_primary_with_standby_eps(&c->ha);
|
||||
|
||||
/* 3-node cluster; self = priority 0. */
|
||||
conf->peers_cfg.candidates_count = 3;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
snprintf(conf->peers_cfg.candidates[i].name,
|
||||
sizeof(conf->peers_cfg.candidates[i].name), "ctrl%d", i);
|
||||
snprintf(conf->peers_cfg.candidates[i].hostname,
|
||||
sizeof(conf->peers_cfg.candidates[i].hostname), "127.0.0.1");
|
||||
conf->peers_cfg.candidates[i].port = 19000 + i;
|
||||
}
|
||||
c->myconf = conf;
|
||||
c->ha.my_priority = 0;
|
||||
c->myhost = &c->myhost_info;
|
||||
|
||||
/* Slow lane for the offload path. */
|
||||
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&c->slowlane, 2, 4));
|
||||
|
||||
/* Reachable standby for peer 1 (absolute index 1), already connected. */
|
||||
memset(standby_ep, 0, sizeof(*standby_ep));
|
||||
standby_ep_ctx_build_for_test(standby_ep, &c->ha, c, 1);
|
||||
standby_ep_ctx_t_test *sctx = (standby_ep_ctx_t_test *)standby_ep->data;
|
||||
sctx->last_push_time = 0; /* stale → heartbeat will be sent */
|
||||
zarray_store(c->ha.standby_endpoints, standby_ep);
|
||||
}
|
||||
|
||||
static void rc_teardown_primary(cluster_t *c, endpoint_t *standby_ep)
|
||||
{
|
||||
slowlane_fini(&c->slowlane);
|
||||
free(standby_ep->data);
|
||||
fini_ha_primary_with_standby_eps(&c->ha);
|
||||
}
|
||||
|
||||
/* Worker thread that drives one heartbeat tick; sets *done=1 when it returns. */
|
||||
typedef struct { cluster_t *c; _Atomic int done; } rc_tick_arg_t;
|
||||
static void *rc_tick_thread(void *p)
|
||||
{
|
||||
rc_tick_arg_t *a = (rc_tick_arg_t *)p;
|
||||
primary_heartbeat_fn_for_test(a->c);
|
||||
atomic_store(&a->done, 1);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/*
|
||||
* Test 15 (Req 2.1, 2.2): with the offload flag ON, a heartbeat tick whose
|
||||
* reconnect target is unreachable (the connect hook parks) RETURNS PROMPTLY —
|
||||
* the blocking connect runs on the lane, not on the delaymgr thread — and the
|
||||
* heartbeat to the reachable standby was still sent.
|
||||
*
|
||||
* With the flag OFF (RED) the inline connect runs on the tick thread and the
|
||||
* tick BLOCKS in the gate, so the same assertion (tick done within the
|
||||
* deadline) FAILS. The flag toggle is what flips RED↔GREEN.
|
||||
*/
|
||||
static void rc_run_offload_tick(int offload_on, const char *label)
|
||||
{
|
||||
cluster_t c;
|
||||
myconf_t conf;
|
||||
endpoint_t standby_ep;
|
||||
rc_setup_primary(&c, &conf, &standby_ep);
|
||||
|
||||
rc_gate_init(&g_rc_gate);
|
||||
ha_reconnect_connect_hook = rc_gated_connect_hook;
|
||||
ha_set_reconnect_offload_for_test(offload_on);
|
||||
|
||||
g_nop_calls = 0;
|
||||
ha_nop_req_hook = stub_nop_req;
|
||||
|
||||
rc_tick_arg_t arg = { .c = &c };
|
||||
atomic_store(&arg.done, 0);
|
||||
|
||||
pthread_t th;
|
||||
pthread_create(&th, NULL, rc_tick_thread, &arg);
|
||||
|
||||
/* The tick should return within a generous deadline IFF the connect was
|
||||
* offloaded. Poll arg.done. */
|
||||
int done = 0;
|
||||
for (int i = 0; i < 200; i++) { /* up to ~2s */
|
||||
if (atomic_load(&arg.done)) { done = 1; break; }
|
||||
struct timespec ts = { .tv_sec = 0, .tv_nsec = 10 * 1000000L };
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
if (offload_on) {
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, done,
|
||||
"Req 2.1/2.2: tick must return promptly with reconnect offloaded "
|
||||
"(connect parked on the lane, delaymgr thread free)");
|
||||
/* The reachable standby still got its heartbeat this tick. */
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_nop_calls,
|
||||
"Req 2.1: heartbeat to the reachable standby must be sent while a "
|
||||
"peer is unreachable");
|
||||
/* The reconnect connect was submitted to the lane (hook entered). */
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc_gate_wait_entered(&g_rc_gate, 1, 1000),
|
||||
"Req 2.1: the offloaded reconnect connect must run on the lane");
|
||||
} else {
|
||||
/* RED: inline connect blocks the tick thread inside the gate. */
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, done,
|
||||
"RED: with offload OFF the inline connect blocks the delaymgr tick");
|
||||
}
|
||||
|
||||
/* Release the parked hook so the worker/tick thread can finish, then join. */
|
||||
rc_gate_open(&g_rc_gate);
|
||||
pthread_join(th, NULL);
|
||||
|
||||
ha_nop_req_hook = NULL;
|
||||
ha_reconnect_connect_hook = NULL;
|
||||
ha_set_reconnect_offload_for_test(1); /* restore production default */
|
||||
rc_gate_destroy(&g_rc_gate);
|
||||
rc_teardown_primary(&c, &standby_ep);
|
||||
printf("%s: PASS\n", label);
|
||||
}
|
||||
|
||||
static void test_reconnect_offload_tick_returns_promptly(void)
|
||||
{
|
||||
rc_run_offload_tick(1, "test_reconnect_offload_tick_returns_promptly");
|
||||
}
|
||||
|
||||
static void test_reconnect_inline_tick_blocks_RED(void)
|
||||
{
|
||||
rc_run_offload_tick(0, "test_reconnect_inline_tick_blocks_RED");
|
||||
}
|
||||
|
||||
/*
|
||||
* Test 17 (Req 2.5): per-peer dedup. While a reconnect to the unreachable
|
||||
* peer is in flight (parked in the hook), a SECOND heartbeat tick enqueues NO
|
||||
* duplicate reconnect for that peer — the connect hook is invoked exactly once
|
||||
* across both ticks.
|
||||
*/
|
||||
static void test_reconnect_offload_dedups_per_peer(void)
|
||||
{
|
||||
cluster_t c;
|
||||
myconf_t conf;
|
||||
endpoint_t standby_ep;
|
||||
rc_setup_primary(&c, &conf, &standby_ep);
|
||||
|
||||
rc_gate_init(&g_rc_gate);
|
||||
ha_reconnect_connect_hook = rc_gated_connect_hook;
|
||||
ha_set_reconnect_offload_for_test(1);
|
||||
|
||||
ha_nop_req_hook = stub_nop_req;
|
||||
|
||||
/* Tick 1: submits the reconnect; the lane worker enters the hook and parks. */
|
||||
primary_heartbeat_fn_for_test(&c);
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc_gate_wait_entered(&g_rc_gate, 1, 1000),
|
||||
"tick 1 must submit the reconnect (hook parks on the lane)");
|
||||
|
||||
/* Tick 2: peer still unreachable (in flight) — must NOT enqueue a duplicate. */
|
||||
primary_heartbeat_fn_for_test(&c);
|
||||
|
||||
/* Give any (erroneously) duplicated task time to reach the gate. */
|
||||
struct timespec ts = { .tv_sec = 0, .tv_nsec = 150 * 1000000L };
|
||||
nanosleep(&ts, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&g_rc_gate.calls),
|
||||
"Req 2.5: a second tick must not enqueue a duplicate reconnect for an "
|
||||
"already in-flight peer");
|
||||
|
||||
/* Release and let the single in-flight reconnect complete. */
|
||||
rc_gate_open(&g_rc_gate);
|
||||
|
||||
/* Drain: wait for the in-flight flag to clear (lane worker done). */
|
||||
for (int i = 0; i < 200; i++) {
|
||||
if (atomic_load_explicit(&c.ha.reconnect_inflight[2],
|
||||
memory_order_acquire) == 0) break;
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
ha_nop_req_hook = NULL;
|
||||
ha_reconnect_connect_hook = NULL;
|
||||
rc_gate_destroy(&g_rc_gate);
|
||||
rc_teardown_primary(&c, &standby_ep);
|
||||
printf("test_reconnect_offload_dedups_per_peer: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Suite entry point ──────────────────────────────────────────────────── */
|
||||
|
||||
void test_ha_liveness(void)
|
||||
|
|
@ -865,5 +1141,9 @@ void test_ha_liveness(void)
|
|||
RUN_TEST(test_cluster_dispatch_nop_skips_when_primary);
|
||||
RUN_TEST(test_cluster_dispatch_ctrlhello_resets_activity_when_standby);
|
||||
RUN_TEST(test_ha_arm_does_not_increase_thread_count);
|
||||
/* raft-drive-async Task 2.1 — reconnect offload (Req 2.1, 2.2, 2.5) */
|
||||
RUN_TEST(test_reconnect_offload_tick_returns_promptly);
|
||||
RUN_TEST(test_reconnect_inline_tick_blocks_RED);
|
||||
RUN_TEST(test_reconnect_offload_dedups_per_peer);
|
||||
UNITY_END();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue