Skip to content

Commit 48d30d0

Browse files
committed
feat(cluster): propose Calvin cross-shard verdict on complete vote tally
Add a `SequencerEntry::Verdict` entry that the sequencer leader proposes once every participant has voted on a staged cross-shard transaction, and apply it on all replicas to store the authoritative commit/abort decision in the completion registry. The registry now emits a dedup-guarded `(txn, commit)` signal over a channel wired from `CalvinCompletionRegistry` through to `SequencerService`, which the leader-guarded arm of its select loop turns into the proposal. `CalvinCompletionRegistry::new` now takes the verdict channel sender; a `new_detached` constructor is added for callers and tests that don't drive verdict proposal. The stored verdict is observed-only for now — nothing yet reads it to change flush/drop behavior.
1 parent 8c3a49d commit 48d30d0

8 files changed

Lines changed: 283 additions & 30 deletions

File tree

nodedb-cluster-tests/tests/cluster_common/calvin_test_node.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,18 @@ impl CalvinTestNode {
159159
.unwrap_or_else(|p| p.into_inner())
160160
.next_epoch();
161161

162+
// Pair the registry's verdict signal channel with the service's
163+
// receiver so a completed vote tally would reach the leader's propose
164+
// arm (inert for now — nothing reads the verdict).
165+
let (verdict_tx, verdict_rx) = tokio::sync::mpsc::channel(512);
162166
let mut service = SequencerService::new(
163167
config,
164168
self.node_id,
165169
self.multi_raft.clone(),
166170
inbox_receiver,
167171
starting_epoch,
168-
CalvinCompletionRegistry::new(),
172+
CalvinCompletionRegistry::new(verdict_tx),
173+
verdict_rx,
169174
);
170175

171176
let metrics = service.metrics.clone();
@@ -286,7 +291,7 @@ async fn spawn_one_calvin_node(
286291
let sm_metrics = StateMachineMetrics::new();
287292
let state_machine = Arc::new(Mutex::new(SequencerStateMachine::new(
288293
HashMap::new(),
289-
CalvinCompletionRegistry::new(),
294+
CalvinCompletionRegistry::new_detached(),
290295
)));
291296
let applier = CalvinApplier::new(state_machine.clone());
292297

nodedb-cluster/src/calvin/completion.rs

Lines changed: 146 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use std::collections::{BTreeMap, BTreeSet};
44
use std::sync::{Arc, Mutex};
55

6-
use tokio::sync::oneshot;
6+
use tokio::sync::{mpsc, oneshot};
77

88
/// Calvin transaction identity in the sequencer-assigned coordinate space.
99
///
@@ -55,6 +55,14 @@ struct PendingCompletion {
5555
/// tally to change flush/drop behavior yet (that is a follow-up); the
5656
/// leader's local decision still drives.
5757
votes: BTreeMap<u32, bool>,
58+
/// The authoritative commit/abort verdict once applied from a replicated
59+
/// `SequencerEntry::Verdict`. `None` until the leader's verdict is applied.
60+
/// Currently observed-only: nothing reads this to change flush/drop yet.
61+
verdict: Option<bool>,
62+
/// Dedup guard: set the first time the vote tally becomes complete so the
63+
/// verdict signal is emitted exactly once, even if a retry re-proposes a
64+
/// vote and re-tallies afterwards.
65+
verdict_proposed: bool,
5866
}
5967

6068
impl PendingCompletion {
@@ -66,6 +74,8 @@ impl PendingCompletion {
6674
mismatched: false,
6775
routing_failed: None,
6876
votes: BTreeMap::new(),
77+
verdict: None,
78+
verdict_proposed: false,
6979
}
7080
}
7181

@@ -86,14 +96,33 @@ struct Inner {
8696
completions: BTreeMap<TxnId, PendingCompletion>,
8797
}
8898

89-
#[derive(Default)]
9099
pub struct CalvinCompletionRegistry {
91100
inner: Mutex<Inner>,
101+
/// Emits `(txn, commit)` exactly once when a staged cross-shard txn's vote
102+
/// tally becomes complete (all expected participants voted). The paired
103+
/// receiver lives in the `SequencerService`, whose leader-guarded arm turns
104+
/// the signal into a `SequencerEntry::Verdict` proposal.
105+
verdict_tx: mpsc::Sender<(TxnId, bool)>,
92106
}
93107

94108
impl CalvinCompletionRegistry {
95-
pub fn new() -> Arc<Self> {
96-
Arc::new(Self::default())
109+
/// Construct a registry wired to a verdict signal channel. The paired
110+
/// receiver must be handed to the `SequencerService` on this node so the
111+
/// leader can propose the aggregated verdict.
112+
pub fn new(verdict_tx: mpsc::Sender<(TxnId, bool)>) -> Arc<Self> {
113+
Arc::new(Self {
114+
inner: Mutex::new(Inner::default()),
115+
verdict_tx,
116+
})
117+
}
118+
119+
/// Construct a registry with no verdict consumer: the signal channel is
120+
/// created internally and its receiver dropped, so vote-complete transitions
121+
/// are still computed and stored but never delivered to a sequencer service.
122+
/// For callers (and tests) that do not drive verdict proposal.
123+
pub fn new_detached() -> Arc<Self> {
124+
let (verdict_tx, _verdict_rx) = mpsc::channel(1);
125+
Self::new(verdict_tx)
97126
}
98127

99128
pub fn register_submission(&self, inbox_seq: u64) -> oneshot::Receiver<(u64, u32, usize)> {
@@ -280,6 +309,70 @@ impl CalvinCompletionRegistry {
280309
.entry(txn_id)
281310
.or_insert_with(|| PendingCompletion::new(0));
282311
entry.votes.insert(vshard, commit);
312+
313+
// On the transition to a complete tally, emit the aggregated verdict
314+
// exactly once. `expected_participants` is seeded on the sequencer
315+
// leader (the participant count arrives with the assignment); a replica
316+
// whose registry never received that seed keeps `expected == 0` and
317+
// never emits here — correct, because only the leader proposes the
318+
// verdict. Deterministic all-replica seeding for failover is a
319+
// follow-up. The `verdict_proposed` guard dedups a re-tally caused by a
320+
// re-proposed vote on retry.
321+
if entry.expected_participants > 0
322+
&& entry.votes.len() == entry.expected_participants
323+
&& !entry.verdict_proposed
324+
{
325+
let commit_all = entry.votes.values().all(|&v| v);
326+
entry.verdict_proposed = true;
327+
// Non-blocking: a full channel drops the signal, mirroring how the
328+
// apply fan-out drops on backpressure. A dropped signal is a missed
329+
// proposal (the leader re-drives on the next tally that isn't
330+
// deduped), never lost state — the verdict is stored separately via
331+
// `note_verdict` when the leader's proposal is applied.
332+
let _ = self.verdict_tx.try_send((txn_id, commit_all));
333+
}
334+
}
335+
336+
/// Store the authoritative commit/abort verdict for `txn`, applied from a
337+
/// replicated `SequencerEntry::Verdict` on every replica.
338+
///
339+
/// Idempotent: re-applying the same verdict is a no-op. A verdict that
340+
/// differs from a previously stored one is a determinism bug (the tally is
341+
/// computed deterministically from replicated votes) — it is logged at
342+
/// `warn` and the latest value is stored. Currently observed-only: nothing
343+
/// reads the stored verdict to change flush/drop behavior yet.
344+
pub fn note_verdict(&self, txn: TxnId, commit: bool) {
345+
let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
346+
let entry = inner
347+
.completions
348+
.entry(txn)
349+
.or_insert_with(|| PendingCompletion::new(0));
350+
match entry.verdict {
351+
Some(existing) if existing != commit => {
352+
tracing::warn!(
353+
epoch = txn.epoch,
354+
position = txn.position,
355+
existing,
356+
proposed = commit,
357+
"calvin verdict differs from a previously applied one; \
358+
determinism bug — overwriting with the latest"
359+
);
360+
entry.verdict = Some(commit);
361+
}
362+
Some(_) => {}
363+
None => entry.verdict = Some(commit),
364+
}
365+
}
366+
367+
/// Test/inspection accessor: returns the stored commit/abort verdict for
368+
/// `txn_id`, or `None` if no verdict has been applied yet.
369+
pub fn verdict(&self, txn_id: TxnId) -> Option<bool> {
370+
self.inner
371+
.lock()
372+
.unwrap_or_else(|p| p.into_inner())
373+
.completions
374+
.get(&txn_id)
375+
.and_then(|entry| entry.verdict)
283376
}
284377

285378
/// Test/inspection accessor: returns the current per-vshard vote tally for
@@ -311,7 +404,7 @@ mod tests {
311404

312405
#[tokio::test]
313406
async fn completion_entry_removed_after_all_acks() {
314-
let reg = CalvinCompletionRegistry::new();
407+
let reg = CalvinCompletionRegistry::new_detached();
315408
let txn = TxnId::new(7, 0);
316409
reg.note_assigned(1, txn, 2);
317410
let rx = reg.register_completion(txn, 2);
@@ -330,7 +423,7 @@ mod tests {
330423

331424
#[tokio::test]
332425
async fn completion_entry_removed_when_register_arrives_after_acks() {
333-
let reg = CalvinCompletionRegistry::new();
426+
let reg = CalvinCompletionRegistry::new_detached();
334427
let txn = TxnId::new(9, 3);
335428
reg.note_assigned(1, txn, 2);
336429
reg.note_completion_ack(txn, 10);
@@ -350,7 +443,7 @@ mod tests {
350443

351444
#[tokio::test]
352445
async fn mismatch_arriving_before_register_fires_mismatch() {
353-
let reg = CalvinCompletionRegistry::new();
446+
let reg = CalvinCompletionRegistry::new_detached();
354447
let txn = TxnId::new(11, 1);
355448
reg.note_assigned(1, txn, 2);
356449
// Mismatch observed before the coordinator registers its waiter: the
@@ -369,7 +462,7 @@ mod tests {
369462

370463
#[tokio::test]
371464
async fn register_before_mismatch_fires_mismatch() {
372-
let reg = CalvinCompletionRegistry::new();
465+
let reg = CalvinCompletionRegistry::new_detached();
373466
let txn = TxnId::new(12, 5);
374467
reg.note_assigned(1, txn, 2);
375468
let rx = reg.register_completion(txn, 2);
@@ -391,7 +484,7 @@ mod tests {
391484
// register_completion must seed expected_participants from the assignment.
392485
// Without the seed (or with the is_complete>0 guard absent) this would
393486
// spuriously fire Completed with zero acks.
394-
let reg = CalvinCompletionRegistry::new();
487+
let reg = CalvinCompletionRegistry::new_detached();
395488
let txn = TxnId::new(21, 0);
396489
let rx = reg.register_completion(txn, 1);
397490
assert_eq!(
@@ -411,7 +504,7 @@ mod tests {
411504
// coordinator calls register_completion. With expected_participants still
412505
// unknown (0), the ack must persist without firing/evicting; the later
413506
// register_completion seeds the count and then completes.
414-
let reg = CalvinCompletionRegistry::new();
507+
let reg = CalvinCompletionRegistry::new_detached();
415508
let txn = TxnId::new(22, 0);
416509
reg.note_completion_ack(txn, 7);
417510
assert_eq!(
@@ -427,7 +520,7 @@ mod tests {
427520

428521
#[tokio::test]
429522
async fn routing_failed_arriving_before_register_fires_failed() {
430-
let reg = CalvinCompletionRegistry::new();
523+
let reg = CalvinCompletionRegistry::new_detached();
431524
let txn = TxnId::new(14, 1);
432525
reg.note_assigned(1, txn, 2);
433526
// Routing failure observed before the coordinator registers its
@@ -452,7 +545,7 @@ mod tests {
452545

453546
#[tokio::test]
454547
async fn register_before_routing_failed_fires_failed() {
455-
let reg = CalvinCompletionRegistry::new();
548+
let reg = CalvinCompletionRegistry::new_detached();
456549
let txn = TxnId::new(15, 4);
457550
reg.note_assigned(1, txn, 2);
458551
let rx = reg.register_completion(txn, 2);
@@ -475,7 +568,7 @@ mod tests {
475568

476569
#[tokio::test]
477570
async fn routing_failed_takes_precedence_over_pending_acks_and_mismatch() {
478-
let reg = CalvinCompletionRegistry::new();
571+
let reg = CalvinCompletionRegistry::new_detached();
479572
let txn = TxnId::new(16, 0);
480573
reg.note_assigned(1, txn, 2);
481574
// No waiter registered yet: an ack and a mismatch both persist onto
@@ -505,7 +598,7 @@ mod tests {
505598

506599
#[tokio::test]
507600
async fn note_vote_tallies_one_vote_per_vshard() {
508-
let reg = CalvinCompletionRegistry::new();
601+
let reg = CalvinCompletionRegistry::new_detached();
509602
let txn = TxnId::new(30, 0);
510603
reg.note_vote(txn, 1, true);
511604
reg.note_vote(txn, 2, false);
@@ -515,9 +608,47 @@ mod tests {
515608
assert_eq!(tally.get(&2), Some(&false));
516609
}
517610

611+
#[tokio::test]
612+
async fn complete_all_true_tally_emits_commit_verdict_once() {
613+
let (tx, mut rx) = mpsc::channel(8);
614+
let reg = CalvinCompletionRegistry::new(tx);
615+
let txn = TxnId::new(30, 1);
616+
// Seed the expected participant count the way the leader does.
617+
reg.note_assigned(1, txn, 2);
618+
619+
reg.note_vote(txn, 1, true);
620+
// First vote: tally incomplete (1 of 2), nothing emitted yet.
621+
assert!(rx.try_recv().is_err());
622+
623+
reg.note_vote(txn, 2, true);
624+
// Second vote completes the tally → commit verdict emitted exactly once.
625+
assert_eq!(rx.try_recv().expect("verdict emitted"), (txn, true));
626+
627+
// A re-proposed vote re-tallies but must NOT emit again (dedup).
628+
reg.note_vote(txn, 2, true);
629+
assert!(
630+
rx.try_recv().is_err(),
631+
"dedup: verdict must emit only on the first complete tally"
632+
);
633+
}
634+
635+
#[tokio::test]
636+
async fn complete_tally_with_one_abort_emits_abort_verdict() {
637+
let (tx, mut rx) = mpsc::channel(8);
638+
let reg = CalvinCompletionRegistry::new(tx);
639+
let txn = TxnId::new(31, 0);
640+
reg.note_assigned(1, txn, 2);
641+
642+
reg.note_vote(txn, 1, true);
643+
reg.note_vote(txn, 2, false);
644+
// Any abort vote makes the aggregated verdict false.
645+
assert_eq!(rx.try_recv().expect("verdict emitted"), (txn, false));
646+
assert!(rx.try_recv().is_err());
647+
}
648+
518649
#[tokio::test]
519650
async fn mismatch_takes_precedence_over_pending_acks() {
520-
let reg = CalvinCompletionRegistry::new();
651+
let reg = CalvinCompletionRegistry::new_detached();
521652
let txn = TxnId::new(13, 2);
522653
reg.note_assigned(1, txn, 2);
523654
let rx = reg.register_completion(txn, 2);

nodedb-cluster/src/calvin/sequencer/entry.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ pub enum SequencerEntry {
6969
vshard: u32,
7070
commit: bool,
7171
},
72+
/// The global commit/abort verdict for a staged cross-shard txn, proposed by
73+
/// the sequencer leader once every participant has voted (see `Vote`).
74+
/// `commit = all participant votes are true`. Every replica applies this to
75+
/// store the authoritative decision; participants later flush (commit) or drop
76+
/// (abort) their staged buffer on it.
77+
Verdict {
78+
epoch: u64,
79+
position: u32,
80+
commit: bool,
81+
},
7282
}
7383

7484
#[cfg(test)]
@@ -195,4 +205,16 @@ mod tests {
195205
let decoded: SequencerEntry = zerompk::from_msgpack(&bytes).expect("decode");
196206
assert_eq!(entry, decoded);
197207
}
208+
209+
#[test]
210+
fn verdict_msgpack_roundtrip() {
211+
let entry = SequencerEntry::Verdict {
212+
epoch: 5,
213+
position: 2,
214+
commit: false,
215+
};
216+
let bytes = zerompk::to_msgpack_vec(&entry).expect("encode");
217+
let decoded: SequencerEntry = zerompk::from_msgpack(&bytes).expect("decode");
218+
assert_eq!(entry, decoded);
219+
}
198220
}

0 commit comments

Comments
 (0)