brief/requirements updated to reflect the reopened-and-now-closed post-GO gap-closure phase (tasks 7.1-7.3); adds research.md capturing the investigation trail; roadmap.md reflects current spec status.
31 KiB
Research & Design Decisions: job-array-single-wal-record
Purpose: Capture discovery findings, architectural investigations, and rationale that inform design.md.
Summary
- Feature:
job-array-single-wal-record - Discovery Scope: Extension (existing system) — the controller's job-submit and apply-seam machinery already exists (
raft-apply-decoupling, complete); this feature adds an array-shaped WAL record and expansion step on top of it. - Key Findings:
- The brief's premise ("
job_submit()loops callingcreate_job()per element") is stale.raft-apply-decouplingtask 3.2 already removed that loop:handle_jobsubmit()today builds N lightweightxce_job_tWAL payloads (build_job_submit_payloads, job.c:221) and appends N separateWAL_JOB_SUBMITentries viapersist_submit_job_payloads_async()(persist.c:4846), each with its own commit continuation, batched into one client reply (jobsubmit_async_ctx_t, job.c:754).create_job()itself now runs only inside the apply seam (restore_job(), job.c:271). This feature's real job is collapsing those N WAL appends into one, not removing acreate_job()loop that no longer exists. - The client-facing
JobSubmitcapnp struct (controller.capnp:533) already carries exactly(jobid, arraySize, submitData)— the compact shape Requirement 1 needs for the new WAL record. No new capnp struct is needed for encoding; a newwal_entry_type(WAL_JOB_ARRAY_SUBMIT) is needed so the existing single-jobWAL_JOB_SUBMIT/struct Jobformat stays byte-for-byte unchanged (Requirement 8.1). - Because array elements are uniform clones (locked earlier in requirements discovery — no per-index overrides), the new record's encoded size is O(1) in array size: one
JobSubmitDataregardless of whetherarray_sizeis 2 or 32767. Combined with the pre-existing hard caparraySize <= MAX_ARRAY_IDX + 1(32768) enforced at jobid-minting time (idgen(), idgen.c:31, called from the Go apiserver before a request ever reaches the controller), Requirement 7's "bounded record size" is satisfied by construction — no chunking policy needs to be built. apply_committed_entry()(persist.c:2721) is not the only live dispatcher ofWAL_JOB_SUBMIT.apply_wal_entry()(persist.c:2574) is a second, hook-free dispatcher that remains the live standby catch-up/replicated-apply path (confirmed by an explicit comment at persist.c:3722-3725: "apply_wal_entry()... is NOT retired by this change; it remains the live standby catch-up/replicated-apply dispatch used elsewhere (ha.c)"). Today's codebase avoids divergence between these two dispatchers by sharing one primitive,restore_job(), called from both switch statements. This feature must follow the same pattern with a newrestore_job_array()shared primitive, not add expansion logic to only one dispatcher.- The scheduler/apiserver push infrastructure already accepts an N-element
xlist_tin one hook fire (sched_job_new_fun/apiserver_job_new_fununconditionally callxcesched_push_joblist/apiserver_push_joblistwith whatever list they receive) — so "one push call" is already achievable for free. But Requirement 6's wire-shape note explicitly demands more: the wire payload itself must stay compact (a descriptor), not N individually-encodedJob/SchedJobstructs, because the existingList(Job)/List(SchedJob)wire shape re-introduces O(N) payload size even inside a single RPC. This requires a genuinely new compact push message and a new discriminator (xlist->count > 1) in the two existing hook handlers, plus new local-expansion logic on the scheduler (src/scheduler/src/handler.c:112,case Data_data_schedjobs:) and the Go apiserver (src/frontend/apiserver/backend/convert.go, sibling tocJobToModel). persist_apply_pump_run()(persist.c:3244) currently discardsapply_committed_entry()'s return value ((void)apply_committed_entry(...), persist.c:3281) and unconditionally advanceslast_applied_lsn. A resource-exhaustion failure mid-array-expansion would, under today's code, be silently skipped forever — the array would never be constructed and never be retried. This is a genuine, load-bearing gap this feature must close (not a hypothetical): the pump must start checking the return value and gatelast_applied_lsnadvancement on it, mirroring the existingapply_pump_read_entryfailure branch immediately above it.- No existing promotion-gate condition considers
last_applied_lsnvscommitted_lsnat all (ha.cgates only ontruncated,durable_floor,apply_verdict == APPLY_DIVERGENT,self_restart_pending). A node stuck retrying a resource-exhaustion apply failure would look fully promotable today. A new, narrowly-scoped live fence is required — deliberately not reusingrecovery_unrecoverabledirectly, because that flag's existing periodic auto-clear (ha.c:1069-1071, gated on WAL durability catch-up, not apply progress) would wrongly clear a fence whose underlying condition (stuck apply) is unrelated to durability and has not actually resolved. jobj.c'sjob_init(jobj.c:532) does not checkmakeJobsub()'s return value before storing it (jobj.c:543) — a real, pre-existing gap, N×-amplified by this feature's tight per-arraycreate_job()loop.job_destroy'saccountsleak (jobj.c:584,/* FIXME??? */) is likewise pre-existing and N×-amplified. Both are already in scope via Requirement 10.
- The brief's premise ("
Research Log
Job-array submission path: brief vs. live code
- Context: the brief (written before
raft-apply-decouplingfully landed) describes aforloop overcreate_job()injob_submit(). Needed to confirm this is still accurate before designing an insertion point. - Sources Consulted:
src/controller/job.c(live read, lines 196-1076), git history reference in code comments (commit6f1fa97). - Findings: The loop was removed by task 3.2. Current path:
handle_jobsubmit()→build_job_submit_payloads()(builds N plainxce_job_tpayload structs, no RTTI objects) →persist_submit_job_payloads_async()(N WAL appends, N commit continuations sharing onejobsubmit_async_ctx_t) → each continuation hands off topersist_register_pending_apply()→ the apply pump'sapply_committed_entry()constructs the actualjob_tviarestore_job()/create_job(), one per committed entry. - Implications: The N-scaling this feature must eliminate is in WAL appends/commit continuations (job.c/persist.c), not in a
create_job()loop insidehandle_jobsubmit— that loop is already gone. Design must replacebuild_job_submit_payloads+persist_submit_job_payloads_async's per-element loop with a single array-shaped append, and add the N-way expansion loop to the apply seam instead (wherecreate_job()already lives today).
WAL wire format and capnp schema
- Context: Requirement 1 needs "base spec + index range/count" as one record; needed to know whether new capnp schema is required.
- Sources Consulted:
src/schema/controller.capnp(struct Jobline 287,struct JobSubmitline 533),src/include/wal_types.h. - Findings:
struct JobSubmit $C.mapname("xce_job_submit_t")already has exactly(jobid, arraySize, submit: JobSubmitData)— the client request format.struct Job(the current WAL_JOB_SUBMIT payload) has no array fields and is single-job only. - Implications: Reuse
struct JobSubmit's existing capnp encoder (encode_JobSubmit, already generated) as the new WAL_JOB_ARRAY_SUBMIT payload verbatim — zero schema changes for the WAL format itself. Gate on a newwal_entry_typevalue so the single-jobWAL_JOB_SUBMIT/struct Jobformat is untouched (Requirement 8.1).
Record size bound (Requirement 7)
- Context: needed to determine whether a chunking policy must be built.
- Sources Consulted:
src/include/config.h(N_ARRAY_IDX_BITS,MAX_ARRAY_IDX),src/lib/idgen.c(idgen()),src/frontend/apiserver/backend/clib.go(GenerateJobID). - Findings:
idgen()rejectsarraySize > MAX_ARRAY_IDX + 1(32768) at jobid-minting time, called from the Go apiserver before any submission reaches the controller (clib.go:165) — a request whosearray_sizeexceeds this cap never gets a jobid and never reacheshandle_jobsubmit. Combined with the record's O(1) payload (oneJobSubmitData, not per-element), the WAL_JOB_ARRAY_SUBMIT record's byte size is already bounded and does not grow witharray_sizeat all. - Implications: No new chunking mechanism is needed. Requirement 7.1/7.2 are satisfied by (a) reusing the compact
JobSubmitshape (Requirement 1's own design decision) and (b) citing the pre-existingMAX_ARRAY_IDXcap as the hard upper bound on element count. Building "multiple atomically-committed chunks" machinery for a scenario that cannot occur would be scope creep (design-synthesis Simplification lens).
Apply-seam dispatch: two live dispatchers, not one
- Context: Requirement 3 demands one shared expansion path for online apply and replay; needed to confirm
apply_committed_entry()is really the only live dispatcher. - Sources Consulted:
src/controller/persist.c(apply_wal_entry, line 2574;apply_committed_entry, line 2721;persist_recover's replay loop, line ~3742),.kiro/specs/raft-apply-decoupling/design.md. - Findings:
apply_wal_entry()was NOT retired byraft-apply-decoupling— an explicit in-code comment (persist.c:3722-3725) confirms it "remains the live standby catch-up/replicated-apply dispatch used elsewhere (ha.c)".persist_recover()'s own replay loop was switched to callapply_committed_entry()(persist.c:3742), butapply_wal_entry()is a second, separate switch statement over the same WAL types, still reachable fromha.cfor standby catch-up. Both dispatchers avoid divergence today only because they both delegate the actual single-job construction to one shared helper,restore_job(). - Implications: the array-expansion loop must live in one new shared helper (
restore_job_array(), sibling torestore_job()), called from a newWAL_JOB_ARRAY_SUBMITcase added to bothapply_committed_entry()andapply_wal_entry()'s switch statements — matching the codebase's existing (not the aspirational) architecture. Recorded as a correction to Requirement 3's framing: the shared seam is the helper function, not either dispatcher alone.
Live apply-pump failure handling gap
- Context: Requirement 9.4/9.5 need "keep retrying the apply until it succeeds" and "fence non-promotable" for a resource-exhaustion failure during array expansion.
- Sources Consulted:
src/controller/persist.c(persist_apply_pump_run, line 3244),src/controller/ha.c(promotion-gate call sites, lines ~1069 and ~1344). - Findings:
persist_apply_pump_run()'s dispatch loop discardsapply_committed_entry()'s return value ((void)apply_committed_entry(...), persist.c:3281) and unconditionally advanceslast_applied_lsn. The read-failure branch immediately above it (apply_pump_read_entryfailing) DOES stop the walk and leavelast_applied_lsnunadvanced for retry — but nothing analogous exists for anapply_committed_entry()-internal failure. Separately,ha.c's promotion-eligibility gate never inspectslast_applied_lsnvscommitted_lsnat all; it gates on WAL durability/truncation/apply-divergence/self-restart-pending only. - Implications: two concrete, necessary fixes, both required for Requirement 9 to hold, neither optional: (1) the pump must check
apply_committed_entry()'s return value and mirror the existing read-failure branch (stop, don't advance, retried next kick) on a negative result; (2) a new, narrow live fence must gate promotion on "no LSN is currently stuck failing to apply" — deliberately not reusingrecovery_unrecoverable(see next entry).
Why not reuse recovery_unrecoverable for the live apply fence
- Context:
recovery_unrecoverable+persist_recover_loud_fence_if_unrecoverable()already implement the exact "loud, non-promotable, stay alive, keep retrying, self-heal" shape Requirement 9.4/9.5 wants — first instinct was to reuse it directly. - Sources Consulted:
src/controller/ha.c(both promotion-gate call sites),src/include/persist.h(recovery_unrecoverablefield doc comment,APPLY_DIVERGENThandling). - Findings:
recovery_unrecoverablealready has two distinct existing consumers with different clear semantics: (a) a periodic/timer-driven clear (ha.c:1069-1071) gated purely on WAL durability catch-up (durable_floor >= target_hwm), unrelated to apply progress; (b)apply_verdict == APPLY_DIVERGENT, a separate field, explicitly documented as "only ever recomputed by a fresh boot-timepersist_recover()replay" and never live-cleared. If the new live apply-resource fence reusedrecovery_unrecoverabledirectly, condition (a)'s periodic clear would fire as soon as WAL durability caught up — which happens independently of whether the stuck array LSN has actually finished applying — silently un-fencing a node that is still failing to construct the array. - Implications: introduce a new, narrowly-scoped field (design.md calls it the Live Apply Resource Fence) instead of overloading
recovery_unrecoverable. Set by the pump on anapply_committed_entry()failure; cleared by the pump itself the moment a retry of that same LSN succeeds — no dependency on the election/promotion-gate call sites ever running, so a long-lived primary that never loses leadership still self-heals (Requirement 9.5).
Scheduler/apiserver compact push wire shape
- Context: Requirement 6's wire-shape note explicitly rules out "N individually-encoded full job structs bundled into one message" — needed to confirm today's push path does exactly that despite already being list-shaped.
- Sources Consulted:
src/controller/sched.c(sched_job_new_funline 271,xcesched_push_joblistline 14),src/controller/apiserver.c(apiserver_job_new_funline 127,apiserver_push_joblistline 97),src/schema/controller.capnp(struct Dataunion, line 355;SchedJobData/JobData, lines 306-323),src/scheduler/src/handler.c(case Data_data_schedjobs:, line 112),src/frontend/apiserver/backend/convert.go(cJobToModel, line 31). - Findings: both
sched_job_new_funandapiserver_job_new_fununconditionally call their respective..._push_joblist(c, l)regardless ofl->count— for an N-element list (which the new array apply case will produce) this would encode N fullJob/SchedJobcapnp structs on the wire, reintroducing exactly the O(N) payload growth Requirement 6 rules out.struct Data'sdata :union $C.mapuniontag("table")(controller.capnp:356) is an additive discriminated union already carrying 6 variants (schedjobs,schedworkers,jobs,workers,resources,resvals) — adding one more compact variant is the established, low-risk extension pattern (matchesscheduler-controller-msg-gap's own precedent). - Implications:
sched_job_new_fun/apiserver_job_new_funneed a branch onl->count > 1(the only wayapply_committed_entry'sHOOK_DETAIL_JOB_NEWfire ever produces a multi-element list, per Requirement 3.4) that pushes a new compactDataunion variant (reusingJobSubmit's shape again — base jobid + array_size + oneJobSubmitData) instead of the existing per-job list. The scheduler and Go apiserver each need one new local-expansion routine, mirroring the controller's ownbase_jobid + iarithmetic.
Architecture Pattern Evaluation
| Option | Description | Strengths | Risks / Limitations | Notes |
|---|---|---|---|---|
New WAL_JOB_ARRAY_SUBMIT type + reused JobSubmit capnp shape |
Add one new wal_entry_type, reuse the existing JobSubmit struct as its payload |
Zero capnp schema change; keeps WAL_JOB_SUBMIT/struct Job untouched (Req 8.1) by construction; symmetric with client wire format |
New type must be added to two live dispatch switches (apply_committed_entry, apply_wal_entry) and to wal_classify |
Selected |
Overload WAL_JOB_SUBMIT with a variable-shape payload (array flag + optional range) |
Single WAL type handles both single-job and array | Fewer enum values | Directly violates Requirement 8.1 ("leave the non-array WAL record format... unchanged"); forces every existing single-job decode site to branch defensively | Rejected |
| Chunk large arrays into multiple committed records | Bounds record size explicitly via a chunking policy | Handles unbounded array_size |
Unnecessary: array_size is already hard-capped at 32768 by idgen(), and the record is O(1) regardless of count — chunking machinery would have no scenario to ever trigger |
Rejected (Requirement 7 satisfied by construction instead) |
Design Decisions
Decision: Reuse JobSubmit as the WAL_JOB_ARRAY_SUBMIT payload and the compact push payload
- Context: Requirement 1 needs a compact WAL shape; Requirement 6 needs a compact push shape "the same compact shape as the WAL record."
- Alternatives Considered:
- Define a new
JobArraySubmitcapnp struct duplicatingJobSubmit's fields. - Reuse
struct JobSubmitverbatim for both the WAL payload and the push payload.
- Define a new
- Selected Approach: (2) — one capnp struct, three uses (client request, WAL payload, push payload), one encoder/decoder pair already generated.
- Rationale:
JobSubmitalready has exactly the three fields every one of these three uses needs (jobidas base,arraySize, onesubmit: JobSubmitData); requirements.md's own wire-shape note for Requirement 6 explicitly asks for "the same compact shape as the WAL record," making this the requirement-mandated answer, not just a simplification. - Trade-offs: none identified — no functionality is given up by reuse here.
- Follow-up: confirm at implementation time that
encode_JobSubmit/decode_JobSubmit(capnp-generated) round-trip cleanly whensubmitis a shared/interned pointer across all three use sites.
Decision: Array expansion lives in a new shared helper, called from both live dispatchers
- Context:
apply_wal_entry()(standby catch-up) andapply_committed_entry()(primary live apply + boot replay) are both live and both currently delegate single-job construction torestore_job(). - Alternatives Considered:
- Add the expansion loop only inside
apply_committed_entry(), leavingapply_wal_entry()unchanged. - Add a new shared
restore_job_array()helper, called from a new case in both switches.
- Add the expansion loop only inside
- Selected Approach: (2).
- Rationale: Option (1) would silently leave standby catch-up unable to expand an array record, reintroducing exactly the online/replay divergence Requirement 3 exists to prevent — for the array case specifically, on day one.
- Trade-offs: one more call site to keep in sync (mitigated: both call sites do nothing but forward to the shared helper, so there is no logic to keep in sync, only a switch-case line).
- Follow-up: none — this mirrors
restore_job()'s existing, already-proven sharing pattern exactly.
Decision: A new, narrow live apply-resource fence, not a reuse of recovery_unrecoverable
- Context: Requirement 9.4/9.5 need a live, self-healing, non-promotable fence for a resource-exhaustion apply failure.
- Alternatives Considered:
- Set
recovery_unrecoverabledirectly from the live apply-pump failure path. - Introduce a new, dedicated field with its own set/clear triggers.
- Set
- Selected Approach: (2).
- Rationale:
recovery_unrecoverable's existing periodic clear (ha.c:1069-1071) is gated on WAL durability catch-up, a condition orthogonal to whether the stuck array LSN has actually finished applying — reusing it would risk a premature, incorrect un-fence. - Trade-offs: one more boolean/condition threaded through the two promotion-gate call sites in
ha.c; justified by correctness. - Follow-up: verify at implementation time that both
ha.cpromotion-gate call sites (periodic release, election-time) are updated together — a design.md Revalidation Trigger.
Risks & Mitigations
- Apply-pump return-value fix is a behavior change on an existing, heavily-tested path (every WAL type's apply failure, not just arrays, now halts the walk instead of silently skipping) — Mitigation: today's
apply_committed_entry()implementations never actually return a negative value in practice for any existing type (verified during discovery), so this fix is a no-op for all pre-existing call paths and only activates for the new array-expansion failure case; full HA soak (Requirement 8.2) is the regression gate. - Two live dispatchers (
apply_committed_entry,apply_wal_entry) must both gain the new case — Mitigation: shared helper (restore_job_array) means the only per-dispatcher code is one switch-case line; covered explicitly in File Structure Plan and Revalidation Triggers. jobj.chardening (Requirement 10) touches a shared primitive used by every job construction, not just arrays — Mitigation: scoped narrowly to the five specific gaps requirements.md names (NULL-check, table-init-ordering root cause, MOVE_PTR-reuse audit,accountsleak,ckptinfocoverage audit); no broader refactor.
References
.kiro/specs/raft-apply-decoupling/design.md— theapply_committed_entry()seam contract this feature builds on; its own Revalidation Triggers already flag "any new WAL entry type must be added to theapply_committed_entry()dispatch table or it silently fails to apply" and "job-array-single-wal-recordlanding — builds its array-expand step directly onapply_committed_entry().".kiro/specs/ha-commit-engine/,.kiro/specs/ha-commit-barrier-rollout/— majority-commit and idempotency primitives consumed unchanged..kiro/specs/scheduler-controller-msg-gap/— established precedent for extending the controller→scheduler push protocol with a newDataunion variant.src/controller/CLAUDE.md— Process Lifecycle invariant ("a controller process MUST NEVER self-terminate... fail loud, fence, stay alive, keep retrying") governing the Live Apply Resource Fence's behavior.
Gap Analysis: job-array-single-wal-record
Note: design.md and tasks.md-adjacent work for this spec already exist and have passed design review (GO, zero critical issues, 2026-07-13). This gap analysis is produced retroactively per explicit user request and is used here to confirm/cross-check the design's approach against the framework below, not to precede it.
Requirement-to-Asset Map
| Requirement | Existing Asset | Gap |
|---|---|---|
| 1.1-1.3 (single WAL record/barrier) | handle_jobsubmit() (job.c:926), build_job_submit_payloads() (job.c:221), persist_submit_job_payloads_async() (persist.c:4846) — today's N-append, N-continuation path |
Missing — no single-array-append path; wal_entry_type (wal_types.h:102) has no array variant |
| 2.1-2.3 (atomic commit) | commit_submit_async() (commit engine, unchanged), jobsubmit_async_ctx_t/pending refcount (job.c:754, already generalizes to pending=1) |
Constraint — commit-barrier atomicity is free (existing engine); apply-side atomicity (no partial c->jobmgr visibility) is not, since c->jobmgr insertion isn't natively transactional across N constructor calls |
| 3.1-3.4 (shared expand-on-apply) | apply_committed_entry() (persist.c:2721), apply_wal_entry() (persist.c:2574) — two live dispatchers, both call restore_job() for the single-job case today |
Missing — no restore_job_array()-equivalent; neither switch has a WAL_JOB_ARRAY_SUBMIT case |
| 4.1-4.3 (standby reconstruction) | Replication streams whatever WAL bytes exist (repl.c, unchanged) — one record replicates as one entry by construction | None — falls out of 1+3 once built; no separate replication-layer work |
| 5.1-5.3 (identity preservation) | base_jobid + i arithmetic (job.c:238), idgen() (idgen.c:22) reserving a contiguous range |
None — verification-only; new expansion loop reuses this arithmetic identically |
| 6.1-6.5 (compact push) | sched_job_new_fun/apiserver_job_new_fun (sched.c:271, apiserver.c:127), Data union (controller.capnp:355) — already N-capable by call count, not by wire shape |
Missing — no compact Data union variant; no scheduler-side (handler.c:112) or apiserver-side (Go backend/) local-expansion logic |
| 7.1-7.2 (bounded record size) | idgen()'s MAX_ARRAY_IDX cap (config.h:68, idgen.c:31), enforced upstream in the Go apiserver (clib.go:165) before a request reaches the controller |
None — pre-existing cap plus the record's O(1) payload satisfies this by construction; no chunking policy needed |
| 8.1-8.4 (non-regression) | Existing single-job path (job.c), existing /xce-test-ha harness |
Constraint — array_size == 1 must stay byte-for-byte identical; verified achievable by branching before any shared state touches |
| 9.1-9.2 (pre-commit validation) | inflight_range_try_add/jobid_range_exists (job.c:90-212) — already range-shaped from bug-1419 |
None — consumed unchanged, called once per array submission exactly as today |
| 9.3-9.5 (deterministic apply / live fence) | persist_recover_loud_fence_if_unrecoverable/recovery_unrecoverable (persist.c:4259) — boot-replay-only fence shape |
Missing — persist_apply_pump_run() (persist.c:3244) discards apply_committed_entry()'s return value (a real, load-bearing bug, not array-specific); no live-path fence exists |
| 9.6 (fault injection) | This codebase's #ifdef XCE_TESTING-guarded test-only-symbol convention (structure.md) |
Missing — no injection seam exists yet in any job-construction path |
| 10.1, 10.4 (jobj.c NULL-check, leak) | job_init()/job_destroy() (jobj.c:532, 565) |
Missing — both are concrete, located bugs with no fix yet |
| 10.2 (jobsub_table ordering) | jobsub_table_init()/job_obj_init() (jobj.c:80) |
Unknown — root cause suspected (init-ordering) but not confirmed; flagged as a task-level investigation in design.md, not pre-decided |
| 10.3, 10.5 (MOVE_PTR safety, ckptinfo coverage) | job_init()'s MOVE_PTR fields, decode_Job/free_Job (generated code) |
None — both verified as non-issues during design (freshly-calloc'd payloads are always NULL on these fields; ckptinfo has no wire field to leak) |
Implementation Approach Options
Option A: Extend Existing Components Only
Every change lands inside the file that already owns that domain — job.c (submit), persist.c (apply/commit/pump), sched.c/apiserver.c (push), ha.c (promotion), jobj.c (construction) — with no new non-test files.
- ✅ Matches
structure.md's "one domain per file" convention exactly; no domain boundary is invented. - ✅ Reuses
jobsubmit_async_ctx_t, the in-flight range registry, and the commit engine wiring completely untouched. - ❌
persist.cis already one of the largest files in the component; this adds more surface to it. - ❌
ha.cgets touched for a narrow addition (2 conditions, 2 call sites) but still requires the full TLA+/protocol-verification checklist review (src/controller/CLAUDE.md) since it's HA-critical.
Option B: New Standalone Component (e.g. a job_array.c)
Isolate the array-specific logic (restore_job_array, array-submit helpers) into a new file.
- ✅ Cleaner surface for unit-testing
restore_job_array()in isolation. - ❌
restore_job_array()must still be called frompersist.c's two switch statements and sharepersist_t/cluster_tinternals plus locking discipline (apply_pump_lock,c->jobmgr's RTTI lock) — a separate file buys little real isolation given this tight coupling, and risks violating the "No Hidden Shared Ownership" design principle by either exposing currently-internalpersist.cstate or duplicating its lock semantics. - ❌ Array submission isn't a new domain in this codebase's terms — it's a new WAL record shape within the existing job domain — so a new file would be an organizational mismatch, not a genuine boundary.
Option C: Hybrid (extend in place, new functions not new domain files)
Extend the six existing files (Option A's list) plus controller.capnp, adding clearly-named new functions (restore_job_array, persist_submit_job_array_async, xcesched_push_jobarray, apiserver_push_jobarray, persist_apply_resource_fenced/_mark/_clear) rather than modifying existing function bodies destructively. New files are created only where isolation is genuinely warranted: test files (test_job_array_apply.c, test_job_array_fence.c, test_jobj_hardening.c).
- ✅ Every new function has one clear, single-file home matching its existing domain — no fragmentation, no fake isolation.
- ✅ Genuine isolation exactly where it earns its cost (tests).
- ❌ Touches 8 existing files across 2 languages (C controller/scheduler, Go apiserver) plus 1 schema file — coordination surface is wide even though each individual touch is small.
- ❌ The "two live dispatchers" fact (
apply_committed_entryvs.apply_wal_entry) must be correctly carried into task generation and implementation by every task that touches either — a real risk if that fact isn't threaded through explicitly (mitigated: design.md's Revalidation Triggers and File Structure Plan both name both dispatchers explicitly).
Effort & Risk
- Effort: L (1-2 weeks) — significant functionality (new WAL type, apply-seam expansion, HA promotion-gate change, two new push consumers across two languages, five jobj.c fixes) spanning multiple files and one cross-language boundary, but no single piece requires inventing a new architectural primitive — every piece composes an already-proven existing mechanism (commit engine, hook system, RTTI object system,
Dataunion). - Risk: Medium — no unknown technology and no foundational primitive being invented from scratch, but two of the touch points are genuinely HA-sensitive (
ha.c's promotion gate,persist_apply_pump_run()'s return-value handling) where a subtle mistake has correctness consequences beyond this feature. Mitigated by the design's scratch-build atomicity discipline and by full/xce-test-haregression being a stated, non-optional gate (Requirement 8.2). Not High: nothing here requires new consensus/replication mechanics — those are consumed unchanged.
Recommendation
Option C (Hybrid) is the right fit and is what design.md already selected — confirmed by this analysis, not contradicted by it. Option A alone would have been viable too (the "new components" in Option C are functions, not files, in every case except tests), so in practice Option C and a disciplined Option A converge here; the distinction matters mainly for the test files, where isolation is unambiguously worth it.
Research items carried forward (already flagged in design.md's own Risk notes, repeated here for gap-analysis completeness):
- Requirement 10.2's
jobsub_tableinitialization-ordering root cause — confirmed unknown at design time, scoped to a bounded task-level investigation with an explicit boundary note (if the fix turns out to be outsidejobj.c, split into its own task rather than silently widening scope).