myxce/src/controller/testsuite/test_slowlane.c
Rongsong Shen 5650acd375 feat(raft-drive-async): SlowLane bounded executor for long blocking I/O (task 1.2)
Dedicated-pthread pool isolated from the vtask pool; non-blocking slowlane_submit
(0 enqueued / -EEXIST deduped / -EAGAIN cap-full-deferred), admission cap on
running tasks, per-key in-flight dedup, run-exactly-once, drain+join fini. Wired
into controller lifecycle (dormant; consumers land in 2.x/3.x). 7 unit tests,
mutation-verified cap+dedup; full suite 194/0 green, 15x ASAN.
2026-06-26 05:24:00 +00:00

318 lines
11 KiB
C

#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <time.h>
#include <unity/unity.h>
#include "slowlane.h"
/* ====================================================================
* raft-drive-async task 1.2 — SlowLane executor unit tests (RED first).
*
* The SlowLane is a bounded pool of dedicated worker threads that runs
* long blocking I/O off the Raft drive threads, with admission control
* and per-key in-flight dedup. These tests assert the four core
* behavioral contracts (acceptance criteria 3.1-3.4, 1.5):
* (a) slowlane_submit is non-blocking
* (b) concurrent RUNNING tasks never exceed max_inflight
* (c) a submit with an already-in-flight non-zero key returns -EEXIST
* (d) fn runs exactly once on a lane worker
* (+) -EAGAIN is returned (but work still queued) when the cap is full
*
* Determinism: every blocking task parks on a test-owned condvar gate
* (NOT a sleep), so the test controls exactly when a worker is occupied
* and when it releases. Concurrency peaks are observed via an atomic
* running counter the tasks themselves bump.
* ==================================================================== */
/* ── Test-owned gate: a controllable blocking task body ───────────── */
typedef struct {
pthread_mutex_t mu;
pthread_cond_t release_cv; /* test broadcasts to release parked tasks */
pthread_cond_t entered_cv; /* task broadcasts when it has entered/parked */
int gate_open; /* 0 = parked tasks must wait */
_Atomic int running; /* tasks currently inside fn (post-entry) */
_Atomic int max_running; /* high-water mark of `running` */
_Atomic int total_runs; /* how many times any task fn executed */
_Atomic int entered; /* tasks that have reached the gate */
} gate_t;
static void gate_init(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->running, 0);
atomic_store(&g->max_running, 0);
atomic_store(&g->total_runs, 0);
atomic_store(&g->entered, 0);
}
static void gate_destroy(gate_t *g)
{
pthread_mutex_destroy(&g->mu);
pthread_cond_destroy(&g->release_cv);
pthread_cond_destroy(&g->entered_cv);
}
/* Blocking task body: bump running, record the peak, signal entry, then park
* on the gate until the test opens it. Runs on a lane worker. */
static void gated_task(void *arg)
{
gate_t *g = arg;
int now = atomic_fetch_add(&g->running, 1) + 1;
int prev = atomic_load(&g->max_running);
while (now > prev && !atomic_compare_exchange_weak(&g->max_running, &prev, now))
; /* CAS-max */
atomic_fetch_add(&g->total_runs, 1);
pthread_mutex_lock(&g->mu);
atomic_fetch_add(&g->entered, 1);
pthread_cond_broadcast(&g->entered_cv);
while (!g->gate_open)
pthread_cond_wait(&g->release_cv, &g->mu);
pthread_mutex_unlock(&g->mu);
atomic_fetch_sub(&g->running, 1);
}
static void gate_open(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` tasks have entered the gate. */
static int wait_entered_at_least(gate_t *g, int n, int timeout_ms)
{
struct timespec dl;
clock_gettime(CLOCK_REALTIME, &dl);
dl.tv_sec += timeout_ms / 1000;
dl.tv_nsec += (timeout_ms % 1000) * 1000000L;
if (dl.tv_nsec >= 1000000000L) { dl.tv_sec++; dl.tv_nsec -= 1000000000L; }
pthread_mutex_lock(&g->mu);
while (atomic_load(&g->entered) < n) {
if (pthread_cond_timedwait(&g->entered_cv, &g->mu, &dl) == ETIMEDOUT)
break;
}
int got = atomic_load(&g->entered);
pthread_mutex_unlock(&g->mu);
return got;
}
/* ── (a) submit is non-blocking ───────────────────────────────────── */
/* A submit must return promptly even when the task it submits will block
* indefinitely. We submit a gated (never-released until we open) task and
* assert submit returned without us opening the gate. */
static void test_submit_is_non_blocking(void)
{
slowlane_t sl;
gate_t g;
gate_init(&g);
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&sl, 2, 2));
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
int rc = slowlane_submit(&sl, 0, gated_task, &g);
clock_gettime(CLOCK_MONOTONIC, &t1);
TEST_ASSERT_EQUAL_INT(0, rc);
/* submit returned while the task is (or will be) parked: a blocking call
* would not have returned. Bound the wall time generously (<500ms). */
long ms = (t1.tv_sec - t0.tv_sec) * 1000 +
(t1.tv_nsec - t0.tv_nsec) / 1000000;
TEST_ASSERT_TRUE_MESSAGE(ms < 500, "submit blocked the caller");
gate_open(&g);
slowlane_fini(&sl); /* drains + joins */
gate_destroy(&g);
}
/* ── (b) concurrent running tasks never exceed the cap ────────────── */
static void test_admission_cap_bounds_concurrency(void)
{
slowlane_t sl;
gate_t g;
gate_init(&g);
const int n_workers = 4;
const int max_inflight = 2;
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&sl, n_workers, max_inflight));
/* Submit more tasks than the cap; all must enqueue, but only `cap` may run
* concurrently even though 4 workers exist. */
for (int i = 0; i < 6; i++) {
int rc = slowlane_submit(&sl, 0, gated_task, &g);
/* 0 while a slot is free, -EAGAIN once the cap is full (still queued). */
TEST_ASSERT_TRUE_MESSAGE(rc == 0 || rc == -EAGAIN, "submit dropped work");
}
/* Let the first wave run and park at the gate. */
int entered = wait_entered_at_least(&g, max_inflight, 2000);
TEST_ASSERT_EQUAL_INT(max_inflight, entered);
/* Give any (incorrect) over-admission a chance to manifest, then assert the
* peak never exceeded the cap. */
struct timespec nap = { .tv_sec = 0, .tv_nsec = 100 * 1000000L };
nanosleep(&nap, NULL);
TEST_ASSERT_EQUAL_INT(max_inflight, atomic_load(&g.running));
TEST_ASSERT_TRUE_MESSAGE(atomic_load(&g.max_running) <= max_inflight,
"concurrent running tasks exceeded the cap");
/* Release everything; all 6 must eventually run and the peak stays <= cap. */
gate_open(&g);
slowlane_fini(&sl);
TEST_ASSERT_EQUAL_INT(6, atomic_load(&g.total_runs));
TEST_ASSERT_TRUE_MESSAGE(atomic_load(&g.max_running) <= max_inflight,
"cap violated across the full run");
gate_destroy(&g);
}
/* ── (+) -EAGAIN when the cap is full at submit (work still queued) ── */
static void test_submit_returns_eagain_when_cap_full(void)
{
slowlane_t sl;
gate_t g;
gate_init(&g);
const int max_inflight = 1;
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&sl, 4, max_inflight));
/* First task takes the only running slot and parks. */
TEST_ASSERT_EQUAL_INT(0, slowlane_submit(&sl, 0, gated_task, &g));
TEST_ASSERT_EQUAL_INT(1, wait_entered_at_least(&g, 1, 2000));
/* Cap is now full: the next submit is deferred -> -EAGAIN, but queued. */
int rc = slowlane_submit(&sl, 0, gated_task, &g);
TEST_ASSERT_EQUAL_INT_MESSAGE(-EAGAIN, rc,
"over-cap submit must return -EAGAIN");
gate_open(&g);
slowlane_fini(&sl);
/* The deferred task was NOT dropped: both ran. */
TEST_ASSERT_EQUAL_INT(2, atomic_load(&g.total_runs));
gate_destroy(&g);
}
/* ── (c) per-key dedup returns -EEXIST for an in-flight key ────────── */
static void test_dedup_returns_eexist_for_inflight_key(void)
{
slowlane_t sl;
gate_t g;
gate_init(&g);
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&sl, 4, 4));
const uint64_t KEY = 0x51A8;
/* First submit of KEY is accepted and runs/parks. */
TEST_ASSERT_EQUAL_INT(0, slowlane_submit(&sl, KEY, gated_task, &g));
TEST_ASSERT_EQUAL_INT(1, wait_entered_at_least(&g, 1, 2000));
/* Second submit of the SAME in-flight key is a deduped no-op. */
TEST_ASSERT_EQUAL_INT(-EEXIST,
slowlane_submit(&sl, KEY, gated_task, &g));
/* A DIFFERENT key is still admitted. */
TEST_ASSERT_EQUAL_INT(0, slowlane_submit(&sl, KEY + 1, gated_task, &g));
TEST_ASSERT_EQUAL_INT(2, wait_entered_at_least(&g, 2, 2000));
gate_open(&g);
slowlane_fini(&sl);
/* Exactly 2 runs: the duplicate KEY submit did not run a third task. */
TEST_ASSERT_EQUAL_INT(2, atomic_load(&g.total_runs));
gate_destroy(&g);
}
/* After an in-flight key completes, the SAME key is admittable again. */
static void test_dedup_key_reusable_after_completion(void)
{
slowlane_t sl;
gate_t g;
gate_init(&g);
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&sl, 2, 2));
const uint64_t KEY = 7;
TEST_ASSERT_EQUAL_INT(0, slowlane_submit(&sl, KEY, gated_task, &g));
TEST_ASSERT_EQUAL_INT(1, wait_entered_at_least(&g, 1, 2000));
/* Release and let it fully complete (running drops to 0, key cleared). */
gate_open(&g);
/* Re-close the gate for the second wave. */
pthread_mutex_lock(&g.mu);
/* wait until the first task left fn */
while (atomic_load(&g.running) != 0) {
pthread_mutex_unlock(&g.mu);
struct timespec s = { 0, 1 * 1000000L };
nanosleep(&s, NULL);
pthread_mutex_lock(&g.mu);
}
g.gate_open = 0;
atomic_store(&g.entered, 0);
pthread_mutex_unlock(&g.mu);
/* Same key, after completion -> admitted again (0, not -EEXIST). */
TEST_ASSERT_EQUAL_INT(0, slowlane_submit(&sl, KEY, gated_task, &g));
TEST_ASSERT_EQUAL_INT(1, wait_entered_at_least(&g, 1, 2000));
gate_open(&g);
slowlane_fini(&sl);
TEST_ASSERT_EQUAL_INT(2, atomic_load(&g.total_runs));
gate_destroy(&g);
}
/* ── (d) fn runs exactly once ──────────────────────────────────────── */
static void test_fn_runs_exactly_once(void)
{
slowlane_t sl;
gate_t g;
gate_init(&g);
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&sl, 4, 4));
/* The gate starts open so non-blocking tasks run straight through. */
gate_open(&g);
const int N = 32;
for (int i = 0; i < N; i++)
TEST_ASSERT_EQUAL_INT(0, slowlane_submit(&sl, 0, gated_task, &g));
slowlane_fini(&sl); /* drains + joins -> all submitted work has run */
/* Exactly N executions: no task dropped, none double-run. */
TEST_ASSERT_EQUAL_INT(N, atomic_load(&g.total_runs));
gate_destroy(&g);
}
/* ── init/fini hardening: fini on an empty lane, double-fini safety ── */
static void test_fini_on_idle_lane(void)
{
slowlane_t sl;
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&sl, 2, 2));
slowlane_fini(&sl); /* no submits — must drain/join cleanly */
TEST_PASS();
}
void test_slowlane(void)
{
RUN_TEST(test_submit_is_non_blocking);
RUN_TEST(test_admission_cap_bounds_concurrency);
RUN_TEST(test_submit_returns_eagain_when_cap_full);
RUN_TEST(test_dedup_returns_eexist_for_inflight_key);
RUN_TEST(test_dedup_key_reusable_after_completion);
RUN_TEST(test_fn_runs_exactly_once);
RUN_TEST(test_fini_on_idle_lane);
}