merge: sync Rongsong's scheduler-framework (88 commits, f01ae353..c5b172c7)

Two-sided divergence from f01ae353 (2026-08-30): theirs +88 (worker
rejoin-resync reconciliation, terminal-state bug-1737/1738/1739/1764,
scheduler-sim verbosity, hermetic offline build, go mod vendor of Go deps
in apiserver -- 3375 pure-adds), ours +89 (job-finish robustness bug-1509
B1/B3, PEND-reason, worker reconnect resend, apiserver job control).

Conflict resolutions (kept both sides' fixes):
- controller_connector.c checking_connection(): combine theirs
  xcecd_resync_is_pending() rejoin deferral gate with ours
  resend_job_status() actual-state re-report on rejoin success.
- job.c: keep both unknown-finish registry (ours, bug-1509) and
  record_job_finish_fact_at (theirs, bug-1747); keep both
  mark_jobs_unknown_core family (ours) and record_job_reconcile_*
  (theirs). No double-notify: on_job_finish unchanged, theirs is
  resync-path-only.
- scheduler handler.c/test_handlers.c/meson: theirs schedExitJob
  exit_code branch (bug-1737) + ours jobDispatchFail success-ack no-op
  (bug-1512) + configpush enq_pend_update (bug-1513) both present; both
  test suites and REPL/sim targets wired.
- apiserver jobs.go: keep ours normalizeJobInt64/jobArrayIdx superset +
  JobIDHuman; drop theirs strict IntOrInt64 early-reject (superseded --
  ours tolerates float64/json.Number, no panic path).
- jobs_test.go: keep both regression suites (ours bug-1490, theirs
  bug-1742).
- .kiro/specs scheduler-sim-*/worker-rejoin-resync + .wolf/*: keep our
  deletions (established convention -- drop Rongsong bookkeeping on merge).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-09-03 14:11:43 +00:00
commit 5e2985542f
3469 changed files with 1466599 additions and 382 deletions

View file

@ -13,6 +13,7 @@
#include "persist.h"
#include "preason.h"
#include "request.h"
#include "typemap.h" /* xce_job_state_entry_t -- worker-rejoin-resync task 2.2 */
#include "xce/cluster_def.h"
#include "xce/errnox.h"
#include "xce/types.h"
@ -1509,7 +1510,66 @@ static int dispatch_a_job_(cluster_t* c, endpoint_t* ep, xce_job_alloc_t* alloc)
}
lock(w, RTTI_LOCK_READ);
if ((w->ep == NULL) || (w->host.status != 0)) {
if (w->ep == NULL) {
/* worker-rejoin-resync task 2.3 (Req 5.1, 5.4): a dispatch attempt
* to a worker the controller is not currently connected to (w->ep
* == NULL) is resolved here, BEFORE the w->host.status != 0 check
* below ever reaches xce_classify_dispatch_fail() at all --
* deliberately a bypass of that retry-then-terminal-fail machinery,
* not a third value added to xce_dispatch_fail_class_t (design.md's
* "Controller: job.c" Components section: that enum's whole
* contract is a two-way TERMINAL/RETRYABLE choice every existing
* call site's switch already exhaustively handles). Retrying the
* dispatch itself doesn't fit "the worker isn't even connected,"
* and this case must stay recoverable-via-reschedule rather than
* ever resolve to mark_job_fail()'s terminal outcome -- so this
* branch calls neither xce_classify_dispatch_fail() nor
* mark_job_fail(), and never touches j->dispatch_fail_count. */
int dfrc;
int result;
ERROR("worker %s is not connected (ep NULL), status %x",
w->host.name, w->host.status);
free_launchinfo(&launchinfo);
SET_ERROR(E_XCE_BAD_WORKER);
/* Capture errno immediately after SET_ERROR(), before any further
* call -- errno is not guaranteed stable across unlock(w)/
* dereference(w)/lock(j)/etc., mirroring the WORKER_NOT_READY
* branch's own discipline below. */
dfrc = -errno;
unlock(w);
dereference(w);
/* Req 5.4: resolves via record_job_reconcile_pending_fact() (task
* 2.1) directly -- eager mutation + allocs release (bug-1631) under
* this write lock, durable append inside that call, then (only on
* an actual APPLIED transition) this caller's own post-unlock
* persist_notify_job_updated_already_persisted() call, exactly as
* that function's own contract requires (cluster.h doc comment).
* Closes the same bug-1627 reference-leak class as the WORKER_NOT_
* READY branch below -- j's reference (held since the jobmgr lookup
* at the top of this function) is released via dereference(j) on
* every exit from this branch. */
lock(j, RTTI_LOCK_WRITE);
result = record_job_reconcile_pending_fact(c, j);
unlock(j);
if (result == (int)JOB_TRANSITION_APPLIED) {
persist_notify_job_updated_already_persisted(c, j);
}
/* JOB_TRANSITION_REJECTED_BACKWARD/NOOP_DUPLICATE: nothing changed
* (job already terminal, or already PEND) -- no notify needed,
* matching record_job_reconcile_pending_fact's own contract. */
dereference(j);
return dfrc;
} else if (w->host.status != 0) {
int dfrc;
xce_dispatch_fail_class_t dfclass;
job_failinfo_t failinfo = { .jobid = alloc->jobid };
@ -2803,6 +2863,64 @@ int test_unknown_finish_take(uint64_t jobid, xce_jobfinish_t* out)
}
#endif
/* worker-rejoin-resync task 3.2 (bug-1747 fix, Req 4.2): WAL_JOB_FINISH's
* own worker-reported-finish-time sibling of record_job_finish_fact() above
* -- identical guard/hook-fire/durable-append body, except it passes a
* CALLER-supplied finish time (converted from the worker's reported
* finish_time_ms, Req 3.5) to persist_submit_job_finish_fact() instead of
* always deriving time(NULL) at receipt time. record_job_finish_fact()
* itself is deliberately left UNMODIFIED: its own callers (on_job_finish's
* ordinary, near-instantaneous fire-and-forget worker report) are correctly
* served by receipt-time ~= true finish-time; only a RECONCILED finish
* (received long after the fact, on rejoin) needs the worker's own reported
* time instead -- mirroring task 2.1/2.2's own "new function alongside an
* unmodified existing one" pattern (record_job_reconcile_pending_fact/
* record_job_reconcile_assign_fact), not a new precedent.
*
* persist_submit_job_finish_fact()'s own `finish_time` parameter is SECONDS
* (time_t semantics) -- confirmed by its only other call site
* (record_job_finish_fact() above: `time_t finish_time = time(NULL);
* ...(uint64_t)finish_time`) and by the WAL_JOB_FINISH decode side
* (apply_committed_entry, persist.c: `j->job.finish_time =
* (time_t)wire_finish_time;`, a direct, unconverted assignment). The
* worker-reported wire field (xce_job_state_entry_t.finish_time_ms,
* typemap.h) is MILLISECONDS ("finishTimeMs" on the wire, Req 3.5) -- this
* function converts ms -> s (`finish_time_ms / 1000`) before calling
* persist_submit_job_finish_fact(), so the caller (resync.c) can pass the
* raw worker-reported value straight through without needing to know this
* unit detail itself.
*
* Same locking convention as record_job_finish_fact(): caller holds `job`
* under RTTI_LOCK_WRITE on entry and keeps holding it until this call
* returns; this function does not lock/unlock `job` itself. Same DEFERRED
* pattern too: durable append only here, no mutation -- the in-memory state
* change happens later via apply_committed_entry()'s replay, exactly as an
* ordinary finish already works (unlike record_job_reconcile_pending_fact/
* assign_fact's own EAGER pattern) -- so the caller must NOT call
* persist_notify_job_updated_already_persisted() after this returns; that
* notify happens later, at apply time, exactly as it already does for
* record_job_finish_fact(). */
int record_job_finish_fact_at(cluster_t* c, job_t* job,
xce_jobfinish_t* finish, uint64_t finish_time_ms)
{
if (job_state_is_terminal(job->job.state)) {
apply_job_finish_transition(job, finish); /* logs + no-op, unchanged */
return 0;
}
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_BEFORE_EVENT,
HOOK_DETAIL_JOB_EXIT, c, finish, job);
(void)persist_submit_job_finish_fact(&c->persist, job->job.jobid,
(finish->exit_code == 0)
? JOB_STAT_DONE
: JOB_STAT_EXIT,
finish_time_ms / 1000,
finish->exit_code);
return 0;
}
int on_job_finish(cluster_t* c, xce_jobfinish_t* finish)
{
job_t* job = NULL;
@ -3157,6 +3275,188 @@ void mark_worker_jobs_exit(cluster_t* c, const char* hostname)
}
iter_destroy(&it);
unlock(c->jobmgr);
/* worker-rejoin-resync task 2.1 (Req 4.1-4.6, 5.4, 7.1-7.3): returns a job
* the worker reports as unrecognized back to PEND, releasing any resource
* allocation it was carrying so a subsequent redispatch attempt never
* inherits a stale allocation from a prior dispatch (bug-1631, confirmed
* live and deliberately left unfixed until this feature provided the first
* real re-admission path -- see design.md's Error Handling section).
*
* Follows dispatch_a_job_()'s own established pattern (job.c, its `if (rc
* >= 0) { ... }` success block): eager, synchronous mutation under the
* write lock the caller already holds, immediately followed by a durable
* WAL append, immediately followed (by the CALLER, after unlocking) by an
* explicit persist_notify_job_updated_already_persisted() call -- NOT
* record_job_finish_fact's/record_job_fail_fact's purely-deferred pattern
* (mutation withheld until apply_committed_entry replays the committed
* fact). The fact recorded here is something the worker already confirmed
* (or, in this case, failed to confirm) really happened -- ground truth
* already changed outside this controller -- which is exactly dispatch_a_
* job_'s own justification for going eager rather than deferred (design.md,
* "Controller: job.c" Components section).
*
* Precondition: caller holds `job` under RTTI_LOCK_WRITE on entry and keeps
* holding it until this call returns -- this function does not lock/unlock
* `job` itself, matching apply_job_update_transition's own calling
* convention (its other caller, mark_job_update above, is called under the
* exact same already-held write lock).
*
* apply_job_update_transition()'s existing job_state_is_backward guard is
* the ONLY commit/idempotency guard exercised here -- reused unchanged,
* never duplicated: a terminal job's transition is rejected
* (JOB_TRANSITION_REJECTED_BACKWARD, Req 4.4) and an already-PEND job's
* transition is a no-op (JOB_TRANSITION_NOOP_DUPLICATE, Req 4.3) -- neither
* outcome touches job->job.allocs or appends a WAL fact. Only on
* JOB_TRANSITION_APPLIED does this release job->job.allocs (if non-NULL)
* and durably append the fact via the existing generic persist_submit_job_
* fact() -- the plain {jobid, state} shape is sufficient here, same as
* every other WAL_JOB_UPDATE caller (no extra fields, unlike DISPATCH/
* START/FINISH's richer siblings).
*
* Returns the job_transition_result_t of the underlying transition, cast to
* int, so the caller can tell APPLIED apart from REJECTED_BACKWARD/
* NOOP_DUPLICATE. Unlike record_job_finish_fact/record_job_fail_fact, this
* function does NOT call persist_notify_job_updated_already_persisted --
* the caller must do so itself, after releasing the write lock, exactly
* once, for every JOB_TRANSITION_APPLIED result (cluster.h's doc comment on
* the prototype carries this same contract). Omitting it reproduces
* bug-1679 at this call site. */
int record_job_reconcile_pending_fact(cluster_t* c, job_t* job)
{
job_transition_result_t result;
result = apply_job_update_transition(job, JOB_STAT_PEND);
if (result != JOB_TRANSITION_APPLIED)
return (int)result;
if (job->job.allocs != NULL) {
free_JobAlloc_ptr(&(job->job.allocs));
job->job.allocs = NULL;
}
(void)persist_submit_job_fact(&c->persist, WAL_JOB_UPDATE, job->job.jobid,
JOB_STAT_PEND);
return (int)result;
}
/* worker-rejoin-resync task 2.2 (Req 4.1, 4.3, 4.4, 4.7, 4.8, 7.1-7.3):
* records a job the worker reports as running that the controller has no
* existing non-terminal record of -- an "unqueried extra" (Req 3.6) the
* controller never had a chance to ask about -- as assigned to the
* reporting worker, through the same idempotent commit-then-apply path an
* ordinary dispatch uses.
*
* Follows dispatch_a_job_()'s own established pattern (job.c, its `if (rc
* >= 0) { ... }` success block, job.c:1686-1748) exactly as record_job_
* reconcile_pending_fact above does: eager, synchronous mutation under the
* write lock the caller already holds, immediately followed by a durable
* WAL append, immediately followed (by the CALLER, after unlocking) by an
* explicit persist_notify_job_updated_already_persisted() call -- NOT
* record_job_finish_fact's/record_job_fail_fact's purely-deferred pattern.
* The fact recorded here is something the worker already confirmed really
* happened (it is running there right now) -- ground truth already changed
* outside this controller -- exactly dispatch_a_job_'s own justification
* for going eager rather than deferred (design.md, "Controller: job.c"
* Components section).
*
* Precondition: caller holds `job` under RTTI_LOCK_WRITE on entry and keeps
* holding it until this call returns -- this function does not lock/unlock
* `job` itself, matching record_job_reconcile_pending_fact's own calling
* convention above. Unlike that sibling, this function performs no
* backward-transition guard check of its own: design.md's Service Interface
* Invariants note that both new functions are "never called for a job the
* controller already knows is terminal or more-final" as a PRECONDITION the
* CALLER enforces, not something either function re-checks with its own
* guard -- and for this specific outcome the precondition holds by
* construction: Req 4.8's own trigger condition is precisely "the
* Controller has no existing non-terminal record of that job assigned
* elsewhere", so there is no terminal/more-final in-memory state this call
* could possibly clobber. This mirrors dispatch_a_job_'s own inline
* success-path mutation (job.c:1686-1717), which likewise sets
* job->job.state directly, with no apply_job_update_transition/
* job_state_is_backward call.
*
* "Host assignment": dispatch_a_job_'s own inline mutation (job.c:1686-
* 1717) carries "which host" ENTIRELY via job->job.allocs -- the host name
* lives inside xce_host_alloc_t.host, nested under xce_job_alloc_t.allocs.
* Neither job_t nor xce_job_t (xce/types.h) has any OTHER field
* representing a job's assigned worker/host independent of allocs
* (confirmed by direct inspection of both struct definitions -- not a
* guess). Since this function deliberately leaves job->job.allocs NULL (the
* controller genuinely does not know what the scheduler originally computed
* for this job -- design.md's Data Models "Documented scope decision"),
* there is structurally no separate field left for this function to set to
* represent "host assignment" beyond job->job.state itself. `w` is accepted
* only to match design.md's own cited service-interface signature -- it is
* otherwise inert here (see the (void)w; below): the still-running-outcome
* check just below operates on `report->status`, not on `w`, and nothing on
* job_t/xce_job_t is mutated from `w` either.
*
* Returns job_transition_result_t (cast to int), matching record_job_
* reconcile_pending_fact's return-type convention above -- always
* JOB_TRANSITION_APPLIED (this outcome has no reject/no-op case; see the
* Precondition note). Unlike record_job_finish_fact/record_job_fail_fact,
* this function does NOT call persist_notify_job_updated_already_persisted
* -- the caller must do so itself, after releasing the write lock, exactly
* once -- omitting it reproduces bug-1679 at this call site. */
int record_job_reconcile_assign_fact(cluster_t* c, job_t* job, worker_t* w,
xce_job_state_entry_t* report)
{
(void)w;
switch (report->status) {
case JobStateStatus_running:
break;
default:
/* This function's only defined caller usage (design.md's sequence
* flow -- task 3.2, not yet implemented) is the "unqueried extra
* job, still running" outcome: a finished extra routes through the
* existing record_job_finish_fact/record_job_fail_fact instead
* (Req 4.7), same as a queried finished job. Fail loud rather than
* silently mis-recording a finished job as RUN, mirroring
* dispatch_a_job_'s own "impossible per today's call convention"
* ERROR+assert(0)-in-XCE_TESTING-then-safe-fallback idiom used
* throughout this file. */
ERROR("record_job_reconcile_assign_fact: called for job %lu with "
"unexpected report status %d (expected JobStateStatus_running "
"-- a finished/unrecognized outcome must route through a "
"different fact function)",
(unsigned long)job->job.jobid, report->status);
#ifdef XCE_TESTING
assert(0
&& "record_job_reconcile_assign_fact called with a "
"non-running report status");
#endif
break;
}
job->job.state = JOB_STAT_RUN;
/* bug-1697: start_time is stamped HERE, synchronously alongside the RUN
* transition, mirroring dispatch_a_job_'s own identical stamp
* (job.c:1710) -- so that if this job's worker later also sends an
* ordinary WAL_JOB_START report for it, that report always finds
* state/start_time already set and applies as a harmless no-op, never
* the genuine first RUN transition. There is no better time source: the
* controller never observed this job's real dispatch time. */
job->job.start_time = time(NULL);
/* Req 4.8 / design.md "Documented scope decision": job->job.allocs is
* deliberately left untouched (NULL for a freshly-constructed job_t,
* per this file's own makeJob()/create-job convention) -- the
* controller has no way to learn what resource allocation the
* scheduler originally computed for a job it never held any record of.
* bug-1678's persist_submit_job_dispatch_fact() NULL-alloc case is
* reused verbatim below, exactly as design.md specifies, rather than
* fabricating one. */
(void)persist_submit_job_dispatch_fact(&c->persist, job->job.jobid,
JOB_STAT_RUN,
(uint64_t)job->job.start_time,
NULL);
return (int)JOB_TRANSITION_APPLIED;
}
/* Guarded state-transition core for a postdone fact, extracted from