Requirements approved; design generated (not yet approved -- pending /kiro-validate-design fixes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y4M5MJM2zK6nU43da267Ku
45 KiB
Design Document: worker-rejoin-resync
Overview
On every worker (re)join — a fresh network reconnect after a disconnect, or a reconnect to a new primary after controller failover — this feature has the controller ask the worker directly what really happened to every job the controller still believes is running there, and reconciles the worker's answer into the job table through the existing idempotent commit-then-apply path. This closes the gap left by fire-and-forget job start/finish/fail notifications (XCECLI_FF_CALL): a report lost to a disconnect or failover window today leaves a job stuck in RUN forever, with nothing in the cluster ever correcting it (bug-1741, bug-396).
Users: Internal controller/worker/scheduler subsystems — there is no end-user-facing surface. Cluster operators benefit indirectly: jobs self-heal out of a stuck-RUN state without manual intervention.
Impact: Extends rejoin_worker/apply_join_state (worker-join-refactor, implemented) with a job-state reconciliation round; adds one new opcode pair to the wire protocol; adds a short-lived finished-job retention to the worker daemon (xcecd), which retains none today; adds two narrow new fact-recording functions to the controller's job table, reusing its existing idempotency guards for everything else; adds two new shared-config fields.
Goals
- Trigger reconciliation on every worker join where the controller holds non-terminal jobs for that host, without needing to distinguish first-join from reconnect (Requirement 1).
- Query only the jobs in question; let the worker answer definitively for each, plus report anything running/recently-finished the controller didn't ask about (Requirements 2, 3).
- Apply every reconciled outcome through the existing idempotent commit-then-apply path — no second mutation mechanism (Requirement 4).
- Withhold new dispatch to a worker while its reconciliation is outstanding, and fail fast (not queue) a dispatch to a disconnected worker (Requirement 5).
- Stay safe under repeated/interrupted rejoin (Requirement 6) and reach the scheduler through the same notification path as any ordinary job-state change (Requirement 7).
- Work regardless of how many jobs a host is running, via a bounded/paginated exchange (Requirement 8).
Non-Goals
- apiserver/scheduler cluster/queue/job-list resync on reconnect (
component-rejoin-resync, a disjoint sibling spec — different data shape, not merged here). - Any change to the primary-side pending-facts queue (the commit engine) itself — this feature is an additional caller into it, never a modification of it.
- Delta-based / session-token re-attach optimization for rejoin in general (separate, deferred, explicit YAGNI).
- Coupling the Requirement 3.7 reporting window to
commit-batch-window's (separately parked) commit-urgency tuning — disclaimed in requirements.md's Boundary Context and not referenced anywhere in this design. - Recovering a reconciliation-assigned job's original resource allocation (see Data Models — this is a real, explicitly-documented limitation, not a silent gap).
Boundary Commitments
This Spec Owns
- The wire protocol for the reconciliation exchange: two new opcodes,
OPCode_jobStateQuery(controller → worker) andOPCode_jobStateReport(worker → controller), both paginated. - Controller-side: deciding when to trigger reconciliation (Requirement 1); tracking one reconciliation round per worker (queried job IDs, round token, deadline); applying every reported outcome via the job table's existing idempotent fact path, plus two new narrow fact-recording functions for the two outcomes with no existing analog (reconcile-to-pending, reconcile-assign); gating new dispatch on reconciliation-outstanding state; notifying the scheduler through the existing inbound notify path. Reconciliation is modeled as extra steps that run strictly after the worker-join RPC (
xce_join/xcecli_join) has already completed — never as an extension of that call's own blocking wait (see Out of Boundary). - Worker-side: a new short-lived retention of recently-finished job outcomes (jobid, exit status, real finish time), bounded by a configurable window; answering the controller's query from that retention plus the still-running job list; reporting extra jobs outside the query; a new local gate flag that delays the worker's own resumption of normal post-join operation until its report has finished sending (Requirement 3.8).
- Two new shared-config fields (the Requirement 3.7 reporting window; the Requirement 5.3 reconciliation timeout).
Out of Boundary
- apiserver/scheduler resync-on-reconnect content (
component-rejoin-resync). - The commit engine / pending-facts queue mechanism itself (
commit.c) — used, never modified. - Delta/session-token rejoin optimization.
commit-batch-window's commit-urgency tuning — no dependency, no reference.- Recovering
job->allocs(the scheduler's resource allocation) for a job reconciled via Requirement 4.8 that the controller never held a dispatch record for — the data was never known to this controller and cannot be reconstructed from the worker's report. Converges via the existing reserved/actual accounting policy on the job's next rusage report (see Data Models), same as any legitimately-unreserved job today. - Fixing
WAL_JOB_DISPATCH's own pre-existing, separately-trackedjob->allocsfield-completeness gap (persistence.md'sAPPLY_DIVERGENTsection; roadmap.md'sraft-apply-decouplingitem #4) — this design does not make that gap worse, but does not fix it either. - Extending
xce_join()/xcecli_join()'s own blocking wait (cbuf_deadline_10s,xceapi.c) to also cover config push or job-state reconciliation. That call is a shared, role-agnostic API (worker, apiserver, scheduler, andxce_test/xceadminclients all use it) and already returns as soon as the join reply arrives — well beforeon_worker_join(config push) even runs, unchanged by this feature. Reconciliation has its own, independent, server-side timeout (resync.c'sdelaymgrtrigger againstworkerResyncTimeoutMs) and is not gated on, or gating of, the join RPC's own deadline in any way.
Allowed Dependencies
rejoin_worker/apply_join_state(worker-join-refactor, implemented) — the plug-in point for triggering reconciliation; this design must not regress its existing attribute/endpoint-update/admin-bit-preservation behavior.- The job table's commit-then-apply seam (
record_job_*_fact,apply_job_*_transition,job_state_is_terminal,job_state_is_backward,apply_committed_entry) — reused, never duplicated. XCE_CONTROLLER_HOOK_JOBfan-out,sched.c's hook callbacks, andnotify()(dataex.c:399) — the existing controller→scheduler notify path.xce_reply_get/xce_push_data_req's{pos, count, n_total}pagination idiom (request.c) — the template for the new opcode pair.commit.c'sCOMMIT_WAIT_RESERVEDadmission-reservation pattern — the template for any bounded wait on the worker's report.config-design.md's shared-config classification rule, applied to the two new config fields.
Revalidation Triggers
- Any change to
worker_t.host.statusbitmask semantics ordispatch_a_job_'s connected-state check (job.c:1511-1512) — the dispatch-gating reuse (Requirement 5.1) depends on both staying exactly as observed. - Any change to
apply_job_update_transition/job_state_is_backward's RUN↔PEND handling. - Any change to the
XCE_CONTROLLER_HOOK_JOBfan-out shape or theNotifyDatacapnp union. - Any change to
JoinData's capnp union arms (this design deliberately does not touch it, but a future change there should re-check this design's choice to use a separate opcode pair instead). component-rejoin-resync's eventual design, if it ever proposes touching the join/rejoin wire exchange mechanics this design also touches.
Architecture
Existing Architecture Analysis
rejoin_worker/apply_join_state (worker.c:403, :321) already run under the per-worker write lock at the exact moment w->ep is set to the new live endpoint and XCE_WORKER_UNAVAIL is cleared — this is the moment the host becomes "connected" again, and the natural trigger point for Requirement 1's "does the controller hold non-terminal jobs for this host" check. handle_worker_join (join.c:48) commits the join durably (persist_commit_barrier) before on_worker_join runs — a second, later point, after the join itself can no longer be rolled back, where the reconciliation query can be sent.
The job table's existing commit-then-apply split is the load-bearing idempotency mechanism this feature must reuse (per requirements.md's Boundary Context): record_job_*_fact() functions append a WAL fact (durable, no mutation), and apply_job_*_transition() functions are the guarded mutation core, invoked both as a peek at receipt time and later by apply_committed_entry once the fact commits. job_state_is_terminal/job_state_is_backward reject duplicate or backward (terminal→non-terminal) transitions — this is the exact "no duplicate facts, no clobbering newer state" guarantee Requirement 4 asks for, already built and tested (test_guard_replay_finish_equals_single_application).
Dispatch gating (dispatch_a_job_, job.c:1278) already refuses to dispatch when w->ep == NULL || w->host.status != 0 — any nonzero status bit blocks it. A new bit reuses this check with zero changes to dispatch_a_job_ itself.
The controller→scheduler notification path is a hook→notify→wire→dequeue pipeline: job-state-change code fires XCE_CONTROLLER_HOOK_JOB (job.c), sched.c's registered callback (sched_job_hook_fun) dispatches by HOOK_DETAIL_* to sched_job_finish_fun/sched_job_fail_fun/sched_job_update_fun, each of which calls the shared notify() (dataex.c:399) with a distinct NotifyData_data_sched* event over c->sched.ep. On the scheduler side, sched_handle_notify() (handler.c:525) is the inbound receipt switch that calls sched_job_state_change(); this is architecturally distinct from scheduler.c/driver.c's own outbound decision-publish direction, which a standing test (test_no_worker_resync_symbols_in_scheduler_publish_path) mechanically guards against ever referencing "resync." This fixes the only permitted integration seam: handler.c's inbound switch.
The worker daemon retains no finished-job state today: cleanup_worker_list() (xcecd_worker_utils.c:385) deletes both the in-memory entry and its sqlite row on the pass immediately after a job exits, and neither xcecd_worker_t nor xcecd_job_t carries a timestamp field. JoinData's capnp union (nodata/attrs) carries no job content and cannot be extended in place without forcing the exchange through xce_join()'s single-shot synchronous reply, which cannot paginate.
Architecture Pattern & Boundary Map
graph TB
subgraph Controller
JC[handle_worker_join / join.c]
RW[rejoin_worker / apply_join_state / worker.c]
RS[Reconciliation state: worker_t.resync]
RQ[Reconciliation query+timeout / new resync.c]
JOBC[job.c: record_job_finish_fact / record_job_fail_fact<br/>record_job_reconcile_pending_fact NEW<br/>record_job_reconcile_assign_fact NEW]
COMMIT[commit.c / apply_committed_entry — unchanged, reused]
HOOK[XCE_CONTROLLER_HOOK_JOB fan-out]
SCHEDC[sched.c hook callbacks]
NOTIFY[notify / dataex.c]
end
subgraph Worker[xcecd]
WJOIN[Join / rejoin]
RETAIN[NEW: finished-job retention]
RUNNING[Existing: xcecd_worker_t running list]
QHANDLE[NEW: job-state query handler]
end
subgraph Scheduler
HANDLER[handler.c sched_handle_notify calls sched_job_state_change]
PUBLISH[scheduler.c / driver.c: outbound decision publish — NOT touched]
end
JC --> RW --> RS
RS -->|non-terminal jobs for host?| RQ
RQ -->|OPCode_jobStateQuery, paginated| WJOIN
WJOIN --> QHANDLE
QHANDLE --> RETAIN
QHANDLE --> RUNNING
QHANDLE -->|OPCode_jobStateReport, paginated| RQ
RQ --> JOBC
JOBC --> COMMIT --> HOOK --> SCHEDC --> NOTIFY --> HANDLER
HANDLER -.->|never referenced from| PUBLISH
Architecture Integration:
- Selected pattern: additive extension of an existing daemon-to-daemon RPC + existing fact/apply pipeline — no new subsystem, no new process.
- Domain/feature boundaries: wire protocol (new opcode pair) / controller reconciliation bookkeeping (new, small) / job-table fact recording (two new functions, rest reused) / worker retention (new) / scheduler notify (zero new code — reused as-is).
- Existing patterns preserved: commit-then-apply idempotency,
{pos,count,n_total}pagination, hook→notify scheduler path, per-worker write-lock discipline. - New components rationale: reconciliation bookkeeping and worker retention have no existing analog to extend; everything else is reuse.
- Steering compliance: async-I/O thread-role contract (bounded wait via admission reservation, not raw blocking); persistence per-field completeness review (see Data Models); config-design.md classification (see Data Models).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Wire protocol | Cap'n Proto, src/schema/controller.capnp |
New OPCode_jobStateQuery/OPCode_jobStateReport, new payload structs |
Mirrors JobData/WorkerData/ResourceVals's existing {ntotal,pos,count} shape |
| Controller | C (gnu17), src/controller/ |
Reconciliation trigger, bookkeeping, fact recording, notify | worker.c, job.c, new resync.c, cluster.c (opcode dispatch) |
| Worker daemon | C (gnu17), src/worker/ |
Finished-job retention, query handling | xcecd_worker_utils.c/h, new xcecd_resync.c |
| Async I/O | iomgr/vtaskmgr/delaymgr, src/lib/ |
Non-blocking query send/receive; bounded wait via admission reservation | Reuses commit.c's COMMIT_WAIT_RESERVED pattern |
| Shared config | Cap'n Proto ClusterConfig |
Reporting window (Req 3.7); reconciliation timeout (Req 5.3) | Alongside existing heartbeatInterval/inactiveHeartbeatCount |
File Structure Plan
Modified Files
src/schema/controller.capnp— addOPCode_jobStateQuery,OPCode_jobStateReportto theOPCodeenum; addJobStateQuery{jobids: List(UInt64), pos, count, ntotal},JobStateReport{entries: List(JobStateEntry), pos, count, ntotal},JobStateEntry{jobid, status: JobStateStatus, exitCode, finishTimeMs, queried: Bool}(queried=falsemarks a Requirement 3.6 extra job); add both asRequest/Replyunion arms; addworkerResyncWindowMs/workerResyncTimeoutMsfields toClusterConfig.src/controller/worker.c—rejoin_worker/apply_join_state: after clearingXCE_WORKER_UNAVAIL, check for non-terminal jobs assigned to this host (Requirement 1) and, if any, setXCE_WORKER_RECONCILINGand initialize the per-worker reconciliation record before releasing the write lock. No change to admin-bit preservation or attribute/endpoint update behavior.on_worker_join(worker.c:623) — extended, nothandle_worker_join: it already callsworker_push_configas its one existing step (the config the worker needs — hosttype/hostmodel/resource variables — is foundational baseline state, and its own stub comment,/* consider to push jobs on hosts to */, already anticipates this exact follow-on). The reconciliation query send is added right after it, in that fixed order (config push, then query) — both sent fire-and-forget, no new ack/reply invented for this. This ordering is safe, not just conventional: the worker's incoming-request dispatch (myreq_handler,controller_connector.c:1563) is a plain synchronousswitchinvoked one message at a time off its single long-lived client connection (xcecli_ctx) — confirmed by reading it, not assumed — so the two requests are not just sent in order, they are handled to completion in that same order on the worker side; no thread-pool fan-out is in the path (the worker's ownthread_pool_tis for job-forking work, unrelated to request dispatch). The actual wait in this design belongs at the end of the sequence, not in the middle: the controller does not consider the worker's rejoin/reconciliation complete until the job-state report arrives (or the Requirement 5.3 timeout fires) — see Error Handling.src/controller/join.c— no change.handle_worker_joinitself is untouched; the reconciliation query is sent from the (extended)on_worker_join, whichhandle_worker_joinalready calls unconditionally after the join reply.src/controller/job.c— two new functions:record_job_reconcile_pending_fact()(Requirement 4.6, routes through the existingWAL_JOB_UPDATE/apply_job_update_transitionseam),record_job_reconcile_assign_fact()(Requirement 4.8, mirrorsdispatch_a_job_'s assignment logic for host/state only — see Data Models for the allocation limitation). Existingrecord_job_finish_fact/record_job_fail_factare called as-is for the finished-success/finished-failure cases.src/controller/cluster.c— newcase OPCode_jobStateReport:inmyreq_handler's dispatch switch (runs afterha_dispatch_gate); route to the new reconciliation-apply logic.src/include/xce/types.h— addXCE_WORKER_RECONCILINGto the worker status bitmask; add aresync_state_t(queried job-ID set, round token, deadline) referenced fromworker_t.src/lib/request.c—xce_job_state_query_req()/xce_job_state_report_req()encode/decode helpers, followingxce_reply_get/xce_push_data_req's pagination loop shape.
New Files
src/controller/resync.c(+src/controller/include/resync.h) — per-worker reconciliation bookkeeping: arm/clearXCE_WORKER_RECONCILING, track queried job IDs and round token (Requirement 6.3's late-report discard), the delaymgr-triggered timeout (Requirement 5.3), and the glue that maps eachJobStateEntryoutcome to the correctjob.ccall.src/worker/src/xcecd_resync.c(+ header) — handles incomingOPCode_jobStateQuery: looks up each queried job ID against the running list (xcecd_worker_t) and the new finished-job retention; builds and sends paginatedOPCode_jobStateReportreplies, followed by extra-job pages (Requirement 3.6).src/controller/testsuite/test_worker_resync.c— controller-side unit tests (trigger, query construction, per-outcome fact application, dispatch gating, timeout, discard-on-disconnect), followingtest_worker_join.c's fixture conventions.src/worker/testsuite/— new directory;src/workerhas notestsuite/today (perstructure.md/testing.md). This feature is the first worker-side C test coverage; addtest_xcecd_resync.cand the minimal harness scaffolding it needs (mirroringcontroller/testsuite/test.c's runner pattern).
Modified Worker Files
src/worker/include/xcecd_worker_utils.h/src/worker/src/xcecd_worker_utils.c— add a finished-job retention structure (jobid, exit status, real finish time, retain-until) populated at job exit beforecleanup_worker_list()would otherwise reap the entry;cleanup_worker_list()retains an entry in this new structure instead of discarding all trace of it, until the configured window elapses.
System Flows
sequenceDiagram
participant W as Worker
participant JC as ControllerJoin
participant WC as ControllerWorker
participant R as ControllerResync
participant JOBC as ControllerJob
participant SCHED as Scheduler
W->>JC: OPCode_join (reconnect or failover reconnect)
JC->>WC: rejoin_worker(...)
WC->>WC: clear UNAVAIL, set ep (per-worker WRITE lock)
WC->>R: non-terminal jobs assigned to this host?
alt none
R-->>WC: no reconciliation needed
else one or more
R->>WC: arm XCE_WORKER_RECONCILING, init round token + deadline
end
WC-->>JC: idx
JC->>JC: persist_commit_barrier (join durable)
JC->>W: xce_reply (join success reply, unchanged)
JC->>JC: on_worker_join
JC->>W: OPCode_push (config push, unchanged worker_push_config call)
JC->>W: OPCode_jobStateQuery (NEW — sent right after, same on_worker_join)
Note over W: single connection, synchronous per-message dispatch<br/>(myreq_handler in controller_connector.c line 1563) — config push<br/>is handled to completion before the query is even read as<br/>a distinct message, no ack or reply needed to enforce this
loop each query page
W->>JC: OPCode_jobStateReport (queried=true entries)
end
W->>JC: OPCode_jobStateReport (queried=false entries — extras, Req 3.6)
Note over W: worker holds off entering its normal post-join operating<br/>mode (resuming periodic background reporting) until its full<br/>report has finished sending (Req 3.8) — a new gate flag,<br/>mirroring the existing needs_rejoin pattern in<br/>controller_connector.c, not a new blocking wait
loop each reported entry
alt finished success
JC->>JOBC: record_job_finish_fact (existing, reused)
else finished failure
JC->>JOBC: record_job_fail_fact (existing, reused)
else still running
JC->>R: clear this jobid from outstanding set (no-op fact)
else unrecognized
JC->>JOBC: record_job_reconcile_pending_fact (NEW)
JOBC->>SCHED: notify scheduler of new PEND state (Req 7.2) via the same HOOK_DETAIL_JOB_UPDATE path
else unqueried extra job
JC->>JOBC: record_job_reconcile_assign_fact (NEW)
end
JOBC->>JOBC: commit-then-apply, existing guards, unchanged
JOBC->>SCHED: existing HOOK_DETAIL_JOB event notify path, unchanged
end
R->>WC: outstanding set empty? clear XCE_WORKER_RECONCILING
Note over R: if deadline fires first (delaymgr trigger),<br/>resolve outstanding as answered/timed out (Req 5.3)<br/>rather than withholding dispatch indefinitely
Gating conditions: dispatch_a_job_ already refuses dispatch whenever w->host.status != 0 (job.c:1511) — XCE_WORKER_RECONCILING reuses this unchanged. A dispatch attempt to a disconnected worker (w->ep == NULL) fails immediately and returns the job to pending (Requirement 5.4) via the same new reconcile-to-pending fact path used for an unrecognized job, rather than the existing retry-then-terminal-fail branch (whose retry semantics — retry the dispatch itself — don't fit "the worker isn't even connected").
Discard-on-disconnect (Requirement 6): if the worker disconnects before its report is fully processed, rejoin_worker's next invocation (on the next join) finds the prior round's token stale and starts a fresh one; any report that later arrives carrying the stale token is discarded (Requirement 6.3) rather than applied against the wrong round.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1, 1.2, 1.3 | Trigger reconciliation iff non-terminal jobs exist for the host, regardless of join type | rejoin_worker, resync.c |
trigger check | Sequence: rejoin → trigger |
| 2.1, 2.2 | Query lists exactly the non-terminal jobs by unique ID | resync.c, wire JobStateQuery |
xce_job_state_query_req |
Sequence: OPCode_jobStateQuery |
| 3.1–3.5 | Worker reports definitive status + real finish time per queried job | worker xcecd_resync.c, retention |
JobStateEntry |
Sequence: report loop |
| 3.6 | Worker reports extra unqueried jobs | worker xcecd_resync.c |
JobStateEntry.queried=false |
Sequence: extras page |
| 3.7 | Bounded, cluster-configurable reporting window | ClusterConfig.workerResyncWindowMs, worker retention |
config field | Data Models |
| 3.8 | Worker delays resuming normal post-join operation until its report has finished sending | worker xcecd_resync.c, new gate flag |
resync-in-progress flag | Sequence: post-report note |
| 4.1–4.5 | Apply via existing idempotent commit-then-apply; no-op duplicates; never clobber newer/terminal state | job.c (existing fns), apply_job_*_transition |
existing | Sequence: apply per outcome |
| 4.6 | Unrecognized → return to pending, eligible for scheduling | record_job_reconcile_pending_fact (NEW) |
new fn | Sequence: unrecognized branch |
| 4.7, 4.8 | Reconcile jobs outside the query; assign if no existing non-terminal record | record_job_reconcile_assign_fact (NEW) |
new fn | Sequence: extras branch; Data Models limitation |
| 5.1, 5.2 | Gate dispatch while reconciliation outstanding; restore eligibility on completion | XCE_WORKER_RECONCILING bit, dispatch_a_job_ (reused) |
status bitmask | Architecture: dispatch gating reuse |
| 5.3 | Bounded timeout resolves outstanding reconciliation | resync.c delaymgr trigger, ClusterConfig.workerResyncTimeoutMs |
timeout | Sequence: deadline note |
| 5.4 | Dispatch to disconnected worker fails fast, returns to pending | new dispatch-fail classification, record_job_reconcile_pending_fact (NEW, reused) |
— | Error Handling |
| 6.1–6.3 | Disconnect mid-round discards and re-triggers; idempotent re-application; stale-round reports discarded | resync.c round token |
round token | Sequence: discard-on-disconnect note |
| 7.1–7.3 | Reconciled changes reach the scheduler via the existing notify path | XCE_CONTROLLER_HOOK_JOB, sched.c, handler.c (all reused, unchanged) |
notify() |
Sequence: apply → notify |
| 8.1–8.3 | Works for any job count; paginated multi-message exchange | JobStateQuery/JobStateReport pagination |
{pos,count,ntotal} |
Sequence: loop each query/report page |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
resync.c (new) |
Controller | Per-worker reconciliation bookkeeping + timeout | 1, 5.3, 6 | worker.c (P0), commit.c admission pattern (P1) |
State, Batch |
job.c additions |
Controller | Two new idempotent fact-recording functions | 4.6, 4.8, 5.4 | existing commit-then-apply seam (P0) | Service |
xcecd_resync.c (new) |
Worker | Answer job-state query from running list + retention | 3, 8 | xcecd_worker_utils.c (P0) |
Batch, Event |
xcecd_worker_utils.c additions |
Worker | Short-lived finished-job retention | 3.5, 3.7 | none new | State |
| Wire opcode pair | Protocol | Paginated query/report exchange | 2, 3, 8 | xce_reply_get/xce_push_data_req pattern (P0) |
API/Batch |
Controller: resync.c
| Field | Detail |
|---|---|
| Intent | Own the reconciliation-outstanding lifecycle for one worker: arm on trigger, track queried IDs + round token, resolve on completion or timeout |
| Requirements | 1.1–1.3, 5.1–5.3, 6.1–6.3 |
Responsibilities & Constraints
- Called from
rejoin_worker/apply_join_stateunder the per-worker write lock already held there — must not introduce a new lock ordering. - Owns the round token that distinguishes the current reconciliation attempt from a stale/superseded one (Requirement 6.3).
- Schedules the Requirement 5.3 timeout via a delaymgr one-shot trigger (non-blocking fire, per the async-I/O thread-role contract) — never a blocking wait on iomgr or delaymgr.
Dependencies
- Inbound:
worker.c(P0) — trigger call site - Outbound:
job.c(P0) — per-outcome fact application - Outbound:
commit.c's admission-reservation pattern (P1) — template for any bounded wait, if a synchronous variant is ever needed instead of the pure delaymgr-trigger design above
Contracts: Batch [x] / State [x]
Batch / Job Contract
- Trigger:
rejoin_workerfinds ≥1 non-terminal job for the rejoining host - Input / validation: the controller's own non-terminal job set for that host (Requirement 2.1) — no external input to validate
- Output / destination:
job.cfact-recording calls, one per reported entry - Idempotency & recovery: round token discards stale reports (6.3); a fresh round replaces a discarded one on the next join (6.1); applying a report twice for the same round is a no-op by construction of the underlying
job.cguards (6.2)
State Management
- State model: one
resync_state_tperworker_t—{round_token, queried_jobids[], deadline}; absent/cleared when no reconciliation is outstanding - Persistence & consistency: not itself persisted — it is in-memory reconciliation-in-progress bookkeeping, not a durable fact; the durable facts it produces are the existing job-table WAL entries. A controller crash mid-reconciliation loses this bookkeeping, which is correct: the next rejoin (post-failover) re-triggers reconciliation from scratch (Requirement 1.3), so nothing needs to survive a crash here.
- Concurrency strategy: guarded by the same per-worker write lock
rejoin_workeralready holds
Controller: job.c — record_job_reconcile_pending_fact, record_job_reconcile_assign_fact
| Field | Detail |
|---|---|
| Intent | The two reconciliation outcomes with no existing idempotent fact analog |
| Requirements | 4.6, 4.7, 4.8, 5.4 |
Responsibilities & Constraints
record_job_reconcile_pending_fact(cluster_t*, job_t*)— submits aWAL_JOB_UPDATEfact targetingJOB_STAT_PEND, through the existingapply_job_update_transition/job_state_is_backwardguard (RUN→PEND is non-terminal→non-terminal, already permitted); fires the existingHOOK_DETAIL_JOB_UPDATEpath exactly as any other job-state-update fact would. Also used for Requirement 5.4 (dispatch-to-disconnected-worker fail-fast).record_job_reconcile_assign_fact(cluster_t*, job_t*, worker_t*, xce_jobstate_report_t*)— mirrorsdispatch_a_job_'s own host/state assignment logic (job.c:1686-1734) for a job the controller has no existing non-terminal record of; does not attempt to populatejob->allocs(see Data Models).- Neither function introduces a new guard — both submit through the existing
apply_job_*_transitionseam.
Contracts: Service [x]
Service Interface (C)
int record_job_reconcile_pending_fact(cluster_t* c, job_t* job);
int record_job_reconcile_assign_fact(cluster_t* c, job_t* job,
worker_t* w, xce_jobstate_report_t* report);
- Preconditions: caller holds
job's write lock (matching every otherrecord_job_*_factsibling);jobis non-terminal (pending case) or has no existing non-terminal assignment (assign case) - Postconditions: a WAL fact is durably appended; mutation happens later via
apply_committed_entry, identical to every other fact type - Invariants: never called for a job the controller already knows is terminal or more-final (Requirement 4.4/4.5 — enforced by the existing guard, not by new logic here)
Worker: xcecd_resync.c (new)
| Field | Detail |
|---|---|
| Intent | Answer the controller's job-state query from the running list and the new finished-job retention; report extras; gate the worker's own post-join wake-up on completion |
| Requirements | 3.1–3.8, 8.2 |
Responsibilities & Constraints
- For each queried job ID: check the running list (
xcecd_worker_t) first; if not running, check the new retention; if in neither, reportunrecognized(Requirement 3.1, 3.3). Every job ID present in the query gets an entry in the reply — none are silently skipped (Requirement 3.2). - Include the real finish time from retention, not the time the report is sent (Requirement 3.5).
- After answering all queried IDs, walk the running list and retention for anything not in the query and report it too (Requirement 3.6), using the same
JobStateEntryshape withqueried=false. - Send both in paginated
OPCode_jobStateReportmessages (Requirement 8.2). - Sets a new local
resync_pendingflag (worker-side, mirroringcontroller_connector.c's existingneeds_rejoinboolean pattern) whenOPCode_jobStateQueryis received; clears it only once every report page (queried and extra) has finished sending. Whatever the worker's own post-join sequence does next (resuming periodic background reporting, entering normal operating mode) checks this flag first (Requirement 3.8). This is a local gate on the worker's own follow-on behavior, not a new wire-level ack and not an extension ofxce_join()'s blocking wait (see Boundary Commitments' Out of Boundary).
Dependencies
- Inbound: query receipt (P0)
- Outbound:
xcecd_worker_utils.c's running list + new retention (P0)
Contracts: Batch [x] / Event [x]
Batch / Job Contract
- Trigger:
OPCode_jobStateQueryreceived - Input / validation: job IDs in the query — no validation needed beyond matching against local state
- Output / destination:
OPCode_jobStateReport, one or more pages - Idempotency & recovery: re-sending the same query (e.g. after the worker's own reconnect mid-exchange) produces the same answers — the worker's local state is the read-only source of truth for this exchange
Worker: xcecd_worker_utils.c additions — finished-job retention
| Field | Detail |
|---|---|
| Intent | Retain a job's true outcome (status, exit code, real finish time) for a bounded window after exit, instead of discarding it at the next cleanup_worker_list() pass |
| Requirements | 3.4, 3.5, 3.7 |
Responsibilities & Constraints
- Populated at the same point a job's exit is currently detected, before
cleanup_worker_list()would otherwise delete the entry. - Bounded by
ClusterConfig.workerResyncWindowMs(Requirement 3.7) — entries older than the window are dropped, not retained indefinitely. - Does not change
cleanup_worker_list()'s existing behavior for the in-flightxcecd_worker_t/sqlite bookkeeping — it adds a second, smaller, timestamped structure fed at the same moment, not a rework of the existing one.
Contracts: State [x]
State Management
- State model: a small bounded list/ring of
{jobid, exit_status, exit_code, finish_time_ms}, in-memory only - Persistence & consistency: not persisted — a worker restart loses this retention, which is acceptable: a restarted worker has no running jobs of its own to report on rejoin either way (its child processes died with it), so the retention's only job is to survive a controller-side disconnect/reconnect window, not a worker restart
- Concurrency strategy: same locking discipline already used for
xcecd_worker_t's list
Data Models
Wire/Protocol Model
JobStateEntry {
jobid: UInt64
status: JobStateStatus (RUNNING | FINISHED_OK | FINISHED_FAIL | UNRECOGNIZED)
exitCode: Int32 # meaningful only for FINISHED_FAIL
finishTimeMs: UInt64 # meaningful only for FINISHED_OK/FINISHED_FAIL; worker-reported, per Req 3.5
queried: Bool # false = Requirement 3.6 extra, not part of the controller's original query
}
JobStateQuery { jobids: List(UInt64), pos: UInt32, count: UInt32, ntotal: UInt32 }
JobStateReport { entries: List(JobStateEntry), pos: UInt32, count: UInt32, ntotal: UInt32 }
Controller-Side In-Memory Model
resync_state_t { // new, attached to worker_t
round_token: uint64 // discards stale/superseded reports (Req 6.3)
queried_jobids: set<uint64>
deadline: timestamp // Req 5.3
}
Persistence Field-Completeness Review
Per design-principles.md's Persistence Review requirement and persistence.md's APPLY_DIVERGENT/bug-1678 precedent, every fact this feature introduces or reuses is enumerated below:
| Outcome | Fact path | New fields? | Both apply seams? |
|---|---|---|---|
| Finished success | record_job_finish_fact (existing, reused as-is) |
None — existing complete fact shape (already carries finish_time, exit info); the reconciliation caller passes the worker-reported finishTimeMs (Requirement 3.5) as that fact's finish time, never the time the controller processed the report (Requirement 4.2) |
Already covered by existing apply_committed_entry/standby-catch-up handling — unchanged |
| Finished failure | record_job_fail_fact (existing, reused as-is) |
None — same Requirement 4.2 worker-reported-time rule applies | Unchanged, existing |
| Still running | No fact — pure no-op against the outstanding set | N/A | N/A |
| Unrecognized → pending | record_job_reconcile_pending_fact (NEW) |
None beyond the existing WAL_JOB_UPDATE state field |
Must confirm at implementation time that both apply_committed_entry (primary boot replay) and the standby catch-up apply path decode WAL_JOB_UPDATE identically for an arbitrary target state — expected yes, since this is the same fact type as any other state update, just a new call site, but this is a mechanical check to perform, not assume |
| Unqueried job → assign | record_job_reconcile_assign_fact (NEW) |
jobid, host assignment, state — explicitly does NOT include job->allocs (see below) |
Both seams must decode/adopt jobid+host+state identically — new call site into what is otherwise the same assignment shape dispatch_a_job_ already produces |
Documented scope decision — job->allocs on a reconciliation-assigned job (Requirement 4.8): when the controller reconciles a job it never held any prior record of (e.g. it was dispatched by a controller instance before a failover whose dispatch fact was itself lost), it has no way to learn what resource allocation the scheduler originally computed for that job — the worker's report carries the job's execution outcome, not its original resource request. This design deliberately does not attempt to reconstruct job->allocs for this case. This is not a new problem: persistence.md's APPLY_DIVERGENT section already documents WAL_JOB_DISPATCH itself not carrying job->allocs through replay as a tracked, separate gap (roadmap.md's raft-apply-decoupling item #4); this design does not worsen it. The practical consequence is bounded by resource-accounting.md's existing policy: a job with an unknown/zero reservation is charged max(reserved=0, actual) — i.e., its real usage — the moment its next rusage tick arrives, identical to how a legitimately-unreserved job is already handled today. The job's state is correct immediately; its resource accounting converges within one rusage cycle, not instantly. No fabricated allocation value is ever written.
Configuration Model
Per config-design.md's classification rules, applied explicitly (not left implicit):
| Field | Rule 0 (bootstrap) | Independence | Frequency | Verdict | Why |
|---|---|---|---|---|---|
workerResyncWindowMs (Req 3.7) |
passes — not needed to obtain config | passes — one worker's reporting window has no cross-node correctness dependency | passes — rarely changed | Shared, via operational-uniformity override | Structurally identical to heartbeatInterval/inactiveHeartbeatCount (controller→worker liveness domain): no correctness invariant demands agreement, but operators want uniform, predictable reconciliation behavior across the cluster rather than per-node drift — same documented exception, same rationale |
workerResyncTimeoutMs (Req 5.3) |
passes | passes — one worker's reconciliation deadline has no cross-node dependency | passes | Shared, via operational-uniformity override | Same reasoning as above; kept alongside workerResyncWindowMs in ClusterConfig rather than splitting the override rationale across two places |
Both fields are added to the existing ClusterConfig capnp struct, distributed via the existing shared-config push mechanism — no new configuration surface or distribution channel.
Error Handling
Error Strategy
Every reconciliation outcome resolves definitively — there is no "leave it ambiguous" outcome. A worker that cannot determine a job's true status reports UNRECOGNIZED rather than guessing (Requirement 3.3), which the controller treats as "return to pending," never as a fabricated finish.
Error Categories and Responses
- Worker disconnects mid-round (Requirement 6.1): the outstanding round is discarded (not applied partially); the next join re-triggers a fresh round via the same Requirement 1 check.
- Stale/superseded report arrives (Requirement 6.3): discarded by round-token mismatch in
resync.c— never applied against a round that's already been superseded. - Reconciliation report never arrives / no reply within the bound (Requirement 5.3): a delaymgr-scheduled trigger (non-blocking fire, per the async-I/O thread-role contract) resolves the outstanding round — accepted-as-answered, timed out, or left for the next rejoin to retry — rather than withholding that worker from dispatch indefinitely. This must not be a blocking wait anywhere in the iomgr/delaymgr call chain; if a synchronous wait style is preferred at implementation time instead of a pure trigger-and-resolve design, it must follow
commit.c'sCOMMIT_WAIT_RESERVEDadmission-reservation pattern (bounded blocking under an explicit cap on the vtask pool), never a raw wait. - Dispatch attempted to a disconnected worker (Requirement 5.4): fails immediately (not queued for later delivery) and returns the job to pending via
record_job_reconcile_pending_fact— a distinct classification from the existing retry-then-terminal-fail branch indispatch_a_job_, whose retry semantics (retry the dispatch attempt itself against the same worker) don't fit "the worker isn't even connected." - New opcode reaches a non-primary controller: per
api-standards.md's "Adding a New Opcode" checklist (bug-1700 precedent) — verify at implementation time whetherOPCode_jobStateQuery/OPCode_jobStateReportneed an explicit redirect-reply path. Expected answer: no, since a worker only ever holds a connection to the current primary (connection-lifecycle-tied-to-primary-status, per the HA connection-ownership model), so this opcode pair should never reach a standby in practice — but this must be confirmed, not assumed, given bug-1700's documented history of this exact class of gap recurring across two other opcodes.
Monitoring
Reuses existing job-state-change logging (job.c's existing log call sites for finish/fail/update facts) — no new logging framework. A reconciliation-driven fact should log which outcome and round produced it, for operator diagnosis of a rejoin event, matching the existing style (structured, operator-visible on the ERROR/WARN path per error-handling.md; INFO for normal reconciliation completion).
Testing Strategy
Unit Tests (Controller, src/controller/testsuite/test_worker_resync.c)
- Reconciliation triggers iff the host has ≥1 non-terminal job at rejoin (Requirement 1.1/1.2), for all three join circumstances (first join, network reconnect, failover reconnect) without the code needing to distinguish them (Requirement 1.3).
- Query construction lists exactly the controller's non-terminal jobs for that host by ID (Requirement 2).
- Each of the five reported outcomes (running, finished-success, finished-failure, unrecognized, unqueried-extra) applies through the correct existing/new fact function exactly once, and a repeated report for the same round is a no-op (Requirement 4, mirroring
test_guard_replay_finish_equals_single_application's existing style). XCE_WORKER_RECONCILINGblocksdispatch_a_job_and clears once the round resolves (Requirement 5.1/5.2).- Timeout resolves an outstanding round without a blocking call (Requirement 5.3).
- Disconnect mid-round discards and re-triggers; a stale-round report is discarded (Requirement 6).
- A reconciled state change reaches the scheduler via the existing notify path, asserted the same way existing finish/fail notify tests assert it (Requirement 7).
Unit Tests (Worker, new src/worker/testsuite/test_xcecd_resync.c)
- Query answered correctly from the running list, from retention, and as
UNRECOGNIZEDwhen in neither (Requirement 3.1–3.3). - Real finish time reported, not send time (Requirement 3.5).
- Extras reported for jobs outside the query (Requirement 3.6).
- Retention window boundary: an entry just inside/outside
workerResyncWindowMsis/isn't reported (Requirement 3.7). resync_pendinggate: set on receivingOPCode_jobStateQuery, stays set across multiple report pages, and only clears after the last page (queried and extra) has been sent — the worker's post-join wake-up path must observe it set throughout, never cleared early (Requirement 3.8).
Integration Tests
- Full rejoin-reconciliation round trip against a live controller+worker pair, mirroring
test_worker_join.c's fixture conventions (setup_test_cluster/teardown_test_cluster, direct calls, one test per behavior). - Failover-during-job: a job's finish notify is lost across a primary failover; the new primary's rejoin reconciliation recovers it (this is the feature's core bug-1741 regression scenario).
- Burst/array scenario resembling bug-1741's original reproduction (many near-simultaneous finish reports lost on one host) — resync must recover all of them, unbounded by count (Requirement 8).
Performance/Load
- Reconciliation exchange for a worker running an order of magnitude more jobs than a single message can carry completes correctly across multiple pages, not truncated (Requirement 8.1–8.3).
Security Considerations
No new authentication/authorization surface — the new opcode pair travels over the same authenticated controller↔worker connection as every other daemon message, subject to the same ha_dispatch_gate role-based admission already governing every opcode. No new external input validation beyond matching reported job IDs against the controller's own records (an unrecognized ID is simply itself information, per Requirement 3.3/4.5 — never treated as an error condition to reject).
Supporting References
Deep file:line citations, the alternatives considered for the wire-protocol shape, and the full rationale for each design decision above live in research.md.