myxce/.kiro/specs/job-array-single-wal-record/tasks.md
Rongsong Shen 1cbd7df1a8 fix(job-array-single-wal-record): wire dispatcher was silently dropping compact array pushes
extract_data() (src/lib/request.c), the shared reassembly step every
inbound push passes through inside vtask_entry() before any receiver's
handler runs, had no case for Data_data_jobArray -- it hit
default: rc=-EINVAL. Because vtask_entry()'s callnext check treats any
nonzero return (including a negative errno) as "reassembly complete,
proceed," this never surfaced as an error: the real payload was freed
and the receiver silently handed an empty, zeroed dataset_t, with no
log anywhere in the chain. Tasks 5.1-5.3 were each individually
correct and tested in isolation, but the feature was dead on the real
wire end-to-end.

Adds case Data_data_jobArray: passing the single, unchunked
descriptor straight through (no pagination needed, unlike the
existing LIST-shaped cases) and nulls the source pointer after
transfer -- verified this is not cosmetic, since free_Request()'s
pushmsg case would otherwise double-free it via free_Data()'s live
Data_data_jobArray branch.

Proven with a real xce_encode()/xce_decode() round-trip through the
fixed extract_data() -> sched_handle_dataset() -> sched_on_job_array()
(task 5.3, unmodified) chain, not just a unit-level check on
extract_data() alone. Also confirms task 5.4's future API-server
receive path shares this exact fix, since both go through the same
xcecli_ctx_init() -> vtask_entry() -> extract_data() pipeline.

