forked from lei.chen/myxce
Full Kiro spec for the apply-after-commit refactor: canonical append->commit->apply->notify ordering, a single apply_committed_entry seam shared by live commit-advance and restart replay, and the Facts-Apply invariant (ha-consistency-invariants criterion 2.9) reconciling worker-reported facts onto the same seam.
18 KiB
18 KiB
Research & Design Decisions
Summary
- Feature:
raft-apply-decoupling - Discovery Scope: Complex Integration (extension of a live, fast-moving HA/Raft subsystem — light discovery upgraded to comprehensive verification because the source brief (
brief.md, 2026-06-24/25) is 9 days old relative to the code and several sibling specs land in the exact same seam) - Key Findings:
bug-1306(commit15be5d9, 2026-06-30) already extracted the WAL-append + majority-commit barrier out ofpersist_job_hook_funfor job submit, ahead of this spec's formal implementation — Requirement 1 is partially pre-landed for the WAIT path.- The in-memory job-submit mutation (
create_job()) still runs before the commit barrier (persist_submit_wal_and_commit()), and a failed/timed-out commit does not roll it back — the exact optimistic-divergence window Requirement 2 targets is still live today, just narrowed by the bug-1306 fix (which only fixed the reply-ack race, not the apply-ordering). ha-term-safetyis fully done (20/20 tasks);on_commit_advanced/raft_advance_commitare wired and live on the replication-ACK path (advisory-only, Option A) — no conflict with this spec's keystone.raft-drive-asyncalready builtvtask_enq_classified+ a slow-lane executor + admission classes (VTASK_LIVENESS/VTASK_LIVE_BLOCK/VTASK_REQ_BLOCK) — this closes the "no general non-IO task-submit path" gap the brief flagged as missing, so the keystone conversion can reuse it instead of building a new primitive.- Online-apply and replay-apply are not unified today: job submit online uses raw
create_job(); replay usesrestore_job()(decode + idempotent-EEXISTguard + LSN tag). Fact state transitions online go through guardedmark_job_start/finish/fail/update()(monotonic-rank checks); replay applies facts viarestore_job_state(), a rawj->job.state = stateoverwrite with no guard at all. This is concrete, evidenced proof of the divergence Requirement 3/7 exist to close. WAL_MODE_RETAINfacts already register into the commit engine (commit_submit(ce, lsn, COMMIT_MODE_RETAIN, 0)) for durability bookkeeping, but nothing today reacts to a RETAIN entry's commit — the state mutation already happened synchronously at receipt, before the WAL append. The "apply pump" driven off commit-advance (Requirement 5/6) does not exist in any form yet.raft-sim-conformance's harness structurally enforces apply-only-after-commit already (it callsraft_advance_commit, then applies viaon_commit_advanced, then checks State Machine Safety) — Requirement 10's conformance assertions mostly compose with existing harness structure rather than requiring new harness machinery.
Research Log
Current submit/hook path (job.c, persist.c)
- Context: Brief claimed
persist_job_hook_fundoes WAL-append + commit-wait inside anAFTER_EVENThook (persist.c:2532/2198, 2026-06-24 citation). - Sources Consulted:
src/controller/job.c:560-662(handle_jobsubmit),src/controller/persist.c:3392-3478(persist_submit_wal_and_commit,persist_job_hook_fun), git log (15be5d9). - Findings:
handle_jobsubmit(job.c:560):BEFORE_EVENThook (validation) →job_submit()→create_job()(in-memory insert intoc->jobmgr, job.c:583) → explicitpersist_submit_wal_and_commit()(job.c:626, persist.c:3406) reading the commit verdict directly → on failure, reply error and return (job.c:627-637) — the already-created job object is not removed fromc->jobmgr→ on success,AFTER_EVENThook fires as notification-only (job.c:643, persist.c:3466-3478 confirms the WAIT branch insidepersist_job_hook_funis now a no-op) → reply success.persist_job_hook_fun'sWAL_MODE_WAITbranch is dead code by design (returns 0 unconditionally, persist.c:3466-3478) — kept as a documented no-op, not removed, so hook registration order doesn't matter for job submit anymore.
- Implications: Requirement 1 (extract raft step from hook) is done for the job-submit WAIT path. Requirement 2 (apply-after-commit ordering) is not done —
create_job()still runs before the barrier, and there is no rollback on commit failure. Design must move the job object construction itself (not just a secondary mutation) into the post-commit apply step.
Commit engine and ha-term-safety completion (commit.c, ha.c)
- Context: The keystone prerequisite (
repl_wait_majority_ackblocking-to-event-driven conversion) is claimed as this spec's first-landed item; needed to confirm nothing else already did it and thatha-term-safety's concurrent work on the same seam doesn't conflict. - Sources Consulted:
src/controller/repl.c:533-558,src/controller/commit.c:195-409,src/controller/ha.c:1680,1776,1829-1831,3764,.kiro/specs/ha-term-safety/tasks.md. - Findings:
repl_wait_majority_ack(repl.c:557) unconditionally delegates tocommit_submit(ce, lsn, COMMIT_MODE_WAIT, timeout_ms)(commit.c:358), which blocks viapthread_cond_timedwait(commit.c:409) under theCOMMIT_WAIT_RESERVEDadmission cap. Unchanged — still a pooled-worker blocking wait, 500ms default timeout.ha-term-safety: all 20 tasks done.on_commit_advanced(ha.c:1776) is wired and live, firing fromraft_advance_commit(raft.c call site ha.c:3764) on the replication-ACK path; it feedscommit_set_raft_commit_index()as an advisory cap only (Option A — the durable-majority barrier remains authoritative; the library commit index does not gate anything today).
- Implications: No conflict —
ha-term-safetysupplies the library-side commit-index plumbing this spec's apply pump can key off of (on_commit_advanced), but the blocking-wait-to-resolve conversion itself is untouched and squarely this spec's keystone to build.
Reusable async primitives from raft-drive-async (vtaskmgr.c)
- Context: The brief identified a gap — no general (non-IO) task-submit path exists — as an API prerequisite for the keystone's async continuations.
- Sources Consulted:
.kiro/specs/raft-drive-async/tasks.md,src/controller/vtaskmgr.c:467,497,528. - Findings: Task 1.2 (slow-lane executor, non-blocking submit) and Task 1.6 (
vtask_class_t+vtask_enq_classified, admission-gated by class:VTASK_LIVENESS/VTASK_LIVE_BLOCK/VTASK_REQ_BLOCK) are both done and live (default-on since 2026-07-02, bug-1376).vtask_enq_classified(mgr, task, class)is a general, non-packet-bound submit path. - Implications (Build vs. Adopt): Adopt
vtask_enq_classifiedwith aVTASK_REQ_BLOCK-equivalent class for the resolved-commit continuation (reply dispatch / apply trigger) instead of building a new submission primitive. This directly resolves the brief's "API gap" concern at zero new-code cost.
Restart replay and the apply-seam divergence (persist.c, job.c)
- Context: Requirement 3 requires one shared
apply_committed_entry()seam for online and replay; needed to establish exactly how much divergence exists today. - Sources Consulted:
src/controller/persist.c:2452-2562(apply_wal_entry),src/controller/persist.c:2667-2723(replay_walCRC verification),src/controller/job.c:44-177(create_job,restore_job,restore_job_state),src/controller/job.c:664-774(mark_job_start,mark_job_finish). - Findings:
apply_wal_entrydispatchesWAL_JOB_SUBMIT→restore_job()(persist.c:2505) andWAL_JOB_{START,FINISH,FAIL,...}→restore_job_state()(persist.c:2520).restore_job()(job.c:96) decodes the Cap'n Proto payload, checks for an existing job by jobid (idempotent-EEXISTguard, job.c:117-130), and calls the samecreate_job()(job.c:44, thin wrapper overmakeJob) that the online submit path uses. So submit-side apply is half-unified: replay already funnels through a shared primitive; online does not (it callscreate_job()directly, skipping the idempotency guard and LSN tagging thatrestore_job()provides).restore_job_state()(job.c:164) is a rawj->job.state = (int)stateoverwrite — nojob_state_is_backward/job_state_is_terminalcheck at all. The online path'smark_job_start/mark_job_finish/mark_job_update(job.c:664-850) do carry those monotonic-rank guards. This is a genuine, currently-live idempotency gap: a duplicate or out-of-order fact entry replayed today (segment-boundary restart replay, or a future retained-fact retry) can regress job state, because replay's mutation path bypasses the same guard the online path enforces.
- Implications:
apply_committed_entry()should standardize onrestore_job()for submits (already idempotent, already reusable) and on a guarded state-transition function for facts — meaningmark_job_start/finish/fail/update's guard logic must become reachable from both the post-commit online path and replay, andrestore_job_state()must stop being a second, weaker mutation path. This is a Build-vs-Adopt case: adopt the existing guards, relocate their call site.
raft-sim-conformance harness readiness
- Context: Requirement 10 needs regression coverage for apply-ordering; checked whether the existing deterministic simulator can carry these assertions without new harness machinery.
- Sources Consulted:
.kiro/specs/raft-sim-conformance/{spec.json,requirements.md,design.md}. - Findings: The harness already asserts Election Safety, Log Matching, Leader Completeness, and State Machine Safety, and its commit/apply loop is structured as
raft_advance_commit→on_commit_advanced→ apply → State-Machine-Safety check — i.e., it is already apply-after-commit by construction. - Implications: New assertions (no-reaction-before-commit, idempotent re-apply) are additive checks layered on the existing harness loop, not a new harness. The live-cluster kill-mid-fact-stream scenario (Requirement 10.4) is new and belongs to the
/xce-test-ha-style soak tooling, not the simulator.
Architecture Pattern Evaluation
| Option | Description | Strengths | Risks / Limitations | Notes |
|---|---|---|---|---|
Single apply_committed_entry() dispatch function, keyed on WAL type |
One function, switch on wal_type, called from both the post-commit online path and apply_wal_entry() |
Matches steering's "library-owned, thin-adapter" pattern already used for raft callbacks; smallest change that satisfies Req 3 | Must not become a god-function — keep per-type logic in the existing restore_*/mark_job_* helpers, apply_committed_entry() only dispatches |
Selected |
| Separate apply seams per entry type (no unifying dispatcher) | Each WAL type keeps its own online-vs-replay pair, just guard-aligned | Less code to touch per type | Does not satisfy Req 3's "same seam" requirement literally; re-invites future divergence | Rejected |
| Event-sourced full replay-log-as-truth (rebuild all reactions from log on every apply) | Maximal purity | Over-engineered — Design Risk A in facts-in-raft.md already concluded reactions must NOT be replayed, only state; resync (existing) is the re-derivation mechanism |
Rejected (already settled in brief.md) |
Design Decisions
Decision: Keystone conversion reuses the existing per-LSN commit registry and vtask_enq_classified, not a new registry or submit path
- Context:
repl_wait_majority_ack→commit_submit(..., COMMIT_MODE_WAIT, ...)blocks a pooled worker onpthread_cond_timedwait. Converting to event-drivenresolve()needs (a) a way to register a continuation instead of parking a thread, and (b) a way to run that continuation without new infrastructure. - Alternatives Considered:
- Build a new pending-request registry alongside the commit engine's existing per-LSN structure.
- Reuse the commit engine's existing per-LSN structure (
ce), replacingcond_broadcastwith aresolve(lsn, outcome)call that invokes a registered continuation.
- Selected Approach: (2).
commit_submitkeeps its per-LSN bookkeeping;COMMIT_MODE_WAITcallers register a continuation (function pointer + context) instead of blocking;commit_advance_locked's existingcond_broadcastcall site becomes the single place that walks newly-committed/newly-failed LSNs and invokesresolve(), which dispatches the continuation viavtask_enq_classified(raft-drive-async) rather than a bespoke thread hop. - Rationale: No new concurrent data structure to get wrong; reuses the already-verified admission-cap discipline (
COMMIT_WAIT_RESERVED/VTASK_*classes) instead of inventing a second one. - Trade-offs: The commit engine's internal contract changes from "callers block on
ce->cond" to "callers register a callback" — every existingCOMMIT_MODE_WAITcall site (job submit today; future command types) must be converted together, not incrementally, or the engine would need to support both calling conventions simultaneously. - Follow-up: Verify during implementation that
commit_advance_locked's lock-ordering discipline (documented at persist.c:3347-3350: never holdp->lockacrossce->lock) still holds once resolution dispatches intovtask_enq_classifiedfrom inside the commit-advance path.
Decision: apply_committed_entry() standardizes on restore_job() / a guard-carrying state-transition function, not new mutation primitives
- Context: See Research Log finding above — replay already has an idempotent submit primitive (
restore_job); online submit doesn't use it. Online facts have a guarded transition primitive (mark_job_*); replay doesn't use it. - Alternatives Considered:
- Write new, from-scratch apply functions for both submit and facts.
- Adopt
restore_job()as-is for submit; extract the guard logic embedded inmark_job_start/finish/fail/updateinto a form callable from both the post-commit path andapply_wal_entry, retiringrestore_job_state()'s unguarded overwrite.
- Selected Approach: (2).
- Rationale: Both primitives already carry the exact idempotency semantics Requirement 7 asks for; the gap is call-site wiring, not missing logic. Writing new functions would duplicate logic that already exists and is already unit-tested.
- Trade-offs:
mark_job_start/finish/fail/updatetoday fire their ownBEFORE_EVENT/AFTER_EVENThooks inline (job.c:680-681, 699-700) as part of the online receipt path — that hook-firing must move to occur only when these functions are invoked from the post-commit apply step, not from the (now durable-append-only) receipt handler, or the ordering guarantee (Req 5) would be violated. This is a real refactor, not a pure reuse. - Follow-up: Confirm no other caller of
restore_job_state()(grep found onlyapply_wal_entry) depends on its current unguarded-overwrite behavior before retiring it.
Decision: egress.c's job_submit_committed() watermark gate becomes redundant for job submit once Req 2 lands, but is not removed by this spec
- Context: Requirement 4.3 says downstream consumers should not need to re-check a commit watermark once notifications only fire post-commit.
job_submit_committed()(egress.c:47) exists precisely because today a job is visible inc->jobmgrbefore it's committed. - Selected Approach: Design leaves
job_submit_committed()in place as a defense-in-depth predicate (it becomes a tautology once Req 2 lands — a job is never injobmgrbefore commit — so it's harmless to keep), and records its removal as a documented candidate cleanup, not an in-scope deletion. - Rationale: Removing it requires confirming every caller of the scheduler-export path relies solely on this gate and not on some other assumption — that audit is a distraction from the core ordering fix and belongs in a follow-up cleanup pass, matching the Simplification lens's guidance not to over-scope "just in case."
- Follow-up: Revalidation trigger — if a future spec removes
job_submit_committed(), it must re-verify no path still relies on it as a correctness gate rather than a no-op.
Risks & Mitigations
- Risk: Converting every
COMMIT_MODE_WAITcall site to the continuation-based API in one change is a wide, mechanically risky diff (touches the commit engine's core contract). — Mitigation: today there is exactly oneCOMMIT_MODE_WAITcall site (repl_wait_majority_ack, used only by job submit); the conversion is narrow in practice even though the contract change is conceptually broad. - Risk: Relocating
mark_job_*'s hook-firing out of the receipt-time handler could silently change BEFORE_EVENT hook timing/semantics for subscribers that assume BEFORE fires at receipt. — Mitigation: audit allHOOK_DETAIL_JOB_START/_EXIT/etc. subscribers before relocating; BEFORE_EVENT for facts should fire at receipt (validation), only the AFTER_EVENT + state mutation defers to apply. - Risk:
worker-rejoin-resync(hard dependency for full fact-reaction correctness perfacts-in-raft.mdDesign Risk A) does not exist yet. — Mitigation: accepted, documented gap, consistent with prior specs (ha-consistency-invariants,ha-commit-barrier-rollout) that already ship with this same accepted gap. - Risk: The keystone conversion changes lock-ordering-sensitive code in the commit engine that several other specs depend on (
ha-commit-durability,ha-term-safety). — Mitigation: preserve the documented lock-ordering rule (never holdp->lockacrossce->lock) as an explicit invariant in design and tests; run the full existing HA soak suite (Req 11) before/after.
References
.kiro/specs/raft-apply-decoupling/brief.md— original discovery brief (2026-06-24/25).kiro/specs/raft-apply-decoupling/facts-in-raft.md— facts-through-raft candidate plan, D1-D3 decisions, keystone rationale.kiro/specs/ha-consistency-invariants/requirements.mdcriterion 2.9 — Facts-Apply invariant, ownership assignment.kiro/specs/ha-term-safety/tasks.md— confirms full completion, no conflict.kiro/specs/raft-drive-async/tasks.md— confirmsvtask_enq_classified/ slow-lane availability.kiro/specs/raft-sim-conformance/{requirements.md,design.md}— existing conformance harness structure- Commit
15be5d9— bug-1306 fix, partial pre-landing of Requirement 1