Skip to content

Commit 8c3a49d

Browse files
committed
feat(control): replicate per-participant Calvin commit votes via sequencer
Add a SequencerEntry::Vote entry so a data-group leader durably proposes its read-set validation outcome for a staged cross-shard txn through the Calvin sequencer Raft log, keyed by vshard so retries overwrite rather than double-count. CalvinCompletionRegistry tallies the votes per txn; the tally is observed-only for now and does not yet drive the commit verdict, which the local leader decision continues to determine.
1 parent c2217d1 commit 8c3a49d

5 files changed

Lines changed: 114 additions & 0 deletions

File tree

nodedb-cluster/src/calvin/completion.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ struct PendingCompletion {
4949
/// `mismatched` and completion: a routing failure is never retried and never
5050
/// falsely reported as success.
5151
routing_failed: Option<String>,
52+
/// Durable per-participant commit votes tallied from `SequencerEntry::Vote`.
53+
/// Keyed by vshard so a re-proposed vote (retry) overwrites deterministically
54+
/// rather than double-counting. Currently observed-only: nothing reads this
55+
/// tally to change flush/drop behavior yet (that is a follow-up); the
56+
/// leader's local decision still drives.
57+
votes: BTreeMap<u32, bool>,
5258
}
5359

5460
impl PendingCompletion {
@@ -59,6 +65,7 @@ impl PendingCompletion {
5965
completion_tx: None,
6066
mismatched: false,
6167
routing_failed: None,
68+
votes: BTreeMap::new(),
6269
}
6370
}
6471

@@ -258,6 +265,34 @@ impl CalvinCompletionRegistry {
258265
}
259266
}
260267

268+
/// Record one participant vshard's durable commit vote for `txn`, tallied
269+
/// from a replicated `SequencerEntry::Vote`.
270+
///
271+
/// This is observed-only: it accumulates the vote per `vshard` (last write
272+
/// wins, deterministic across re-proposals from retries) but does NOT
273+
/// compute a verdict or wake any waiter — the local flush/drop decision
274+
/// still drives. A follow-up aggregates the tally into the commit
275+
/// verdict.
276+
pub fn note_vote(&self, txn_id: TxnId, vshard: u32, commit: bool) {
277+
let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
278+
let entry = inner
279+
.completions
280+
.entry(txn_id)
281+
.or_insert_with(|| PendingCompletion::new(0));
282+
entry.votes.insert(vshard, commit);
283+
}
284+
285+
/// Test/inspection accessor: returns the current per-vshard vote tally for
286+
/// `txn_id`, or `None` if no entry (and therefore no votes) exist yet.
287+
pub fn vote_tally(&self, txn_id: TxnId) -> Option<BTreeMap<u32, bool>> {
288+
self.inner
289+
.lock()
290+
.unwrap_or_else(|p| p.into_inner())
291+
.completions
292+
.get(&txn_id)
293+
.map(|entry| entry.votes.clone())
294+
}
295+
261296
/// Test-only: returns the number of pending completion entries.
262297
/// Used to verify entries are removed once all acks arrive (no leak).
263298
#[cfg(test)]
@@ -468,6 +503,18 @@ mod tests {
468503
);
469504
}
470505

506+
#[tokio::test]
507+
async fn note_vote_tallies_one_vote_per_vshard() {
508+
let reg = CalvinCompletionRegistry::new();
509+
let txn = TxnId::new(30, 0);
510+
reg.note_vote(txn, 1, true);
511+
reg.note_vote(txn, 2, false);
512+
let tally = reg.vote_tally(txn).expect("entry created by note_vote");
513+
assert_eq!(tally.len(), 2);
514+
assert_eq!(tally.get(&1), Some(&true));
515+
assert_eq!(tally.get(&2), Some(&false));
516+
}
517+
471518
#[tokio::test]
472519
async fn mismatch_takes_precedence_over_pending_acks() {
473520
let reg = CalvinCompletionRegistry::new();

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ pub enum SequencerEntry {
5757
position: u32,
5858
detail: String,
5959
},
60+
/// One participant vShard's durable commit vote for a staged cross-shard txn.
61+
/// Generalizes `OllpMismatch` (an implicit vote=abort). Unlike
62+
/// `OllpMismatch`/`CompletionAck` which key only on `(epoch, position)`, `Vote`
63+
/// carries `vshard` because the verdict aggregator must attribute exactly one
64+
/// vote per participant to know when the tally is complete. `commit` is the
65+
/// participant's `read_set_valid` outcome (true = valid/commit, false = abort).
66+
Vote {
67+
epoch: u64,
68+
position: u32,
69+
vshard: u32,
70+
commit: bool,
71+
},
6072
}
6173

