- ha-async-io.md: document the same-batch endpoint lifetime hazard
(bug-1447) and its ref-whole-batch-before-dispatch fix pattern
- xce-audit-invariants SKILL.md: add object-lifetime-within-a-dispatch-batch
as a 6th audit dimension, so a future pass checks other batch loops
(vtaskmgr drain, peer-list iteration) for the same shape
- product.md: replace the stale "HA consistency family is the current
focus" framing with the actual 2026-07-18 status (umbrella complete,
9-spec validate-impl batch closed, job-array-single-wal-record shipped)
- roadmap.md: correct 5 entries that were stale relative to actual spec
status (ha-consistency-invariants/ha-wal-replication/libxce-failover
pulled out of "In Flight", ha-term-safety and raft-drive-async marked
implemented+GO, raft-sim-conformance and job-array-single-wal-record
given closure annotations) -- struck through, not deleted, per this
file's own additive-update convention
- testing.md: document that meson's aggregate Fail:0 can mask Unity
sub-group failures -- always check the standalone binary's own summary
- src/controller/CLAUDE.md: fix a stale line-citation for the votedFor
atomicity invariant (had drifted onto unrelated post-election code)
/xce-audit-invariants controller found two live violations of the async
I/O thread-role contract: ha_demotion_convergence_fn (ha_convergence.c)
ran WAL truncation + tlog_exit()+execv() directly on the delaymgr thread,
and ha_catchup_continuation_fn (config_repl.c) ran unbounded WAL-streaming
to a standby directly on delaymgr -- the same single thread that drives
heartbeats and election timeouts cluster-wide, so either blocks liveness
for the whole cluster and risks a spurious election.
A prior vtask-pool offload attempt (2026-07-10, bug-1429) deadlocked
controller_unit_test because c->ha.pool is never initialized by the
lightweight unit-test fixtures these functions run under, and was fully
reverted. This fix instead uses the codebase's established slow-lane
pattern (the same mechanism already used for snapshot-send, config-push,
and peer connect) -- a 4th and 5th instance, not a new mechanism.
New g_drive_async_demotion_offload / g_drive_async_catchup_offload flags
ship default OFF, matching the original rollout discipline of the three
reference offloads (shipped OFF, flipped ON later after a dedicated
compatibility audit). Flipping ON was tried during validation and
reproduced two distinct hazards against pre-existing fixtures that never
call slowlane_init: the already-known hang, and a new ASan SEGV in
commit_advance_locked (logged as bug-1452, not root-caused, deferred to
the future flip-to-ON audit). Both flags verified OFF and the full
976-test suite clean, then the committed state re-verified clean against
a live 10-round chaotic and 10-round sequential HA soak (zero job loss,
zero divergence, both PASS).
job-array-single-wal-record shipped end-to-end, ha-consistency-invariants
umbrella closed out, Unity test-harness suite-wide fix, and today's Group 1
kiro-validate-impl batch (9 specs) which surfaced and fixed a real
same-batch endpoint UAF crash (bug-1447) plus a harness timing race
(bug-1449).
Raft-drive-async's reconnect-offload burst (Task 2.1/2.2) exposed a
latent race in io_worker's epoll dispatch loop: ha.c's
on_standby_connected() evicts a stale duplicate peer channel via
iomgr_unmonitor()+endpoint_unref(), but epoll_ctl(DEL) only stops
FUTURE epoll_wait() calls -- it does not invalidate an event for that
same endpoint already sitting in the CURRENT batch's events[] array.
A later event in the same batch then dereferences the freed
endpoint_t/SSL*, producing the ASAN SEGV observed deep inside
OpenSSL's ssl3_read_bytes during raft-drive-async's round-9 soak.
The original "concurrent SSL_do_handshake from two io_worker threads"
hypothesis was wrong and is retracted -- iomgr_start() creates exactly
one io_worker thread per iomgr_t.
Fix: extract io_worker_dispatch_batch(), which takes endpoint_ref() on
every endpoint in the batch before dispatching any of them and
endpoint_unref()s each only after the whole batch completes -- the
same lock-free ref/unref bracket already used for bug-1298 and
INVOKE_MSG_HANDLER, just never applied to io_worker's own loop. Pure
atomics, no locks, no blocking added to the hot path.
Adds test_netio_batch_dispatch_uaf.c, which drives the real dispatch
loop with a synthetic epoll_event[] batch (TDD RED confirmed via ASAN
heap-use-after-free before the fix, GREEN after). Verified further
with two independent clean 10-round live sequential HA soaks (33 and
36 jobs, zero loss) and full ASAN suite runs (xcelib_test 294/0,
controller_unit_test 972/0). raft-drive-async's kiro-validate-impl
re-run flips NO-GO to GO.
run-ha-soak.sh's sequential mode waited for full 3-of-3 reconvergence
between rounds 2+ but had no equivalent wait before round 1's first
kill. A slow cold-start election could let the kill land before
config-push reached both standbys, leaving them at config tag=0 --
ha_on_primary_ctrl_fd_rdhup's voter-only guard then correctly refuses
to let an unconfigured standby self-elect, producing a false "no
primary elected" round-1 failure. Adds node_config_tag/
cluster_config_converged/wait_for_initial_config_convergence,
mirroring the existing reconvergence-wait's polling style, and gates
sequential mode's round 1 on it.
libxce-failover, raft-sim-conformance, ha-commit-durability,
ha-wal-replication, ha-heartbeat-committed-propagation, ha-prevote,
ha-elect-ctx-lifecycle, and ha-entity-snapshots all had 100% of tasks
checked but had never run a formal /kiro-validate-impl closure pass
(spec.json stuck at phase "tasks-generated", some with stale
ready_for_implementation flags). Each now has a fresh validate_impl
GO decision backed by an ASAN build, full standalone test-suite run,
and a live 10-round sequential HA soak with zero committed-job loss.
Records the controller_unit_test nested UNITY_BEGIN/UNITY_END fix
(commit a81f2a2) in cerebrum.md for future sessions, plus routine
anatomy/memory/token-ledger session bookkeeping.
Only 21 of 86 test_*.c group functions called their own nested
UNITY_BEGIN()/UNITY_END(). Each nested call reset Unity's global
NumberOfTests/TestFailures counters, so controller_test.c's final
`return UNITY_END();` -- the value meson test actually gates on --
only ever reflected whichever small window of tests ran since the
LAST nested reset, silently discarding pass/fail evidence for
everything before it (measured: 16 of 972 total tests in one run).
The same nested-BEGIN calls also caused PASS/FAIL banners to show
the wrong source file for tests in files that don't reset it
themselves (Unity.TestFile just carries over from whichever group
last called UNITY_BEGIN()).
Fix: replace every nested UNITY_BEGIN() with UnitySetTestFile(__FILE__)
(sets file attribution only, no counter reset) and drop the matching
UNITY_END(); add the same UnitySetTestFile(__FILE__) as the first
statement to the other group functions that had neither. The 2
genuinely standalone binaries (test_job_array_sched_push.c,
test_job_array_apiserver_push.c -- each has its own int main(), not
registered via controller_test.c's TEST() macro) are untouched.
Verified: single consolidated summary now reads
"972 Tests 0 Failures 0 Ignored OK", exactly matching the
ground-truth RUN_TEST call-site count across all 83 registered
group files (976 total minus 4 belonging to the 2 standalone
binaries). Every PASS/FAIL banner now names its own true source
file. Full xce-build.sh all regression pass clean (Ok: 9, Fail: 0).
No production code touched -- test-harness plumbing only.
All tasks (1.1-6.4) were already [x] with no prior validate_impl
record. Fixed stale delta.impl_status (still said task 6.4 blocked on
sandbox overlayfs despite tasks.md recording it VERIFIED live since
2026-07-02).
Full suite 9/9 green, 36/36 requirement acceptance criteria mapped,
cross-task wire contract verified field-for-field end to end (schema
-> typemap -> handler -> dispatch -> libxce client -> CLI), two live
smoke runs (3-round sequential HA soak + targeted 3-node deploy
exercising Req 10/11 directly) both PASS. One transient test failure
during the run traced to a concurrent second session competing for
the same build directory, not a regression -- reproduced clean 3x in
isolation.
All 19 tasks were already [x] with no prior validate_impl record.
Full suite 9/9 green, 46/46 requirement acceptance criteria mapped,
fresh 6-round live chaotic-multi-kill soak PASS (zero committed
divergence/job loss), src/lib/raft protocol-ownership boundary
respected across all 35 tagged commits.
Three known_limitations recorded, all non-blocking: two are
design.md describing an originally-planned architecture that shipped
differently after reviewer-approved safety proofs (commit-source
switch stayed on the durable-majority barrier + own-term WAL_NOOP
gate rather than switching to raft_commit_index; AppendEntries send
side still goes through repl_broadcast, not raft_send_append_entries)
-- behavior is correct, docs just weren't updated to match. Third is
a stale TODO comment in ha.c whose own body already says IMPLEMENTED.
raft_cb_become_leader()'s eligibility gate read
persist_apply_resource_fenced() before this process's own first
cluster_init_phase2()/persist_recover()/replay_wal() call ever ran --
so a node whose first-ever replay hit a resource-exhaustion apply
failure could still become CTRL_PRIMARY, because the fence that
replay would engage didn't exist yet when the gate checked it. Every
LATER promotion evaluation was already correctly protected; this was
a narrow gap specific to a process's single very-first election.
Fix: re-check persist_apply_resource_fenced() immediately after
cluster_init_phase2() completes on first boot, refusing promotion
(same WARN + ha_arm_promotion_fence_timer() + return pattern as the
existing refusal branch) if that first replay just engaged the fence.
Confirmed ha_elect_evaluate_and_signal (the other election path) is
dead in production for the promotion outcome -- every live call site
of handle_request_vote_reply passes vote_granted=0 since bug-828
redirected real grant recording to the record-only, non-signaling
ha_record_vote_reply -- so raft_cb_become_leader is the sole live
promotion path and this fix's scope is complete.
TDD: new test test_first_boot_promotion_evaluates_own_first_replay_fence
wires ha_phase2_hook to actually call persist_recover(), reproducing
production ordering exactly. RED-confirmed against the unfixed code
at the precise predicted assertion; GREEN-confirmed after the fix.
Full controller_unit_test regression (bundles every HA-adjacent
testsuite/test_*.c) green, 0 new failures.
Originally documented (not fixed) in job-array-single-wal-record
task 7.2's Implementation Notes; logged as bug-1445 and fixed here,
outside that spec's own task list per its persist.c-only boundary.
Root cause and reproduction path were already documented inline in
tasks.md task 7.2's Implementation Notes but never had a standalone
buglog entry. Cross-referenced from spec.json's known_limitations.
Not fixed -- suggested shape captured in the bug entry: a small,
single-call-site ha.c ordering fix (move the first promotion-gate
evaluation to run after the first replay_wal(), or have that one gate
call treat "not yet replayed" as non-promotable), not a new Kiro spec.
spec.json: phase -> implementation-complete, new GO decision (2026-07-17)
covering the full feature plus phase 7's residual-gap closure; prior
decision preserved as prior_decision; bug-1443 known_limitations entry
retired (closed by task 7.1) and replaced with the one limitation that
is still genuinely open (task 7.2's first-boot promotion-ordering gap).
tasks.md: task 3.1's implementation note updated from "open, unowned
gap" to "closed by task 7.1" -- it predated that fix and had gone
stale.
Validation evidence: full C suite 9/9 green (incl. libxce test post
rtti-fix), Go suite green (88.1% backend coverage), clean TBD/secrets
grep, live array-soak smoke PASS (O(1) replay time confirmed at
2-element vs 10,000-element scale), cross-task integration verified at
every seam phase 7 touched, 10/10 requirement groups covered, zero
design drift, zero blocked tasks.
Upstream librtti repin (see prior commit) resolves the SIGABRT this
note warned future tasks to expect and ignore -- updated so it no
longer reads as a live gotcha.
librtti master 7212ba1 -> 53cf11e (revCount 20->21): makeInstance() was
not freeing its calloc'd allocation when a type's .init() returned
negative, causing a 240-byte LeakSanitizer leak (SIGABRT under
abort_on_error=1) on any job_init() failure path -- most visibly
libxce test's test_job_init_propagates_makeJobsub_failure.
Verified fixed: fresh rebuild resolves the new rev, full libxce test
suite green, and test_jobj_hardening passes clean under
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 with no leak stack.
sched_handle_dataset()'s Data_data_jobArray case never freed
dataset->rows (the decoded xce_job_submit_t*) after sched_on_job_array()
consumed it -- free_Dataset() is an empty no-op for every table type, so
nothing else owned this call. One bounded leak per delivered array push.
Free it via the existing free_JobSubmit_ptr() teardown on both the
success and error path, mirroring the sibling list-shaped cases'
ownership-transfer convention.
Rewrote test_job_array_expand.c's init_job_array() fixture to
heap-allocate + strdup its fields (matching the real wire-decode shape)
since freeing the old stack/string-literal fixture crashed. Redelivery
test now builds a fresh descriptor per delivery, matching how a real
redelivery is always a freshly-decoded wire message.
Closes task 7.3 (job-array-single-wal-record), the last of the three
post-GO residual gaps reopened 2026-07-16.
replay_wal() now halts logical apply and engages persist_mark_apply_resource_fenced()
on a negative apply_committed_entry() return during boot/restart replay, mirroring
persist_apply_pump_run()'s existing live-path halt-and-fence behavior (task 4.1).
Physical scan bookkeeping (current_lsn, CRC chain) stays unconditional per entry,
consistent with persist_recover()'s own established current_lsn/last_applied_lsn
divergence precedent.
Went through one remediation round: round 1 exercised the wrong ha.c gate (periodic
re-alert timer); round 2 corrected the test to drive the real election-time gate
(raft_cb_become_leader_with_cluster_for_test()), with teeth independently confirmed
via a temporary fence-clear RED/GREEN cycle. Round-2 reviewer APPROVED.
Documents a real, NOT-fixed residual gap found during this task: a node's very
first-ever promotion evaluation in a fresh process runs before that node's own
first persist_recover()/replay_wal() call, so this fence is invisible to that one
promotion check (ha.c:1303-1408 gate runs before ha.c:1427-1439's first-boot
recovery). Every promotion evaluation after a process's first one is protected.
Out of this task's persist.c-only boundary; flagged for a dedicated ha.c follow-up.
restore_job_array()'s bulk-insert pass could fail partway through
(key_add() on realloc growth exhaustion), leaving an already-inserted
prefix permanently visible in c->jobmgr but excluded from
out_new_jobs -- so those elements existed and were individually
addressable but never reached HOOK_DETAIL_JOB_NEW, never scheduled or
pushed, not on this attempt or any later successful retry. A literal
violation of Requirements 2.3 and 9.4, tracked as bug-1443.
Adds kvmgr_reserve()/xzarray_reserve(), new purely-additive primitives
in shared kvmgr.c/zarray.c (following the existing kvmgr_capture_keys()
template -- bare extern function, no RTTI vtable slot, caller-holds-
write-lock contract), that pre-grow c->jobmgr's backing store before
the bulk-insert pass begins, closing the dominant N-scaling failure
trigger structurally.
Found a genuine off-by-one in the existing, unmodified
xzarray_ensure_items(): its strict less-than sufficiency check means
reserving exactly array_size and then performing array_size real
stores still triggers one more growth cycle on the last store.
Reserving array_size + 1 is the exact minimal correct compensation,
independently re-derived and confirmed by review with concrete
arithmetic.
For the now-much-narrower residual case (kvtab_add()'s own small
per-entry allocation, or a content collision -- both non-N-scaling,
honestly documented as still theoretically possible): restore_job_array()
now populates out_new_jobs with whatever prefix was genuinely inserted
before a failure, and apply_committed_entry() fires HOOK_DETAIL_JOB_NEW
for that non-empty partial prefix even when the overall return is
negative, while still propagating the negative rc so the LSN correctly
halts and the Live Apply Resource Fence still engages for the
remainder. A genuinely-inserted element is never left un-pushed again.
Task 7.1 of job-array-single-wal-record (Requirements 2.3, 9.4, 9.6).
User directed (2026-07-16) that this feature's own open residual gaps
be fixed: bug-1443 (restore_job_array()'s bulk-insert partial-visibility
gap), task 4.2's boot-replay fence gap, and task 5.3b's bounded leak.
Unrelated pre-existing bugs 1437/1438 explicitly excluded from this
reopening per user's scope decision.
Adds Phase 7 (tasks 7.1-7.3) to tasks.md:
- 7.1: add a purely-additive kvmgr_reserve() capacity-reservation
primitive (mirroring the existing kvmgr_capture_keys() template) so
restore_job_array()'s bulk-insert pass can pre-grow its backing store
before mutating, closing the dominant N-scaling failure trigger; plus
make a genuinely-inserted partial prefix always get notified even in
the now-much-narrower residual failure case
- 7.2: extend the Live Apply Resource Fence's halt-and-fence protection
to replay_wal() (boot/restart replay), reusing the same primitives
task 4.1/4.2 already built for the live path
- 7.3: free dataset->rows in sched_handle_dataset()'s jobArray case
spec.json phase reverted to tasks-generated to reflect the reopening.
Both unrelated to job-array-single-wal-record's own code:
- bug-1437 (vendored rtti makeInstance leak): recurred via this
feature's own test_job_init_propagates_makeJobsub_failure, which is
simply the first test to drive job_init() through the failure path
that trips the latent characteristic. Confirmed same signature, not
a new leak, not a regression.
- bug-1438 (capnp parallel-build race): recurred on a clean build with
a different symptom (missing header vs. corrupted content) that
confirms the root cause is a missing meson dependency edge on the
controller.capnp.h custom_target, not just a non-atomic-write timing
fluke -- useful for whoever eventually fixes it, out of this spec's
scope.
A stale, long-orphaned background agent (dispatched early in this
session for task 6.1, interrupted by an API error, and only resuming
~8 hours later after task 6.0a/6.1/6.2/6.3 and the validate-impl GO
decision were already committed) independently re-discovered and
filed the exact bug commit 16747aa already fixed, before noticing the
fix was already on the branch. Its filed entry (bug-1444) accurately
describes the error/root-cause but was left marked NOT FIXED.
Corrected the fix field to reflect the actual resolution (commit
16747aa, task 6.0a) and cross-referenced it. Kept as its own entry
rather than deleting it -- task 6.0a's fix was documented in
tasks.md's Implementation Notes but had never been logged to
buglog.json, so this backfills that gap. Distinct from bug-1443
(the still-open bulk-insert partial-visibility gap in the same
feature).
No code changes; the stale agent's independently-derived
run-ha-soak.sh/soak-verdict.sh/waldump.c changes were confirmed
byte-identical to HEAD and made no git writes.
Feature-level validation ran 4 parallel dimensions: requirements
coverage (38 ACs, 33 full, 5 partial -- all tracing to the already-known
bulk-insert partial-visibility gap plus an admitted commit-barrier-count
inference), design/boundary alignment (ALIGNED, no drift), cross-task
integration (SEAMS_SOUND, wire format/arithmetic/contracts verified
consistent end to end), and mechanical checks including a live smoke
boot (fresh single-node deploy, real array submission, one WAL record,
all elements queryable) -- PASS.
The coverage pass sharpened restore_job_array()'s known bulk-insert
gap: it's a literal, code-proven violation of Requirements 2.3 and 9.4,
not just a soft nuance -- a resource-exhaustion failure during the
bulk-insert pass can permanently orphan array elements that exist and
are addressable but never get scheduled. Filed as bug-1443
(.wolf/buglog.json) for tracking. User decision: GO, ship with the
gap tracked rather than blocking on a fast-follow fix -- the trigger
condition is narrow and every other validation dimension is clean.
Closing bug-1443 needs a new kv.c/kvmgr.c primitive or a re-derive-
and-renotify path, explicitly out of this spec's approved scope.
spec.json phase -> implementation-complete.
Final task of the spec. Ran the complete unit, integration, and soak
suite (controller_unit_test, all scheduler binaries, xcelib_test, Go
apiserver tests under both CGO_ENABLED=0/1, and every soak mode this
spec added) -- all clean, zero new job loss, divergence, or liveness
stall.
Designed and executed a genuine empirical byte-identical baseline
comparison for the non-array (array_size=1) path: built the commit
immediately preceding this spec's first commit in an isolated
worktree, deployed independent clusters from both the baseline and
current-tree binaries, and did a byte-for-byte comparison of the raw
on-disk WAL entries for an identical single-job submission.
Independently reproduced end-to-end by the reviewer with a consistent
result -- every differing byte maps to inherently run-specific fields
(timestamp/crc/jobid/sub_time), zero differences in term/lsn/type/
payload_len or the remaining ~6485 payload bytes. Corroborated by a
structural code-diff argument (the single-job WAL-encode path is
untouched, purely-additive diff only) and the pre-existing task 2.3
unit test asserting the same invariant.
Confirmed the three self-flagged residual gaps (task 3.1 bulk-insert
orphan prefix, task 4.2 boot-replay fence, task 5.3b bounded leak)
remain the complete and only set of open items across all 24
completed tasks.
Task 6.3 of job-array-single-wal-record (Requirements 8.1, 8.2, 8.4).
Overall verdict: GO. This completes the spec's implementation phase.
Extends the HA soak harness with --array-chaos-retry: submits an
array, kills the primary essentially immediately (before commit is
known), retries the identical submission against the new primary, and
hard-asserts exactly one committed instance results -- no silent loss,
no double-submit, no partial duplication across the array's full
index range.
Confirms (no production code changes needed) that the existing
single-job cross-primary dedup mechanism (bug-1419: client-side jobid
minted once and never re-sent on retry; server-side
jobid_range_exists()/inflight_range_try_add(), both genuinely
range-shaped over [base,base+array_size); xce_failover_classify()'s
idempotent-replay rule) generalizes correctly to arrays, treating the
whole range as one dedup unit rather than per-element.
No mutation-test RED phase against production code was performed
(correctly out of this task's boundary); reviewer compensated by
extracting the harness's own assertion logic into a standalone
mutation-harness with stubbed collaborators, confirming real
fail()-gated teeth on all four outcomes (clean/silent-loss/
double-submit/partial-duplication).
Live-verified across 4 independent fresh cluster deployments (2 by
implementer, 2 by reviewer), array sizes 6/10/15, 17 total chaotic
rounds, zero failures, genuine election/term diversity each time.
Task 6.2 of job-array-single-wal-record (Requirements 8.2, 8.3).
Extends the HA soak harness with a new --array-soak mode: small (N=2)
and large (N=10000) array submission sub-rounds, each with one clean
kill/failover, asserting one WAL record per submission regardless of
array size, per-element addressability (status/cancel/completion)
spread across low/mid/high indices post-failover, and a real
millisecond-resolution replay-time bound comparing large-array vs
small-array kill-to-addressable latency.
waldump.c gains a WAL_JOB_ARRAY_SUBMIT case in wal_type_name() so the
scenario can grep an exact type name for its live WAL-record-count
delta check.
Went through one remediation round: the replay-time measurement
initially used whole-second resolution (collapsed to 0s/n-a on a real
run) and never asserted on the comparison, giving zero actual evidence
for the O(1)-replay-time requirement. Fixed with $EPOCHREALTIME
(millisecond resolution -- this sandbox's BusyBox date silently
mangles the more common `date +%s%3N` idiom) and a real bound
assertion that hard-fails the run on a genuine O(N) regression,
independently verified to have real teeth.
Live-verified 3 times with genuinely discriminating numbers each time;
final run: small=210ms, large=163ms -- large-array replay was not
slower, live evidence of the feature's O(1) claim.
Task 6.1 of job-array-single-wal-record (Requirements 4.3, 5.2, 5.3,
8.2, 8.3). Depends on and reuses task 6.0a's own soak verification.
replay_wal() (persist.c ~3902) tallied *new_job_submits_out only for
hdr.type == WAL_JOB_SUBMIT. WAL_JOB_ARRAY_SUBMIT entries were
dispatched and correctly materialized N real jobmgr entries via
apply_committed_entry()'s array case, but never counted --
persist_verify_apply_correctness() then saw real jobmgr growth exceed
the undercounted expectation and declared APPLY_DIVERGENT, engaging a
PERMANENT, non-self-healing fence (distinct from task 4.2's
self-healing Live Apply Resource Fence).
Live-reproduced during task 6.1's soak attempt on 2 of 3 nodes in a
single small-array (N=2) sub-round via two different trigger paths.
Deterministic, not size-dependent -- the first array submission to a
live cluster would fence any node that restarts or hits an
election-path recovery fallback before its next checkpoint, shrinking
the promotable pool. A pre-existing comment at persist.c ~3863-3874
(predating this spec) had already flagged this exact code region as
needing re-verification before tasks 4.1/6.1+ went live; this is that
re-verification, and it failed.
Fix adds a WAL_JOB_ARRAY_SUBMIT branch tallying apply_rc (the genuine
per-record element count apply_committed_entry()'s array case already
returns) when apply_rc > 0, mirroring the existing dedup-safe pattern
for WAL_JOB_SUBMIT. Traced the full return-value space of
restore_job_array()/apply_committed_entry()'s array case to confirm
every tallied value is provably equal to real jobmgr growth -- the fix
cannot overcount and so cannot mask a genuine divergence.
Verified via a from-scratch live 3-node HA soak re-run (--array-soak),
independently by both implementer and reviewer: zero APPLY_DIVERGENT
across all three original trigger paths, one-WAL-record/one-commit-
barrier confirmed for N=2 and N=10000, per-element post-failover
status/cancel/completion confirmed.
Task 6.0a of job-array-single-wal-record (Requirements 3.1, 3.2, 4.1,
9.4). Inserted mid-run after live soak testing surfaced the bug; user
confirmed sequencing (fix now, blocking task 6.1+).
Cross-daemon integration test, test-only, spanning the scheduler (C)
and API-server (Go/CGO) receive paths built in tasks 5.1-5.4/5.3b.
Drives real xce_encode()/xce_decode() wire bytes through the actual
production dispatch chain on both daemons -- extract_data() ->
scheduler_req_handler() -> sched_handle_dataset() ->
sched_on_job_array() and go_push_handler() -> handleDataset() ->
cJobArrayToModels()/dispatchJobArray() -- skipping only vtask_entry()'s
~10 lines of static glue, mirrored verbatim in test code (the
if (callnext) treats-any-nonzero-as-proceed behavior that caused
5.3b's bug is reproduced exactly).
Redelivery is tested via genuinely independent fresh decodes of
separately-encoded bytes, not a decode-once-call-twice shortcut.
Bug-catching capability proven by temporarily reverting task 5.3b's
fix and confirming both the C and Go tests fail meaningfully, then
restoring to green -- confirmed independently by both the implementer
and reviewer.
Task 5.5 of job-array-single-wal-record (Requirements 6.1-6.5, 8.4).
This completes Phase 5 (Compact Scheduler/API-Server Push).
Adds the Go/CGO receive path for Data.jobArray, mirroring task 5.3's
scheduler-side C implementation: rawJobArrayToModels() (pure-Go
base_jobid + i expansion), cJobArrayToModels() (CGO adapter sibling to
cJobToModel), dispatchJobArray() (dedup via the same store.Jobs.Has()
the ordinary Data_data_jobs path already writes through, single
confirmation log only when at least one element was newly
dispatched), and a new case C.Data_data_jobArray in handleDataset.
Elements route through OnJobUpdated, matching the ordinary single-job
push's own routing. The CGO boundary needed no changes -- both
C.xce_job_submit_t and C.Data_data_jobArray were already exposed from
prior tasks, including task 5.3b's extract_data() fix which this
receive path depends on identically to the scheduler's.
store.ArrayJobs (the array-level rollup aggregate) is deliberately
left unpopulated -- confirmed out of this spec's scope, deferred to a
future spec per requirements.md's Boundary Context.
Task 5.4 of job-array-single-wal-record (Requirements 6.2, 6.4, 6.5, 8.4).
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).
Adds sched_on_job_array() (handler.c), a sibling case to
Data_data_schedjobs decoding the new Data.jobArray variant and
expanding it locally into N ordinary schedulable tasks via
base_jobid + i, routed through the existing 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, since per-element teardown (free_Job_ptr) frees it
independently and a shared pointer would double-free.
Dedup reuses sched_get_job() against the same kvtab sched_add_job()
guards, rather than a new mechanism. A confirmation log line fires
once per successful expansion (skipped on a fully-duplicate
redelivery).
Adds a new task (5.3b) discovered during this task's review: 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 -- outside every current task's
boundary, confirmed to affect the future API-server receive path
(task 5.4) identically. design.md's File Structure Plan corrected to
record this. User confirmed sequencing: fix it now, before 5.4.
Task 5.3 of job-array-single-wal-record (Requirements 6.3, 6.4, 6.5, 8.4).
Adds apiserver_push_jobarray() (apiserver.c), mirroring task 5.1's
xcesched_push_jobarray() exactly: same (base jobid, array_size, one
spec) Data.jobArray derivation, same direct xce_request()/OPCode_push
transport. apiserver_job_new_fun() branches on l->count > 1; the
existing single-job path (apiserver_push_joblist()) is byte-for-byte
unchanged.
Additionally, and unlike the scheduler (single target), replicates
apiserver_push_joblist()'s existing per-connected-server fan-out loop
(N_APISERVER_MAX iteration, same lock discipline) -- verified against
2 simulated connected servers, each receiving exactly one push.
New standalone apiserver_push_unit_test binary (same link-collision
rationale as task 5.1's sched_push_unit_test), correctly omitting
egress.c since apiserver_push_joblist() has no WAL-commit filter.
Task 5.2 of job-array-single-wal-record (Requirements 6.2, 6.4).
Adds xcesched_push_jobarray() (sched.c), encoding one compact
(base jobid, array_size, one spec) Data.jobArray descriptor -- reusing
the JobSubmit capnp struct already added to the Data union in task 1.2
-- instead of N individually-encoded job structs. sched_job_new_fun()
branches on l->count > 1 to call it; the existing single-job path
(l->count == 1 -> xcesched_push_joblist()) is byte-for-byte unchanged.
Sends via direct xce_request()/OPCode_push/Request_body_pushmsg rather
than the existing xce_push_data_req() helper, which hard-rejects any
payload outside jobs/workers/resources/resvals and cannot carry a
single non-list descriptor. design.md corrected to record this
transport deviation for task 5.2 to follow.
New standalone sched_push_unit_test binary (registered in
meson.build): sched.c/dataex.c/egress.c were never linked into
controller_unit_test, and two existing test files there ship local
fakes of on_scheduler_join()/notify()/collect_confvars() that would
collide with the real definitions at link time.
Task 5.1 of job-array-single-wal-record (Requirements 6.1, 6.3, 6.4).
New test_job_array_fence.c exercises the full Requirement 9.4-9.6
engage-refuse-clear-recover sequence through real production seams:
forced array-expansion failure -> fence engages -> promotion refused
at a real ha.c gate site -> zero partial elements visible -> a second,
independent node's progress is unaffected -> clearing the failure and
retrying self-heals the fence and re-opens promotion eligibility.
Deliberately drives this through task 3.1's construction-phase
(scratch-build) seam rather than the bulk-insert-phase seam: the
bulk-insert seam has a known, separately-tested residual gap (an
already-inserted element prefix can survive a mid-pass key_add()
failure with no key_del to undo it), so asserting "no partial set"
against it would misrepresent an already-documented open gap as
resolved. That gap remains open and unowned (see tasks.md
Implementation Notes) -- Phase 4 delivers the generic halt/fence/gate
machinery for it, not the partial-visibility guarantee itself.
Task 4.4 of job-array-single-wal-record (Requirements 9.4, 9.5, 9.6).
This completes Phase 4 (Live Apply Resource Fence).
Threads !persist_apply_resource_fenced(&c->persist) as a required
conjunct into both existing promotion-eligibility call sites in ha.c:
the periodic fence-release check (promotion_fence_timeout_fn) and the
election-time check (raft_cb_become_leader). A node with the fence
engaged is refused promotion at both points; once it self-clears
(task 4.2), the node is eligible again with no other action.
Task 4.3 of job-array-single-wal-record (Requirements 9.4, 9.5).
Adds a new atomic LSN field on persist_t (distinct from the existing
boot-only unrecoverable-fence flag) plus three primitives:
persist_apply_resource_fenced(), persist_mark_apply_resource_fenced(),
persist_clear_apply_resource_fence(). Wired into
persist_apply_pump_run(): engages on task 4.1's halt condition, clears
unconditionally on the walk's next successful advance — provably
equivalent to "clears only when the same stuck LSN's retry succeeds"
since the walk is strictly sequential and never advances past a
failed position.
Known residual gap, out of this task's boundary (documented in
tasks.md Implementation Notes): replay_wal() during boot/restart
replay does not yet share this protection, so a node crashing while
fenced can silently re-pass the same failure on restart. Flagged for
a follow-up task, not fixed here.
Task 4.2 of job-array-single-wal-record (Requirements 9.4, 9.5).
persist_apply_pump_run() no longer discards apply_committed_entry()'s
return value. On a negative rc for any WAL type (not just the new
array type), the walk halts at that LSN — no last_applied_lsn advance,
no pending-apply hand-off — logs a loud error, and retries from the
same position on the next kick, mirroring the existing
apply_pump_read_entry failure branch's shape.
Investigated whether this newly turns the pre-existing job-fact
-ENOENT path into a permanent-wedge hazard: it does not, since
c->jobmgr never deletes entries and facts are only ever applied after
their causally-prior submit already succeeded, so a live -ENOENT there
can only mean genuine divergence — halting is the correct behavior.
Task 4.1 of job-array-single-wal-record (Requirement 9.4).
Adds 2 targeted tests after confirming tasks 3.1/3.2/3.3 already
cover most of this task's observable criteria:
- N-way expansion swept across sizes {2,3,100,1000} with exact-
endpoint non-leakage checks (existing coverage only tested size 5)
- Requirement 5.2 (individually addressable exactly as a per-element
job) exercised through the real production command dispatch
(job_ctrl(), not the raw key_find() every existing test used) --
confirms an array-expanded element can be cancelled by identity,
siblings stay untouched, and an out-of-range jobid is rejected
Idempotent no-op, forced-failure-then-retry, and partial-reapply
were already fully covered by 3.1/3.2 -- no new tests needed.
Requirement 9.4's node-fencing behavior is confirmed out of this
task's boundary (Live Apply Resource Fence, task 4.x); restore_job_
array()'s own atomicity contract that the fence depends on is
already proven. No production code changed -- test-only.
Task 3.4 of job-array-single-wal-record.
Adds case WAL_JOB_ARRAY_SUBMIT to apply_wal_entry() (standby
catch-up dispatch), calling restore_job_array() hook-free, matching
the existing WAL_JOB_SUBMIT case's shape exactly.
Review found apply_wal_entry() has zero production call sites -- a
2026-07-10 refactor (ha_standby_advance_committed_and_apply) moved
standby entity materialization onto the same apply_committed_entry()
pump the primary uses; the code comment claiming otherwise predated
that refactor and was never updated. Requirement 3.1/3.2/4.1 are
fully delivered by task 3.2 alone. This task's wiring is kept as
defensive parity with WAL_JOB_SUBMIT's existing, equally-dead case
in the same switch, not as an independent delivery path -- design.md
and tasks.md corrected accordingly.
Task 3.3 of job-array-single-wal-record.