feat(raft-drive-async): peer-control vtask dispatch shim, dormant (task 3.1)

Lands the PeerCtrlDispatch machinery behind a DEFAULT-OFF flag
(g_drive_async_peerctrl_dispatch_enabled); production behavior is
UNCHANGED. peer_ctrl_msg_handler becomes a thin shim that takes
ownership of ep->fsm_in.pkt and either runs the routing body inline
(flag OFF, byte-for-byte the prior path) or dispatches it to the vtask
pool via vtask_enq_classified (flag ON). The 3 routers now take pkt as
an explicit param (no cross-thread ep->fsm_in.pkt re-read). Per-endpoint
arrival order is enforced by bind_vtask's endpoint_block_tasks.

Packet ownership: shim clears ep->fsm_in.pkt at dispatch (sole-owner
transfer); peer_ctrl_vtask_entry NULLs iod->pkt before the body frees
it once. New vtask_t.owns_io_pkt (default 0) lets taskq_item_killer free
the carried packet for an un-executed queued task at vtaskmgr_fini WITHOUT
double-freeing the libxce endpoint-owned path (bug-1199).

Adversarial review found two FLAG-ON hazards, documented with loud
comments (flag is OFF so production is unaffected), to be resolved before
task 3.4 flips the flag ON:
  - bug-1197 (CRITICAL): raft_cb_become_follower endpoint_fini()s peer
    vote-eps from a pool worker, racing the io_worker's read of ep->data;
    needs iomgr_unmonitor before endpoint_fini in every pool-reachable
    teardown.
  - bug-1198 (HIGH): peer_ctrl_classify tags all work VTASK_LIVENESS, but
    the CTRL_PRIMARY repl-ack path can run a blocking snapshot checkpoint
    inline; resolved by task 3.2 (offload snapshot to SlowLane).

Per spec, 3.1 is not 'done' until the task 3.4 deadlock gate is green.
Tests: 3 new peerctrl tests; controller 196/196, lib 282/0; 0 net leaks.
This commit is contained in:
Rongsong Shen 2026-06-26 11:12:04 +00:00
parent 21e26a17b6
commit e17a6a613c
7 changed files with 1678 additions and 31 deletions

View file

