Define xce_dispatch_fail_site_t/xce_dispatch_fail_class_t and xce_classify_dispatch_fail() in job.c, mapping each of dispatch_a_job_()'s six post-lookup failure points to retryable or terminal per Requirement 4.1/4.2. Standalone, not yet wired into dispatch_a_job_() (deferred to tasks 3.2/3.3). Also adds this spec's requirements/design/research/tasks docs (first commit touching this spec). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
18 KiB
18 KiB
Research & Design Decisions
Summary
- Feature:
scheduler-dispatch-commit - Discovery Scope: Extension (existing scheduler + controller subsystems)
- Key Findings:
scheduler.c'ssched_update()/sched_job_state_change()(the notify-driven state-change apply path) is dead code in the live production driver —main.c's loop calls onlyxce_sched_driver_run_normal_mode_round(), which drains pending controller events viaxce_sched_driver_translate_pending_events()→xce_core_push_events()→core_events.c'sXCE_CORE_EVENT_JOB_STATE_CHANGEcase, a parallel, independently-maintained mirror of the same logic. Design must target the live path, not the vestigial one.builtin_fcfs_on_job_state_change()is confirmed idempotent for a repeated RUN notification (comment: "a no-op, viaordq_del's 'not found' return, if it wasn't a candidate already — e.g. two non-pending transitions in a row"). A later, now-redundantschedStartJobconfirmation re-applyingJOB_STAT_RUNto an already-RUN job is therefore safe with zero code change to that path.sched_commit_decision()'s DISPATCH branch (scheduler.c:1441-1447) is the correct, still-live integration point for the scheduler-side transition — it is the sole call site ofsched_charge_account()/job->accountsassignment already, and is invoked from the decision callback (xce_sched_driver_on_decision()), a separate (and still-live) mechanism from the notify-apply path above.dispatch_a_job_()(job.c:831-963) currently setsj->job.allocsunconditionally, after attemptingxce_startjob_req(), regardless of that call's return code — an existing, unrelated latent bug (stale allocation left behind on RPC failure) that Requirement 4's cleanup obligation subsumes rather than leaving as a separate patch.- The
limitplugin (plugins/limit.c) does not gate any of its own usage accounting onJOB_IS_RUN/job->state(grepfound zero matches) — it is not a second instance of bug-1624's class of bug, and is correctly out of this spec's boundary. commit_resolve_dispatch_t/commit_resolve_dispatch_entry(commit.c) is the generic WAL-commit continuation dispatch primitive fromraft-apply-decoupling— a naming coincidence ("dispatch" = vtask dispatch, not job dispatch), not a pre-built mechanism for this feature. The relevant precedent instead isrecord_job_fail_fact()'s "durable append only, no synchronous job mutation, apply-time recompute" pattern.
Research Log
Which driver code path actually applies a JOB_STATE_CHANGE in production
- Context: Requirement 1 requires the scheduler's dispatch-commit transition to interoperate correctly with the existing RUN-confirmation transition. Needed to find where that existing transition is actually applied today, not just where an equivalently-named function exists.
- Sources Consulted:
src/scheduler/src/main.c(production loop),src/scheduler/src/driver.c(xce_sched_driver_run_normal_mode_round,xce_sched_driver_translate_pending_events),src/scheduler/src/scheduler.c(sched_update,sched_job_state_change),src/scheduler/src/core_events.c(XCE_CORE_EVENT_JOB_STATE_CHANGEcase). - Findings:
main.c'swhile(1)loop calls onlyxce_sched_driver_run_normal_mode_round(&sched), whose body never callssched_update().sched_update()(and thereforesched_job_state_change(), only reachable through it) has no live caller in the current driver architecture. The actual apply path isxce_sched_driver_translate_pending_events()→xce_core_push_events()→core_events.c'sXCE_CORE_EVENT_JOB_STATE_CHANGEcase, documented there as mirroringsched_job_state_change()"logic-identical, signature-relocated." - Implications: The design does not modify or reuse
scheduler.c'ssched_job_state_change()/sched_update(). All discussion of "the existing RUN-confirmation path" in the design refers tocore_events.c's case, which needs no code change — it already behaves correctly (idempotently) when applied to a job whose state is already RUN.
Ordering of the controller-side state transition relative to the worker RPC
- Context: Requirement 2 AC1 (as drafted) says the transition happens "before attempting to notify the assigned worker." Tracing
dispatch_a_job_()'s actual failure points revealed a simpler alternative. - Findings:
dispatch_a_job_()has five failure points that return before ever attemptingxce_startjob_req()(jobid lookup, invalid alloc, worker lookup, worker-not-ready, queue-exec-info lookup) and one failure point at the RPC call itself. If the state transition (andjob.allocscommitment) is gated onxce_startjob_req()returning success, none of these six failure modes ever produces a RUN transition that needs to be reversed — the job simply remains PEND, exactly where it already was. This eliminates an entire class of "revert RUN back to PEND" complexity on the controller side that would otherwise be needed to match Requirement 4. - Implications: Design decision below (Gate controller commit on RPC outcome) supersedes the literal "before attempting to notify" phrasing in requirements.md AC1 — the intent (transition and allocation commit as one atomic, worker-latency-independent operation, not gated on separate confirmation) is preserved; only the specific ordering relative to the RPC changes, and only because it removes a whole reversal path rather than adding one. Flagged for requirements.md wording cleanup during task generation, not a scope change.
Design Decisions
Decision: Gate the controller's dispatch-time RUN transition and allocation commit on RPC success, not on decision receipt
- Context: Where exactly within
dispatch_a_job_()to place the state transition, and whether dispatch-RPC failure needs a state-reversal path. - Alternatives Considered:
- Transition to RUN immediately on receiving the decision, before any worker contact — matches the literal AC1 wording, but requires a full reversal path (RUN → PEND) for every one of
dispatch_a_job_()'s six failure points. - Transition to RUN (and commit
job.allocs) only afterxce_startjob_req()returns success — no reversal path needed for any failure point; the job simply never leaves PEND on failure.
- Transition to RUN immediately on receiving the decision, before any worker contact — matches the literal AC1 wording, but requires a full reversal path (RUN → PEND) for every one of
- Selected Approach: Option 2.
- Rationale: Every one of
dispatch_a_job_()'s failure points already returns before or at the RPC call. Gating on RPC success collapses "was RUN, must revert" into "never became RUN," which is strictly simpler and removes a whole category of possible bugs (a reversal that's forgotten on one of the five early-return paths). - Trade-offs: A job dispatched to a worker that never replies at all (hangs, rather than erroring) would not reach RUN until the RPC layer's own timeout fires — acceptable, since that is already how
job.allocscommitment behaves today, and no requirement asks for a stronger guarantee under a hung RPC. - Follow-up: None — this closes the finding above rather than opening a new question.
Decision: Reuse mark_job_fail()/WAL-fact-append pattern for durability of the new transition, rather than the deferred-apply-first pattern record_job_start_fact() uses
- Context: Requirement 2 AC4 requires the transition to survive a controller failover.
record_job_start_fact()uses a "durable append only, no synchronous mutation, recompute at apply time" pattern specifically because a JOB_START fact can race against other pending facts for the same job (merge-at-apply-time, perwal_types.h). The dispatch-commit transition has no such racing-fact concern: it is the first transition after a job leaves PEND, triggered synchronously by the one function that already holds the authoritative decision. - Alternatives Considered:
- Full deferred-apply (submit fact, mutate only when the WAL entry is later applied) — correct but makes the transition NOT synchronously observable, conflicting with Requirement 2 AC1/AC3's "before/from" ordering language.
- Synchronous in-memory mutation (matching today's
j->job.allocsassignment) plus a separate, best-effort durable WAL fact append recording the same transition for recovery purposes.
- Selected Approach: Option 2.
- Rationale: Matches this function's own existing precedent for
job.allocs(synchronous, not deferred-apply) while still giving the transition a durable record for post-failover recovery, without introducing a same-job merge-ordering hazardrecord_job_start_fact()'s pattern exists to solve elsewhere. - Trade-offs: A crash in the narrow window after the in-memory mutation but before the WAL fact durably commits would leave the pre-crash primary's own view momentarily ahead of what a promoted standby recovers to. Bounded by the existing commit-durability guarantee (
ha-commit-durability) once the fact is submitted; the narrow pre-submission window is the same class of gapjob.allocs's own non-durable commit already accepts today, not a new one this feature introduces. - Follow-up: Design phase specifies the new WAL fact's contract (extends
wal_jobstate_t's existing_pad-discriminated shape, precedent:WAL_JOB_START/WAL_JOB_FAIL/WAL_JOB_UPDATE). Exact bit layout is implementation-task detail.
Decision: Controller classifies once, authoritatively; jobDispatchFail becomes exclusively the retry signal, reusing the existing schedFailJob path for terminal resolution
- Context: Requirement 4 requires the same failure to resolve the same way (retry vs. terminal) regardless of which component acts on it. The controller observes a dispatch failure synchronously and directly (it makes the RPC); the scheduler only learns of it later, asynchronously.
- Alternatives Considered:
- Each side classifies independently against a shared error-code table — risks drift if the bounded-retry counter (Requirement 4 AC3) is tracked separately on each side.
- Controller classifies once (owns the freshest information and the durable retry counter) and sends the scheduler an explicit resolution field alongside the existing
jobDispatchFailnotify. - Controller classifies once, and routes the two outcomes through two already-distinct existing channels instead of one overloaded one: terminal failures call the existing
mark_job_fail()(job.c:1844), whose already-wiredHOOK_DETAIL_JOB_FAIL→sched.chook handler already sendsNotifyData_data_schedFailJob— a notify the scheduler'ssched_handle_notify()already handles today, unmodified, correctly freeingjob->accounts/job->allocson the transition away from RUN. Retryable failures keep using the existing (currently unhandled)notify_dispatch()/NotifyData_data_jobDispatchFail, whose meaning narrows to exclusively "return this job to pending."
- Selected Approach: Option 3.
- Rationale: Discovered while tracing
mark_job_fail()'s hook chain (job.c→HOOK_DETAIL_JOB_FAIL→sched.c:502→NotifyData_data_schedFailJob) — the terminal path already has a fully-working, already-scheduler-handled notification mechanism with zero new code required. This reduces the scheduler-side new-code surface (bug-1625's actual fix) to a single newcaseinsched_handle_notify()'s switch that unconditionally appliesJOB_STAT_PEND— no resolution field, no classification logic, no new payload shape needed on the scheduler side at all. - Trade-offs: None identified — this is strictly less new surface than Option 2 for an equivalent guarantee.
- Follow-up: Design phase specifies exactly where the controller-side classification (including the bounded-retry check) is computed and where
dispatch_fail_countis stored/reset.
Addendum (2026-08-23, found during /kiro-validate-design)
dispatch_a_job_()andjob_ctrl()'s existing call tomark_job_fail()never takeRTTI_LOCK_WRITEon the job object, despiteapply_job_fail_transition()'s own doc comment requiring it (a genuinepthreadrwlock,my_rwlock_wlock(), confirmed insrc/lib/jobj.c:708-725— not a liveness/reference guard). Traced directly:dispatch_a_job_()(job.c:831-963) takes onlyRTTI_LOCK_READon the containing maps and the worker, never on the job itself;job_ctrl()releases its ownRTTI_LOCK_READon the job (job.c:1016) well before callingmark_job_fail()(job.c:1083). Logged as bug-1626, pre-existing, independent of this feature. This directly affected the "no staleness window" locking claim originally drafted for design.md's Controller Dispatch Commit component — corrected there to explicitly acquireRTTI_LOCK_WRITEaround this feature's own new/restructured mutations, closing the gap for those call sites specifically (job_ctrl()'s own separate call site is left as the still-open, out-of-boundary instance of bug-1626).- The scheduler-side
dispatch_fail_countfield originally planned forxce_sched_job_twas removed — nothing in the design ever reads it (only the controller's copy drives classification, per the Decision above), so it was dead weight. Caught during the same validation pass. notify_dispatch()(job.c:1108-1125) unconditionally emitsNotifyData_data_jobDispatchFailregardless of whethercodeis 0 (success) or negative (failure) — confirmed by reading its body.handle_jobdispatch()'s existing loop calls it for everydispatch_a_job_()return, success or failure alike. Combined with classification originally being planned forhandle_jobdispatch()(the caller), this would have produced two conflicting notifies for a terminal failure —jobDispatchFail(apply PEND) racing againstschedFailJob(apply EXIT) for the same event. Fixed by moving classification and all dispatch-outcome notification ownership fully insidedispatch_a_job_()(which also resolves the redundant-lookup/TOCTOU concern a caller-side classification would have had, sincehandle_jobdispatch()only retainsalloc->jobidafterdispatch_a_job_()returns) and removinghandle_jobdispatch()'s ownnotify_dispatch()calls entirely. Found during round 2 of/kiro-validate-design.persist.chas TWO separate functions that each need their own newWAL_JOB_DISPATCHcase, not one —apply_wal_entry()(persist.c:3375+, recovery/replay-from-disk path) andapply_committed_entry()(persist.c:3570+, live-apply-on-commit path, the oneraft-apply-decouplingintroduced_pad-discriminated routing for) each have their own independent fall-through group for simple absolute-value job-state facts (persist.c:3452-3466andpersist.c:3700-3706respectively). An earlier draft of design.md pointed only at the first (recovery) group, which alone would satisfy replay-after-restart but not live application when the WAL entry actually commits during normal operation — the primary case Requirement 2.4 is actually about. Found by tracing both functions directly rather than assuming a single switch statement.dispatch_a_job_()leaks onereference(j)on exactly its three retryable-class failure paths (workermgr lookup failure, worker not found, worker not ready) —reference(j)is taken once at entry (job.c:854) but never released with a matchingdereference(j)on these three returns, confirmed by direct comparison against the two terminal-class failure paths (nc <= 0,get_queue_exec()failure), both of which already correctlydereference(j). Logged as bug-1627, pre-existing. Since this design already restructures exactly these three call sites (to add the retryable-path lock/counter/notify sequence), the missingdereference(j)is added there too — closed as a side effect of the restructuring already underway, not a separate patch. Found during round 3 of/kiro-validate-design.
Risks & Mitigations
- Risk: A dispatched-then-terminally-failed job's resource-allocation cleanup on the scheduler side must run through the same code that already frees
job->accounts/job->allocson any transition away from RUN (core_events.c'sXCE_CORE_EVENT_JOB_STATE_CHANGEcase) — if the new dispatch-failure handling bypasses this and frees ad hoc, it risks a double-free or a leak. Mitigation: route dispatch-failure resolution through the existingXCE_SCHED_OP_JSTATE_CHANGE→XCE_CORE_EVENT_JOB_STATE_CHANGEmachinery (same enqueue pathschedStartJob/schedExitJob/schedFailJobalready use), not a new, parallel mutation path. - Risk: The bounded-retry counter (Requirement 4 AC3) is new per-job state with no existing analog — if not reset on a fresh successful dispatch, a job that failed twice, succeeded, ran to completion, and was later resubmitted with the same in-memory job object (should not happen given job objects are per-jobid, but worth stating) could inherit a stale count. Mitigation: reset the counter in the same operation that commits a fresh dispatch decision (
sched_commit_decision()'s DISPATCH branch), not only on success at the end of a retry sequence. - Risk:
--sim/simulation mode does not callsched_commit_decision()at all (confirmed in the siblingscheduler-sim-eager-reconciliationinvestigation) — no risk of driver divergence from this feature's scheduler-side change, but worth stating explicitly sincescheduler-driver-equivalence.mdis a hard project invariant. Mitigation: none needed; documented as a boundary note in requirements.md and design.md rather than a code change.
References
.kiro/steering/scheduler-driver-equivalence.md— state-equivalence invariant between normal-mode and simulation-mode drivers; confirms this feature's scheduler-side change has no simulation-mode counterpart to keep in sync (see Risks)..kiro/specs/scheduler-sim-decision-parity/design.md— house style precedent for C-domain adaptation of the design template (Components as C modules/functions, Service Interface blocks as real C signatures with pre/post/invariants).src/include/wal_types.h—wal_jobstate_t's_pad-discriminated shape, the precedent this feature's new WAL fact type follows.