fix: the controller is the ground truth for job state on scheduler rejoin (bug-1860)

A job the controller requeued to PEND while the scheduler was disconnected
stayed RUN (with its old allocation) in the scheduler forever: the
bug-1812 guard in core_events.c's JOBS_PUSH merge refused any non-RUN,
non-terminal resync claim over a job holding a real allocation, because
it could not tell a genuine requeue from a push built before the
scheduler's own fresh dispatch.

Design (per review): the scheduler persists nothing, so on rejoin the
controller's state wins, and the race the guard protected against is
closed where the truth lives:

- scheduler JOBS_PUSH: the bug-1812 refusal is removed; the controller's
  state replaces the scheduler's and its allocation is released. The
  terminal-is-permanent and phantom-RUN (RUN without allocation data)
  guards stay.
- controller dispatch_a_job_(): refuses a job that is not PEND (RUN or
  finished) before any worker is touched -- previously nothing checked,
  so a stale second dispatch would start a running job again (or, through
  a retryable failure, requeue it). The job is left as is, no WAL fact,
  and its true state is re-sent (HOOK_DETAIL_JOB_UPDATE). A PEND job with
  a leftover allocation stays dispatchable.
- scheduler JOB_UPDATE: a RUN report carrying the controller's committed
  allocation is adopted, so a corrected job is never RUN with nothing
  charged.

Tests (all RED before): test_jobs_push_duplicate_jobid_controller_pend_
wins_over_local_run (replaces the bug-1812 test that pinned the
refusal), test_job_update_run_adopts_controller_allocation,
test_dispatch_of_non_pending_job_is_refused. Two tests that seeded a RUN
job to reach retry branches (a precondition their own headers called
unreachable) now use the real paths: the counter-reset test requeues via
record_job_reconcile_pending_fact() first; the disconnected-worker RUN
test asserts the refusal contract.

Controller suite 1238/0, scheduler 87/87. HA soaks: chaotic 6 rounds PASS
(0 divergence/loss, 6 worker-resync jobs), sequential 6 rounds PASS
(SIMVERIFY 7 files, 0 mismatches). The race did not occur in either run
(0 refusals logged), so the new paths are proven by the unit tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-09-27 04:32:37 +00:00
parent 0f7b467c2e
commit c69705ae54
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
4 changed files with 236 additions and 119 deletions

View file

@ -1313,6 +1313,36 @@ static int dispatch_a_job_(cluster_t* c, endpoint_t* ep, xce_job_alloc_t* alloc)
return -errno;
}
/* bug-1860: only a PEND job may be dispatched. Nothing checked this, so
* a second dispatch for a job already RUN -- e.g. from a scheduler
* acting on a rejoin push older than the job's own dispatch -- would
* start it on a worker again (or, via a retryable failure, requeue a
* running job). Refuse it before any worker is touched, leave the job
* exactly as it is, and re-send its true state to the scheduler, which
* adopts it (the controller is the ground truth). */
{
int state;
bool dispatchable;
lock(j, RTTI_LOCK_READ);
state = j->job.state;
/* State only: a PEND job still carrying a leftover allocation
* stays dispatchable (dispatch replaces it), never refused
* forever. */
dispatchable = JOB_IS_PEND(state) && !JOB_IS_RUN(state)
&& !JOB_IS_FINISH(state);
unlock(j);
if (!dispatchable) {
WARN("dispatch_a_job_: job %s is not pending (state 0x%x) -- "
"refusing a stale dispatch and re-sending its state",
jobidStr, state);
persist_notify_job_updated_already_persisted(c, j);
dereference(j);
return -EALREADY;
}
}
nc = alloc->nhosts;
if (nc <= 0) {
/* scheduler-dispatch-commit task 3.2 (Req 4.2, 4.5): resolves this