@ -1480,6 +1480,271 @@ static void test_reconnect_preserves_other_peer_channel(void)
printf("test_reconnect_preserves_other_peer_channel: PASS\n");
}
/* ═══════════════════════════════════════════════════════════════════════════
* raft-drive-async Task 3.1 — PeerCtrlDispatch
*
* The registered peer-control handler (peer_ctrl_msg_handler) must, when the
* dispatch flag is ON, hand the decoded packet to the vtask pool and return
* PROMPTLY (so peer-control no longer runs on the iomgr io_worker thread), while
* preserving per-connection arrival order; with the flag OFF it must run the
* routing body INLINE exactly as before (no behavior change — the production
* default for this commit). The flag toggle is the RED↔GREEN driver.
*
* Test 18 (Req 1.4, 4.5, 8.1, 8.2): flag ON → handler returns promptly and
* the routing body runs on a POOL worker (a different thread).
* Test 19 (Req 6.x no-regression): flag OFF → body runs INLINE, synchronously,
* on the calling thread (identical to the pre-task behavior).
* Test 20 (Req 4.1, 4.2): two messages on ONE endpoint are processed in
* arrival order — the second cannot run until the first completes
* (per-endpoint endpoint_block_tasks serialization).
* ═══════════════════════════════════════════════════════════════════════════ */
extern int peer_ctrl_msg_handler_for_test(endpoint_t *ep, io_packet_t *pkt);
extern io_packet_t *vote_reply_build_pkt_for_test(uint64_t term, int vote_granted,
uint64_t peer_tag,
uint64_t peer_lsn);
/* Probe observation state (set via ha_peerctrl_body_probe). */
static _Atomic int g_pc_runs = 0; /* body invocation count */
/* _Atomic: written on the pool worker, read on main — avoid a formal C11 data
* race (raft-drive-async review finding-2). relaxed ordering is sufficient:
* g_pc_runs (acquire/release via fetch_add/load) already orders visibility. */
static _Atomic pthread_t g_pc_last_thread; /* thread the body ran on */
static _Atomic int g_pc_gate_on = 0; /* 1 → body parks at the gate */
static pthread_mutex_t g_pc_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_pc_entered_cv = PTHREAD_COND_INITIALIZER;
static pthread_cond_t g_pc_release_cv = PTHREAD_COND_INITIALIZER;
static int g_pc_parked = 0; /* a body is parked at the gate */
static int g_pc_release = 0; /* gate open → bodies may proceed */
static void pc_probe(endpoint_t *ep)
{
(void)ep;
atomic_store_explicit(&g_pc_last_thread, pthread_self(), memory_order_relaxed);
atomic_fetch_add(&g_pc_runs, 1);
if (atomic_load(&g_pc_gate_on)) {
pthread_mutex_lock(&g_pc_mu);
g_pc_parked = 1;
pthread_cond_broadcast(&g_pc_entered_cv);
while (!g_pc_release)
pthread_cond_wait(&g_pc_release_cv, &g_pc_mu);
g_pc_parked = 0;
pthread_mutex_unlock(&g_pc_mu);
}
}
/* Wait (bounded) until a body has parked at the gate. */
static int pc_wait_parked(int timeout_ms)
{
struct timespec d;
clock_gettime(CLOCK_REALTIME, &d);
d.tv_sec += timeout_ms / 1000;
d.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
if (d.tv_nsec >= 1000000000L) { d.tv_sec++; d.tv_nsec -= 1000000000L; }
pthread_mutex_lock(&g_pc_mu);
int rc = 0;
while (!g_pc_parked) {
rc = pthread_cond_timedwait(&g_pc_entered_cv, &g_pc_mu, &d);
if (rc != 0) break;
}
int parked = g_pc_parked;
pthread_mutex_unlock(&g_pc_mu);
return parked ? 0 : -1;
}
static void pc_release_gate(void)
{
pthread_mutex_lock(&g_pc_mu);
g_pc_release = 1;
pthread_cond_broadcast(&g_pc_release_cv);
pthread_mutex_unlock(&g_pc_mu);
}
/* Bounded poll until g_pc_runs >= want; returns the observed count. */
static int pc_wait_runs(int want, int timeout_ms)
{
for (int i = 0; i < timeout_ms; i++) {
if (atomic_load(&g_pc_runs) >= want) break;
struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000L }; /* 1 ms */
nanosleep(&ts, NULL);
}
return atomic_load(&g_pc_runs);
}
static void pc_reset_probe_state(void)
{
atomic_store(&g_pc_runs, 0);
atomic_store(&g_pc_gate_on, 0);
pthread_mutex_lock(&g_pc_mu);
g_pc_parked = 0;
g_pc_release = 0;
pthread_mutex_unlock(&g_pc_mu);
}
/*
* Test 18 (Req 1.4, 4.5, 8.1, 8.2): with the dispatch flag ON, the registered
* handler enqueues the decoded packet onto the vtask pool and RETURNS PROMPTLY;
* the routing body then runs on a POOL worker — a DIFFERENT thread from the
* caller (proving peer-control no longer runs on the iomgr io_worker thread).
* The body (role=CTRL_INIT → bounded default-drop path) frees the packet.
*/
static void test_peerctrl_dispatch_runs_on_pool(void)
{
vtaskmgr_t mgr;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&mgr, 4));
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&mgr));
cluster_t c;
memset(&c, 0, sizeof(c));
ha_init(&c.ha, &g_cfg, 0);
c.ha.pool = &mgr;
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_INIT, memory_order_release);
endpoint_t ep;
memset(&ep, 0, sizeof(ep));
standby_ep_ctx_build_for_test(&ep, &c.ha, &c, 0);
pc_reset_probe_state();
ha_peerctrl_body_probe = pc_probe;
ha_set_peerctrl_dispatch_for_test(1);
pthread_t caller = pthread_self();
io_packet_t *pkt = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt);
int rc = peer_ctrl_msg_handler_for_test(&ep, pkt);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"Req 8.1/8.2: dispatch handler must return promptly (0) after enqueue");
int runs = pc_wait_runs(1, 2000);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, runs,
"Req 1.4/4.5: the routing body must run on the pool exactly once");
TEST_ASSERT_FALSE_MESSAGE(
pthread_equal(atomic_load_explicit(&g_pc_last_thread, memory_order_relaxed),
caller),
"Req 1.4: the body must run on a POOL worker, NOT the caller's "
"(io_worker) thread");
ha_set_peerctrl_dispatch_for_test(0); /* restore production default (OFF) */
ha_peerctrl_body_probe = NULL;
vtaskmgr_fini(&mgr);
free(ep.data);
ha_fini(&c.ha);
printf("test_peerctrl_dispatch_runs_on_pool: PASS\n");
}
/*
* Test 19 (Req 6.x no-regression): with the dispatch flag OFF (the production
* default), the handler runs the routing body INLINE on the calling thread,
* synchronously — byte-for-byte the pre-task behavior. This is the RED side of
* the flag toggle: the body did NOT move to a pool thread.
*/
static void test_peerctrl_inline_when_flag_off(void)
{
cluster_t c;
memset(&c, 0, sizeof(c));
ha_init(&c.ha, &g_cfg, 0);
c.ha.pool = NULL; /* OFF path must not need the pool */
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_INIT, memory_order_release);
endpoint_t ep;
memset(&ep, 0, sizeof(ep));
standby_ep_ctx_build_for_test(&ep, &c.ha, &c, 0);
pc_reset_probe_state();
ha_peerctrl_body_probe = pc_probe;
ha_set_peerctrl_dispatch_for_test(0); /* explicit OFF */
pthread_t caller = pthread_self();
io_packet_t *pkt = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt);
int rc = peer_ctrl_msg_handler_for_test(&ep, pkt);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Synchronous: the body already ran on THIS thread before the handler
* returned — no pool, no wait. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&g_pc_runs),
"Req 6.x: with the flag OFF the body must run inline (synchronously)");
TEST_ASSERT_TRUE_MESSAGE(
pthread_equal(atomic_load_explicit(&g_pc_last_thread, memory_order_relaxed),
caller),
"Req 6.x: with the flag OFF the body must run on the caller's thread "
"(inline), unchanged from before the task");
ha_peerctrl_body_probe = NULL;
free(ep.data);
ha_fini(&c.ha);
printf("test_peerctrl_inline_when_flag_off: PASS\n");
}
/*
* Test 20 (Req 4.1, 4.2): two peer-control messages arriving on the SAME
* endpoint are processed in arrival order — the second body cannot start until
* the first completes. Proof: with the flag ON, the first body parks at the
* gate (holding its pool worker; the endpoint is blocked by bind_vtask's
* endpoint_block_tasks). While it is parked, dispatching a second message on
* the same endpoint does NOT run its body (run count stays 1). Releasing the
* first lets it complete (endpoint_unblock_tasks) and only THEN does the second
* run (count → 2).
*/
static void test_peerctrl_preserves_per_endpoint_order(void)
{
vtaskmgr_t mgr;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&mgr, 4));
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&mgr));
cluster_t c;
memset(&c, 0, sizeof(c));
ha_init(&c.ha, &g_cfg, 0);
c.ha.pool = &mgr;
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_INIT, memory_order_release);
endpoint_t ep;
memset(&ep, 0, sizeof(ep));
standby_ep_ctx_build_for_test(&ep, &c.ha, &c, 0);
pc_reset_probe_state();
atomic_store(&g_pc_gate_on, 1); /* bodies park at the gate */
ha_peerctrl_body_probe = pc_probe;
ha_set_peerctrl_dispatch_for_test(1);
/* Message 1 — dispatched; its body parks at the gate. */
io_packet_t *pkt1 = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt1);
TEST_ASSERT_EQUAL_INT(0, peer_ctrl_msg_handler_for_test(&ep, pkt1));
TEST_ASSERT_EQUAL_INT_MESSAGE(0, pc_wait_parked(2000),
"the first body must reach the gate (running on the pool)");
/* Message 2 — dispatched on the SAME endpoint while body 1 is parked. */
io_packet_t *pkt2 = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt2);
TEST_ASSERT_EQUAL_INT(0, peer_ctrl_msg_handler_for_test(&ep, pkt2));
/* Give a generous window: body 2 must NOT run while body 1 holds the
* endpoint (per-connection serialization). */
struct timespec ts = { .tv_sec = 0, .tv_nsec = 200 * 1000000L }; /* 200 ms */
nanosleep(&ts, NULL);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&g_pc_runs),
"Req 4.1/4.2: the second message must NOT be processed while the first "
"is in flight on the same connection");
/* Release body 1; body 2 then becomes eligible and runs. */
pc_release_gate();
int runs = pc_wait_runs(2, 2000);
TEST_ASSERT_EQUAL_INT_MESSAGE(2, runs,
"Req 4.1/4.2: the second message runs only AFTER the first completes "
"(arrival order preserved)");
ha_set_peerctrl_dispatch_for_test(0); /* restore production default (OFF) */
ha_peerctrl_body_probe = NULL;
vtaskmgr_fini(&mgr);
free(ep.data);
ha_fini(&c.ha);
printf("test_peerctrl_preserves_per_endpoint_order: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────────── */
void test_ha_liveness(void)
@ -1513,5 +1778,9 @@ void test_ha_liveness(void)
/* raft-drive-async Task 2.3 — reconnect-offload INTEGRATION (Req 2.1, 2.2, 2.5) */
RUN_TEST(test_reconnect_offload_sustains_survivor_heartbeats);
RUN_TEST(test_reconnect_inline_stalls_survivor_heartbeats_RED);
/* raft-drive-async Task 3.1 — PeerCtrlDispatch (Req 1.4, 4.1, 4.2, 4.5, 6.x, 8.1, 8.2) */
RUN_TEST(test_peerctrl_dispatch_runs_on_pool);
RUN_TEST(test_peerctrl_inline_when_flag_off);
RUN_TEST(test_peerctrl_preserves_per_endpoint_order);
UNITY_END();
}