myxce/src/scheduler/testsuite/test_scheduler_backpressure.c
Rongsong Shen b7df8d602c
feat(component-failover-adoption): validate scheduler backpressure, fix candidate-name diagnostics
Adds a worst-case-occupancy test proving the scheduler's picker/allocator
pipeline buffers briefly past a stalled publish (decisions cbuf cap 5,
jbuf cap 1) then genuinely blocks until connectivity recovers, with zero
job loss (Req 3 AC2). Adds a source-scan regression guard confirming the
scheduler makes no periodic heartbeat call of its own (Req 3 AC3).

Also fixes a real gap found along the way: sched_init()'s
all-candidates-unreachable and join-failure log lines named only a
candidate count, never which candidates were attempted (same class of
bug worker's task 2.3 fixed). Added sched_format_candidate_list(),
covered by a real dup2()-based stdout capture around a live sched_init()
failure path (round-1 review caught this diagnostic shipping with zero
test coverage; fixed in round 2).

Multi-candidate join and kill-primary/reconnect were validated manually
against a live 3-node cluster rather than committed as an automated
test, following worker's task 2.3 precedent -- no automated live-deploy
E2E harness exists for any C daemon in this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNB4zGPCsHyDUCDJyMbiQN
2026-08-11 14:00:02 +00:00

550 lines
23 KiB
C

/*
* test_scheduler_backpressure.c — worst-case decision/allocation
* backpressure test (component-failover-adoption, task 3.3, boundary:
* SchedulerControllerClient, Req 3 AC2).
*
* Scope note: this file proves Req 3 AC2 only -- the bounded, occupancy-
* dependent buffering upstream of the publish call (picker/allocator
* continue briefly, then genuinely block, then resume with nothing lost).
* It does NOT exercise Req 7's retry-until-success-or-shutdown wrapper
* around a `-E_XCE_NOT_READY` return from `xcecli_jobdispatch()`/
* `xcecli_set_pend_reason()` (that wrapper, and its own test coverage, is
* task 3.2's `sched_publish_dispatch()`/`sched_publish_pend_reason()` work,
* see test_scheduler_decision_publish_retry.c). The wrapped
* `xcecli_set_pend_reason()` double below BLOCKS on a condvar rather than
* ever returning `-E_XCE_NOT_READY`, so it never enters that retry loop at
* all -- it stands in for the call still being in-flight/stuck mid-attempt,
* one layer below where Req 7 applies.
*
* Links the REAL production scheduler_srcs (same technique
* test_scheduler_connect.c / test_scheduler_decision_publish_retry.c
* already established for this boundary -- see meson.build) and calls the
* REAL, public sched_init(), which -- unlike task 3.2's rejoin test, which
* deliberately made its own setup join fail -- is made to SUCCEED here
* (xcecli_start()/xcecli_join() wrapped to return success with no I/O),
* so the real sched_job_picker() and sched_allocator() threads (scheduler.c)
* actually start. xcecli_set_pend_reason() is wrapped to BLOCK on a
* condition variable under test control, standing in for an ongoing
* controller outage (an XCECLI_FAILOVER_CALL retry budget stuck retrying)
* without any real network I/O or busy-spinning.
*
* ── Why the picker thread is never driven, and jobs are hand-built ──────
*
* sched_job_picker()/sched_allocator() are `static` (scheduler.c) and can
* only be started via the real sched_init() -- both threads start together,
* there is no way to run just one. sched_job_picker() is kept PERMANENTLY
* DORMANT here by never calling sched_go(): it blocks forever on
* cbuf_get(&core.wbuf) (scheduler.c), so it never touches core.jbuf itself
* and never contends with this file's own direct cbuf_put()/cbuf_tryput()
* calls on core.jbuf, which stand in for the picker's role of feeding one
* jobid at a time -- exactly the "without also running the full
* sched_job_picker thread" narrowing this task's own text pre-authorizes.
*
* sched_allocator() IS the real, unmodified production function under
* test here (not a stand-in) -- proving its genuine cbuf_put()-blocks
* behavior against a full `decisions` cbuf is this test's actual point.
* Driving it through sched_make_allocation() with a fully realistic
* job/queue/worker/resource model (parse_resreq(), the seqjob_allocator
* plugin's "slots" resource lookup, sched_collect_workers() against a real
* resmgr) would need substantial scaffolding unrelated to what this test
* verifies. Instead, each test job's xce_sched_obj_t is built directly
* with tag=XCE_RESREQ_COMPOUND and a COMPOUND resreq with an EMPTY
* `primaries` list: sched_make_compound_allocation()'s for-loop
* (scheduler.c) then has zero iterations, so it returns rc=0 ("pend")
* without ever touching a worker, a resource, or the seqjob_allocator
* plugin -- alloc_rc==0 is exactly the same "job is pend" branch
* sched_allocator() takes for a real no-matching-worker pend outcome, so
* this fixture exercises the identical decision-construction and
* cbuf_put(&core.decisions, ...) code path a real pend decision would.
* The pend reason itself is pre-set directly on the job's top-level
* `schedobj->reason` field before sched_allocator() ever sees it;
* sched_make_compound_allocation() only overwrites that field when its own
* ctx.reason is non-NULL, which never happens with zero primaries, so the
* pre-set reason survives untouched into make_pend_decision().
*
* ── Worst-case derivation (do not copy a number from design.md prose
* without re-deriving it against live cbuf state -- this task's own
* instruction) ──────────────────────────────────────────────────────
*
* Both cbuf capacities are read LIVE from the real, freshly-initialized
* core (sched.core.decisions.ncap / sched.core.jbuf.ncap -- xce_sched_core_t
* struct fields, mylock.h's cbuf_t) rather than hardcoded, and asserted
* against the documented values (ndecisions=5, jbuf capacity=1, core.c) as
* a sanity check only.
*
* The worst case (least slack) is: `decisions` is ALREADY FULL at the
* instant the stuck decision is popped off it (design.md's "decisions cbuf
* already at or near its capacity-5 occupancy at the moment the stall
* begins"). This test constructs exactly that precondition (prefills
* `decisions` to full capacity BEFORE starting the stall), then proves,
* against the REAL sched_allocator() thread and REAL cbuf blocking
* semantics (mylock.c, not mocked):
*
* 1. `decisions` has room for exactly ONE more decision once the stuck
* one is popped (occupancy ncap-1) -- job A's PEND decision lands.
* 2. The NEXT decision (job B's) is fully built (not dropped, not
* errored -- Req 3 AC2's "no job picked or decided ... is ever lost")
* but then genuinely BLOCKS trying to enqueue, because `decisions` is
* full again.
* 3. `jbuf` (capacity 1) absorbs exactly ONE more job (C) beyond that --
* the allocator had already drained job B out of jbuf before getting
* stuck on `decisions` -- and then a further job (D) is refused for a
* sustained bounded window, proving the picker itself would now
* block too (Req 3 AC2's "and then sched_job_picker genuinely
* block"), not silently drop work.
* 4. Once the stall clears, every decision produced before AND during
* the outage (the prefilled backlog plus jobs A/B/C) is eventually
* drained and published -- none lost (Req 3 AC2's "no job picked or
* decided during that bounded window is ever lost").
*
* This matches requirements.md's "as few as 1-2" floor (job A enqueued +
* job B built-but-blocked = 2 additional decisions) at the `decisions`
* layer, plus the analogous one-slot-of-slack-then-genuine-block pattern
* one layer down at `jbuf` -- derived here from the live cbuf mechanics,
* not copied from prose.
*
* All "prove it's genuinely blocked" checks use cbuf_tryput() (non-blocking)
* in a short bounded-retry loop, never a real blocking call -- this test
* can never hang even if the backpressure guarantee were broken.
*/
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unity/unity.h>
#include "client.h"
#include "controller.capnp.h"
#include "core.h"
#include "decision.h"
#include "mylock.h"
#include "preason.h"
#include "resreq.h"
#include "sched/types.h"
#include "scheduler.h"
#include "xce/cluster.h"
#include "xlist.h"
extern int scheduler_req_handler(xce_request_t*, void*);
#define N_DUMMY_PREFILL 5
#define N_REAL_JOBS_FED 3 /* A, B, C */
#define N_TOTAL_DECISIONS (N_DUMMY_PREFILL + N_REAL_JOBS_FED)
#define POLL_STEP_NS (5 * 1000 * 1000) /* 5ms */
#define POLL_TIMEOUT_MS 2000
/* ════════════════════════════════════════════════════════════════════════
* Test double: __wrap_xcecli_set_pend_reason -- blocks on a condvar until
* the test releases it, standing in for a stuck outage retry (see file
* header comment). __wrap_xcecli_start / __wrap_xcecli_join are trivial
* always-succeed doubles (same rationale as
* test_scheduler_decision_publish_retry.c's own __wrap_xcecli_start) so
* this file's one real sched_init() call actually starts the picker/
* allocator threads instead of returning early.
* ════════════════════════════════════════════════════════════════════════ */
static pthread_mutex_t g_pend_mtx = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_pend_release_cond = PTHREAD_COND_INITIALIZER;
static pthread_cond_t g_pend_entered_cond = PTHREAD_COND_INITIALIZER;
static int g_pend_release = 0;
static int g_pend_entered = 0;
static void reset_pend_wrap(void)
{
g_pend_release = 0;
g_pend_entered = 0;
}
int __wrap_xcecli_start(xcecli_ctx_t c)
{
(void)c;
return 0;
}
int __wrap_xcecli_join(xcecli_ctx_t c, int role, hostattrs_t* attrs)
{
(void)c;
(void)role;
(void)attrs;
return 0;
}
int __wrap_xcecli_set_pend_reason(xcecli_ctx_t c, xce_preason_set_t* reasons)
{
(void)c;
(void)reasons;
pthread_mutex_lock(&g_pend_mtx);
g_pend_entered = 1;
pthread_cond_broadcast(&g_pend_entered_cond);
while (!g_pend_release) {
pthread_cond_wait(&g_pend_release_cond, &g_pend_mtx);
}
pthread_mutex_unlock(&g_pend_mtx);
return 0;
}
static void wait_for_pend_wrap_entered(void)
{
pthread_mutex_lock(&g_pend_mtx);
while (!g_pend_entered) {
pthread_cond_wait(&g_pend_entered_cond, &g_pend_mtx);
}
pthread_mutex_unlock(&g_pend_mtx);
}
static void release_pend_wrap(void)
{
pthread_mutex_lock(&g_pend_mtx);
g_pend_release = 1;
pthread_cond_broadcast(&g_pend_release_cond);
pthread_mutex_unlock(&g_pend_mtx);
}
void setUp(void) { reset_pend_wrap(); }
void tearDown(void) { return; }
/* ════════════════════════════════════════════════════════════════════════
* Fixtures
* ════════════════════════════════════════════════════════════════════════ */
/* build_pend_only_job - see file header comment for why COMPOUND + zero
* primaries is used: it drives sched_allocator()'s real "job is pend"
* decision-construction path without needing any worker/resource/queue
* scaffolding. */
static xce_sched_job_t* build_pend_only_job(uint64_t jobid)
{
xce_sched_job_t* job = (xce_sched_job_t*)calloc(1, sizeof(*job));
xce_sched_obj_t* schedobj = (xce_sched_obj_t*)calloc(1, sizeof(*schedobj));
xce_compound_schedobj_t* compound
= (xce_compound_schedobj_t*)calloc(1, sizeof(*compound));
xce_compound_resreq_t* resreq
= (xce_compound_resreq_t*)calloc(1, sizeof(*resreq));
xlist_init(&(resreq->primaries), 1);
compound->owner = schedobj;
compound->resreq = resreq;
compound->reason = NULL;
schedobj->tag = XCE_RESREQ_COMPOUND;
schedobj->u.compound = compound;
/* Top-level field, distinct from compound->reason above -- see file
* header comment for why this survives sched_make_compound_allocation()
* untouched. */
schedobj->reason = make_pendreason(jobid);
job->jobid = jobid;
job->state = JOB_STAT_PEND;
job->schedobj = schedobj;
return job;
}
static xce_decision_t* build_dummy_pend_decision(uint64_t jobid)
{
return make_pend_decision(make_pendreason(jobid));
}
/* ════════════════════════════════════════════════════════════════════════
* Drain thread -- stands in for driver.c's decision-drain consumer
* (xce_sched_driver_run_normal_mode_round()'s cbuf_get(&core.decisions)
* loop). Pops exactly N_TOTAL_DECISIONS decisions, publishing each via the
* REAL sched_publish_pend_reason() (scheduler.c). Its first iteration is
* the one that blocks in the wrapped call (see file header comment).
* ════════════════════════════════════════════════════════════════════════ */
typedef struct {
xce_scheduler_t* sched;
uint64_t seen_jobids[N_TOTAL_DECISIONS];
int seen_rcs[N_TOTAL_DECISIONS];
int n_seen;
} drain_ctx_t;
static void* drain_thread_fn(void* arg)
{
drain_ctx_t* dctx = (drain_ctx_t*)arg;
int i;
for (i = 0; i < N_TOTAL_DECISIONS; i++) {
xce_decision_t* decision = NULL;
xce_pendreason_t* reasons[1];
xce_preason_set_t reason_set;
int rc;
cbuf_get(&(dctx->sched->core.decisions), &decision);
reasons[0] = decision->u.reason;
reason_set.nreasons = 1;
reason_set.reasons = reasons;
rc = sched_publish_pend_reason(dctx->sched, &reason_set);
dctx->seen_jobids[dctx->n_seen] = decision->u.reason->jobid;
dctx->seen_rcs[dctx->n_seen] = rc;
dctx->n_seen++;
}
return NULL;
}
/* ════════════════════════════════════════════════════════════════════════
* The test
* ════════════════════════════════════════════════════════════════════════ */
static void test_worst_case_backpressure_blocks_then_recovers_no_loss(void)
{
xce_client_config_t config;
xce_cluster_config_t cluster_cfg;
xce_scheduler_t sched;
pthread_t drain_tid;
drain_ctx_t dctx;
int rc;
int i;
int ncap_decisions;
int ncap_jbuf;
memset(&config, 0, sizeof(config));
config.nmasters = 1;
config.masters[0] = "127.0.0.1";
config.master_ports[0] = 19997;
config.from = "sched_backpressure_test";
config.key = "keepsecret$";
memset(&cluster_cfg, 0, sizeof(cluster_cfg));
cluster_cfg.candidates_count = 1;
strncpy(cluster_cfg.candidates[0].hostname, "127.0.0.1",
XCE_MAXHOSTNAME_LEN - 1);
cluster_cfg.candidates[0].port = 19997;
strncpy(cluster_cfg.identity, "sched_backpressure_test",
sizeof(cluster_cfg.identity) - 1);
memset(&sched, 0, sizeof(sched));
rc = sched_init(&sched, &config, &cluster_cfg, scheduler_req_handler);
TEST_ASSERT_TRUE_MESSAGE(
rc >= 0, "fixture: sched_init() must succeed (xcecli_start()/"
"xcecli_join() wrapped to succeed with no I/O) so the "
"real picker/allocator threads actually start");
ncap_decisions = sched.core.decisions.ncap;
ncap_jbuf = sched.core.jbuf.ncap;
TEST_ASSERT_TRUE_MESSAGE(ncap_decisions >= 1,
"sanity: decisions cbuf must have positive "
"capacity");
TEST_ASSERT_TRUE_MESSAGE(ncap_jbuf >= 1,
"sanity: jbuf cbuf must have positive "
"capacity");
TEST_ASSERT_EQUAL_INT_MESSAGE(
N_DUMMY_PREFILL, ncap_decisions,
"documented invariant (core.c: ndecisions=5) -- if this fails, "
"N_DUMMY_PREFILL/N_TOTAL_DECISIONS above must be re-derived "
"against the new live capacity, not just this assertion changed");
TEST_ASSERT_EQUAL_INT_MESSAGE(
1, ncap_jbuf,
"documented invariant (core.c: jbuf capacity fixed at 1, a "
"scheduler-core determinism invariant this task does not "
"adjust)");
/* --- Step 1: prefill `decisions` to FULL capacity -- the worst-case
* (least-slack) precondition. --- */
for (i = 0; i < ncap_decisions; i++) {
xce_decision_t* d = build_dummy_pend_decision(9000 + (uint64_t)i);
TEST_ASSERT_NOT_NULL(d);
rc = cbuf_tryput(&(sched.core.decisions), &d);
TEST_ASSERT_TRUE_MESSAGE(
rc >= 0, "fixture: prefilling decisions to capacity must not "
"itself block");
}
/* --- Step 2: start the drain thread. Its first cbuf_get() pops the
* first prefilled decision and calls the REAL
* sched_publish_pend_reason(), which blocks inside the wrapped
* xcecli_set_pend_reason() -- simulating an ongoing outage. --- */
dctx.sched = &sched;
dctx.n_seen = 0;
rc = pthread_create(&drain_tid, NULL, drain_thread_fn, &dctx);
TEST_ASSERT_EQUAL_INT(0, rc);
wait_for_pend_wrap_entered();
TEST_ASSERT_EQUAL_INT_MESSAGE(
ncap_decisions - 1, sched.core.decisions.len,
"the drain thread's single cbuf_get() must have removed exactly "
"one decision, leaving decisions at capacity-1");
/* --- Step 3: feed job A through the REAL sched_allocator() thread
* (via jbuf, standing in for the picker). With one free slot in
* `decisions`, job A's PEND decision must land successfully -- this is
* the "as few as 1" floor. --- */
{
xce_sched_job_t* jobA = build_pend_only_job(9101);
uint64_t jobidA = jobA->jobid;
TEST_ASSERT_TRUE(sched_add_job(&(sched.core), jobA) >= 0);
rc = cbuf_tryput(&(sched.core.jbuf), &jobidA);
TEST_ASSERT_TRUE_MESSAGE(
rc >= 0, "jbuf must have room for job A -- it starts empty "
"since the dormant picker thread never touches it "
"(sched_go() is never called in this test)");
}
{
int waited_ms = 0;
while ((sched.core.decisions.len < ncap_decisions)
&& (waited_ms < POLL_TIMEOUT_MS)) {
struct timespec ts = { 0, POLL_STEP_NS };
nanosleep(&ts, NULL);
waited_ms += 5;
}
TEST_ASSERT_EQUAL_INT_MESSAGE(
ncap_decisions, sched.core.decisions.len,
"job A's decision must be the ONE additional decision the "
"worst-case (already-full) occupancy has room for");
}
/* --- Step 4: feed job B. `decisions` is full again, so
* sched_allocator() must build job B's decision (Req 3 AC2: deciding is
* not lost) but then genuinely BLOCK trying to enqueue it -- the
* second half of the "1-2" floor. --- */
{
xce_sched_job_t* jobB = build_pend_only_job(9102);
uint64_t jobidB = jobB->jobid;
TEST_ASSERT_TRUE(sched_add_job(&(sched.core), jobB) >= 0);
rc = cbuf_tryput(&(sched.core.jbuf), &jobidB);
TEST_ASSERT_TRUE_MESSAGE(
rc >= 0, "jbuf must have room for job B -- the allocator "
"already drained job A's jobid out of it");
}
{
/* Bounded window for the allocator to reach its blocking
* cbuf_put() -- decisions occupancy must not exceed capacity once
* it does. */
struct timespec ts = { 0, 100 * 1000 * 1000 };
nanosleep(&ts, NULL);
}
TEST_ASSERT_EQUAL_INT_MESSAGE(
ncap_decisions, sched.core.decisions.len,
"job B's decision must be stuck trying to enqueue -- decisions "
"occupancy must never exceed its own capacity");
/* --- Step 5: prove the genuine block reaches jbuf too (Req 3 AC2:
* "...and then sched_job_picker genuinely block"). jbuf absorbs
* exactly one more job (C) -- the allocator already drained jbuf
* before getting stuck on `decisions` -- and a further push (D) must
* then fail for a sustained bounded window. --- */
{
xce_sched_job_t* jobC = build_pend_only_job(9103);
uint64_t jobidC = jobC->jobid;
TEST_ASSERT_TRUE(sched_add_job(&(sched.core), jobC) >= 0);
rc = cbuf_tryput(&(sched.core.jbuf), &jobidC);
TEST_ASSERT_TRUE_MESSAGE(
rc >= 0, "jbuf's one slot of slack must still accept job C");
}
{
uint64_t jobidD = 9104;
int any_succeeded = 0;
int probe;
for (probe = 0; probe < 10; probe++) {
struct timespec ts = { 0, 20 * 1000 * 1000 };
rc = cbuf_tryput(&(sched.core.jbuf), &jobidD);
if (rc >= 0) {
any_succeeded = 1;
break;
}
nanosleep(&ts, NULL);
}
TEST_ASSERT_FALSE_MESSAGE(
any_succeeded,
"genuine block, Req 3 AC2: with sched_allocator stuck on a "
"full decisions cbuf, jbuf's single slot is already occupied "
"by job C -- a further job (D) must never be accepted over a "
"sustained bounded window, proving the picker itself would "
"now block too, not silently drop work");
}
/* --- Step 6: the outage clears. Req 3 AC2: the scheduler must resume
* automatically and publish every decision produced during the bounded
* window, with none lost. --- */
release_pend_wrap();
rc = pthread_join(drain_tid, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT_MESSAGE(
N_TOTAL_DECISIONS, dctx.n_seen,
"every decision produced before AND during the outage -- the "
"prefilled backlog plus jobs A/B/C -- must eventually be drained "
"and published, none silently lost (Req 3 AC2)");
for (i = 0; i < dctx.n_seen; i++) {
TEST_ASSERT_TRUE_MESSAGE(
dctx.seen_rcs[i] >= 0,
"every drained decision's publish call must eventually "
"report success");
}
{
int saw_a = 0, saw_b = 0, saw_c = 0;
for (i = 0; i < dctx.n_seen; i++) {
if (dctx.seen_jobids[i] == 9101) {
saw_a = 1;
}
if (dctx.seen_jobids[i] == 9102) {
saw_b = 1;
}
if (dctx.seen_jobids[i] == 9103) {
saw_c = 1;
}
}
TEST_ASSERT_TRUE_MESSAGE(
saw_a && saw_b && saw_c,
"jobs A, B, and C -- decided/picked during the bounded outage "
"-- must all eventually be published, not just the "
"pre-outage backlog");
}
printf("test_worst_case_backpressure_blocks_then_recovers_no_loss: "
"PASS (decisions.ncap=%d jbuf.ncap=%d, worst-case additional "
"slack before genuine block = 1 decision fully enqueued + 1 "
"decision built-but-blocked + 1 job buffered in jbuf)\n",
ncap_decisions, ncap_jbuf);
/* --- Teardown: cancel + join the real threads before tearing down
* core state, matching sched_fini()'s own ordering (scheduler.c) minus
* the apictx cleanup calls (xcecli_wakeup/xcecli_stop/xcecli_ctx_fini)
* this fixture's wrapped xcecli_start() never fully set up (it does no
* real connect, so ctx->failover.lock was never mylock_init'd) --
* deliberately skipped rather than also wrapped, consistent with this
* test suite's existing detect_leaks=0 convention (meson.build). --- */
pthread_cancel(sched.core.jobpicker);
pthread_cancel(sched.core.allocator);
{
uint64_t wake = 0;
cbuf_tryput(&(sched.core.jbuf), &wake);
}
pthread_join(sched.core.jobpicker, NULL);
pthread_join(sched.core.allocator, NULL);
xce_sched_core_fini(&(sched.core));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_worst_case_backpressure_blocks_then_recovers_no_loss);
return UNITY_END();
}