One self-flagged, non-blocking residual gap (pre-existing since task
5.3, out of this task's request.c-only boundary): dataset->rows for
the jobArray case is never deep-freed downstream, leaking one bounded
xce_job_submit_t per successfully-delivered array push commit.

Task 5.3b of job-array-single-wal-record (Requirements 6.1, 6.2, 6.4, 8.4).
Inserted mid-run after independent review confirmed the gap; user
confirmed sequencing (fix now, before task 5.4).
2026-07-15 10:59:39 +00:00

30 KiB

Implementation Plan: job-array-single-wal-record

  • 1. Foundation: WAL/schema additions and jobj.c hardening

  • 1.1 (P) Add the array WAL record type

    • Add a new WAL_JOB_ARRAY_SUBMIT value to the WAL entry type set, distinct from WAL_JOB_SUBMIT
    • Classify it as a WAIT-mode (requester-awaiting) commit, same as the existing single-job type
    • Observable: the new type is recognized by the WAL commit-mode classifier and compiles cleanly; the existing single-job type's classification is unchanged
    • Requirements: 1.1, 1.2, 1.3
    • Boundary: WAL Array Submission
  • 1.2 (P) Add the compact array push wire variant

    • Add one new additive variant to the existing controller-to-scheduler/apiserver push message, reusing the client submit request's existing (base jobid, array size, one spec) shape verbatim — no new struct
    • Observable: the schema compiles and generates encode/decode functions for the new variant without touching any existing variant's wire layout
    • Requirements: 6.1, 6.2
    • Boundary: Compact Scheduler/API-Server Push
  • 1.3 (P) jobj.c hardening

    • Make job construction check the interning/string-construction return value and propagate failure instead of silently storing a NULL success
    • Root-cause and fix the previously observed construction-table initialization-ordering gap; if root-causing shows the actual gap is outside this file's own init/call ordering (e.g. controller boot sequencing), stop and flag it as a separate follow-up rather than forcing a fix into this task's boundary
    • Verify (with a test) that the destructive-move fields on a freshly-built job payload are always NULL, so repeated use against a shared template is provably benign
    • Fix the known leak on job teardown for the one field that was never freed
    • Verify (with a test) that the field claimed to be an undocumented leak risk actually has no wire representation to leak in the first place
    • Observable: a new construction-failure path returns an error instead of a silently-broken success; the leak-fix is visible under a leak-detection test run; all five items have a passing test each
    • Requirements: 10.1, 10.2, 10.3, 10.4, 10.5
    • Boundary: jobj.c Hardening
  • 2. WAL Array Submission: encode, append, commit

  • 2.1 Implement the single-record array append and commit path

    • Encode one compact payload carrying (base jobid, array size, one shared job spec) — the same shape as the client's own submit request
    • Append it as exactly one WAL_JOB_ARRAY_SUBMIT WAL entry and register exactly one commit continuation for it, regardless of array size
    • Observable: submitting an array produces one WAL append call and one registered commit continuation in a controlled test, not one per element
    • Requirements: 1.1, 1.2, 1.3, 4.2, 9.2
    • Boundary: WAL Array Submission
  • 2.2 Wire array submissions into the request handler

    • Branch the submission handler on array size: exactly 1 continues through today's existing single-job code path completely unmodified; greater than 1 routes to 2.1's single-append path
    • Preserve the existing pre-commit range validation (collision check plus in-flight claim) unchanged and in the same order, still rejecting strictly before any WAL append
    • Reuse the existing batched-reply mechanism, setting it to expect exactly one outcome instead of one per element
    • Observable: a single-job submission (array size 1) is byte-identical in behavior to before this feature; an array submission returns exactly one reply after exactly one outcome resolves
    • Requirements: 1.1, 1.2, 1.3, 8.1, 9.1, 9.2
    • Boundary: WAL Array Submission
    • Depends: 1.1
  • 2.3 Unit tests for the array append/commit path

    • Verify exactly one WAL entry and one commit continuation are produced for an array submission, for several array sizes
    • Verify the encoded payload's size does not grow with array size (stays constant regardless of element count)
    • Verify a single-job (array size 1) submission produces a byte-identical WAL record, commit-barrier count, and reply compared to the pre-feature baseline
    • Observable: a passing test suite demonstrating all three properties above
    • Requirements: 1.1, 1.2, 1.3, 7.1, 7.2, 8.1
    • Boundary: WAL Array Submission
  • 3. Array Apply Expansion: shared N-way expansion and dispatch wiring

  • 3.1 Implement the shared array expansion primitive

    • Decode the committed array payload and, for each index in the array's range, build a job entry without yet making it visible to any reader
    • Skip building an entry for any index that already exists (idempotent re-apply); if every index already exists, treat the whole record as an already-applied no-op
    • Once every index has successfully built, make the whole set visible in one atomic step; if any index fails to build, discard everything built in this attempt without ever making any of it visible — a retry starts over from the first index
    • Include a test-only, forceable failure point (compiled out in production) that a test can use to force a failure at a chosen index
    • Observable: a test can force a mid-array failure and confirm zero elements are visible afterward, then force success and confirm the full set becomes visible in one step
    • Requirements: 2.1, 2.2, 2.3, 3.3, 4.1, 5.1, 5.2, 5.3, 9.3, 9.6
    • Boundary: Array Apply Expansion
    • Depends: 1.3
  • 3.2 Wire array expansion into live apply

    • Call 3.1's primitive from the live-apply/boot-replay dispatch path for the new WAL record type
    • After a successful expansion, fire the existing job-created notification exactly once, carrying every newly created element in one list — never once per element
    • Observable: applying one committed array record produces exactly one notification event carrying all N elements
    • Requirements: 3.1, 3.4
    • Boundary: Array Apply Expansion
  • 3.3 Wire array expansion into standby catch-up (defensive parity — see note below)

    • Call 3.1's same primitive from the separate standby catch-up/replicated-apply dispatch path for the new WAL record type, without firing any notification (matching that path's existing hook-free behavior for the single-job case)
    • Observable: a test applies the same committed array record through both the live-apply dispatch and the standby catch-up dispatch and confirms byte-identical resulting job state either way
    • Requirements: 3.1, 3.2, 4.1 — delivered by task 3.2 alone (see Implementation Notes); this task is defensive parity, not an independent delivery path
    • Boundary: Array Apply Expansion
  • 3.4 Unit tests for array expansion

    • Verify N-way expansion produces the correct contiguous set of element identities for several array sizes
    • Verify idempotent re-apply of an already-fully-applied record is a no-op that creates nothing new
    • Verify, using 3.1's forceable failure point, that a failure partway through leaves nothing visible, and that a subsequent successful retry produces the complete, non-duplicated set
    • Observable: a passing test suite covering all three properties above
    • Requirements: 2.1, 2.2, 2.3, 3.3, 5.1, 5.2, 5.3, 9.4, 9.6
    • Boundary: Array Apply Expansion
  • 4. Live Apply Resource Fence

  • 4.1 Make the apply pump respect apply failures

    • Stop discarding the array-expansion (and every other WAL type's) apply outcome; on a failure, halt the apply walk at that position without advancing past it, and log a loud, operator-visible error naming the stuck position
    • Observable: a simulated apply failure at a given position causes the walk to stop exactly there and not proceed to later entries
    • Requirements: 9.4
    • Boundary: Live Apply Resource Fence
  • 4.2 Add the live, self-healing fence

    • Add a new piece of state (distinct from the existing boot-only unrecoverable-fence flag) recording "a position is currently stuck failing to apply"
    • Engage it when 4.1's halt condition triggers; clear it automatically, from inside the apply walk itself, the moment a retry of that same stuck position succeeds
    • Observable: after a forced failure the new state reads "fenced"; after the same position later succeeds on retry, the state reads "clear" without any external action
    • Requirements: 9.4, 9.5
    • Boundary: Live Apply Resource Fence
  • 4.3 Gate promotion on the live fence

    • Add the new fence state as a required condition at both existing points where a node's eligibility for promotion is decided (the periodic release check and the election-time check)
    • Observable: a node with the fence engaged is refused promotion at both points; once the fence clears, the node is eligible again without any other change
    • Requirements: 9.4, 9.5
    • Boundary: Live Apply Resource Fence
  • 4.4 Fault-injection test for the live fence

    • Using 3.1's forceable failure point, force a resource-exhaustion-style failure during array expansion and verify: the node fences itself and is refused promotion, constructs no partial or divergent set of elements, does not affect any other (simulated) node's independent progress, and self-heals — clearing the fence and completing the apply — once the forced condition is removed and the retry succeeds
    • Observable: a single passing test exercising the full engage-refuse-clear-recover sequence
    • Requirements: 9.4, 9.5, 9.6
    • Boundary: Live Apply Resource Fence
    • Depends: 3.1
  • 5. Compact Scheduler/API-Server Push

  • 5.1 (P) Build the compact scheduler push

    • Add a function that encodes the compact (base jobid, array size, one spec) message for the scheduler and branch the existing job-created push handler to call it, instead of the existing per-job push, whenever the notification carries more than one element
    • Leave the single-element (ordinary single-job) branch completely unmodified
    • Observable: a synthetic multi-element notification produces exactly one compact push call, not N individually-encoded job structs; a synthetic single-element notification is unaffected
    • Requirements: 6.1, 6.3, 6.4
    • Boundary: Compact Scheduler/API-Server Push (controller-side scheduler push)
  • 5.2 (P) Build the compact API-server push

    • Same construction as 5.1, for the API-server push handler and its own existing per-job push
    • Observable: a synthetic multi-element notification produces exactly one compact push per connected API server, not N individually-encoded job structs; the single-element branch is unaffected
    • Requirements: 6.2, 6.4
    • Boundary: Compact Scheduler/API-Server Push (controller-side apiserver push)
  • 5.3 (P) Scheduler-side local expansion

    • Decode the compact push and expand it locally into N ordinary schedulable tasks using the same base-jobid-plus-index arithmetic the controller itself uses
    • Deduplicate by element identity so a redelivered push (e.g. after a scheduler reconnect) does not create duplicate tasks
    • Log one identifiable confirmation line (base jobid plus element count) once expansion completes
    • Observable: feeding the same compact push twice to the receiver produces the task set once, not twice, and exactly one confirmation log line per successful expansion
    • Requirements: 6.3, 6.4, 6.5, 8.4
    • Boundary: Compact Scheduler/API-Server Push (scheduler daemon receive path)
    • Depends: 5.1
  • 5.3b Fix the shared wire dispatcher to carry the compact array push

    • extract_data() (src/lib/request.c) is the shared reassembly step every inbound push (scheduler and API-server alike) passes through inside vtask_entry() (src/lib/xcectx.c) before a receiver's handler ever runs. Its dispatch switch has no case for the Data_data_jobArray variant (added task 1.2) and falls into default: rc = -EINVAL. Because vtask_entry()'s if (callnext) treats ANY nonzero return — including a negative errno — as "reassembly complete, proceed," this doesn't surface as an error: the real payload is freed and the receiver is silently handed an empty, zeroed dataset_t (rows=NULL, nrows=0), with no log anywhere in the chain (vtask_entry() discards the handler's own return value unconditionally)
    • Add a case Data_data_jobArray: to extract_data() that passes the single, unchunked JobSubmit descriptor straight through to the caller's dataset_t — no pagination/accumulation loop is needed (unlike the existing LIST-shaped cases), since this push is always exactly one descriptor per commit, matching the "wire shape note" both xcesched_push_jobarray() (task 5.1) and apiserver_push_jobarray() (task 5.2) already establish
    • Confirms both the scheduler (task 5.3, already landed) and the future API-server Go receiver (task 5.4) share this exact fix — this task only needs to land once
    • Observable: a synthetic Data_data_jobArray push driven through the REAL vtask_entry()/extract_data() path (not task 5.3's white-box sched_handle_dataset()-direct test) reaches the scheduler's sched_on_job_array() with the genuine payload intact, not an empty dataset
    • Requirements: 6.1, 6.2, 6.4, 8.4
    • Boundary: shared wire transport (src/lib/request.c, src/lib/xcectx.c) — out of the Compact Scheduler/API-Server Push component's original File Structure Plan; discovered during task 5.3's review (2026-07-15), design.md corrected to record it
    • Depends: 5.1, 5.2
  • 5.4 (P) API-server-side local expansion

    • Decode the compact push and expand it locally into N per-element records using the same base-jobid-plus-index arithmetic
    • Deduplicate by element identity so a redelivered push does not create duplicate records
    • Log one identifiable confirmation line (base jobid plus element count) once expansion completes
    • Observable: feeding the same compact push twice to the receiver produces the record set once, not twice, and exactly one confirmation log line per successful expansion
    • Requirements: 6.2, 6.4, 6.5, 8.4
    • Boundary: Compact Scheduler/API-Server Push (apiserver receive path)
    • Depends: 5.2
  • 5.5 Integration test: end-to-end compact push

    • Commit one array record end-to-end and confirm the scheduler and the API server each receive exactly one push and each expand it into N correctly-addressable entries
    • Confirm a simulated redelivery (reconnect) to either receiver does not double-expand
    • Observable: a passing integration test covering both receivers' one-push/N-entries/no-double-expand behavior, including both receivers' confirmation log lines
    • Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 8.4
    • Boundary: Compact Scheduler/API-Server Push
  • 6. Integration & non-regression soak

  • 6.1 Large-array soak scenario

    • Extend the existing HA soak harness with a large-array submission scenario asserting: one WAL record and one commit barrier regardless of array size, failover replay time governed by the record's size rather than element count, and every array element individually addressable (status, cancel, completion) after a failover
    • Observable: a soak run reporting the one-record/one-barrier assertion, a replay-time measurement independent of array size, and successful per-element status checks post-failover
    • Requirements: 4.3, 5.2, 5.3, 8.2, 8.3
    • Boundary: Non-Regression & Testing
  • 6.2 Chaotic-failover array retry scenario

    • Extend the soak harness with a scenario submitting an array immediately before killing the primary, then retrying the identical submission against the new primary
    • Assert the retry follows the same cross-primary dedup mechanism as single-job submission, extended to the array's full index range as one unit, and does not double-submit or partially duplicate
    • Observable: a soak run showing the retried array resolves to exactly one committed instance of the array, with no partial duplication
    • Requirements: 8.2, 8.3
    • Boundary: Non-Regression & Testing
  • 6.3 Full regression pass

    • Run the complete existing unit, integration, and soak suite (sequential and chaotic) and confirm zero new job loss, divergence, or liveness stall attributable to this feature
    • Confirm the non-array (array size 1) WAL format, commit-barrier count, and reply are byte-identical to the pre-feature baseline
    • Observable: a clean full-suite run with the byte-identical single-job baseline check passing alongside it
    • Requirements: 8.1, 8.2, 8.4
    • Boundary: Non-Regression & Testing

Implementation Notes

  • Task 1.3: meson test --suite xce's libxce test target SIGABRTs under ASAN leak detection whenever any code path exercises a job_init() failure — root cause is the vendored rtti library's makeInstance() not freeing its allocation when a type's .init() returns negative (no .c source available, headers-only in the nix store). This is pre-existing and independently reproduced against an unmodified pre-task jobj.c, not introduced by this feature. It will fire again on any future task whose tests exercise a create_job()/makeJob() failure path (e.g. task 3.1/3.4's fault-injection tests for restore_job_array()) — expect the same SIGABRT-not-assertion-failure signature (grep ":FAIL" on the Unity log will be empty; the failure is LeakSanitizer-only) and do not treat it as a new regression without the same stash-and-reproduce check task 1.3's review used.
  • Task 1.3: job_obj_init()/job_obj_fini() are now idempotent (atomic_flag-guarded) — safe to call more than once without an intervening fini(). Any future test construction can rely on this instead of coordinating a single call site.
  • Task 1.3: makeJobsub()/makeJobstartinfo() now return -ENODEV (not a generic -EINVAL) when the interning table isn't initialized — if a later task's error handling branches on job_init()'s return value, -ENODEV is a distinct, checkable signal for "table not ready" vs. other construction failures.
  • Task 3.3: apply_wal_entry() (persist.c:2574) is dead code in production — confirmed via exhaustive grep (zero call sites outside its own declaration/definition/comments) and via ha.c:8024's ha_standby_advance_committed_and_apply (dated 2026-07-10), whose own doc comment explicitly says standby entity materialization now goes through the SAME apply_committed_entry()/persist_apply_pump_kick pump the primary uses, not apply_wal_entry(). The persist.c:3722 comment claiming otherwise predates that refactor and was never updated (design.md corrected accordingly). Requirement 3.1/3.2/4.1 are fully delivered by task 3.2 alone. Task 3.3's code was kept (not discarded) as defensive parity with WAL_JOB_SUBMIT's existing, also-dead case in the same switch — it is tested and correct, just not currently reachable in production. If apply_wal_entry() is ever revived or removed entirely in a future spec, this task's case should move or be deleted with it.
  • Task 3.1: restore_job_array()'s atomicity has one narrow, documented exception (design.md updated to match): the final bulk-insert-into-c->jobmgr pass can itself fail on allocation/growth exhaustion (c->jobmgr grows via realloc(); no capacity-reservation primitive exists anywhere in kv.c/kvmgr.c/zarray.c to pre-check this before mutating). On this specific failure, the function never silently claims success — it returns a negative errno — but an already-inserted prefix [0, inserted) can be left genuinely, permanently visible in c->jobmgr (no key_del exists to undo it), and that prefix is NEVER included in out_new_jobs, so HOOK_DETAIL_JOB_NEW never fires for it, not on this attempt or any later successful retry of the same LSN. Tasks 3.2/4.x must account for this: those elements are individually addressable (status/cancel/completion work normally) but permanently un-pushed to the scheduler/API server unless something later re-derives and re-notifies them — this is a real gap in the current design, not yet solved, and task 4.x's Live Apply Resource Fence design should explicitly decide whether/how to handle it (e.g. detecting this residual state and re-notifying on self-heal) rather than only handling the construction-phase failure case. UPDATE (post-4.4): this gap was NOT closed by Phase 4. Tasks 4.1-4.4 correctly deliver the generic negative-rc halt/fence/gate/self-heal machinery — a bulk-insert-phase failure DOES still engage the fence and block promotion, same as any other failure — but none of them add the "no partial/divergent set" guarantee specifically for this seam, because that guarantee is structurally false on this path with current APIs (no key_del). Task 4.4's own capstone test deliberately used the (fully atomic) construction-phase seam instead, precisely because asserting "zero partial elements" against the bulk-insert seam would be dishonest. This residual gap remains open and unowned by any task in this spec's current scope — flag at /kiro-validate-impl time; closing it would require either a key_del/rollback primitive in kv.c/kvmgr.c, or a re-derive-and-re-notify recovery path for the orphaned prefix, either of which is design-level work beyond this spec's approved task list.
  • Task 4.1: persist_apply_pump_run() now halts the walk (no last_applied_lsn advance) on ANY negative return from apply_committed_entry(), not just the new array type — this is a general fix. Investigated whether this newly exposes a hazard for the pre-existing job-state fact entries (WAL_JOB_START/FINISH/FAIL/UPDATE), which have their own real -ENOENT branch when the target jobid isn't in c->jobmgr: confirmed NOT a hazard, because c->jobmgr (xkvmgr_type) has no key_del (jobs are never removed for the process lifetime, corroborated by job.c:428,455,645), and this task's own strict sequential-no-skip apply means a fact's LSN is only reached after its causally-prior submit already applied. So a live -ENOENT on a fact can now only mean genuine corruption/divergence — halting is correct, not a new footgun. Independent reviewer confirmed this via test_term_safety_adapter.c:2039-2043's synthetic (non-production-representative) trigger. design.md's "Existing Architecture Analysis" table row overclaims ("every existing WAL type... returns 0 or positive in practice") — technically inaccurate re: the fact-ENOENT branch — left as-is since the persist.c code comment at the site already documents the exception correctly; low-severity, non-blocking.
  • Task 4.2: New _Atomic uint64_t apply_resource_fenced_lsn field on persist_t (0 = clear, nonzero = the stuck LSN) plus persist_apply_resource_fenced()/persist_mark_apply_resource_fenced()/persist_clear_apply_resource_fence(), wired into persist_apply_pump_run()'s existing halt branch (engage) and unconditionally after every successful advance (clear). The unconditional clear is provably equivalent to "clear only on the fenced LSN's own successful retry" because last_applied_lsn has exactly 3 writers codebase-wide, the other two (replay_wal, persist_recover) are boot-only and temporally disjoint from the live pump, and a failed walk never advances past the stuck LSN — so the next success by construction IS that LSN's retry. Known residual gap (out of this task's boundary, flagged by review, NOT yet fixed): replay_wal() (boot/restart replay, persist.c:3875-3888) still discards apply_committed_entry()'s failure return the same way the live pump did before task 4.1, and persist_init() unconditionally zeroes the fence field on every restart. A node that crashes while fenced and re-hits the identical resource-exhaustion condition during boot replay will silently pass it and look fully promotable while actually divergent — task 4.1/4.2's protection does not yet extend to the boot-replay path. This is a pre-existing gap (not introduced by this feature) but is now inconsistent with the live path's new guarantee; worth a dedicated follow-up task before anything downstream places full trust in this fence across a restart.
  • Task 5.1: New xcesched_push_jobarray() (sched.c) sends via direct xce_request()/OPCode_push/Request_body_pushmsg rather than the existing xce_push_data_req() helper — xce_push_data_req()'s dispatch switch hard-rejects (-EINVAL) anything outside jobs/workers/resources/resvals, so it cannot carry a single non-list Data.jobArray descriptor. design.md corrected to match (Dependencies + API Contract for the Compact Scheduler/API-Server Push component). Task 5.2's API-server push will hit the identical fork and should follow the same direct-xce_request() pattern rather than re-deriving it. One accepted, low-severity trade-off: the direct path does not go through xce_push_data_req()'s checkf-gated check_controller/c->running liveness check that every other push gets — a narrow shutdown-race behavioral difference from sibling pushes, not a new safety hazard; not fixed here since threading a liveness check into a single new call site duplicates rather than reuses the shared gate. sched.c/dataex.c/egress.c were never linked into controller_unit_test (two existing test files there ship their own fake on_scheduler_join()/notify()/collect_confvars() precisely because of this, and linking the real ones would collide) — task 5.1's tests live in a new standalone sched_push_unit_test binary instead; task 5.2 will likely face the same fork for apiserver.c and should check for an analogous existing-fakes collision before choosing a test-binary strategy.
  • Task 5.2: apiserver_push_jobarray() (apiserver.c) mirrors task 5.1's xcesched_push_jobarray() exactly — same field derivation, same direct-xce_request() transport, same l->count > 1 discriminator — and additionally, correctly replicates apiserver_push_joblist()'s existing per-connected-server fan-out loop (N_APISERVER_MAX iteration, same lock discipline), independently verified against 2 simulated connected servers each receiving exactly one push. Confirmed the same apiserver.c/dataex.c link-collision task 5.1 hit for sched.c (a fake notify() already exists in test_commit_barrier_reply.c) — new standalone apiserver_push_unit_test binary added, correctly omitting egress.c since apiserver_push_joblist(), unlike sched.c's push path, never references job_submit_committed()/collect_jobs_committed(). One low-severity, non-blocking nit (not fixed): the new test file's comment justifying its apiserver_init() idempotency guard is factually inaccurate — it claims a second apiserver_init() call would double-register hooks, but xce_add_hook() has deduped by (fn,data) since bug-845, so that specific claim is wrong; the guard itself is still correct/necessary for a different, real reason (avoids re-mylock_init()-ing an already-initialized mutex, UB).
  • Task 5.3: sched_on_job_array() (handler.c) decodes Data_data_jobArray, derives each element's jobid via base_jobid + i, and routes constructed elements through the unmodified per-job pipeline (sched_on_jobs()). Each element gets its OWN deep clone (clone_job_submit_data/clone_job_startinfo) of the shared submit spec — required because sched_pend_update_killer()'s per-element teardown (free_Job_ptr) frees each element's submit independently and a shared pointer across N elements would double-free; independently verified. Dedup reuses sched_get_job() against the same sched->jobs kvtab sched_add_job()'s key_add authoritatively guards — sound at the data-integrity level, but review found a narrow, non-corrupting TOCTOU: the dedup pre-check runs synchronously at receive time while the actual insert only happens later when sched_update() drains pend_updates[0], so a redelivery landing before that drain can re-construct/re-enqueue a duplicate (harmless) object and double the confirmation log line — the same pre-existing, task-5.3-unrelated pattern as sched_update_jobs()'s already-ignored key_add -EEXIST return on the ordinary single-job path. Not fixed (pre-existing class of gap, narrow window, non-corrupting). CRITICAL discovery during this task's review (2026-07-15), NOT a defect in task 5.3 itself: the shared wire dispatcher extract_data() (src/lib/request.c) has no case for Data_data_jobArray and silently drops every real array push before ANY receiver's handler runs (see new task 5.3b, inserted immediately after this task per explicit user decision). Confirmed to affect task 5.4's future API-server receive path identically (both share the same xcecli_ctx_init() → vtask_entry() → extract_data() pipeline). Task 5.3's own tests are unaffected by this gap since they use the same established white-box convention as every sibling handler.c test (calling sched_handle_dataset() directly, bypassing the transport layer) — this is not a new testing shortcut, it just means task 5.3's passing tests do not, and were never intended to, prove real-wire delivery.
  • Task 5.3b: Added case Data_data_jobArray: to extract_data() (request.c) — sets dataset->rows/nrows=1 directly (no pagination, matching the always-one-shot wire shape) and nulls getdata->data.job_array after transferring it. The null is NOT cosmetic — independently verified free_Request()'s Request_body_pushmsg case calls free_Data(), which has a live Data_data_jobArray branch that would double-free/UAF without it. Proven end-to-end with a real xce_encode()/xce_decode() round-trip through extract_data() → sched_handle_dataset() → sched_on_job_array() (task 5.3, unmodified), not just a unit-level check. Also independently confirmed the alternate hypothesis some earlier notes floated — that scheduler_req_handler() itself has a pushmsg-rejecting branch — is dead code on the live wire path, since vtask_entry() always converts body_which to datamsg before invoking any handler. New residual gap, self-flagged, NOT fixed (pre-existing since task 5.3, not introduced here, genuinely out of this task's request.c-only boundary): dataset->rows for the jobArray case is never deep-freed downstream — free_Dataset() is an empty no-op for all table types, and sched_handle_dataset()'s jobArray case (task 5.3) doesn't null it after consuming it. Net effect: one bounded xce_job_submit_t + embedded spec leaks per successfully-delivered array push commit (not per element, not unbounded) — real but small; worth a dedicated follow-up before this spec is considered fully closed, alongside the other self-flagged residual gaps (task 3.1's bulk-insert orphan prefix, task 4.2's boot-replay fence gap).