fix(controller): mark RUN jobs unknown on worker loss and restart (bug-1509 P2)

Phase 2 of the job-finish robustness design (Part B, B1+B3).

B1 (worker liveness): a worker that stops reporting (heartbeat timeout,
already detected by check_worker_timeout_) or disconnects now has its RUN
jobs marked JOB_STAT_UNKWN|JOB_STAT_TRANSIENT (transient_since set, durable
WAL_JOB_UPDATE) instead of lingering as RUN forever. Marking happens
AFTER the worker lock is released (B5: avoids worker->jobmgr inversion
with the scheduler's jobmgr->worker order).

B3 (restart): persist_recover marks all recovered RUN jobs UNKWN+TRANSIENT
in-memory after replay — a controller restart leaves worker connectivity
unknown. In-memory only (no WAL append) because persist_recover may run
on a future standby that must never append.

Unit tests (red->green): worker-loss marks its RUN jobs unknown, other-host
jobs untouched, restart marks all RUN jobs. test_replay_apply_seam updated:
replay still drives facts to RUN, then B3 adds UNKWN+TRANSIENT.
This commit is contained in:
lei.chen 2026-08-06 05:57:59 +00:00
parent a793015f74
commit fc091348e8
7 changed files with 224 additions and 4 deletions

View file

@ -2098,6 +2098,91 @@ static int mark_job_update(cluster_t* c, job_t* job, int state)
return 0;
}
/* bug-1509 Phase 2 (B1): a worker that stops reporting (liveness timeout) or
* disconnects is marked UNAVAIL, but its RUN jobs must not linger as RUN
* forever — mark them UNKWN+TRANSIENT (transient_since = now) so (a) the
* state is visibly "unknown" and (b) job_should_retain's TRANSIENT TTL can GC
* them if the worker never comes back. hostname == NULL marks ALL RUN jobs
* (used by persist_recover's restart marking, B3).
*
* Lock discipline (B5): the caller must NOT hold any worker or jobmgr lock.
* This function takes the jobmgr read lock and per-job write locks, which
* would invert the scheduler's jobmgr→worker order if a worker lock were held
* here. */
/* Shared core of the bug-1509 unknown-marking. persist=1 writes a durable
* WAL_JOB_UPDATE (worker-death path, B1); persist=0 mutates in-memory only
* (restart path, B3 — persist_recover runs before the controller knows it
* will be primary, and a standby must never append). */
static void mark_jobs_unknown_core(cluster_t* c, const char* hostname,
int persist)
{
iterator_t it;
job_t* j;
if (c == NULL) {
return;
}
lock(c->jobmgr, RTTI_LOCK_READ);
if (iter_make(&it, c->jobmgr) < 0) {
unlock(c->jobmgr);
return;
}
for (;;) {
int rc = iter_next(&it, (void**)&j);
if (rc < 0 || rc == RTTI_ITER_NO_DATA) {
break;
}
if (j == NULL) {
continue;
}
if (hostname != NULL) {
int on_host = 0;
if (j->job.allocs != NULL && j->job.allocs->allocs != NULL) {
for (int i = 0; i < j->job.allocs->nhosts; i++) {
xce_host_alloc_t* ha = j->job.allocs->allocs[i];
if (ha != NULL && ha->host != NULL
&& strcmp(ha->host, hostname) == 0) {
on_host = 1;
break;
}
}
}
if (!on_host) {
continue;
}
}
lock(j, RTTI_LOCK_WRITE);
if (JOB_IS_RUN(j->job.state)
&& !job_state_is_terminal(j->job.state)) {
j->job.transient_since = time(NULL);
if (persist) {
mark_job_update(c, j, j->job.state | JOB_STAT_UNKWN
| JOB_STAT_TRANSIENT);
} else {
j->job.state |= JOB_STAT_UNKWN | JOB_STAT_TRANSIENT;
}
}
unlock(j);
}
iter_destroy(&it);
unlock(c->jobmgr);
}
void mark_worker_jobs_unknown(cluster_t* c, const char* hostname)
{
mark_jobs_unknown_core(c, hostname, 1);
}
/* bug-1509 B3: restart path — after replay, RUN jobs' state is uncertain
* (worker connectivity unknown). In-memory only; idempotent. */
void mark_all_run_jobs_unknown_in_memory(cluster_t* c)
{
mark_jobs_unknown_core(c, NULL, 0);
}
/* Guarded state-transition core for a postdone fact, extracted from
* on_job_postdone's old inline mutation (raft-apply-decoupling task 6.1
* round 2, Req 3.3/7.1-7.3). Unlike a generic UPDATE, postdone's formula