6274
#[cfg(test)]
@@ -170,4 +182,17 @@ mod tests {
170182
assert_eq!(position, 3);
171183
assert_eq!(detail, "unroutable plan: Vector");
172184
}
185+
186+
#[test]
187+
fn vote_msgpack_roundtrip() {
188+
let entry = SequencerEntry::Vote {
189+
epoch: 5,
190+
position: 2,
191+
vshard: 9,
192+
commit: true,
193+
};
194+
let bytes = zerompk::to_msgpack_vec(&entry).expect("encode");
195+
let decoded: SequencerEntry = zerompk::from_msgpack(&bytes).expect("decode");
196+
assert_eq!(entry, decoded);
197+
}
173198
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ fn entry_txn_count(entry: &SequencerEntry) -> usize {
255255
SequencerEntry::CompletionAck { .. } => 0,
256256
SequencerEntry::OllpMismatch { .. } => 0,
257257
SequencerEntry::TxnRoutingFailed { .. } => 0,
258+
SequencerEntry::Vote { .. } => 0,
258259
}
259260
}
260261

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ impl SequencerStateMachine {
197197
// Routing-failure signals are coordinator-side notifications
198198
// only, like `OllpMismatch`; they carry no txn data to replay.
199199
SequencerEntry::TxnRoutingFailed { .. } => {}
200+
// Vote signals are coordinator-side notifications only, like
201+
// `OllpMismatch`/`TxnRoutingFailed`; they carry no txn data to
202+
// replay. Replay re-derives tallies via live `apply` instead.
203+
SequencerEntry::Vote { .. } => {}
200204
}
201205
}
202206

@@ -341,6 +345,22 @@ impl SequencerStateMachine {
341345
self.completion_registry
342346
.note_routing_failed(crate::calvin::TxnId::new(epoch, position), detail);
343347
}
348+
// Durable per-participant commit vote for a staged cross-shard txn.
349+
// Currently observed-only: the registry tallies votes but nothing
350+
// yet reads the tally to change flush/drop behavior (that is a
351+
// follow-up); the leader's local decision still drives.
352+
SequencerEntry::Vote {
353+
epoch,
354+
position,
355+
vshard,
356+
commit,
357+
} => {
358+
self.completion_registry.note_vote(
359+
crate::calvin::TxnId::new(epoch, position),
360+
vshard,
361+
commit,
362+
);
363+
}
344364
}
345365
}
346366
}

nodedb/src/control/cluster/calvin/scheduler/driver/core/commit_resolve.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ impl Scheduler {
4444
staged_response: &Response,
4545
) {
4646
let committed = staged_response.read_set_valid != Some(false);
47+
48+
// Durably propose this participant's commit vote via the sequencer
49+
// Raft group, leader-guarded like `OllpMismatch`: only the data-group
50+
// leader ran read-set validation, so only a leader's vote is
51+
// authoritative. Currently observed-only — the registry tallies the
52+
// vote (`CalvinCompletionRegistry::note_vote`) but nothing yet reads
53+
// the tally to change flush/drop behavior; the local `committed`
54+
// decision below still drives the resolve path unchanged.
55+
if self.is_group_leader() {
56+
self.propose_sequencer_entry(
57+
SequencerEntry::Vote {
58+
epoch: txn_id.epoch,
59+
position: txn_id.position,
60+
vshard: self.vshard_id,
61+
commit: committed,
62+
},
63+
txn_id,
64+
"commit vote",
65+
);
66+
}
67+
4768
if !committed {
4869
// The staged slice's read-set was no longer current: observe it, the
4970
// same node-global signal the direct-apply path records.

0 commit comments

Comments
 (0)