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.
53 KiB
Design Document: job-array-single-wal-record
Overview
Purpose: This feature collapses a job-array submission's persistence, replication, and downstream-notification cost from O(array size) to O(1). Today, handle_jobsubmit() builds one WAL payload per array element and appends them through persist_submit_job_payloads_async(), registering one commit continuation per element (job.c:1055). A 10,000-element array therefore produces 10,000 WAL appends and 10,000 commit registrations before a single client reply is sent. This design replaces that per-element loop with one WAL_JOB_ARRAY_SUBMIT record — base job spec plus (base_jobid, array_size) — committed through exactly one majority-commit barrier, and expanded into individual job_t entries inside the existing apply_committed_entry()/apply_wal_entry() seam shared by live apply, restart replay, and standby catch-up.
Users: Cluster operators submitting large job arrays (commit-engine load, replication bandwidth, and failover replay time no longer scale linearly with array size); maintainers of the controller's submit/apply/push code (one array-shaped record instead of N single-job records to reason about).
Impact: Adds one new wal_entry_type (WAL_JOB_ARRAY_SUBMIT) alongside the unchanged single-job WAL_JOB_SUBMIT; adds one new shared expansion primitive (restore_job_array()) consumed by both live dispatchers of committed WAL entries; adds a compact push variant to the existing scheduler/API-server notification path; closes a live apply-pump gap (a resource-exhaustion apply failure is currently silently skipped rather than retried); hardens five specific, pre-existing gaps in src/lib/jobj.c's job-construction primitives that this feature's N-way apply-time loop amplifies.
Goals
- One WAL record, one majority-commit barrier, one scheduler push, one API-server push per job-array submission — regardless of array size.
- Online apply, restart replay, and standby catch-up expand a committed array record identically, through one shared primitive.
- A committed array is applied deterministically: content-derived failures are impossible post-commit (caught pre-commit); resource-exhaustion failures fence the affected node without rejecting the array or diverging from peers, and self-heal automatically.
- Zero behavior change to non-array (single-job) submission: same WAL format, same commit-barrier count, same wire shape.
Non-Goals
- Per-element override semantics (array elements remain uniform clones of one base spec, distinguished only by jobid — no client-facing API change).
- Bulk query/cancel of a whole array as one operation (deferred; array elements can be in heterogeneous states at request time).
- Any change to the commit engine's or commit barrier's internal mechanics (consumed unchanged from
ha-commit-engine/ha-commit-barrier-rollout). - Scheduler dispatch/allocation policy for array elements (scheduled as ordinary single jobs once expanded).
Boundary Commitments
This Spec Owns
- The
WAL_JOB_ARRAY_SUBMITwire format (reusing the existingJobSubmitcapnp struct) and its encode/append/commit path inhandle_jobsubmit(). - The shared array-expansion primitive (
restore_job_array()) and its wiring into both live dispatchers of committed WAL entries (apply_committed_entry(),apply_wal_entry()). - Atomicity of one array record's apply: all-or-nothing per attempt, idempotent re-apply, no partial/divergent element sets ever observable.
- The compact scheduler/API-server push variant for a committed array and the corresponding local-expansion logic on both receivers.
- The live apply-pump return-value fix (currently discarded) and the new live, self-healing, non-promotable fence for a resource-exhaustion apply failure.
- Hardening
src/lib/jobj.c'sjob_init/makeJobsub/job_destroy/decode_Job/free_Jobfor the five specific gaps requirements.md names, since the apply-time N-way loop amplifies each by array size.
Out of Boundary
- The apply-ordering/hook-decoupling refactor itself (delivered by
raft-apply-decoupling, consumed unchanged) —persist_job_hook_fun'sWAL_JOB_SUBMITbranch is already a no-op and stays untouched. - The non-array single-job submission WAL format, commit path, or reply plumbing —
array_size == 1continues through today's exact code, unmodified. - The commit engine's/commit barrier's internal mechanics — consumed via
commit_submit_async()unchanged. - Bulk array cancel/query as one client operation (deferred to a future spec — heterogeneous per-element state at request time is a materially different problem).
- Scheduler placement/dispatch policy for array elements — out of scope once elements are ordinary schedulable jobs.
Allowed Dependencies
raft-apply-decoupling(complete):apply_committed_entry()and its dispatch contract,persist_apply_pump_run()'s LSN-ordered walk,persist_register_pending_apply()/persist_take_pending_apply()hand-off,jobsubmit_async_ctx_t's batched-reply mechanism (consumed as-is,pendingset to 1 for the array path instead of N).ha-commit-engine/ha-commit-barrier-rollout(complete):commit_submit_async(), per-LSN idempotency guards — consumed unchanged.- bug-1419's in-flight jobid-range registry (
inflight_range_try_add/inflight_range_remove/jobid_range_exists, job.c) — already range-shaped; consumed unchanged, called exactly once per array submission as it is today. idgen()'s existingMAX_ARRAY_IDX(32767) cap onarray_size, enforced at jobid-minting time in the Go apiserver before a request ever reaches the controller — relied on as the structural bound satisfying Requirement 7, not re-implemented.scheduler-controller-msg-gap(complete): the controller→scheduler push protocol and its established pattern for adding a newDataunion variant.
Revalidation Triggers
- Any change to
apply_committed_entry()'s orapply_wal_entry()'s dispatch signature or contract (perraft-apply-decoupling's own trigger: a new WAL entry type not added to both switches silently fails to apply). - Any change to
struct JobSubmit's capnp shape (controller.capnp:533) — it is now load-bearing for three wire uses (client request, WAL payload, push payload), not one. - Any change to
jobsub_table/makeJobsub()'s interning contract insrc/lib/jobj.c— every array element'sjob_tconstruction depends on it. - Any change to
ha.c's promotion-gate eligibility conditions — the new Live Apply Resource Fence check must stay wired at both existing call sites (periodic release, election-time). - Any change to
MAX_ARRAY_IDX/N_ARRAY_IDX_BITS(src/include/config.h) or toidgen()'sarraySizevalidation — Requirement 7's "no chunking needed" conclusion depends on this cap continuing to hold upstream of the controller.
Architecture
Existing Architecture Analysis
The brief's original framing (job_submit() looping create_job() per element) predates raft-apply-decoupling task 3.2 and is stale. Verified current state:
| Aspect | Current state | Gap vs. this design |
|---|---|---|
| Array submission WAL cost | handle_jobsubmit() → build_job_submit_payloads() builds N plain xce_job_t payloads (job.c:221) → persist_submit_job_payloads_async() appends N WAL_JOB_SUBMIT entries, registers N commit continuations (persist.c:4846) |
Requirement 1: N WAL appends / N commit barriers must become 1 |
create_job() call site |
Only inside the apply seam, via restore_job() (job.c:307) — never in handle_jobsubmit (removed by task 3.2) |
None — this is already the correct location for this design's N-way expansion loop |
| Client reply batching | Already one reply per request regardless of array_size, via jobsubmit_async_ctx_t (job.c:754) and pending refcounting |
None — reused as-is; the array path sets pending = 1 instead of plist.count |
| Live apply dispatch | Correction (found during task 3.3 review, 2026-07-14): apply_wal_entry() (persist.c:2574) has zero production call sites — a 2026-07-10 refactor (ha_standby_advance_committed_and_apply, ha.c:8024) moved standby entity materialization onto the SAME apply_committed_entry()/persist_apply_pump_kick pump the primary uses; the persist.c:3722 comment claiming apply_wal_entry() "remains the live standby catch-up dispatch" predates that refactor and was never updated. apply_committed_entry() (persist.c:2721) is the only live dispatcher — for primary live apply, boot replay, and standby catch-up. |
Requirement 3/4.1 is fully delivered by task 3.2's apply_committed_entry() wiring alone. Task 3.3's parallel wiring into apply_wal_entry() is kept as defensive parity with WAL_JOB_SUBMIT's existing (also-dead) case in the same switch, not as a requirement-delivery path. |
| Live apply-pump failure handling | persist_apply_pump_run() discards apply_committed_entry()'s return value ((void)apply_committed_entry(...), persist.c:3281) and unconditionally advances last_applied_lsn |
Requirement 9.4/9.5: a resource-exhaustion apply failure would silently be skipped forever, never retried, never fenced |
| Promotion-gate conditions | ha.c gates on truncated, durable_floor vs HWM, apply_verdict == APPLY_DIVERGENT, self_restart_pending — never on last_applied_lsn vs committed_lsn |
Requirement 9.4: a node stuck on a resource-exhaustion apply failure looks fully promotable today |
| Client-facing array wire format | struct JobSubmit (controller.capnp:533) already carries (jobid, arraySize, submit: JobSubmitData) |
None — this is exactly the compact shape Requirement 1/6 need; reused verbatim, not redesigned |
| Array-size bound | idgen() already rejects arraySize > MAX_ARRAY_IDX + 1 (32768) at jobid-minting time, upstream of the controller (clib.go:165) |
None — Requirement 7 is satisfied by this pre-existing cap plus the record's O(1) payload; no chunking policy needed |
| Scheduler/API-server push | sched_job_new_fun/apiserver_job_new_fun unconditionally call xcesched_push_joblist/apiserver_push_joblist with whatever xlist_t the hook fire carries — already N-capable by call count |
Requirement 6: the wire payload itself still enumerates N full Job/SchedJob structs; a compact descriptor push is a new wire path, not a byproduct of the existing one |
Architecture Pattern & Boundary Map
graph TB
Client[Client array submit]
Handler[handle_jobsubmit]
ArrayAppend[Single array WAL append]
CommitEngine[Commit engine one barrier]
ApplyPumpLive[Apply pump live]
ApplyPumpReplay[Boot replay]
StandbyCatchup[Standby catch up]
RestoreArray[restore_job_array shared helper]
Fence[Live apply resource fence]
HookFire[HOOK_DETAIL_JOB_NEW one fire N jobs]
SchedPush[Compact push to scheduler]
ApiPush[Compact push to apiserver]
SchedExpand[Scheduler local expansion]
ApiExpand[Apiserver local expansion]
Client --> Handler
Handler --> ArrayAppend
ArrayAppend --> CommitEngine
CommitEngine -->|majority ack| ApplyPumpLive
ApplyPumpLive --> RestoreArray
ApplyPumpReplay --> RestoreArray
StandbyCatchup --> RestoreArray
RestoreArray -->|resource exhaustion| Fence
RestoreArray -->|success| HookFire
HookFire --> SchedPush
HookFire --> ApiPush
SchedPush --> SchedExpand
ApiPush --> ApiExpand
Architecture Integration:
- Selected pattern: extend the existing commit-then-apply pipeline (
raft-apply-decoupling) with one new WAL entry type and one new shared expansion primitive, rather than introducing a parallel array-specific pipeline. The array record travels through the exact same append → majority-commit → apply-pump → hook-fire stages a single-job submission does; only the payload shape and the expansion fan-out differ. - Domain/feature boundaries:
restore_job_array()is the only place array-record bytes become Njob_tentries — both live dispatchers call it, neither reimplements it, mirroring howrestore_job()already anchors single-job construction for both dispatchers today. - Existing patterns preserved:
jobsubmit_async_ctx_t's batched single-reply mechanism,persist_register_pending_apply()'s command hand-off, the in-flight jobid-range registry, thet_apply_seam_notifyingthread-local guard,wal_classify's WAIT/RETAIN table, theDataunion's additive-variant extension pattern. - New components rationale:
restore_job_array()(no array-aware expansion primitive exists); the Live Apply Resource Fence (no live-path analog of the boot-onlyrecovery_unrecoverable/persist_recover_loud_fence_if_unrecoverablefence exists); the compact push variant (existing push wire format is per-job, not descriptor-shaped). - Steering compliance: preserves the synchronous-commit-as-policy invariant (
ha-async-io.md) — the array append still runs off the iomgr thread through the unchanged commit-engine contract; the Live Apply Resource Fence follows the controller's Process Lifecycle invariant exactly (src/controller/CLAUDE.md: loud ERROR, non-promotable, stay alive, keep retrying, self-heal — never self-terminate).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Controller core | C (gnu17), existing | All changes land in src/controller/{job.c,persist.c,sched.c,apiserver.c,ha.c} and src/lib/jobj.c |
No new language/runtime |
| Serialization | Cap'n Proto (existing controller.capnp) |
WAL_JOB_ARRAY_SUBMIT payload and the new compact push payload both reuse the existing struct JobSubmit; one new Data union variant added for the push path |
No new struct; encode_JobSubmit/decode_JobSubmit already generated |
| Scheduler daemon | C (gnu17), existing | src/scheduler/src/handler.c gains a sibling case to Data_data_schedjobs for local array expansion |
Follows scheduler-controller-msg-gap's established extension pattern |
| API server | Go 1.23.4, existing | src/frontend/apiserver/backend/ gains a sibling decode path to cJobToModel for local array expansion |
CGO bridge, consistent with existing job-push consumption |
| Test harness | controller_unit_test, xcelib_test, /xce-test-ha |
Unit coverage for the new apply path and fence; live soak for non-regression | Extends existing harnesses, no new framework |
File Structure Plan
Modified Files
src/include/wal_types.h— addWAL_JOB_ARRAY_SUBMITtowal_entry_type(a new value distinct fromWAL_JOB_SUBMIT, keeping the single-job format untouched per Requirement 8.1).src/controller/job.c—handle_jobsubmit(): branch onsubmit->array_size == 1(today's exact code path, unmodified) vs.> 1(new single-append path: encode once via a newcapnp_encode_job_array()-equivalent usingencode_JobSubmit, append via a newappend_job_array_wal_entry_local(), register one continuation via a newpersist_submit_job_array_async(), setactx->pending = 1).jobsubmit_commit_continuation/jobsubmit_apply_complete/jobsubmit_async_ctx_tare reused unmodified — both are already parametric onpendingand keyed by LSN, not by element count. New shared helperrestore_job_array()(sibling torestore_job(), same file) implements atomic N-element construction: scratch-builds all elements first, bulk-inserts intoc->jobmgronly once every element succeeds, and discards the scratch list directly (never touchingc->jobmgr) on failure.src/controller/persist.c—wal_classify(): addWAL_JOB_ARRAY_SUBMIT → WAL_MODE_WAIT(same classification asWAL_JOB_SUBMIT).apply_committed_entry()andapply_wal_entry(): each gains one newcase WAL_JOB_ARRAY_SUBMITcalling the sharedrestore_job_array();apply_committed_entry()'s case additionally builds the N-elementxlist_tand firesHOOK_DETAIL_JOB_NEWonce (mirroring its existing single-job case);apply_wal_entry()'s case stays hook-free (mirroring its existing single-job case).persist_apply_pump_run(): captureapply_committed_entry()'s return value; on negative rc, stop the walk without advancinglast_applied_lsn, log loud, engage the Live Apply Resource Fence (mirrors the existingapply_pump_read_entryfailure branch immediately above it); on success following a prior failure at this LSN, clear the fence. New small primitives:persist_apply_resource_fenced(),persist_mark_apply_resource_fenced(),persist_clear_apply_resource_fence().src/controller/ha.c— both promotion-gate eligibility call sites (periodic fence-release check, election-time check) gain one additional required condition:!persist_apply_resource_fenced(&c->persist), threaded alongside the existingapply_divergent/self_restart_pendingconditions.src/controller/sched.c—sched_job_new_fun(): branch onl->count > 1(the only way aHOOK_DETAIL_JOB_NEWfire ever carries more than one element, per Requirement 3.4) to call a newxcesched_push_jobarray()(encodes the compactDataunion array variant) instead ofxcesched_push_joblist().src/controller/apiserver.c—apiserver_job_new_fun(): samel->count > 1branch, calling a newapiserver_push_jobarray().src/schema/controller.capnp—struct Data's union gains one new variant reusingJobSubmit(e.g.jobArray @6: JobSubmit $C.mapname("job_array")), following the existing additive-variant pattern (schedjobs/jobs/workers/resources/resvals).src/scheduler/src/handler.c— sibling case to the existingData_data_schedjobs(line 112) decoding the new array variant and expanding it locally into N schedulable tasks via the samebase_jobid + iarithmetic used by the controller, logging one confirmation line per array (Requirement 6.4).src/frontend/apiserver/backend/— sibling decode path tocJobToModel(convert.go:31) for the new array variant, expanding Nmodels.JobInfoentries locally, logging one confirmation line per array (Requirement 6.4).src/lib/jobj.c—job_init(): checkmakeJobsub()'s (andmakeString(src->ckptinfo)'s) return value; propagate failure instead of storing NULL with a success return (Requirement 10.1). Root-cause and fix thejobsub_tableinitialization-ordering gap behind the previously observed NULL-with-EINVAL failure (Requirement 10.2).job_destroy(): fix theaccountsleak (currently/* FIXME??? */, jobj.c:584) by adding the missing free call, following the existing pattern used forallocs/pidinfo/run_usage(Requirement 10.4).
New Test Files
src/controller/testsuite/test_job_array_apply.c— unit tests: single-record N-way expansion, idempotent re-apply (no duplicate elements), all-or-nothing scratch-discard on a simulated construction failure (never touchesc->jobmgr), identical results fromapply_committed_entry()vs.apply_wal_entry()for the same record.src/controller/testsuite/test_job_array_fence.c— unit tests:persist_apply_pump_run()halts and does not advancelast_applied_lsnon a negativeapply_committed_entry()return; Live Apply Resource Fence engages, blocks promotion eligibility, and self-clears on a successful retry (Requirement 9.6's fault-injection scenario).src/lib/testsuite/test_jobj_hardening.c— unit tests for the five Requirement 10 gaps:job_initpropagates amakeJobsub()/makeString()NULL;jobsub_tableis initialized before first use under the previously-observed ordering; MOVE_PTR fields are confirmed NULL on a freshly-built array-element payload;job_destroyno longer leaksaccounts;decode_Job/free_Jobckptinfocoverage is confirmed correct as-is (no wire field exists, so no leak).- Extend
/xce-test-ha's soak scenario set with a large-array submission case (commit/push count assertions) and a chaotic-failover case submitting an array immediately before killing the primary (Requirement 8.2, 8.3).
System Flows
Array submission: append, commit, apply, push
sequenceDiagram
participant C as Client
participant H as handle_jobsubmit
participant CE as Commit engine
participant AP as Apply pump
participant RA as restore_job_array
participant N as HOOK_DETAIL_JOB_NEW
participant S as Scheduler push
participant A as Apiserver push
C->>H: array submit base spec plus array_size
H->>H: validate, jobid_range_exists, inflight_range_try_add (unchanged, range-shaped)
H->>CE: append ONE WAL_JOB_ARRAY_SUBMIT, register ONE continuation
CE-->>CE: majority ack (one barrier)
CE->>AP: resolve committed, hand off to apply pump
AP->>RA: restore_job_array (base_jobid, array_size, base spec, lsn)
alt all N constructed
RA->>N: fire once, N-element list
N->>S: compact push (base spec + range)
N->>A: compact push (base spec + range)
AP->>H: reply success (base jobid)
else resource exhaustion mid-loop
RA->>RA: discard scratch list (c->jobmgr never touched)
RA-->>AP: negative rc
Note over AP: last_applied_lsn NOT advanced; fence engaged; retried next kick
end
Key decision not obvious from the diagram: restore_job_array() builds every element in a scratch list and only bulk-inserts into c->jobmgr once the whole array has successfully constructed — so the array is never observably partial to a concurrent reader on the success path either, not only when a retry starts over from index 0 after a failure (Requirement 2.3, 9.4).
Live apply resource fence: engage and self-heal
flowchart LR
Apply[apply_committed_entry fails resource exhaustion] --> Log[Loud ERROR logged]
Log --> Fence[Live Apply Resource Fence engaged]
Fence --> Gate[Promotion gate refuses this node]
Gate --> Retry[Apply pump retries same LSN on next kick]
Retry -->|still failing| Log
Retry -->|succeeds| Clear[Fence cleared by apply pump itself]
Clear --> Eligible[Node eligible for promotion again]
Key decision: the fence clears from inside the apply pump the moment the retried LSN succeeds — it does not depend on an election or on the unrelated WAL-durability catch-up check ever running, so a long-lived primary that never loses leadership still self-heals (Requirement 9.5). This is deliberately a new, narrowly-scoped fence rather than a reuse of recovery_unrecoverable, whose existing periodic clear is gated on WAL durability catch-up alone and would wrongly clear before the stuck apply actually resolves (see research.md, Design Decisions).
Requirements Traceability
| Requirement | Summary | Components |
|---|---|---|
| 1.1-1.3 | Single WAL record, single commit barrier, no per-element append | WAL Array Submission |
| 2.1-2.3 | Atomic, all-or-nothing commit per record | WAL Array Submission (commit), Array Apply Expansion (no partial construction from an uncommitted record) |
| 3.1-3.4 | Shared expand-on-apply, online = replay = standby catch-up, idempotent re-apply, one hook fire | Array Apply Expansion |
| 4.1-4.3 | Standby reconstruction from one committed position | WAL Array Submission (replication carries one record, unchanged replication mechanics), Array Apply Expansion |
| 5.1-5.3 | Preserved per-element identity/addressing | Array Apply Expansion (unchanged base_jobid + i arithmetic — verification-only, no new mechanism) |
| 6.1-6.5 | Single compact push to scheduler and API server, local expansion | Compact Scheduler/API-Server Push |
| 7.1-7.2 | Bounded record size | WAL Array Submission (satisfied by construction — see Architecture table) |
| 8.1-8.4 | No regression to non-array submission or existing tests | Non-Regression & Testing (cross-cutting; array_size == 1 path untouched) |
| 9.1-9.6 | Deterministic, total apply — pre-commit rejection, live resource fence | WAL Array Submission (9.1, 9.2 — pre-commit validation), Array Apply Expansion (9.3 — total apply post-commit), Live Apply Resource Fence (9.4, 9.5, 9.6) |
| 10.1-10.5 | jobj.c hardening | jobj.c Hardening |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| WAL Array Submission | job.c, persist.c, wal_types.h | Encode and commit one array record through one barrier | 1, 4.1-4.2, 7, 9.1, 9.2 | Commit Engine (P0), in-flight range registry (P0) | Service, Batch |
| Array Apply Expansion | persist.c, job.c (restore_job_array) |
Expand a committed array record into N job_t entries, identically for live apply / replay / standby catch-up |
2, 3, 4.3, 5, 9.3 | apply_committed_entry/apply_wal_entry (P0), jobj.c primitives (P0) |
Service, Batch |
| Live Apply Resource Fence | persist.c, ha.c | Fence a node non-promotable on a resource-exhaustion apply failure, self-heal on success | 9.4, 9.5, 9.6 | Apply pump (P0), promotion gate (P0) | State |
| Compact Scheduler/API-Server Push | sched.c, apiserver.c, controller.capnp, scheduler handler.c, apiserver backend/ |
One compact push per committed array, expanded locally by each receiver | 6 | HOOK_DETAIL_JOB_NEW (P0), Data union (P0) |
Event, API |
| jobj.c Hardening | src/lib/jobj.c | Reliable, leak-free job construction under N-element reuse | 10 | RTTI object system (P0) | Service |
WAL Array Submission
Single array record append and commit
| Field | Detail |
|---|---|
| Intent | Encode the whole array as one WAL_JOB_ARRAY_SUBMIT record and commit it through one barrier |
| Requirements | 1.1, 1.2, 1.3, 4.1, 4.2, 7.1, 7.2, 9.1, 9.2 |
Responsibilities & Constraints
handle_jobsubmit()branches onarray_size:== 1runs today's unmodified single-job path (Requirement 8.1);> 1runs the new single-append path.- Pre-commit validation (
jobid_range_exists,inflight_range_try_add) is unchanged and already range-shaped — no new range-claim logic is needed (Requirement 9.1). - The record's encoded payload is
struct JobSubmit(jobid= base,arraySize, onesubmit: JobSubmitData) — O(1) inarray_sizeby construction, andarray_sizeis already hard-capped at 32768 upstream byidgen()(Requirement 7 satisfied without a chunking policy). - Rejection on failed pre-commit validation happens strictly before any WAL append (Requirement 9.2), identical ordering to today's single-job path.
Dependencies
- Outbound:
commit_submit_async()via a newpersist_submit_job_array_async()(Criticality P0) - Outbound:
inflight_range_try_add/jobid_range_exists(Criticality P0, unchanged) - Inbound:
handle_jobsubmit()(Criticality P0)
Contracts: Service [x] / Batch [x]
Service Interface
/* Encodes and appends ONE array-shaped WAL entry (WAL_JOB_ARRAY_SUBMIT,
* payload = encoded JobSubmit), registers ONE commit continuation. Mirrors
* persist_submit_job_payloads_async's per-entry contract but for a single
* array-shaped entry -- no loop over elements at this layer. */
int persist_submit_job_array_async(persist_t* p, uint64_t base_jobid,
uint64_t array_size,
const xce_job_submit_t* base_spec,
uint64_t* out_lsn,
persist_job_commit_notify_fn fn, void* ctx);
- Preconditions:
array_size > 1; the[base_jobid, base_jobid + array_size)range has already been claimed viainflight_range_try_add. - Postconditions: on success, exactly one WAL entry is durably appended locally and exactly one continuation is registered;
fnis invoked exactly once with the outcome (mirrorspersist_submit_job_payloads_async's per-payload contract, specialized to one payload). - Invariants: no
job_tobject exists inc->jobmgrfor any array index until the commit succeeds and the apply seam runs (same non-negotiable ordering as the single-job path).
Batch / Job Contract
- Trigger:
handle_jobsubmit()'s array-size-branch, after pre-commit validation passes. - Input / validation:
(base_jobid, array_size, base_spec), already range-claimed. - Output / destination: one WAL entry; one registered commit continuation feeding
jobsubmit_commit_continuation/jobsubmit_apply_completeunmodified (actx->pending = 1). - Idempotency & recovery: a client retry after a commit timeout produces a second, independent
WAL_JOB_ARRAY_SUBMITentry for the same range — the array-scoped analog of bug-1419's existing single-job dedup story (see Testing Strategy, Requirement 8.3).
Array Apply Expansion
restore_job_array — shared N-way expansion primitive
| Field | Detail |
|---|---|
| Intent | Expand one committed array record into N job_t entries, atomically (scratch-build then bulk-insert — never partially visible), identically for every caller |
| Requirements | 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 3.4, 4.3, 5.1, 5.2, 5.3, 9.3 |
Responsibilities & Constraints
- Decodes
struct JobSubmitfrom the committed payload; foriin[0, array_size), computesjobid = base_jobid + i(the exact arithmeticbuild_job_submit_payloadsuses today, Requirement 5.1) and constructs ajob_tviacreate_job(), sharing the one decodedsubmitpointer across elements (mirroringrestore_job()'s existingsubmit/ckptinfoownership discipline for a single job). - Idempotent per index: if
jobidalready exists inc->jobmgr, that index is skipped, not treated as an error (handles a re-apply of an already-fully-applied record). If every index already exists, returns-EEXIST(mirrorsrestore_job()'s convention), and the caller fires no hook (Requirement 3.3). - Scratch-construct-then-bulk-insert, not construct-then-rollback: all
array_sizeelements are built viacreate_job()-equivalent construction into a local scratch list first, never touchingc->jobmgrwhile the construction loop is running. Only after every element in the scratch list succeeds does one final pass insert all of them intoc->jobmgrunder a single write-lock acquisition (one atomic visibility step, not per-element lock/unlock cycles — this is what makes the array invisible-then-visible rather than visible-then-rolled-back on the success path). This means there is no window during construction, success or failure, where a concurrent reader ofc->jobmgr(a status query, a cancel request) can observe some array indices present and others absent. If construction fails at indexk(resource exhaustion only — content-derived failures are impossible here because Requirement 9.1's pre-commit validation already ran), the0..k-1scratch objects are destroyed directly (noc->jobmgrmutation ever occurred, so nokey_del/rollback race is possible) and the function returns a negative rc. A retry re-enters the loop from index 0 against a fresh scratch list, not from a saved offset.- Residual gap in the bulk-insert step itself (found during implementation, task 3.1 review round 1): the final insert-into-
c->jobmgrpass is not actually failure-proof —key_add()can itself fail on allocation/growth exhaustion (c->jobmgr's underlying table grows viarealloc(), and no capacity-reservation primitive exists anywhere in this codebase'skv.c/kvmgr.c/zarray.cstack to pre-check this before mutating). Because the underlying table has nokey_del, an already-inserted prefix[0, inserted)cannot be cleanly un-inserted if a later element in the same bulk pass fails. The implemented contract is therefore:restore_job_array()never silently claims success on a bulk-insert failure (it returns a negative errno, honestly), but a prefix of elements may be left genuinely, permanently visible inc->jobmgrwhile the overall call reports failure — and critically, that prefix is never included inout_new_jobs, soHOOK_DETAIL_JOB_NEWnever fires for it, on this attempt or any later successful retry of the same LSN. Tasks 3.2/4.x must account for this: a bulk-insert-phase failure leaves elements that are individually addressable (status/cancel/completion work) but permanently un-pushed to the scheduler/API server unless something later re-derives and re-notifies them. This is a narrower, real exception to "atomically invisible-then-visible," not the general case — it fires only on late-stage resource exhaustion, not on ordinary construction failures (which remain fully atomic per the paragraph above).
- Residual gap in the bulk-insert step itself (found during implementation, task 3.1 review round 1): the final insert-into-
- Called identically from
apply_committed_entry()(which additionally builds the N-element list and firesHOOK_DETAIL_JOB_NEWonce, Requirement 3.4) and fromapply_wal_entry()(hook-free, matching its existing single-job case) — this is the one place expansion logic exists (Requirement 3.1, 3.2). Correction (2026-07-14, see the Existing Architecture Analysis table's "Live apply dispatch" row):apply_wal_entry()has zero production call sites —apply_committed_entry()alone delivers Requirement 3.1/3.2/4.1 for live apply, boot replay, and standby catch-up. Theapply_wal_entry()wiring is retained as defensive parity withWAL_JOB_SUBMIT's existing (also-dead) case in the same switch, not as a requirement-delivery path. - Every constructed element is tagged with
submit_lsn = entry_lsn(mirrorsrestore_job(), feeds the existing egress-watermark predicate unchanged). - Exposes an
XCE_TESTING-guarded fault-injection seam (a forceable construction failure at a caller-chosen element index, compiled out in production builds, matching this codebase's existing#ifdef XCE_TESTING-guarded test-only-symbol convention perstructure.md) — the concrete unittest_job_array_apply.cdrives directly to satisfy Requirement 9.6's fault-injection scenario.
Dependencies
- Outbound:
create_job()(job.c, unchanged),jobj.c's hardened primitives (Criticality P0) - Inbound:
apply_committed_entry(),apply_wal_entry()(Criticality P0, both)
Contracts: Service [x] / Batch [x]
Service Interface
/* Expands one committed WAL_JOB_ARRAY_SUBMIT payload into job_t entries.
* Builds every element into a local scratch list first -- c->jobmgr is
* never mutated until every element has successfully constructed, then one
* final pass inserts them all (a pass that cannot fail). Returns the count
* of NEWLY created elements (0..array_size) on success, -EEXIST if every
* index already existed (idempotent no-op, no hook should fire), or a
* negative errno on a genuine construction failure -- in which case the
* scratch list is discarded directly and c->jobmgr was never touched (Req
* 9.4: no partial set ever observable, on the failure path OR the success
* path). */
int restore_job_array(cluster_t* c, const void* capnp_payload, size_t len,
uint64_t entry_lsn, xlist_t* out_new_jobs /* optional */);
- Preconditions:
lsnis committed; payload decodes to a validJobSubmit(guaranteed by Requirement 9.1/9.3 — pre-commit validation makes a content-derived decode failure here impossible). - Postconditions: either all
array_sizeelements exist inc->jobmgrtagged withentry_lsn, or none of this attempt's elements do — and this holds at every instant, not only at return:c->jobmgris untouched until the entire array is ready to insert as one bulk pass, so no concurrent reader can ever observe a partially-constructed array (Requirement 2.3). - Invariants: applying the same
(WAL_JOB_ARRAY_SUBMIT, lsn, payload)twice produces the same observable state as applying it once (Requirement 3.3).
Batch / Job Contract
- Trigger: a new
case WAL_JOB_ARRAY_SUBMITinapply_committed_entry()(live apply pump, boot replay) and inapply_wal_entry()(standby catch-up). - Input / validation: committed WAL payload, already CRC-verified by the point apply is reached (unchanged from the single-job path).
- Output / destination: N
job_tentries inc->jobmgr; oneHOOK_DETAIL_JOB_NEWfire (live-apply path only) carrying all N. - Idempotency & recovery: guarded per-index existence check;
last_applied_lsnis the resumable cursor for restart (unchanged mechanism, now also gated on this function's return value — see Live Apply Resource Fence).
Live Apply Resource Fence
Apply-pump return-value gating and live promotion fence
| Field | Detail |
|---|---|
| Intent | Never silently skip a resource-exhaustion apply failure; fence the node non-promotable while retrying; self-heal on success |
| Requirements | 9.4, 9.5, 9.6 |
Responsibilities & Constraints
persist_apply_pump_run()starts checkingapply_committed_entry()'s return value (currently discarded) — on a negative result, the walk stops at that LSN without advancinglast_applied_lsn, mirroring the existingapply_pump_read_entryfailure branch immediately above it in the same function. This is a general fix (any WAL type's apply failure, not array-specific) that this feature's array-expansion failure is the first live trigger for.- On engaging, logs a loud, operator-visible ERROR (LSN, reason) and sets a new, narrow fence flag — deliberately not
recovery_unrecoverable(its existing periodic clear is gated on WAL-durability catch-up alone and would wrongly clear before the stuck apply actually resolves; seeresearch.mdDesign Decisions). - The fence clears itself, from inside the apply pump, the instant a retry of the same LSN succeeds — no dependency on an election or on any other periodic check running (Requirement 9.5).
- A fence on one node never blocks any other node's independent apply pump — each node's fence state is local (Requirement 9.5, satisfied structurally: nothing here is cross-node).
- Both existing
ha.cpromotion-gate call sites (periodic fence-release check, election-time eligibility check) gain one additional required condition alongside the existingapply_divergent/self_restart_pendingchecks.
Dependencies
- Outbound: none new — a local atomic flag on
persist_t - Inbound:
persist_apply_pump_run()(sets/clears),ha.c's two promotion-gate call sites (reads) (Criticality P0, both)
Contracts: State [x]
State Management
- State model: one new field on
persist_t(an atomic LSN or bool) recording "an LSN is currently stuck failing to apply." Orthogonal torecovery_unrecoverableandapply_verdict— no shared state, no risk of one clearing the other's condition. - Persistence & consistency: in-memory only, like
last_applied_lsnitself — recomputed by construction (the pump re-derives "stuck" the next time it fails to apply the same LSN after a restart; it is not itself a correctness-critical persisted fact). - Concurrency strategy: set/cleared only from
persist_apply_pump_run()'s single-threaded walk (the existingapply_pump_lockalready serializes this); read fromha.c's promotion-gate paths under their existing locking.
Data Models
No new persistent entity type. job_t, xce_job_t, and job_submit_data_t are unchanged — array elements are ordinary jobs once expanded, addressed exactly as single-submission jobs are today.
Data Contracts & Integration
WAL payload (WAL_JOB_ARRAY_SUBMIT, new wal_entry_type value): the existing struct JobSubmit capnp struct, unmodified —
struct JobSubmit $C.mapname("xce_job_submit_t") {
jobid @0: UInt64; # base jobid
arraySize @1: UInt64 $C.mapname("array_size");
submit @2: JobSubmitData; # one shared spec for every element
}
Push payload (new Data union variant, additive): reuses the same struct —
struct Data $C.mapname("xce_data_t"){
data :union $C.mapuniontag("table") {
schedjobs @0: SchedJobData $C.mapname("jobs");
schedworkers @1: WorkerData $C.mapname("workers");
jobs @2: JobData $C.mapname("jobs");
workers @3: WorkerData $C.mapname("workers");
resources @4: ResourceData $C.mapname("resources");
resvals @5: ResourceVals $C.mapname("resvals");
jobArray @6: JobSubmit $C.mapname("job_array"); # NEW
}
}
Both reuses are deliberate (Requirement 6's own wire-shape note: "the same compact shape as the WAL record") — one capnp struct, three uses (client request, WAL payload, push payload), zero new encoder/decoder pairs to write or maintain.
Compact Scheduler/API-Server Push
One push per committed array, expanded locally
| Field | Detail |
|---|---|
| Intent | Deliver a committed array to the scheduler and API server as one compact descriptor; each expands it locally into N entries |
| Requirements | 6.1, 6.2, 6.3, 6.4, 6.5 |
Responsibilities & Constraints
sched_job_new_fun()/apiserver_job_new_fun()(the existingHOOK_DETAIL_JOB_NEWsubscribers) branch onl->count > 1— the only wayapply_committed_entry()ever fires this hook with more than one element (Requirement 3.4) — to call a newxcesched_push_jobarray()/apiserver_push_jobarray()instead of the existing per-job..._push_joblist(). Thel->count == 1branch (ordinary single-job submission) is untouched (Requirement 8.1).- The new push functions derive
(base_jobid, array_size)from the already-constructedxlist_t(first element's jobid, list count) and encode oneData.jobArraymessage — never N individually-encodedJob/SchedJobstructs. - The scheduler (
handler.c, sibling case toData_data_schedjobs) and the Go apiserver (backend/, sibling path tocJobToModel) each expand the descriptor locally into N entries using the identicalbase_jobid + iarithmetic the controller itself uses — the same algorithm at three layers, not three different implementations. - Redelivery on reconnect/rejoin must not double-expand: both receivers dedupe expanded entries by
jobidusing the exact same key space and lookup an ordinary single-job push already uses downstream (no new dedup mechanism) — a scheduler or API-server reconnect that re-receives an already-expanded array's compact push is a no-op per index, mirroringrestore_job_array()'s own idempotent-per-index contract at the controller layer. - Each receiver logs one identifiable confirmation line (base jobid + element count) once local expansion completes, satisfying Requirement 6.4's log-based verification without a new query API.
- Elements are treated as ordinary single jobs for scheduling purposes once expanded (Requirement 6.3) — no new scheduling logic.
Dependencies
- Inbound:
HOOK_DETAIL_JOB_NEWfire fromapply_committed_entry()(Criticality P0) - Outbound:
xce_push_data_req()(unchanged transport, Criticality P0)
Contracts: Event [x] / API [x]
Event Contract
- Published events:
HOOK_DETAIL_JOB_NEW(unchanged detail, unchanged signature) — the branch on element count lives entirely inside the two existing subscribers, not in the hook system itself. - Subscribed events: none new.
- Ordering/delivery guarantees: unchanged — one push per committed array, delivered after the array's own commit (same ordering guarantee single-job pushes already have).
API Contract
| Direction | Message | Payload | Notes |
|---|---|---|---|
| Controller → Scheduler | Data.jobArray push |
JobSubmit (base jobid, array_size, one spec) |
New variant; existing xce_push_data_req transport |
| Controller → API server | Data.jobArray push |
JobSubmit (base jobid, array_size, one spec) |
Same variant, same transport, per connected API server (mirrors existing fan-out) |
Implementation Notes
- Integration: no new opcode —
Data's existing union discriminator (table) already routes to the new variant on both the scheduler and the API-server receive paths. - Validation: confirm the scheduler's local expansion treats a partially-received/decode-failed array push the same way a malformed single-job push is handled today (existing error path, not new).
- Risks: the scheduler and API server must never assume
array_size == l->countimplies the array is contiguous starting at any index other than the pushedjobid— enforced by construction here sincebase_jobid + iis the only arithmetic either side uses.
jobj.c Hardening
Reliable, leak-free construction under N-element reuse
| Field | Detail |
|---|---|
| Intent | Close five specific, pre-existing gaps in job/job-submit construction that this feature's N-way apply-time loop amplifies by array size |
| Requirements | 10.1, 10.2, 10.3, 10.4, 10.5 |
Responsibilities & Constraints
- 10.1 —
job_init()(jobj.c:532) currently storesmakeJobsub()'s return value intodst->job.submitunconditionally (jobj.c:543), including a NULL on allocation/interning failure, and still returns success. Fix: check the return, propagate failure. Same check formakeString(src->ckptinfo). - 10.2 — the previously observed
makeJobsub()NULL-with-EINVAL-for-a-valid-src, traced to a likelyjobsub_table(jobj.c:16) initialization-ordering issue:job_obj_init()must complete before the firstmakeJob/makeJobsubcall on every path that can reach one, including this feature's new N-wayrestore_job_array()loop. Root cause to be confirmed and fixed at the actual ordering dependency, not papered over by 10.1's error propagation alone. - 10.3 —
job_init()'sMOVE_PTRfields (pidinfo, allocs, accounts, run_usage, exit_info, pend_reasons) are confirmed always NULL for a freshly-submitted job:build_job_submit_payloads()(job.c:229) andrestore_job_array()'s equivalent per-element construction bothcalloc()a freshxce_job_tper element and never set these six fields — repeatedMOVE_PTRfrom a shared template is benign (NULL→NULL) and, for the array case specifically, each element gets its own independently-zeroed struct in the first place. No code change required; documented as a verified invariant. - 10.4 —
job_destroy()'saccountsleak (jobj.c:584,/* FIXME??? */) is fixed by adding the missing free call, following the existing pattern already used forallocs/pidinfo/run_usagein the same function. Array submission multiplies this leak by element count, making it a correctness requirement here, not a nice-to-have. - 10.5 — verified:
struct Job's capnp schema (controller.capnp:287) has nockptinfofield at all, sodecode_Jobnever populates it andfree_Jobcorrectly never frees it — not a gap. Documented as a verified non-issue, no code change.
Dependencies
- Inbound:
create_job()/makeJob()(job.c, jobj.c) — every caller, including this feature'srestore_job_array()(Criticality P0)
Contracts: Service [x]
Service Interface
/* job_init's contract changes: propagates a construction failure instead of
* silently storing NULL and reporting success. */
static int job_init(meta_obj_t* obj, va_list ap); /* now fails on
makeJobsub()/makeString(ckptinfo) returning NULL, not only on src==NULL */
- Preconditions: unchanged (
src != NULL,src->submit != NULL). - Postconditions: on success,
dst->job.submitanddst->job.ckptinfoare never NULL-with-success;job_obj_init()has run before this call on every reachable path. - Invariants: a
job_tnever exists in a half-initialized state visible to any caller.
Implementation Notes
- Integration:
restore_job_array()'s all-or-nothing scratch-discard (Array Apply Expansion component) is the safety net if 10.1's now-propagated failure occurs mid-array — it is treated exactly like any othercreate_job()failure in that loop, discarded from the scratch list, never inserted intoc->jobmgr. - Validation:
src/lib/testsuite/test_jobj_hardening.c(File Structure Plan) exercises all five items directly, independent of the array-expansion path, since these are generaljobj.cfixes the single-job path benefits from too (Requirement 8.1's note explicitly permits this). - Risks: 10.2's root cause is not yet confirmed at design time — flagged as a task-level investigation, not a pre-decided fix, consistent with Design vs. Implementation (state the contract: "
makeJobsubmust never observe an uninitializedjobsub_table," not the exact ordering fix). Boundary note: this item's File Structure Plan entry issrc/lib/jobj.conly, on the assumption the fix is local tojobj.c's own init/call-ordering. If root-causing 10.2 shows the actual gap is elsewhere (e.g. controller boot sequencing, a test-harness call path that skipsjob_obj_init()), that is a signal to split 10.2 into its own task with its own explicit file scope at task-generation time, not to silently widen this component's boundary.
Error Handling
Error Strategy
- Pre-commit validation failure (Requirement 9.2):
handle_jobsubmit()rejects before any WAL append, identical to today's single-job path — no new error class. - Commit failure/timeout for the array record: the single registered continuation resolves failed;
jobsubmit_reply_failure_once()fires (unchanged); no job ever constructed (Requirement 2.2, 2.3 by construction — nothing to roll back). - Post-commit content-derived apply failure: structurally impossible per Requirement 9.3 (9.1's pre-commit validation already ran) — if it somehow occurred, it is a programming-invariant violation, handled the same way the Live Apply Resource Fence handles any negative
apply_committed_entry()return: loud ERROR, fence, retry forever. Noassert()/abort (src/controller/CLAUDE.mdProcess Lifecycle invariant). - Post-commit resource-exhaustion apply failure:
restore_job_array()rolls back this attempt's partial construction;persist_apply_pump_run()halts the walk at this LSN; Live Apply Resource Fence engages; retried on the next apply-pump kick; self-heals on success (Requirement 9.4, 9.5). jobj.cconstruction failure (post-hardening): propagates as an ordinarycreate_job()failure into the same rollback path above — no new error class, just a previously-silent failure now made visible.
Monitoring
- Loud
ERROR()on Live Apply Resource Fence engagement, naming the stuck LSN and reason (mirrorspersist_recover_loud_fence_if_unrecoverable's existing log shape). INFO()on fence self-heal (LSN, retry count if tracked).- One
INFO()confirmation per array from the scheduler and one from the API server on successful local expansion (base jobid + element count), the log-based verification Requirement 8.4/6.4 rely on.
Testing Strategy
- Unit Tests:
persist_submit_job_array_async()appends exactly one WAL entry and registers exactly one continuation forarray_size > 1(Requirement 1.1-1.3).restore_job_array(): N-way expansion producesbase_jobid..base_jobid+array_size-1; idempotent re-apply (all-existing) is a no-op returning-EEXIST; a partial-existing edge case skips existing indices without duplicating (Requirement 2, 3.3, 5.1).- Simulated construction failure at element
kofN, driven viarestore_job_array()'sXCE_TESTING-guarded injection seam (forces a construction failure at a chosen index): elements0..k-1exist only in the discarded scratch list, never inc->jobmgr(nothing to tear down there); a subsequent successful retry builds a fresh scratch list from index 0 and bulk-inserts once, producing the full, non-duplicated set (Requirement 9.4, fault-injection scenario for 9.6). apply_committed_entry()vs.apply_wal_entry()applied to the same committed array record produce byte-for-byte identicalc->jobmgrstate (Requirement 3.1, 3.2).persist_apply_pump_run(): a negativeapply_committed_entry()return halts the walk without advancinglast_applied_lsn; a subsequent success advances it and clears the Live Apply Resource Fence (Requirement 9.4, 9.5).- Promotion-gate refusal while the Live Apply Resource Fence is set, at both existing
ha.ccall sites (Requirement 9.4). jobj.c: all five Requirement 10 items, independent of array submission.
- Integration Tests:
- End-to-end array submit: commit success → all N elements visible and individually addressable (status/cancel/completion) via existing per-element APIs, unchanged (Requirement 5.2, 5.3).
- Single push received by both the scheduler and API server, each expanding N local entries and logging one confirmation (Requirement 6.1-6.5).
- Non-array (
array_size == 1) submission: byte-identical WAL record, identical commit-barrier count, identical reply, before and after this feature (Requirement 8.1).
- Regression / Live Soak:
/xce-test-haextended with a large-array submission scenario asserting WAL/commit-barrier count (one record/one barrier, not N) and failover replay time for the committed array (Requirement 8.3, 4.3).- A client retrying an identical array submission across a primary failover follows the same cross-primary dedup mechanism as single-job submission (bug-1419), extended to the array's full index range as one unit — asserted not to double-submit or partially duplicate (Requirement 8.3).
- Full sequential and chaotic
/xce-test-hasoak, including array submission immediately before killing the primary, shows no new job loss, divergence, or liveness stall (Requirement 8.2). - Non-array (
array_size == 1) submission WAL format, commit-barrier count, and reply are byte-identical before and after this feature (Requirement 8.1).
Performance & Scalability
- WAL/replication cost for an array submission becomes O(1) in
array_size(one record, one barrier, one replicated entry) versus today's O(N) — the target metric is commit-engine load and replication bandwidth for a large-array submission, measured via the/xce-test-halarge-array scenario. - Apply-time cost (constructing N
job_tentries inside oneapply_committed_entry()call) is O(N) CPU inside a single, strictly-ordered apply-pump step — an accepted, bounded cost (N is hard-capped at 32767 byidgen()), not a new unbounded-latency risk; noted here as a deliberate trade-off, not re-litigated as a chunking requirement (see Requirement 7's Architecture-table resolution). - Failover replay time for a committed array is governed by the size of the one array record (O(1)) rather than a per-element replay cost proportional to array size, since replay decodes and expands through the same
restore_job_array()primitive as live apply (Requirement 4.3) — measured via the/xce-test-halarge-array scenario's failover-replay-time assertion.