feat(dlv): Class K drives a bundle to a consume-once decision over the generic register - #773
Merged
Merged
Conversation
…e generic register Rev 15 §6.8 / Def 6.21 / Req 6.22 / Theorem 18.1: the client-driven QuorumBind transaction, restored as the sans-IO decision engine the owner directed, plus the thin async runner that performs its operations. Consumes the Class N primitive from #772; the settle paths still call the one-shot register until PR 5. Two phases, because the Class N compare-and-exchange is a max-round CAS register that lets a strictly higher round overwrite a different value. A single accept phase is unsafe over it — a value on a quorum at a low round can be overwritten by a higher round whose proposer read before it landed, letting two bundles both become binding-final. Req 6.22 prescribes Paxos-style prepare/accept, so: - Learn: ReadBinding(K(B)) a quorum for the safe value (highest-round accepted) and any already-chosen value. - Promise: compare-exchange a PROMISED record at round 2*ballot. The member's monotonic-round refusal is the promise. - Accept: compare-exchange an ACCEPTED record at round 2*ballot+1. The phase rides in the round counter and `status` records it. A bundle owns every key or yields, so at most one bundle accepts on a shared key. COMMITTED is q distinct authenticated members holding this bundle's accepted record at the same round on every key; ABORTED only when nothing is chosen anywhere. The engine (dsm::dlv::quorum_bind) performs no I/O, sleeping, or timers: poll() emits ops, deliver_* folds authenticated answers, recover() opens the next ballot. The runner (dsm_sdk::sdk::quorum_bind_runner) is the only place timing lives, and authenticates every answer against BOTH the committed member id and the committed register incarnation (Req 15.8) before it counts. Safety is proven by driving two bundles through ALL 4096 interleavings of 12 single-operation steps against a fleet double that mirrors decide_compare_exchange exactly (binding-race and overlapping {A,B}/{B,C}), asserting never-two-committed, one-chosen-value, and non-vacuity. A mutation degenerating the engine to single-phase fires the double-commit assertion, so the control is real. Rust 1.98.0: workspace board 4001/0 (incl. the interleaving tests), make lint 0, production_safety_checks 0. The trader-parent fence and durable INDETERMINATE (PR 3), the HTTP transport (PR 4), and switching the settle paths (PR 5) are not in this change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 2 of the QuorumBind restoration (owner decision 2026-09-05: keep Rev-15's architecture, replace the one-shot register with conformant QuorumBind). This is the Class K decision engine — the sans-IO state machine that drives one bundle to a consume-once decision — plus the thin async runner that performs its operations. It consumes the Class N primitive PR #772 landed; the settle paths still call the old register (switched in PR 5).
Builds on
feat/class-n-generic-binding-primitive(#772) and is rebased ontomainonce that merges.Why two phases
The Class N compare-and-exchange is a max-round CAS register: a strictly higher round with a different value may overwrite what a quorum already holds. A single accept phase over such a register is not safe — a value on a quorum at a low round can be overwritten by a higher round whose proposer read before that value landed, which is exactly the state Paxos forbids and would let two bundles both become binding-final, violating Theorem 18.1. I built a single-phase version first and it had this hole; the exhaustive race test caught it.
Req 6.22 prescribes Paxos-style prepare/accept rounds, and this engine implements them over PR #772's exact primitive:
ReadBinding(K(B))a quorum to find the safe value (the value of the highest-round accepted record) and any already-chosen value.PROMISEDrecord at round2·ballot. The member's monotonic-round rule makes this the promise: a member holding a promise at ballotbrefuses every accept belowb.ACCEPTEDrecord at round2·ballot+1(which supersedes this ballot's own promise).The phase rides in the round counter (
2·ballotpromise,2·ballot+1accept), so one monotonic round expresses both andstatusrecords which. A bundle either owns every key or yields: the engine proposes only when no key's highest accepted value is a foreign bundle's, so at most one bundle accepts on a shared key.Sans-IO, by owner decision
The core (
dsm::dlv::quorum_bind) performs no networking, sleeping, retry timers, or async I/O. It emits the member operations to perform and folds authenticated answers;poll()returnsContact(ops)/Recovering(reason)/Done(outcome), and the caller feeds results back withdeliver_read/deliver_cas. All timing, backoff, and concurrency live in the runner. That is what lets the two-bundle races be driven at single-operation granularity under every interleaving against a deterministic fleet double, rather than forcing real Tokio scheduling.The runner (
dsm_sdk::sdk::quorum_bind_runner) is the only place timing lives: poll → perform each op through aBindingTransport→ authenticate every answer against both the committed member id and the committed register incarnation (Req 15.8) → deliver → back off and recover when stuck.BindingTransportis the async boundary (an ordinary async trait — the engine is sans-IO, the plumbing is async); the concrete HTTP client for/api/v2/storage/binding/{cas,read}arrives in PR 4.Safety, proven by exhaustive interleaving + a mutation control
binding-race/— two complete bundles over one shared parent, driven through all 4096 interleavings of 12 single-operation steps, then both driven home. Asserted: never twoCommitted, the committed value is exactly the one chosen value, and both bundles win under some schedules (non-vacuity — the race is real).overlap-liveness/—K(T1)={A,B}againstK(T2)={B,C}, both drivers active, all 4096 interleavings; never both commit; both can win.dsm_storage_node::db::binding::decide_compare_exchangeexactly (byte-identical replay; prior set digest must match; replacement round must strictly supersede).binding-racefail withboth bundles committedat a concrete schedule (0b…011). Restored on an identical tree. This proves the two-phase mechanism is load-bearing and that the test actually detects a double-commit rather than passing vacuously.Tests
dsm/tests/quorum_bind_conformance.rs, 10): begin refuses a non-strict-majorityqand a bad key set; a clean transaction commits at quorum;quorum-fixed(one down still commits, two down never commits — safety, not a timeout);quorum-client(an unattributed answer never counts); a chosen foreign value isConflictFinal; recovering our own completed transaction is an idempotentCommitted(Theorem 18.4); abort is safe only when nothing is chosen;binding-race;overlap-liveness;multi-vault-atomic(a commit covers every key).dsm_sdk, 2): drives a clean transaction toCommitted; an answer echoing the wrong register incarnation is not counted, so a sub-quorum of honest members reports the transaction unresolved rather than counting mis-attributed acks.Verification (Rust 1.98.0)
make lint— exit 0--workspace --exclude dsm_storage_node --release, incl. the 4096×2 interleaving tests) — 4001 passed, 0 failed (75 suites)production_safety_checks(all-features clippy + TLA+) — exit 0 (TLA+ included)Not in this PR (by design)
mutated()and the runner returnsUnresolved { mutated }for PR 3 to persist and fence.BindingTransportover the generic endpoints, with the SDK-side(member, incarnation)attribution wired to real echoes — PR 4.proposer_idis taken to be the Class K device id (stated assumption).