Prove Requirement 6.2 with a real, code-backed mechanism instead of the originally hypothesized lost_and_found re-homing, which turned out to be aspirational and never implemented (confirmed by exhaustive grep at both the pre-fix baseline commit and HEAD — bug-857 itself was flagged in buglog.json as never empirically reproduced). test_bug857_regression.c demonstrates both halves of the requirement entirely within controller_unit_test, no live soak or old-commit build needed: schedule_checkpoint's pre-existing WAL-threshold trigger is decoupled from the config-marker boundary and produces genuinely divergent per-member checkpoint content, while the marker-triggered checkpoint this spec added converges members regardless of differing per-member processing pace. design.md's Requirements Traceability table and Testing Strategy section are corrected to match; the redirection reasoning is recorded in tasks.md's Implementation Notes for the benefit of tasks 8.6-8.9, which still need live multi-process HA-soak infrastructure for Requirement 6.1 that this task deliberately did not build.
52 KiB
Design Document: config-upgrade-snapshot
Overview
Purpose: This feature makes a configuration upgrade on an already-running XCE cluster materialize one identical, entity-bearing state snapshot at the configuration-change marker's (term, lsn) boundary, embed the new configuration in it, and install that same snapshot on every member — closing bug-857 (post-upgrade materialized-state divergence) by construction, rather than by per-node WAL replay under the new configuration.
Users: Cluster operators pushing a configuration change (via xceadmin/the upgrade-config admin path) and, transitively, every job/queue/host in flight at the moment of the change — this feature is what keeps their materialized state identical across all controller members through the transition.
Impact: Retires the current "commit marker → restart every controller → each member replays its own pre-upgrade WAL under the new configuration" behavior (do_upgrade_config_with_bytes, config_repl.c:1591-1773, step 9) in favor of a synchronous, in-process materialization trigger on marker apply. No controller restart is required for a configuration upgrade after this change.
Goals
- Materialize a full entity-bearing snapshot (jobs, queues, hosts, embedded configuration) at the exact
(term, lsn)of the committed configuration-change marker, on every member, without any member re-deriving state by WAL replay. - Gate a serving primary's acceptance of state-changing client requests on its OWN materialization-and-hash-verification for the current marker having completed — independent of Raft's promotion eligibility.
- Survive crash, restart, and leadership change at any point in the upgrade sequence, with deterministic, idempotent resumption.
- Prove the fix with a regression that reproduces bug-857 on the old code path and passes on the new one, including chaotic multi-kill failover interleaved with the upgrade.
Non-Goals
- Initial/bootstrap configuration replication (config tag zero → first config) — owned by
ha-config-bootstrap; not unified with this mechanism (requirements.md Boundary Context, decided and rejected 2026-07-23). - The configuration-change marker's consensus ordering itself, or the Raft protocol — owned by
ha-term-safety/ the Raft TLA+ Conformance library series. - Controller voting-set/membership changes —
ha-config-bootstrap. - Fixing the pre-existing, out-of-scope
durable_lsn-not-advanced-by-snapshot-install gap (research.md, this session) — real but bounded to self-promotion-eligibility bookkeeping, predates this spec, applies to any snapshot install (not upgrade-specific).
Boundary Commitments
This Spec Owns
- The
WAL_CONFIG_MARKERapply-time side effect: triggering a forced, synchronous checkpoint at marker-apply time (newapply_committed_entrycase). - Embedding full configuration bytes (not just a
{tag, sha}reference) in the upgrade snapshot, and in every subsequent checkpoint until rotated away. - The host admin-state snapshot frame (new) and the queue admin-state frame's migration to Cap'n Proto (existing raw layout retired) — one
SNAPSHOT_FORMAT_VERSIONbump covers both. - The activation fence: a new, independent condition gating primary request-serving and promotion on this member's own materialization-and-hash-verification for the current marker.
- Retiring the per-node restart/replay upgrade path (
do_upgrade_config_with_bytesstep 9). - Regression coverage: bug-857 reproduction, crash/leadership-change injection at the marker→snapshot-durable and marker→all-installed windows, host-placeholder/rejoin, overlapping upgrades, and format-mismatch fail-loud behavior.
Out of Boundary
- Entity-serialization machinery itself (job/queue capnp encode, checkpoint file layout, chunked transport) — owned by
ha-entity-snapshots, already shipped; this spec extends it in the two narrow, explicit ways above, not otherwise. - The configuration-change marker's append/commit and
(term, lsn)ordering — owned byha-term-safetyRequirement 7; this spec only reacts to the marker once committed. - Raft election, log replication, term safety — the Raft TLA+ Conformance library series.
- Fixing
durable_lsnnon-advancement on snapshot-install (see Non-Goals). - Initial/bootstrap config replication —
ha-config-bootstrap.
Allowed Dependencies
ha-entity-snapshots'swrite_snapshot/load_snapshot/persist_checkpointmachinery (persist.c) — extended, not replaced.ha-term-safety's committed configuration-change marker (config_committed_marker,WAL_CONFIG_MARKER) — consumed as-is.- The existing
snapshot_transfer_schunked install transport (ha_handle_snapshot_chunk) — reused as-is; this spec does not modify chunk transfer mechanics. - The existing
config_serve_fencepattern (ha.h:307-322,config_repl.c:304-377) — mirrored structurally for the new activation fence, not merged into the same field. - The existing
lost_and_foundplaceholder-worker precedent (makeWorker()without join,config_repl.c:1114-1130) — mirrored for host-frame restore.
Revalidation Triggers
- Any change to
apply_committed_entry's dispatch shape or to the fact that it is the single unified apply seam for all members (currently confirmed —apply_wal_entryhas zero live callers) would require re-verifying the marker-apply hook still fires for every member. - Any change to the on-disk snapshot header/frame layout (
SNAPSHOT_FORMAT_VERSION) beyond this spec's own bump must re-checksnapverify/snapdiff/this spec's config-bytes section for continued validity. - Any change to
config_serve_fence's semantics or toha_dispatch_gate's composition order must re-check the new activation fence's placement alongside it. - If
entity-bearing-snapshots/ha-entity-snapshotsever changes its frame format, this spec's host/queue frame additions must be re-verified against the new format.
Architecture
Existing Architecture Analysis
Three existing subsystems already do almost everything this feature needs; the actual new-work surface is narrow:
- The apply seam is already unified.
apply_committed_entry(persist.c:2751-3132) is the single dispatch point for entity mutation on every member — primary online-apply (vtask-pool dispatched viapersist_apply_pump_kick), standby normal apply (ha_standby_advance_committed_and_apply, heartbeat-learned commit position, since the 2026-07-10 commit-then-apply change), and boot/restart replay.WAL_CONFIG_MARKERcurrently falls into its genericdefault:no-op. The sibling dispatcherapply_wal_entryhas zero live callers — fully superseded, not a second seam to worry about. - The checkpoint/snapshot pipeline is already entity-bearing.
write_snapshot/load_snapshot(persist.c) already serialize job and queue frames at every checkpoint, guarded by a single-in-flight CAS (checkpoint_in_progress) and already stamping a config reference (cfgref_tag/cfgref_sha) viascan_latest_committed_config_ref.persist_checkpoint()is a plain, already-force-callable, synchronous function (a second direct call site already exists atha.c:3308). - The activation-fence pattern already exists for a materially identical problem.
config_serve_fencealready gates client-request-serving (viaha_dispatch_gate) and promotion on a hash-verify condition, with a fully-wired non-blocking retry loop — this is a direct structural precedent for the gate 3.2/3.3 need, not a new pattern to invent.
The upgrade path being retired (do_upgrade_config_with_bytes step 9, config_repl.c:1753-1772) already carries a NOTICE comment (config_repl.c:1737-1751) naming this exact spec and its target end-state — the maintainers who wrote the interim restart/replay path already anticipated this design.
Architecture Pattern & Boundary Map
graph TB
Marker[WAL_CONFIG_MARKER committed]
Apply[apply_committed_entry]
Trigger[Forced checkpoint trigger]
Checkpoint[persist_checkpoint]
Snapshot[write_snapshot entity plus config bytes]
Fence[Upgrade materialization fence]
Gate[ha_dispatch_gate]
Promo[Promotion gate]
Transport[snapshot_transfer_s install]
Behind[Behind or joining member]
Marker --> Apply
Apply --> Trigger
Trigger --> Checkpoint
Checkpoint --> Snapshot
Snapshot --> Fence
Fence --> Gate
Fence --> Promo
Behind --> Transport
Transport --> Fence
Architecture Integration:
- Selected pattern: extend the existing per-member apply-and-checkpoint pipeline in place; no new distribution/orchestration component. Each member independently materializes on its own apply of the marker (primary and standby alike, since both route through
apply_committed_entry); a member that is behind receives the same entity-bearing snapshot through the existing generic catch-up transport, unrelated to upgrade-specific push logic. - Domain/feature boundaries: persistence (
persist.c) owns materialization and on-disk format; HA (ha.c/ha.h) owns the activation fence and dispatch-gate integration; config replication (config_repl.c) owns config-bytes sourcing and retiring the old restart path; worker/queue restore owns entity-specific create-vs-update semantics. - Existing patterns preserved: single-in-flight checkpoint CAS, fail-loud format-version mismatch, never-self-terminate fence-and-retry,
#ifdef XCE_TESTINGfault-hook shape. - New components rationale: a new
apply_committed_entrycase (nothing existed for this entry type); a new fence field (the existing one is logically a different condition); new capnp structs (existing ones bundle unwanted dynamic/static fields); a create-if-missing host restore path (queues' update-only assumption doesn't hold for hosts). - Steering compliance: never blocks a vtask handler (the new checkpoint trigger runs inside
apply_committed_entry's own vtask-pool context, not on iomgr/delaymgr); never self-terminates (fence-and-retry, mirroringconfig_serve_fence).
Key simplification found during design: 5.4 ("new leader resumes driving materialization and distribution") needs no new distribution-tracking state machine. Because every member independently materializes on its own apply of the marker (there is no primary-driven push loop — install is receiver-pulled via the existing generic catch-up mechanism), a new leader satisfies 5.4 automatically by continuing to apply its own committed log (which already includes the marker) — the same apply_committed_entry hook that handles this for any member handles it for a newly-elected one, with no leader-specific "resume" logic to write.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Persistence / Serialization | Cap'n Proto (existing, src/schema/controller.capnp) |
Encodes HostAdminState/QueueAdminState frames and embedded config bytes in the snapshot |
Same library already used for Job/Worker messages; no version change |
| Backend / Runtime | C (gnu17), existing controller daemon | All new logic lives in src/controller/ |
No new language/runtime |
| Async I/O | Existing vtask pool / delaymgr (ha-async-io.md) |
New checkpoint trigger runs on the existing apply-pump vtask worker; new fence retry mirrors config_schedule_activate_retry's delaymgr→vtask trampoline |
No new concurrency primitive |
No new external dependency is introduced anywhere in this spec.
File Structure Plan
Modified Files
src/controller/persist.c— newcase WAL_CONFIG_MARKER:inapply_committed_entry(callspersist_checkpointdirectly);write_snapshot/load_snapshot/load_snapshot_entitiesextended with a host-frame section and a config-bytes section;collect_queue_frames/queue-frame decode migrated from raw layout toQueueAdminStatecapnp; newcollect_host_frames/restore_host_state;SNAPSHOT_FORMAT_VERSIONbumped 1→2; new#ifdef XCE_TESTINGfault hook for the marker-apply→checkpoint-durable window.src/include/persist.h—SNAPSHOT_FORMAT_VERSIONbump declaration site (if defined here rather than persist.c); no new persistent fields needed beyond what already exists (cfgref_*reused for the reference; the embedded bytes are read fresh from disk at write time viamyconf's own fields, not cached onpersist_t— see the config_repl.c bullet below for why this avoids a new cross-module call).src/controller/ha.h— new_Atomic int upgrade_materialization_fencefield onha_state_t(sibling toconfig_serve_fence,ha.h:307-322); new accessor declaration.src/controller/ha.c— newupgrade_materialization_verify()(mirrorsconfig_activate_committed's hash-verify-then-set/clear shape); new accessorupgrade_materialization_fenced();ha_dispatch_gate'sCTRL_PRIMARYbranch extended with the new fence check (composed alongside, not merged with,config_serve_fenced()); promotion gate (ha.c:1425area) extended the same wayconfig_serve_fenced()already gates it; newschedule_upgrade_materialization_retry()mirroringconfig_schedule_activate_retry's trampoline.src/controller/config_repl.c—do_upgrade_config_with_bytesstep 9 (config_repl.c:1753-1772, restart_requested/cbuf_put) removed; the NOTICE comment (config_repl.c:1737-1751) replaced with a short comment pointing at the newapply_committed_entrycase; no new accessor function added here —persist.c's snapshot writer reads the active config bytes directly off disk viap->cluster->myconf->workdir/myconf->name(plain struct fields on the shared, publiccluster_t/myconf_t,cluster_def.h/myconf.h), the sameworkdir/name/confpathconf_save_config(config_repl.c:776) already writes to — this deliberately mirrorsscan_latest_committed_config_ref's own existing precedent of avoiding apersist.c → config_repl.cfunction-call dependency (that function's comment states this explicitly), rather than introducing the layering violation a new accessor would create; new#ifdef XCE_TESTINGfault hook for the leadership-change-mid-distribution window, mirroringxce_snapshot_chunk_hook's shape.src/controller/worker.c— newrestore_host_state(cluster_t*, const char*, uint32_t admin_status): create-if-missing via a smallmakeWorker()-based placeholder helper (valid non-NULLhostattrs_t, mirroring thelost_and_foundprecedent inconfig_repl.c:1114-1130), idempotent (look up first, only create if missing, OR-in admin bits either way — 5.8).src/controller/queue.c— no signature change torestore_queue_state; only its caller's decode step (inpersist.c) changes from raw-layout parsing toQueueAdminStatecapnp decode.src/schema/controller.capnp— newstruct HostAdminState { name @0: Text; status @1: UInt32; }andstruct QueueAdminState { name @0: Text; status @1: UInt32; }..claude/skills/xce-test-ha/run-ha-soak.sh— extended with a new scenario covering config upgrade interleaved with chaotic multi-kill failover (requirement 6) and back-to-back overlapping upgrades (7.2); exact flag/mode naming left to tasks.md.
New Files
src/controller/testsuite/test_upgrade_snapshot_apply.c— bare in-memorycluster_tfixture (mirrorstest_apply_committed_entry.c's pattern), drives the newWAL_CONFIG_MARKERcase directly, asserts a forced checkpoint fires exactly once and is idempotent under retry.src/controller/testsuite/test_upgrade_snapshot_restore.c— disk-backed fixture (mirrorstest_replay_apply_seam.c's pattern), covers host-placeholder creation, later real-join-through-rejoin_workeradmin-bit preservation, and idempotent retry of a partially-failed restore.src/controller/testsuite/test_upgrade_activation_fence.c— exercisesupgrade_materialization_verify, the dispatch-gate composition, the promotion-gate composition, and the retry trampoline.src/controller/testsuite/configcompare.c/configcompare.h— new sibling tool towalcompare(not an extension of it —walcompare's internal model is WAL-entry-indexed(lsn → term, pcrc); active-configuration identity across members is a single(tag, sha)per member with no LSN dimension, an awkward fit forwalcompare's report shape). Offline/file-based, mirroringsnapdiff_paths's exact signature shape:int configcompare_paths(const char* const* conf_paths, int n, FILE* out, configcompare_report_t* rep), each path being a member'sworkdir/name/conffile (the same path this spec's snapshot-embed step reads, so both consumers agree on where a member's active config lives) — no live RPC to each cluster member, consistent with every other tool in this family. Compares each member's config content hash (recomputed from the file bytes, not trusted from a cached tag) for 4.2, usable directly by the HA soak scenario against each node's own data directory.src/controller/testsuite/snapverify.c(modified, not new) — extended to decode and assertcfgref_tag/cfgref_sha(not just presence), and to report host-frame count and config-bytes-section presence.
System Flows
sequenceDiagram
participant Admin
participant Primary
participant Standby
participant ApplySeam as apply_committed_entry
participant Checkpoint as persist_checkpoint
participant Fence as Activation Fence
Admin->>Primary: upgrade-config request
Primary->>Primary: config_commit_change (append+commit marker)
Primary->>Standby: replicate WAL_CONFIG_MARKER
par Primary applies
Primary->>ApplySeam: apply marker (own apply pump)
ApplySeam->>Checkpoint: trigger forced checkpoint at marker (term,lsn)
Checkpoint->>Fence: verify hash, clear fence
and Standby applies
Standby->>ApplySeam: apply marker (heartbeat-learned commit)
ApplySeam->>Checkpoint: trigger forced checkpoint at marker (term,lsn)
Checkpoint->>Fence: verify hash, clear fence
end
Fence-->>Primary: serve under new configuration
Key decisions visible above: no primary-orchestrated push step exists — both members reach the same materialized-and-verified state independently through the same seam, which is what makes leadership change mid-upgrade (5.4) self-resolving rather than requiring resume logic.
stateDiagram-v2
[*] --> Fenced: marker applied, materialization not yet verified
Fenced --> Fenced: hash mismatch or materialization incomplete, retry via delaymgr trampoline
Fenced --> Verified: hash-verify succeeds against current marker
Verified --> [*]: fence cleared, serving and promotion allowed
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | Materialize at marker apply, not commit | apply_committed_entry new case |
persist_checkpoint |
Sequence diagram |
| 1.2 | Every member ends up holding the snapshot | Per-member apply seam + existing transport | ha_handle_snapshot_chunk (reused) |
Sequence diagram |
| 1.3 | No member re-derives via replay | do_upgrade_config_with_bytes step 9 removed |
— | — |
| 1.4 | Transfer out-of-band, not in WAL | Existing snapshot_transfer_s (reused) |
— | — |
| 1.5 | Synchronous, non-blocking trigger | New apply_committed_entry case calling persist_checkpoint directly |
— | Sequence diagram |
| 1.6 | Host admin-status in snapshot | collect_host_frames/HostAdminState |
capnp struct | — |
| 1.7 | Exclude dynamic host metrics | collect_host_frames field selection |
— | — |
| 1.8 | Apply does not itself re-home entities | Existing lost_and_found re-homing untouched; apply vs activation separation |
Activation Fence | State diagram |
| 1.9 | Host/queue frames extensible (capnp) | HostAdminState/QueueAdminState |
controller.capnp |
— |
| 1.10 | Create-if-missing host placeholder | restore_host_state |
makeWorker()-based helper |
— |
| 1.11 | Real join routes through rejoin_worker |
restore_host_state placeholder + existing join.c dispatch (unchanged) |
rejoin_worker (reused) |
— |
| 2.1 | Config embedded in snapshot | New config-bytes section in write_snapshot |
p->cluster->myconf direct read |
— |
| 2.2 | Supersede R7 interim byte-push for upgrades | do_upgrade_config_with_bytes retirement |
— | — |
| 2.3 | Config bytes out of WAL | Unchanged (WAL_CONFIG_MARKER already {tag,sha} only) |
— | — |
| 2.4 | Every checkpoint embeds current config | Config-bytes section added to every write_snapshot call, not just marker-triggered |
— | — |
| 3.1 | No new promotion-eligibility mechanism | Existing Raft log-freshness gate (unchanged) | — | — |
| 3.2 | Gate serving until materialization+hash-verify | Activation Fence | ha_dispatch_gate |
State diagram |
| 3.3 | Keep serving under last-verified config while incomplete | Activation Fence (allowlist composition) | ha_dispatch_gate |
State diagram |
| 3.4 | Hash mismatch keeps serving/fences, retries | upgrade_materialization_verify |
schedule_upgrade_materialization_retry |
State diagram |
| 4.1 | Every member's materialized state identical | Per-member apply seam + snapdiff_paths (test) |
— | Sequence diagram |
| 4.2 | Every member's active config identical | New configcompare tool |
configcompare_paths |
— |
| 4.3 | Invariant holds across upgrade x failover | Activation Fence + existing promotion gate composition | — | — |
| 4.4 | Overlapping upgrades supersede cleanly | Per-marker deterministic content (5.1) + fence re-verify on newer marker | — | — |
| 5.1 | Deterministic snapshot content | write_snapshot keyed on (term,lsn,config-hash) |
snapdiff_paths (test) |
— |
| 5.2 | Crash before durable write resumes from marker | Existing checkpoint_in_progress CAS + apply-pump re-apply on restart |
— | — |
| 5.3 | Forced checkpoint shares single-in-flight guard | checkpoint_in_progress CAS (reused) |
persist_checkpoint |
— |
| 5.4 | Leadership change resumes materialization | Per-member apply seam (see Key simplification) | — | Sequence diagram |
| 5.5 | Never serve without durable+verified snapshot | Activation Fence | ha_dispatch_gate, promotion gate |
State diagram |
| 5.6 | Rolling-binary-upgrade format skew fail-loud | Existing fmt != SNAPSHOT_FORMAT_VERSION hard-reject (reused) |
— | — |
| 5.7 | Old raw queue-frame layout rejected fail-loud | SNAPSHOT_FORMAT_VERSION bump 1→2 |
— | — |
| 5.8 | Idempotent retry of placeholder host restore | restore_host_state (find-before-create) |
— | — |
| 6.1 | Chaotic upgrade+failover: identical state/config | HA soak new scenario | configcompare, snapdiff_paths |
— |
| 6.2 | Reproduce bug-857 on old code, pass on new | test_bug857_regression.c (unit-level, controller_unit_test) — see correction note below |
snapdiff_paths |
— |
| 6.3 | Inject crash/leadership-change at marker-to-install window | New fault hooks (persist.c, config_repl.c) |
— | — |
| 6.4 | Host admin-status explicitly asserted post-upgrade+failover | HA soak new scenario assertion | — | — |
| 7.1 | Placeholder-then-rejoin admin status preserved | test_upgrade_snapshot_restore.c |
restore_host_state, rejoin_worker |
— |
| 7.2 | Overlapping upgrades cleanly superseded | HA soak new scenario | — | — |
| 7.3 | Format-mismatch fail-loud (rolling upgrade + queue-layout) | test_upgrade_snapshot_apply.c / manual format-mismatch fixture |
— | — |
| 7.4 | Marker-triggered checkpoint's WAL trim does not strand a mid-catch-up standby | test_upgrade_snapshot_apply.c extended + HA soak new scenario |
trim_wal_segments, repl_trim_lsn (reused, unmodified) |
— |
Correction to row 6.2 (task 8.5, post-implementation): this row originally assigned Requirement 6.2 to "HA soak new scenario," under the same bug-857 mechanism hypothesis as the buglog entry — "config-push + restart + per-node WAL replay, config-dependent via lost_and_found." Task 8.5's investigation found that mechanism was never implemented as code, on any commit: create_lost_and_found (config_repl.c) only ever creates the sentinel lost_and_found queue/host objects; no code path in job.c/queue.c/worker.c ever reassigns an EXISTING job's queue reference onto them (restore_job/restore_job_array never consult c->qmgr; restore_queue_state explicitly skips — returns -ENOENT — rather than re-homes when a queue is absent). create_lost_and_found's body is also unconditionally #ifndef XCE_TESTING'd out. The buglog's own bug-857 entry already flagged this as "NOT empirically reproduced yet" — the re-homing detail was TLA+-derived aspiration, not observed behavior.
The real, code-backed mechanism 6.2 reproduces instead is checkpoint-content divergence from marker-vs-threshold decoupling: pre-task-5, apply_committed_entry's WAL_CONFIG_MARKER case was the generic no-op default: case, so a checkpoint's LSN/content was determined solely by schedule_checkpoint's independent per-member WAL-record-count threshold — completely decoupled from the config-change marker's position in the shared committed log. Two members reaching that threshold at different real-world moments (ordinary job-submission activity interleaved around the upgrade) materialize genuinely different job/queue content, with neither checkpoint anchored to the marker — exactly "no snapshot pinning an identical post-upgrade state" (bug-857's own root-cause wording). Post-task-5, the marker-triggered checkpoint fires synchronously, inline, the instant the marker entry is dispatched, anchoring every member to identical content regardless of processing pace.
This mechanism is fully reproducible at the controller_unit_test level (no cluster.c/live-multi-process linkage needed), so 6.2 was moved off the "HA soak new scenario" assignment onto testsuite/test_bug857_regression.c — a two-scenario regression (threshold-triggered divergence pre-fix; marker-triggered convergence post-fix, verified via snapdiff_paths byte-identity) landed entirely within this one file. Requirement 6.1's full live chaotic-soak-with-upgrade (task 8.6, not yet started) remains the legitimate home for live multi-process testing of the broader upgrade+failover interaction — it will need to build live config-upgrade-trigger infrastructure regardless of what 8.5 did, so nothing is lost by 8.5 staying at the unit level. Not rewriting the original row's history — see task 8.5's Status Report for the full investigation trail.
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| Marker Apply Trigger | Persistence (persist.c) |
Fire a forced checkpoint synchronously when WAL_CONFIG_MARKER is applied |
1.1, 1.5, 1.8, 7.4 | persist_checkpoint (P0), checkpoint_in_progress CAS (P0) |
State |
| Snapshot Entity/Config Payload | Persistence (persist.c) |
Embed host/queue admin frames (capnp) and config bytes in every checkpoint | 1.6, 1.7, 1.9, 2.1, 2.4, 5.1, 5.7 | HostAdminState/QueueAdminState capnp (P0), myconf fields (P0, direct read) |
State |
| Host Restore (create-if-missing) | Worker/Restore (worker.c) |
Restore a host admin-state frame safely whether or not the host has ever joined | 1.10, 1.11, 5.8 | makeWorker (P0), rejoin_worker (P0, unmodified) |
Service |
| Activation Fence | HA (ha.c/ha.h) |
Gate primary serving and promotion on this member's own materialization-and-hash-verify | 1.8, 3.2, 3.3, 3.4, 4.3, 5.5 | ha_dispatch_gate (P0), promotion gate (P0), config_serve_fence pattern (P1, mirrored not shared) |
State |
| Upgrade Path Retirement | Config Replication (config_repl.c) |
Remove the restart/replay fallback now superseded by the Marker Apply Trigger | 1.2, 1.3, 1.4, 2.2, 5.4 | Marker Apply Trigger (P0) | — |
| Regression Tooling | Testsuite | New fault hooks, configcompare, extended snapverify, HA soak scenarios |
4.2, 6.1-6.4, 7.1-7.4 | All above (P0) | Batch |
Persistence (persist.c)
Marker Apply Trigger
| Field | Detail |
|---|---|
| Intent | Add the missing WAL_CONFIG_MARKER case to the single unified apply seam so every member materializes at the marker boundary |
| Requirements | 1.1, 1.5, 1.8, 7.4 |
Responsibilities & Constraints
- Carve a new explicit
case WAL_CONFIG_MARKER:out ofapply_committed_entry's currentdefault:no-op. - Call
persist_checkpoint()directly — safe becauseapply_committed_entryalready runs on the apply-pump's own vtask-pool worker, never the iomgr/delaymgr thread; no further async hand-off is needed at this call site (unlikeschedule_checkpoint's WAL-threshold trigger, which fires from arbitrary vtask handlers and genuinely needs delaymgr's non-blocking return). - Must NOT change how entities are interpreted/re-homed (
lost_and_found) — that remains the activation step's concern (1.8), never a side effect of apply. apply_wal_entry's matchingdefault:case is left untouched — it has zero live callers; adding symmetry there would be speculative, unjustified work (Design Invariant 5 / Simplification lens).
Dependencies
- Inbound: apply pump (
persist_apply_pump_run) and boot replay (P0) - Outbound:
persist_checkpoint(P0) — already tolerates-EBUSYfrom a concurrent scheduled checkpoint via the sharedcheckpoint_in_progressCAS - Outbound: new
#ifdef XCE_TESTINGfault hook, mirroringha_stream_body_read_fault_hook's shape, for 6.3 and 7.3's deterministic marker-to-checkpoint-durable injection
Contracts: State [x]
State Management
- State model:
checkpoint_lsnis pinned tolast_applied_lsninsidepersist_checkpoint— since this case runs exactly when the marker is applied,checkpoint_lsnnaturally equals the marker's own LSN with no new LSN-targeting parameter needed. - Persistence & consistency: guarded by the existing
checkpoint_in_progressCAS; a concurrent WAL-threshold-triggered checkpoint and this marker-triggered one cannot both proceed simultaneously. - Concurrency strategy: runs to completion on the calling vtask-pool worker; no new thread or lane.
Implementation Notes
- Integration: one new
caselabel plus one function call;do_upgrade_config_with_bytesstep 9 becomes dead code and is removed in the same change (Upgrade Path Retirement, below). - Validation:
test_upgrade_snapshot_apply.casserts the checkpoint fires exactly once per marker apply and is safe under a forced retry (idempotent — a second apply of an already-applied marker, e.g. during replay, must not double-checkpoint incorrectly;checkpoint_lsnmonotonicity already guards this at thepersist_checkpointlevel). - Risks: the narrow window between
schedule_checkpoint's CAS release andpersist_checkpoint's own re-acquire (pre-existing,persist.c:2222/2265) now has a second caller; not a new race class, but worth a regression assertion that a marker-triggered and WAL-threshold-triggered checkpoint racing each other never corrupts the snapshot (covered by the existing CAS, asserted for completeness). - Overlapping-marker
-EBUSYhandling (4.4, 7.2): if a secondWAL_CONFIG_MARKERis applied while an earlier marker's forced checkpoint is still in flight, this case'spersist_checkpoint()call returns-EBUSY— this is NOT an error to surface or retry specially. The in-flight checkpoint (for the older marker) completes on its own, and the Activation Fence's own verify step (which re-reads the latest committed marker, not whichever one triggered the checkpoint) naturally re-evaluates against the newer marker once that checkpoint'swrite_snapshotcall runs — either from the older checkpoint's own completion re-triggering a fresh comparison, or from the newer marker's normal WAL-threshold checkpoint eventually firing. No special retry loop is added at this call site for-EBUSY; it is logged atTRACE, notWARN/ERROR, since it is an expected, self-resolving outcome, not a failure. - Trim safety under the new trigger path (7.4):
persist_checkpoint()(this case's own call) already runstrim_wal_segmentsafterward, unchanged — the retention-floor guard (repl_trim_lsn, bug-1147: floor = min(active segment LSN, every connected standby's acked LSN, every recently-disconnected slot's last-disconnected LSN)) is not modified by this spec, only invoked from a new trigger point. Six existing tests (test_checkpoint_wal_trim_after_checkpoint,test_trim_guard_preserves_standby_segments,test_trim_without_standbys_proceeds_normally,test_repl_trim_lsn_no_standbys,test_repl_trim_lsn_disconnected_slot_expiry,test_trim_lsn_with_active_standby_at_lsn_5) already cover this guard through the periodic WAL-threshold trigger — none exercise it through this new marker-triggered path, or under the chaotic multi-kill conditions Requirement 6 exercises, which is a real coverage gap this spec must close, not assume covered by proxy.
Snapshot Entity/Config Payload
| Field | Detail |
|---|---|
| Intent | Extend the on-disk checkpoint format with host admin-state frames, migrate queue frames to capnp, and embed full configuration bytes |
| Requirements | 1.6, 1.7, 1.9, 2.1, 2.4, 5.1, 5.7 |
Responsibilities & Constraints
collect_host_frames: walkc->workermgrwith the same lock/iterate idiomcollect_job_frames/collect_queue_framesalready use; encode onlyw->host.status & XCE_WORKER_ADMIN_MASKper host as aHostAdminStatecapnp frame — never the fullxce_worker_t(excludesresvals_to_sched,dead_since, dynamic status bits, per 1.7).collect_queue_frames: migrated to emitQueueAdminStatecapnp frames instead of the current raw{name_len, name, status}layout — same field selection (name + status), new wire encoding only.- Config-bytes section:
write_snapshotreads the locally-held active configuration bytes directly off disk viap->cluster->myconf->workdir/myconf->name(the sameworkdir/name/confpathconf_save_configwrites, and the same bytesconfig_activate_committedalready hash-verifies), appended after the host frames. No newconfig_repl.cfunction is added for this — see the File Structure Plan note on why (mirrorsscan_latest_committed_config_ref's existing avoid-a-config_repl-dependency precedent). SNAPSHOT_FORMAT_VERSIONbumps 1→2 — this is a genuine breaking layout change (queue frames change shape), so the existing fail-loud version-mismatch behavior (load_snapshot,fmt != SNAPSHOT_FORMAT_VERSION → -EINVAL) applies unchanged; no compatibility shim is added (Design Invariant 4).- Determinism (5.1): content is a pure function of
(term, lsn, config-hash)— independent materialization by any member (including a newly-elected leader) must be byte-identical, verified viasnapdiff_paths.
Dependencies
- Inbound: Marker Apply Trigger (P0, calls
persist_checkpointwhich callswrite_snapshot); periodic WAL-threshold checkpoint (P0, samewrite_snapshotgets the same new sections on every checkpoint per 2.4) - Outbound:
controller.capnp's newHostAdminState/QueueAdminStatestructs (P0) - Outbound:
p->cluster->myconffields (P0, plain struct-field read on the sharedcluster_t/myconf_t— not aconfig_repl.cfunction call, preserving the existing layering precedent)
Contracts: State [x]
State Management
- State model: additive frame sections after the existing job/queue frames; config-bytes section is new, appended last.
- Persistence & consistency: same single-commit-point discipline
load_snapshotalready enforces (restore fully, THEN advance position; a partial restore leavespersist_t's fields untouched). - Concurrency strategy: unchanged — collection happens under the same table read-locks already used, no new locking model.
Implementation Notes
- Integration:
HostAdminState/QueueAdminStatecapnp structs added tocontroller.capnp; regenerate bindings. - Validation:
snapverifyextended to decode/assertcfgref_tag/cfgref_sha(not just presence) and report host-frame count and config-bytes-section presence;snapdiff_pathsused as-is for the determinism check. - Risks: none new beyond the format-version bump's inherent one-time old-snapshot invalidation, already an accepted, existing pattern.
Worker/Restore (worker.c)
Host Restore (create-if-missing)
| Field | Detail |
|---|---|
| Intent | Restore a host admin-state frame correctly whether the target host has ever joined on this member or not |
| Requirements | 1.10, 1.11, 5.8 |
Responsibilities & Constraints
- Classify hosts as join-provisioned (not config-provisioned, unlike queues):
key_find(c->workermgr, name)may legitimately miss for a member receiving the snapshot via the out-of-band install path, since that is precisely the "hasn't locally replayed this host's join record yet" case. - On miss: create a minimal placeholder via
makeWorker()with a valid, non-NULLhostattrs_t(even if minimal/zeroed) — mirroring thelost_and_foundsentinel host precedent exactly, not a new mechanism. A NULL/absentattrshere would crashrejoin_worker's later direct dereference on the first real join (1.11) — this is the one hard implementation constraint carried over from discovery. - On hit (placeholder or previously-joined real host): OR-in the restored admin-status bits, same as today's behavior.
- Idempotency (5.8): a retried restore after a partial failure must find the already-created placeholder and update it, never treat its existence as a create conflict —
key_find-then-create-or-update already gives this for free as long as the create step itself is the only thing guarded (i.e., find-first, not create-first-then-handle-EEXIST). - The later real join is NOT this component's concern:
join.c's existingkey_find-based dispatch (foundw != NULL→rejoin_worker, else →init_worker) already routes a real join against an existing placeholder throughrejoin_worker, which already preserves admin bits (worker.c:374,390) — no change needed there.
Dependencies
- Inbound: Snapshot Entity/Config Payload's restore path (P0, calls this per host frame)
- Outbound:
makeWorker(P0, existing) - Outbound:
rejoin_worker(P0, existing, unmodified — the later real join's correctness depends on this component creating a valid placeholder, not on any change torejoin_workeritself)
Contracts: Service [x]
Service Interface
int restore_host_state(cluster_t* c, const char* name, uint32_t admin_status);
- Preconditions:
namenon-empty;c->workermgrinitialized. - Postconditions: a
worker_tnamednameexists inc->workermgrwithhost.statusincludingadmin_status's bits; if newly created,host.attrsis non-NULL and heap-owned (viamakeWorker's underlyingcopy_workerdeep-copy). - Invariants: never destroys a pre-existing worker's non-admin state; never leaves a partially-constructed placeholder on any error path (mirrors the existing collect/restore all-or-nothing discipline).
Implementation Notes
- Integration: called from
persist.c's host-frame restore loop, one call per frame. - Validation:
test_upgrade_snapshot_restore.ccovers create-then-later-real-join (asserting admin status survives throughrejoin_worker), and retry-after-partial-failure (asserting no duplicate/conflict). - Risks: none beyond the
attrs-non-NULL constraint already called out — mitigated by construction (always pass a valid, even if zeroed,hostattrs_ttomakeWorker).
HA (ha.c/ha.h)
Activation Fence
| Field | Detail |
|---|---|
| Intent | Never let a member serve client requests or become primary under a configuration whose upgrade-snapshot materialization it has not itself completed and hash-verified |
| Requirements | 1.8, 3.2, 3.3, 3.4, 4.3, 5.5 |
Responsibilities & Constraints
- A new, independent atomic fence field on
ha_state_t— NOT merged intoconfig_serve_fence, since the two conditions are logically distinct (byte-mismatch vs. materialization-incomplete) and 3.4 needs to distinguish "keep serving under last-verified configuration" from "no verified configuration exists yet," which requires knowing which fence is active. - Verify function mirrors
config_activate_committed's exact shape: read the current committed marker, compare against this member's own materialized-and-hash-verified state for that marker; set the fence and log loud on mismatch/incomplete, clear it on success — neverexit/abort(controller/CLAUDE.md's never-self-terminate invariant). - Verify against LIVE on-disk config bytes each attempt, never a frozen snapshot's embedded copy. The WAL marker's raft-commit and the R7 config-bytes push (
push_config_to_standbys/handle_repl_config_push) are two independent channels with no strict ordering guarantee between them — a member could apply the marker and trigger its forced checkpoint before its ownworkdir/name/confreflects the pushed bytes, meaning the snapshot's embedded config section could be momentarily stale. This is not a correctness risk (a stale embed is just one more mismatch the hash-verify catches, same as any other), but it IS a liveness trap if the verify function checks the snapshot's frozen embed instead of re-reading current disk bytes fresh on every retry —config_activate_committedalready gets this right (recomputes the hash from current bytes each call,config_repl.c:275-302, never from a cached/frozen copy), and this new verify function must do the same, or the fence could stay stuck until the next unrelated periodic checkpoint happens to re-embed fresher bytes. - Composed into
ha_dispatch_gate'sCTRL_PRIMARYbranch the same wayconfig_serve_fenced()already is — a sequential additional check with its own allowlist (replication + read-only ops stay admitted), not a merge into the existing check's logic. - Also gates promotion, the same way
config_serve_fenced()/ha_config_promotion_fenced()already do — a member whose own materialization is incomplete must not become primary and immediately fail 3.2 the moment it wins an election. - Self-clearing retry: a new
schedule_upgrade_materialization_retry()mirroringconfig_schedule_activate_retry's delaymgr-trampoline-then-vtask shape exactly — non-blocking trigger, blocking verify work done off the delaymgr thread.
Dependencies
- Inbound: Marker Apply Trigger (P0, clears the fence once materialization+hash-verify succeeds)
- Outbound:
ha_dispatch_gate(P0, existing, extended) - Outbound: promotion gate (P0, existing, extended)
- Outbound:
config_schedule_activate_retry's trampoline pattern (P1, mirrored, not shared code)
Contracts: State [x]
State Management
- State model: two states per member — fenced (materialization incomplete or hash-mismatched for the current marker) and verified (cleared).
- Persistence & consistency: in-memory only, re-derived on restart from the actual on-disk snapshot state vs. the current committed marker (no separate durable flag needed — the fence is a function of comparing two already-durable facts).
- Concurrency strategy: same single-writer-plus-atomic-read pattern
config_serve_fencealready uses.
Implementation Notes
- Integration: one new field, one new verify function, one new accessor, two call-site additions (dispatch gate, promotion gate), one new retry trampoline.
- Validation:
test_upgrade_activation_fence.ccovers fence-set-on-incomplete-materialization, fence-clear-on-verify-success, dispatch-gate allowlist behavior while fenced, and promotion refusal while fenced. - Risks: none beyond what the mirrored
config_serve_fencepattern already carries in production today.
Config Replication (config_repl.c)
Upgrade Path Retirement
| Field | Detail |
|---|---|
| Intent | Remove the now-superseded restart/per-node-replay fallback |
| Requirements | 1.2, 1.3, 1.4, 2.2, 5.4 |
Responsibilities & Constraints
- Delete
do_upgrade_config_with_bytesstep 9 (restart_requested/cbuf_put) — materialization is now driven entirely by the Marker Apply Trigger, decoupled from this request-handling function entirely; the function's job now ends at step 8 (ACK the admin tool once the marker+bytes are majority-committed). - Replace the NOTICE comment naming this spec with a short pointer at the new
apply_committed_entrycase, so the historical context isn't lost. - No new distribution-tracking logic is added here (see Architecture's "Key simplification found during design") — 5.4 is satisfied by the per-member apply model, not by anything this function does.
Dependencies
- Inbound: none (this is a deletion, not a new caller)
- Outbound: Marker Apply Trigger (P0) now owns what this code used to trigger via restart
Contracts: none (behavioral removal)
Implementation Notes
- Integration: this change and the Marker Apply Trigger must land together — deleting step 9 without the new case first would silently leave the marker with no materialization trigger at all (a real ordering dependency for tasks.md to sequence correctly).
- Validation: the bug-857 regression (6.2) exercises this path directly — reproduces divergence on the pre-change code (restart+replay), passes on the post-change code (no restart, synchronous materialization).
- Risks: none new; this is strictly subtractive once the replacement is proven.
Data Models
Logical Data Model
Checkpoint file layout (on-disk), SNAPSHOT_FORMAT_VERSION = 2:
- Legacy sentinel header (unchanged): magic
"XCESNAP\0"(8 bytes) +checkpoint_lsn(8 bytes) + config-ref tail (present/tag/sha, 41 bytes). format_version(4 bytes) — now2.- Job frames (unchanged):
job_count+ per-job[payload_len][capnp Job bytes]. - Queue frames (changed):
queue_count+ per-queue[payload_len][capnp QueueAdminState bytes]— was[name_len][name][status]raw. - Host frames (new):
host_count+ per-host[payload_len][capnp HostAdminState bytes]. - Config-bytes section (new):
config_bytes_len+ raw config bytes (the same bytesconfig_activate_committedhash-verifies against the marker's sha256).
Referential integrity: restore order (job → queue → host) does not matter functionally — re-homing decisions (lost_and_found) are already baked into each entity's encoded content at materialization time, never re-derived at restore time (Design Invariant 2).
Versioning: the format_version field is the sole discriminator; any mismatch is a hard fail-loud reject (-EINVAL), consistent with the existing, deliberate no-silent-reinterpretation policy. A short file (nothing past the legacy header) is still detected as a pre-entity-bearing legacy checkpoint, unchanged from today.
Error Handling
Error Strategy
Every new failure path follows the codebase's existing convention: fail loud (log the actionable boundary), never exit/abort on an operational condition, stay alive, keep retrying when self-healing is possible.
Error Categories and Responses
- Format-version mismatch (rolling binary upgrade skew on snapshot-install, or an old raw-queue-frame snapshot post-migration): hard reject, no partial install, no silent reinterpretation — reuses the existing
fmt != SNAPSHOT_FORMAT_VERSIONpath unchanged. - Hash-verify mismatch (embedded config bytes don't match the marker's sha256, or materialization incomplete): Activation Fence sets, member keeps serving under its last-verified configuration (or a loud non-serving fence if none exists yet), retries via the delaymgr trampoline — never activates mismatched configuration.
- Host placeholder creation failure (
makeWorkerallocation failure): treated as any other collect/restore failure today — discard partial state, fail the checkpoint/restore attempt, retriable on the next cycle; never a crash. - Overlapping upgrades (a second marker commits before the first's distribution completes): the newer marker's materialization supersedes the older one's in-flight state cleanly, since content is a deterministic function of
(term, lsn, config-hash)— an in-progress older materialization simply becomes moot, not corrupting, once the newer one completes.
Monitoring
Reuses existing structured-log conventions (INFO/WARN/ERROR per the codebase's HA logging pattern) — a loud ERROR on fence-set (naming the marker's (term,lsn) and the mismatch reason), INFO on fence-clear, matching config_serve_fence's existing log shape.
Testing Strategy
- Unit Tests (
test_upgrade_snapshot_apply.c):WAL_CONFIG_MARKERcase fires a checkpoint exactly once per apply (1.1, 1.5); idempotent under a forced retry (5.2, 5.3); does not itself re-home entities (1.8). - Unit Tests (
test_upgrade_snapshot_restore.c): host-frame create-if-missing via placeholder (1.10); admin status survives through a later real join viarejoin_worker(1.11); idempotent retry after partial failure (5.8). - Unit Tests (
test_upgrade_activation_fence.c): fence set/clear on hash-verify outcome (3.2, 3.4); dispatch-gate allowlist while fenced (3.3); promotion refusal while fenced (4.3, 5.5). - Integration Tests:
snapdiff_pathsdeterminism check — two independent materializations at the same(term, lsn, config-hash)boundary are byte-identical (5.1).configcomparecross-member active-config identity (4.2). Extendedsnapverifydecodes/asserts the embedded config-ref tag/hash and reports host-frame/config-bytes-section presence. bug-857 reproduction (6.2) — see the Requirements Traceability table row and the correction note above this section: reassigned off "E2E / HA soak" totest_bug857_regression.c, a unit-level demonstration thatschedule_checkpoint's pre-existing WAL-threshold trigger (decoupled from the config-marker boundary) diverges two members' checkpoint content, and that the marker-triggered checkpoint added by this spec converges them regardless of per-member processing pace. - E2E / HA soak (
.claude/skills/xce-test-ha): config upgrade interleaved with chaotic multi-kill failover, asserting identical materialized state and active config across all members afterward, including host admin-status specifically (6.1, 6.4); deterministic fault injection at the marker-apply→checkpoint-durable window and the leadership-change-mid-distribution window via the two new#ifdef XCE_TESTINGhooks, rather than relying on chaotic-soak timing probability (6.3); overlapping/back-to-back upgrades before the first's distribution completes, asserting clean supersession (4.4, 7.2); host admin-status change issued while the host is down, followed by an upgrade snapshot restore on a member with no local join record, followed by the host's real first join, asserting preservation end-to-end (7.1); format-mismatch fail-loud for both rolling-binary-upgrade skew and the old raw-queue-frame layout (5.6, 5.7, 7.3); a standby held genuinely mid-catch-up (its acked LSN behind the marker's) while the marker-triggered checkpoint's WAL trim runs, asserting its still-needed segments are not stranded — extendingtest_upgrade_snapshot_apply.cwith a fixture mirroringtest_trim_guard_preserves_standby_segments's existing pattern but driven through the new marker-apply case, plus one HA soak assertion under chaotic conditions specifically (7.4).
Migration Strategy
A single SNAPSHOT_FORMAT_VERSION bump (1→2) covers the queue-frame capnp migration and the new host-frame/config-bytes sections together, so this spec introduces exactly one rollout discontinuity, not several staged ones. An old-format on-disk snapshot becomes unreadable by the new binary until that node's next checkpoint cycle regenerates one — consistent with the existing, deliberate fail-loud version-mismatch policy (not a new risk this spec introduces). No phased rollback path is defined beyond redeploying the prior binary version, matching how every prior SNAPSHOT_FORMAT_VERSION change in this codebase has been handled.
Supporting References
research.md— full discovery findings, exact file:line citations for every function referenced above, and the design-synthesis rationale (generalization/build-vs-adopt/simplification) behind each decision in this document.requirements.md— the 7 requirements / 38 acceptance criteria this design traces to, plus the Design Invariants section this design's component choices were checked against.