Spec reached GO with 15/15 tasks complete (see prior task-confirmation commits); these doc artifacts were generated during the design/research phases but never separately staged.
36 KiB
Design Document: raft-adapter-conformance
Overview
This feature closes the wire-level reply-integrity gaps between the XCE controller's Raft adapter (src/controller/ha.c, repl.c, config_repl.c) and the protocol guarantees raft/raft.tla assumes always hold, and produces a durable, action-by-action conformance record so the next gap is found by inspection rather than by a live incident. It extends the work already landed for bug-1479 (the WAL catch-up reject-reply fix and the catch-up streaming-ceiling fix) to the two other places the same class of defect was found live during this feature's discovery pass: the configuration-push reply channel and the vote/PreVote endpoint failure-routing path.
Users: HA cluster operators (who experience the failure mode as a stalled or slow-to-converge cluster) and XCE maintainers (who consume the conformance record when reasoning about future HA changes).
Impact: Two adapter functions gain previously-missing reply/routing behavior (handle_repl_config_push, vote_ep_err_handler); one Cap'n Proto struct gains an additive field (ReplConfigPushReply.term); src/controller/CLAUDE.md's existing TLA+ mapping tables are extended and reconciled against production behavior; no other subsystem changes.
Goals
- Every reject of a replication-class message (WAL catch-up entry, configuration push) produces an explicit reply distinguishable from a lost message.
- Every vote/PreVote request outcome, including a failure arriving after connect, reaches the production tally.
- Every reply channel that can carry a peer's observed term can trigger term adoption.
- A single, explicit, written decision resolves which
AppendEntriessend mechanism is production-authoritative. - A durable, precise mapping exists from each
raft.tlaaction to the adapter code that realizes it, with a conformance verdict. - The four already-conformant guarantee areas (
ClientRequest,AdvanceCommitIndex/commit-source-ownership, restart/recovery, the two already-fixed term-adoption vectors) are protected by regression coverage so a future change cannot silently reopen them.
Non-Goals
- No change to
src/lib/raft/raft.c's protocol logic (election,logOk,(term,index)truncation,UpdateTerm) — validated byraft-log-replication/raft-commit-safety/raft-sim-conformance. - No configuration-upgrade materialized-state work (
config-upgrade-snapshot's scope). - No controller voting-set/membership change support (
ha-config-bootstrap's scope). - No change to
committed_lsnadvancement semantics beyond confirming the existing durable-majority-authoritative behavior is preserved. - No wiring-in of
raft_send_append_entriesas the production send path (see Decision in Architecture below) — it remains dormant, now formally documented as such.
Boundary Commitments
This Spec Owns
- Reply-integrity behavior on the standby → primary direction for WAL catch-up rejects (regression-lock only, already shipped) and configuration-push rejects (new fix).
- Failure-routing behavior for vote/PreVote endpoints, across all failure timings (connect-time — regression-lock, already shipped; post-connect — new fix).
- The
termfield addition toReplConfigPushReplyand its use for term-adoption. - The written conformance record (
CLAUDE.mdextension) mappingraft.tlaactions to adapter code, including the explicit "documented dormant" resolution for the unused nextIndex-drivenAppendEntriessender. - Regression tests that lock in both the already-shipped guarantees and the guarantees this feature adds.
Out of Boundary
- The Raft protocol library itself (
src/lib/raft/raft.c/raft.h) — this feature never modifies it, only verifies the adapter drives it faithfully. - Configuration-upgrade materialized-state consistency (
config-upgrade-snapshot). - Controller membership/quorum-set changes (
ha-config-bootstrap). - Client-side primary-discovery (bug-854) and multi-segment catch-up payload handling (bug-847).
- Any change to
committed_lsnadvancement authority (ha-commit-engine/ha-term-safetyown that decision; this feature only confirms it is unchanged).
Allowed Dependencies
- Raft library callback contract and entry points (
on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from,on_commit_advanced,raft_log_append,raft_on_append_entries_request/reply,raft_advance_commit, vote/pre-vote handling) — consumed as-is, never modified. - The existing out-of-band configuration-push transport (
ha-term-safety) — this feature adds reply integrity to it without changing what it carries. - The existing Cap'n Proto codegen build step (
src/lib/meson.build) — consumed as-is for the additive schema field. - Constraint: no dependency direction change — the adapter continues to depend on the library, never the reverse.
Revalidation Triggers
- Any change to
vote_ep_ctx_t's layout or to how vote/PreVote endpoints are registered. - Any change to
ReplConfigPushReply's wire shape (this feature is itself one such change; a future one must re-checkconfig_activate_committed's-EAGAINhandling described below). - Any change that reintroduces a hard coupling between
raft_commit_indexandcommitted_lsnadvancement (would violate a guarantee this feature regression-locks). - Any change that gives
raft_send_append_entriesa production caller (would invalidate the "documented dormant" resolution and require revisiting Requirement 3's decision).
Architecture
Existing Architecture Analysis
The controller is a thin adapter over the Raft library (ha-term-safety's framing, confirmed still accurate): the library owns election/commit/truncate decisions; the adapter's job is to persist what the library decides (on_persist_entry), source terms for it (on_get_entry_term), transmit its decisions on the wire, and turn wire replies back into library calls (raft_on_append_entries_reply, raft_on_vote_reply, raft_on_pre_vote_reply). This feature's defects are all instances of one pattern: an adapter code path receives or is meant to send a wire message, but a specific branch of that path (a reject, a late failure) returns without ever calling the corresponding "turn this into a library-visible outcome" step. The fix pattern established by bug-1479 (ha_send_wal_ack_reply_ex, plus its XCE_TESTING send-hook for testability) is reused verbatim for the two new fixes rather than inventing a new mechanism.
Integration points that must be maintained: the ha_dispatch_gate opcode allowlist (unaffected — no new opcode), the five-callback contract (unaffected — no callback signature changes), the two-tier replication model (unaffected — this feature only makes existing tiers report failure explicitly).
Technical debt addressed: three silent-drop-on-reject sites (WAL catch-up — done pre-feature; config-push — this feature) and one silent-drop-on-late-failure site (vote/PreVote post-connect). Technical debt formally reconciled rather than removed: the dormant nextIndex-driven AppendEntries sender (documented, not deleted — see Decision below).
Architecture Pattern & Boundary Map
graph TB
subgraph Library
RaftLib[Raft library raft.c]
end
subgraph Adapter
HaC[ha.c callbacks and dispatch]
ReplC[repl.c WAL replication]
ConfigReplC[config_repl.c config push]
end
subgraph Wire
WalAck[WalAckReply term lsn success]
ConfigPushReply[ReplConfigPushReply accepted versionTag term]
VoteReply[RequestVoteReply and PreVoteReply]
end
subgraph Peer
Standby[Standby controller]
Primary[Primary controller]
end
RaftLib --> HaC
HaC --> ReplC
HaC --> ConfigReplC
ReplC --> WalAck
ConfigReplC --> ConfigPushReply
HaC --> VoteReply
WalAck --> Primary
ConfigPushReply --> Primary
VoteReply --> Primary
Standby --> WalAck
Standby --> ConfigPushReply
Standby --> VoteReply
Primary --> RaftLib
Architecture Integration:
- Selected pattern: extend-in-place (see
research.mdArchitecture Pattern Evaluation) — reply-integrity fixes land inside the existing functions that already own each reply, no new cross-cutting abstraction introduced. - Domain boundaries:
ha.cowns election/vote wiring and top-level dispatch;repl.cowns WAL replication send/ack;config_repl.cowns out-of-band configuration push/ack — unchanged by this feature. - Existing patterns preserved: the
XCE_TESTINGsend-hook pattern (ha_wal_ack_send_hook), theraft_on_higher_term_observedsingle term-adoption entry point, thevote_reply_dispatch/pre_vote_reply_dispatchproduction tally paths. - New components: none introduce a new boundary; all four code changes (config-push reply, config-push term field, vote endpoint handler split, CLAUDE.md conformance record) extend existing ownership.
- Steering compliance: preserves the async-I/O thread-role contract (all changes are in already-non-blocking callback/handler code, no new blocking calls introduced) and the "controller never self-terminates" invariant (no new terminal paths added).
Decision: dormant AppendEntries sender resolution (Requirement 3)
No architecture change. raft_send_append_entries/ha_cb_send_append_entries_stub remains in the codebase, unused in production, and is now explicitly documented as such (see research.md Design Decisions for the full rationale). repl_broadcast + ha_stream_wal_to_standby remain the sole production AppendEntries-equivalent send/retry/backoff mechanism. Per Requirement 3.3 (amended after design review to permit this third resolution alongside retire/wire-in), this documentation-only resolution must additionally be backed by a non-regression guard: an XCE_TESTING-only call-counter on ha_cb_send_append_entries_stub (mirroring the ha_wal_ack_send_hook idiom) exercised by a test that drives the production heartbeat/replication path end-to-end and asserts the counter stays at zero while confirming repl_broadcast/ha_stream_wal_to_standby did fire — so a future change that accidentally starts calling this path is caught, not silently accepted. This adds one small ha.c/test diff beyond the pure documentation change described above (see File Structure Plan and Testing Strategy).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Wire serialization | Cap'n Proto (existing c-capnproto) |
ReplConfigPushReply gains an additive term @2: UInt64 field |
Regenerated automatically by the existing capnp compile Meson custom_target (src/lib/meson.build:1-10); no build/Nix change needed (see research.md) |
| Controller adapter | C (gnu17), existing src/controller/ |
All behavior changes | No new dependency |
No other stack layer is touched.
File Structure Plan
This is a small, surgical extension of an existing subsystem — files are listed individually rather than by directory pattern.
Modified Files
src/schema/controller.capnp— addterm @2: UInt64 $C.mapname("term")tostruct ReplConfigPushReply(Requirement 7.4).src/controller/config_repl.c—handle_repl_config_push: construct and sendReplConfigPushReply{accepted=0, term}on each of its four reject early-returns (malformed request, missing bytes/sha256, sha256 mismatch,conf_save_configfailure); populatetermon the existing success reply too; preserve the existing-EAGAIN-from-config_activate_committedbehavior unchanged (still sendsaccepted=1— activation-deferred is not a rejection). Add anXCE_TESTING-only send-hook (e.g.ha_config_push_reply_send_hook), mirroringha_wal_ack_send_hook(ha.c:3085-3086), so tests can observe the reply payload without a live endpoint. (Requirements 2.2, 2.3, 2.5, 7.4, 7.5)src/controller/cluster.c— the existing primary-sideReplConfigPushReplyhandler (myreq_handler,cluster.c:745-762) already branches oncack->accepted(ACKs viarepl_on_config_ackon true, WARN-and-ignores on false — the false branch has simply never fired in production before this feature's fix). Add a term check alongside the existingacceptedcheck: whencack->termexceeds the primary's current term, callraft_on_higher_term_observed, the same single adoption entry point the heartbeat-reply and WAL-NACK vectors already use. (Requirement 7.4, 7.5)src/controller/ha.c:- Split
vote_ep_err_handlerintovote_ep_err_handler(real-vote endpoints, registered at the existingha.c:688-689call site) andpre_vote_ep_err_handler(PreVote endpoints, registered at the existingha.c:827-828call site), each callingvote_reply_dispatchorpre_vote_reply_dispatchrespectively for the election-phase branch instead of the legacyhandle_request_vote_reply. Extract the identicalCTRL_PRIMARYcleanup branch (standby-endpoint removal,repl_remove_standby, quorum-transition logging) into a shared static helper called by both. (Requirements 4.2, 4.3, 4.4) - Extend the
on_send_append_entriesdoc-comment atha.c:2520-2532to state plainly that this callback is reachable only viaraft_send_append_entries, which has no production caller, and point to the CLAUDE.md conformance record for the authoritative production mechanism. Add anXCE_TESTING-only call counter toha_cb_send_append_entries_stub(e.g.ha_send_append_entries_stub_call_count) as the non-regression guard the amended Requirement 3.3 requires. (Requirement 3.3)
- Split
src/controller/CLAUDE.md— extend the "TLA+ Action-to-Code Mapping" table with corrected/added rows forAppendEntriesreply integrity (WAL catch-up and config-push), vote/PreVote failure routing, and the dormant-sender resolution; extend the "Protocol Verification Checklist" with a checklist item for "every reject path sends an explicit reply." (Requirement 1.1-1.6).kiro/specs/ha-term-safety/spec.json— append a one-line pointer on the existingknown_limitationsentry (index 30, the dormanton_send_append_entriesnote) referencing this spec's resolution, so a future reader lands on the authoritative decision. (Requirement 1.2, 1.6)
New Test Additions (existing files extended, no new test files needed)
src/controller/testsuite/test_config_repl.c— new tests: each of the four config-push reject paths sendsaccepted=0with the current term; the-EAGAINactivation-defer path still sendsaccepted=1; a higher term in an inbound config-push reply triggers adoption. (Requirements 2.2, 2.3, 2.5, 7.4, 7.5)src/controller/testsuite/test_ha_vote_send.c— new tests: a post-connect endpoint failure on a real-vote endpoint reachesvote_reply_dispatch's tally; the same on a PreVote endpoint reachespre_vote_reply_dispatch's tally; reuses/extendsvote_ep_ctx_build_for_test(ha.c:7337) as the test-construction seam. (Requirements 4.2, 4.3, 4.5)src/controller/testsuite/test_ha_core_safety.c— new test assertingha_send_wal_ack_reply_ex(success=0)is actually invoked (viaha_wal_ack_send_hook) on a WAL catch-up reject, closing the reply-construction-boundary coverage gap found during design discovery. (Requirement 2.1, regression-lock)src/controller/testsuite/test_commit_barrier_reply.cortest_commit_barrier_rollout.c— new test: one failed payload in a submitted batch propagates failure to every payload in that batch. (Requirement 5.3)
Unmodified, Regression-Locked Files
These files back the four already-conformant components (ClientLogAppendGuarantee, CommitSourceGuarantee, TermDiscoveryCompleteness, RestartRecoveryGuarantee). No source changes; cited here so each component in the table above resolves to a concrete file, and so a future diff touching them is recognized as revalidation-worthy rather than incidental.
src/controller/persist.c—ha_primary_log_append,persist_submit_job_payloads_async(Requirement 5); confirmed conformant, existing tests intest_commit_barrier_rollout.c/test_commit_barrier_reply.c/test_term_safety_adapter.ccover it (plus the one new batch-propagation test above).src/controller/commit.c—majority_candidate_locked,commit_set_raft_commit_index(Requirement 6); confirmed conformant,test_term_safety_adapter.c:646-780already pins both directions of the advisory-vs-authoritative split, including theXCE_TESTINGassert atcommit.c:222.src/controller/ha.c— heartbeat-reply and WAL-NACK term-adoption call sites (Requirement 7.1, 7.2),ha_sync_applied_lsn_from_recovery/ha_sync_raft_log_from_recovery/phase2 retry helper/JIT log-length resync (Requirement 8); confirmed conformant, existing coverage intest_persist_recovery.c/test_config_repl.c.
No files are created; no files outside src/controller/ (and its one schema dependency) are touched. src/lib/, src/frontend/, src/worker/, src/scheduler/ are untouched.
System Flows
Configuration-push reject reply (Requirement 2, 7)
sequenceDiagram
participant Primary
participant Standby
Primary->>Standby: ReplConfigPushRequest bytes sha256 versionTag
alt Malformed or missing fields
Standby-->>Primary: ReplConfigPushReply accepted false term
else SHA-256 mismatch
Standby-->>Primary: ReplConfigPushReply accepted false term
else conf_save_config fails
Standby-->>Primary: ReplConfigPushReply accepted false term
else Save succeeds, activation deferred EAGAIN
Standby-->>Primary: ReplConfigPushReply accepted true term
Note over Standby: Activation retried later on commit advance
else Save and activation succeed
Standby-->>Primary: ReplConfigPushReply accepted true term
end
Primary->>Primary: distinguish reject from timeout, adopt term if higher
Key decision not obvious from the diagram: accepted means "bytes saved out-of-band," not "activated" — the activation-deferred branch and the fully-succeeded branch both reply accepted=true by design, since activation is gated separately on the committed marker (ha-term-safety) and is expected to retry.
Vote/PreVote endpoint failure routing (Requirement 4)
sequenceDiagram
participant Candidate
participant Peer
Candidate->>Peer: connect and monitor
alt Connect or monitor fails
Candidate->>Candidate: vote_reply_dispatch or pre_vote_reply_dispatch denied
else Connect succeeds, later RDHUP or SSL error before reply
Candidate->>Candidate: vote_ep_err_handler or pre_vote_ep_err_handler
Candidate->>Candidate: vote_reply_dispatch or pre_vote_reply_dispatch denied
else Peer replies before any failure
Peer-->>Candidate: RequestVoteReply or PreVoteReply
Candidate->>Candidate: vote_reply_dispatch or pre_vote_reply_dispatch
end
Candidate->>Candidate: fast quorum decision once every peer outcome is counted
Key decision not obvious from the diagram: today only the "connect or monitor fails" and "peer replies" branches reach the production tally; this feature adds the "later RDHUP" branch to the same tally instead of the legacy no-op-counting path.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | Action-by-action mapping produced | ConformanceRecord | CLAUDE.md table | — |
| 1.2 | Live-vs-dormant path recorded on drift | ConformanceRecord | CLAUDE.md table, ha-term-safety spec.json pointer | Dormant sender decision |
| 1.3 | Gap detail sufficient to re-verify | ConformanceRecord | design.md + research.md | — |
| 1.4 | Fix vs documentation-drift classification | ConformanceRecord | design.md Decisions | — |
| 1.5 | Code-fix findings delivered under Req 2-8 | ConformanceRecord | (cross-reference) | — |
| 1.6 | Documentation-drift findings recorded for reconciliation | ConformanceRecord | ha-term-safety spec.json pointer | Dormant sender decision |
| 2.1 | WAL catch-up reject reply (regression-lock) | WalCatchupReplyIntegrity (existing) | ha_send_wal_ack_reply_ex, ha_wal_ack_send_hook |
— |
| 2.2 | Config-push reject reply (new) | ConfigPushReplyIntegrity | ReplConfigPushReply{accepted=0} |
Config-push reject flow |
| 2.3 | Reject distinguishable from transport loss | ConfigPushReplyIntegrity | ReplConfigPushReply vs no reply/timeout |
Config-push reject flow |
| 2.4 | Unknown outcome not inferred as success | ConfigPushReplyIntegrity, WalCatchupReplyIntegrity | timeout handling (existing) | — |
| 2.5 | No regression to success-reply behavior | ConfigPushReplyIntegrity, WalCatchupReplyIntegrity | existing accepted=1 / success=1 paths |
Config-push reject flow |
| 3.1 | Retry on rejection | Existing (ha_raft_on_repl_nack, repl_start_catchup) |
— | — |
| 3.2 | Multi-round convergence | Existing (catch-up subsystem) | — | — |
| 3.3 | Single authoritative send mechanism | ConformanceRecord (documentation decision) | CLAUDE.md table, ha.c:2520 doc-comment |
Dormant sender decision |
| 3.4 | Transport-level send failure not treated as caught-up | Existing (repl_broadcast, heartbeat re-check) |
— | — |
| 4.1 | Connect-time failure counted (regression-lock) | VoteEndpointFailureRouter (existing behavior) | vote_reply_dispatch, pre_vote_reply_dispatch |
Vote failure routing |
| 4.2 | Post-connect failure counted (new) | VoteEndpointFailureRouter | vote_ep_err_handler, pre_vote_ep_err_handler |
Vote failure routing |
| 4.3 | Same behavior for PreVote | VoteEndpointFailureRouter | pre_vote_ep_err_handler |
Vote failure routing |
| 4.4 | No regression to connect/monitor routing | VoteEndpointFailureRouter | shared CTRL_PRIMARY cleanup helper |
Vote failure routing |
| 4.5 | Fast quorum decision reachable | VoteEndpointFailureRouter | tally functions (existing) | Vote failure routing |
| 5.1 | Append persists or fails explicitly | ClientLogAppendGuarantee (existing) | raft_log_append, ha_primary_log_append |
— |
| 5.2 | No log-length advance on failed persist | ClientLogAppendGuarantee | raft_log_append |
— |
| 5.3 | Batch failure propagates to whole batch | ClientLogAppendGuarantee | persist_submit_job_payloads_async |
— |
| 5.4 | No regression to failure propagation | ClientLogAppendGuarantee | existing tests | — |
| 6.1 | Durable-majority sole authority | CommitSourceGuarantee (existing) | majority_candidate_locked |
— |
| 6.2 | Advisory index informational only | CommitSourceGuarantee | commit_set_raft_commit_index |
— |
| 6.3 | No hard-ceiling reintroduction | CommitSourceGuarantee | commit.c:139-155 invariant |
— |
| 6.4 | Advance proceeds despite lagging advisory index | CommitSourceGuarantee | existing XCE_TESTING assert |
— |
| 7.1 | Heartbeat-reply term adoption (regression-lock) | TermDiscoveryCompleteness (existing) | raft_on_higher_term_observed |
— |
| 7.2 | WAL-NACK term adoption (regression-lock) | TermDiscoveryCompleteness (existing) | raft_on_higher_term_observed |
— |
| 7.3 | Vote/PreVote reply term adoption (regression-lock) | TermDiscoveryCompleteness (existing) | raft_on_vote_reply Step 1 |
— |
| 7.4 | Config-push reply carries term (new) | ConfigPushReplyIntegrity | ReplConfigPushReply.term |
Config-push reject flow |
| 7.5 | No regression to existing term-adoption vectors | TermDiscoveryCompleteness, ConfigPushReplyIntegrity | — | Config-push reject flow |
| 8.1 | Recovery raises applied LSN/log length/term | RestartRecoveryGuarantee (existing) | ha_sync_applied_lsn_from_recovery, ha_sync_raft_log_from_recovery |
— |
| 8.2 | Failed recovery step retries, no false-latch | RestartRecoveryGuarantee | phase2 retry helper (ha.c:415-501) |
— |
| 8.3 | JIT log-length resync beyond boot | RestartRecoveryGuarantee | ha_apply_term_entry_via_library resync |
— |
| 8.4 | No regression to clean-failover convergence | RestartRecoveryGuarantee | existing tests | — |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| ConfigPushReplyIntegrity | Controller/Replication | Send an explicit accept/reject reply, carrying term, for every configuration push | 2.2, 2.3, 2.5, 7.4, 7.5 | ReplConfigPushReply schema (P0) |
Event |
| WalCatchupReplyIntegrity | Controller/Replication | Existing bug-1479 fix; regression-locked, not modified | 2.1, 2.4, 2.5 | WalAckReply schema (P0, existing) |
Event |
| VoteEndpointFailureRouter | Controller/Election | Route every vote/PreVote endpoint failure, at any timing, to the production tally | 4.1-4.5 | vote_reply_dispatch (P0), pre_vote_reply_dispatch (P0), vote_ep_ctx_build_for_test (P1, test-only) |
Service |
| ConformanceRecord | Documentation | Durable action-by-action mapping and dormant-path resolution | 1.1-1.6, 3.3 | CLAUDE.md, ha-term-safety/spec.json |
State |
| ClientLogAppendGuarantee | Controller/Persistence | Existing conformant append-or-explicit-failure behavior; regression-locked | 5.1-5.4 | persist.c (existing) |
State |
| CommitSourceGuarantee | Controller/Commit | Existing conformant durable-majority-authoritative behavior; regression-locked | 6.1-6.4 | commit.c (existing) |
State |
| TermDiscoveryCompleteness | Controller/Election | Existing conformant term-adoption vectors (heartbeat, WAL-NACK, vote reply); regression-locked | 7.1-7.3, 7.5 | raft_on_higher_term_observed (existing) |
Event |
| RestartRecoveryGuarantee | Controller/Recovery | Existing conformant restart/recovery reconcile; regression-locked | 8.1-8.4 | ha.c recovery functions (existing) |
State |
Only the two components introducing new code (ConfigPushReplyIntegrity, VoteEndpointFailureRouter) warrant full detail blocks below; the regression-lock components need no new interface — their existing interfaces are cited in the table above and exercised by the tests in File Structure Plan.
Controller / Replication
ConfigPushReplyIntegrity
| Field | Detail |
|---|---|
| Intent | Every configuration-push outcome (accept or reject) produces an explicit ReplConfigPushReply carrying the replying member's current term |
| Requirements | 2.2, 2.3, 2.5, 7.4, 7.5 |
Responsibilities & Constraints
- Owns the reply-construction boundary inside
handle_repl_config_push; does not own configuration activation (config_activate_committedremains the sole activation gate, unchanged). - Must not conflate an activation-deferred outcome (
-EAGAIN) with a rejection — that path continues to replyaccepted=1. - Must include the current term on every reply, success or failure.
Dependencies
- Outbound:
ReplConfigPushReplyschema field (P0) — the additivetermfield this component populates. - Outbound:
raft_on_higher_term_observed(P0) — invoked by the primary's inbound-reply handler when this component's reply carries a higher term (existing entry point, no change to it). - External: none.
Contracts: Event [x]
Event Contract
- Published:
ReplConfigPushReply {accepted: bool, versionTag: uint64, term: uint64}— one perReplConfigPushRequestreceived, always sent (never silently dropped). - Subscribed: none (this component only replies).
- Ordering/delivery: request/reply pair over the existing outbound control channel (
ha-async-io.mdconnection ownership); no new ordering guarantee introduced.
Implementation Notes
- Integration: reuses the existing
xce_reply/init_reply_by_reqpattern already present inhandle_repl_config_push's success path; the four reject branches gain the same call shape. - Validation:
XCE_TESTING-only send-hook (mirroringha_wal_ack_send_hook) lets unit tests assert the reply payload without a live endpoint. - Risks: none material — narrow, single-function change with a directly analogous shipped precedent (bug-1479).
Controller / Election
VoteEndpointFailureRouter
| Field | Detail |
|---|---|
| Intent | Every vote/PreVote endpoint failure, regardless of when in the round it occurs, is counted through the same production tally as a delivered denial |
| Requirements | 4.1-4.5 |
Responsibilities & Constraints
- Owns endpoint-failure handling for outgoing vote and PreVote connections (
on_errcallbacks); does not own the tally logic itself (vote_reply_dispatch/pre_vote_reply_dispatchare consumed, not modified). - Must preserve the existing
CTRL_PRIMARYcleanup behavior (standby-endpoint deregistration, quorum-transition logging) identically for both endpoint kinds. - Must not introduce a new discriminant field on
vote_ep_ctx_t; the split-handler registration already distinguishes endpoint kind by call site.
Dependencies
- Outbound:
vote_reply_dispatch(P0) — real-vote tally, already correct for connect/monitor failures. - Outbound:
pre_vote_reply_dispatch(P0) — PreVote tally, already correct for connect/monitor failures. - Outbound:
vote_ep_ctx_build_for_test(P1, test-only) — reused/extended as the unit-test construction seam. - Inbound: iomgr
on_errcallback invocation (existing, unchanged trigger).
Contracts: Service [x]
Service Interface
/* vote_ep_err_handler: on_err callback for outgoing real-vote endpoints.
* Election-phase branch now counts a post-connect failure through
* vote_reply_dispatch instead of handle_request_vote_reply. */
static int vote_ep_err_handler(void *ep_raw);
/* pre_vote_ep_err_handler: on_err callback for outgoing PreVote endpoints.
* Election-phase branch counts a post-connect failure through
* pre_vote_reply_dispatch. */
static int pre_vote_ep_err_handler(void *ep_raw);
/* shared helper: identical CTRL_PRIMARY cleanup extracted from both. */
static void vote_ep_handle_promoted_disconnect(ha_state_t *ha, endpoint_t *ep,
vote_ep_ctx_t *vctx);
- Preconditions:
ep->datais a populatedvote_ep_ctx_tfor the endpoint kind the handler is registered against. - Postconditions: exactly one outcome (denied/counted) reaches the applicable tally function per invocation;
CTRL_PRIMARY-role invocations perform standby cleanup identically to today. - Invariants: a candidate's own term is never mistaken for an observed peer term through this path (the synthesized outcome carries the candidate's own term,
granted=0— unchanged from today's behavior).
Implementation Notes
- Integration: registration sites (
ha.c:688-689,827-828) each point at the correspondingly-named handler; no change to endpoint setup beyond the callback pointer. - Validation: new unit tests per File Structure Plan, using the reused test-construction helper to simulate a post-connect failure without a live peer.
- Risks: touches live election-path code — mitigated by keeping the extracted
CTRL_PRIMARYbranch byte-for-byte identical and requiring the new tests before this task is considered done (seeresearch.mdRisks & Mitigations).
Data Models
Data Contracts & Integration
ReplConfigPushReply (Cap'n Proto, src/schema/controller.capnp)
| Field | Ordinal | Type | Change |
|---|---|---|---|
accepted |
0 | Bool | unchanged |
versionTag |
1 | UInt64 | unchanged |
term |
2 | UInt64 | new, additive |
Backward/forward compatibility: Cap'n Proto additive fields are safe under mixed-version rolling upgrade — an old-code standby's reply (no term field) decodes as term=0 on a new-code primary. This is safe by construction: 0 can never present as a term higher than the primary's own current term (terms start at 1 and only increase), so no spurious adoption occurs; the primary simply doesn't get early term-discovery from an old-code peer's config-push replies, which is exactly today's (pre-feature) behavior for that peer. No explicit migration phasing is required beyond the existing rolling-upgrade tolerance the cluster already relies on.
WalAckReply is unchanged (already carries term, lsn, success); listed here only to confirm no modification is needed.
Error Handling
Error Strategy
This feature's domain is internal cluster wire-protocol reply integrity, not user-facing request handling — the applicable categories are protocol-rejection, transport-failure, and validation-failure, each with a distinct, already-established response pattern that this feature extends consistently.
Error Categories and Responses
- Validation/protocol rejection (malformed config-push request, SHA-256 mismatch,
conf_save_configfailure): explicit reject reply (accepted=0) carrying term, so the primary can distinguish "actively rejected" from "lost" and react (retry, log, or escalate) — this is the category this feature's fixes target. - Transport failure (connection drop, send failure, RDHUP): counted as a failure outcome through the same production tally/reply path used for a delivered rejection wherever the protocol model requires an outcome (vote/PreVote tally); where no reply is architecturally expected (snapshot chunk transfer, by explicit design — see requirements.md Boundary Context), recovery remains via the next heartbeat cycle, unchanged by this feature.
- Activation-deferred, not an error (
config_activate_committedreturning-EAGAIN): explicitly not classified as a rejection; the existing loud-fence-and-retry pattern (steeringha-async-io.md, "controller never self-terminates") already governs it and is preserved unchanged.
Monitoring
No new metrics/logging channels introduced; existing WARN/INFO logging in handle_repl_config_push and vote_ep_err_handler is preserved and extended with the same log level conventions already used in each function.
Testing Strategy
- Unit Tests:
handle_repl_config_pushsendsReplConfigPushReply{accepted=0, term}for each of the four reject conditions (malformed request, missing bytes/sha256, sha256 mismatch, save failure) — via the newXCE_TESTINGsend-hook. (2.2, 2.3, 2.5)handle_repl_config_pushstill sendsaccepted=1whenconfig_activate_committedreturns-EAGAIN(activation-deferred is not a rejection). (2.5, 7.5)ha_send_wal_ack_reply_ex(success=0)is actually invoked on a WAL catch-up reject (closing the reply-construction-boundary coverage gap identified during design discovery). (2.1)vote_ep_err_handlerroutes a synthetic post-connect failure throughvote_reply_dispatch's tally, using the reused test-construction helper. (4.2, 4.4)pre_vote_ep_err_handlerroutes the same failure shape throughpre_vote_reply_dispatch's tally. (4.3)- A submitted batch with one failing payload propagates that failure to every payload in the batch. (5.3)
- Driving the production heartbeat/replication path end-to-end never increments
ha_send_append_entries_stub_call_count, whilerepl_broadcast/ha_stream_wal_to_standbydo fire for the same scenario — the Requirement 3.3 non-regression guard. (3.3)
- Integration Tests:
- A three-node cluster with one standby configured to reject a pushed configuration (bad hash) observes the primary correctly distinguishing the rejection from a timeout and not treating the push as silently lost.
- A three-node election round where one peer's vote endpoint fails after connect (simulated) still reaches a quorum decision without waiting for the full election timeout.
- Existing
test_term_safety_adapter.cadvisory-vs-authoritative commit tests re-run unmodified as the Requirement 6 regression lock (no new test needed, confirmed sufficient during design discovery). - Existing
test_persist_recovery.c/test_config_repl.crecovery tests re-run unmodified as the Requirement 8 regression lock.
- Live/Soak Tests (per project convention for HA changes,
xce-test-haskill):- A rolling chaotic-failover soak where the test driver issues a configuration push whose payload deliberately does not match the sha256 it advertises (no special fault-injection hook needed — this exercises
handle_repl_config_push's real SHA-256-mismatch reject branch), confirming the primary observes the explicit reject rather than degrading to the timeout path. - A soak round exercising the post-connect vote/PreVote endpoint failure path end-to-end. Open question for the tasks phase: unlike item 1, this needs a way to sever an already-established outbound vote connection before a reply arrives (e.g. a firewall-rule toggle or proxy in the soak harness) — no existing
xce-test-hamechanism for this was confirmed during design. If no such mechanism exists cheaply, this item may be deferred; the unit tests (Requirement 4.2/4.3, viavote_ep_ctx_build_for_test) already give direct, deterministic coverage of the fix itself.
- A rolling chaotic-failover soak where the test driver issues a configuration push whose payload deliberately does not match the sha256 it advertises (no special fault-injection hook needed — this exercises
Supporting References
research.md— full gap analysis (asset map, options, effort/risk), design-phase discovery log, and the two recorded design decisions (dormant sender resolution, vote-handler split) with full rationale.src/controller/CLAUDE.md— "TLA+ Action-to-Code Mapping" and "Protocol Verification Checklist," extended by this feature as the durable conformance record (Requirement 1).