Skip to content

Commit 444811a

Browse files
committed
feat(cluster): record write-versions for committed Calvin applies
A Calvin apply's committed WAL LSN is only allocated after the apply succeeds (the CalvinApplied WAL record is appended once the executor response returns), so the apply itself could not advance the per-core write-version index in place — leaving cross-shard-committed writes with no recorded version at all, unlike fast-path writes and reads. Once the scheduler obtains that LSN, it now dispatches a one-way, record-only MetaOp::RecordCalvinWriteVersions op back to the same core, funneling the transaction's locally-applied write plans through the shared write-version recorder at that LSN — landing in the same shard-local WAL-LSN space fast-path writes and read watermarks use. Adds a node-global counter so the recording can be observed without reaching into the !Send Data-Plane index, and a cluster test asserting a cross-shard Calvin write advances it.
1 parent 827c7ff commit 444811a

12 files changed

Lines changed: 335 additions & 11 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! A cross-shard, Calvin-committed write RECORDS its write-version in the
4+
//! per-core write-version index — closing the gap where Calvin applies advanced
5+
//! no version at all (fast-path writes and reads already do).
6+
//!
7+
//! The version index itself lives on the `!Send` Data-Plane core and its
8+
//! readers are test-only, so this asserts the recording FIRED via the
9+
//! node-global `calvin_write_versions_recorded` counter, which the per-vShard
10+
//! scheduler increments once per committed Calvin apply for which it dispatched
11+
//! a write-version record op (at the CalvinApplied WAL LSN). The counter is the
12+
//! standalone-observable proof that a cross-shard-committed write now advances
13+
//! the version index; the end-to-end serializability regression (via read-set
14+
//! validation) is covered separately once validation is enforcing.
15+
16+
mod common;
17+
18+
use std::sync::atomic::Ordering;
19+
use std::time::Duration;
20+
21+
use nodedb_cluster::calvin::SEQUENCER_GROUP_ID;
22+
use nodedb_types::id::VShardId;
23+
24+
use common::cluster_harness::{TestClusterNode, wait_for};
25+
26+
/// Observed sequencer-group leader id from a node's local Raft status, or `0`
27+
/// if no leader is known yet.
28+
fn sequencer_leader(node: &TestClusterNode) -> u64 {
29+
let Some(status_fn) = node.shared.raft_status_fn.get() else {
30+
return 0;
31+
};
32+
status_fn()
33+
.into_iter()
34+
.find(|g| g.group_id == SEQUENCER_GROUP_ID)
35+
.map(|g| g.leader_id)
36+
.unwrap_or(0)
37+
}
38+
39+
/// Count of committed Calvin applies whose write versions were recorded into
40+
/// the per-core write-version index.
41+
fn write_versions_recorded(node: &TestClusterNode) -> u64 {
42+
node.shared
43+
.calvin_write_versions_recorded
44+
.load(Ordering::Relaxed)
45+
}
46+
47+
/// A `(src, dst)` pair of graph node keys whose home vShards differ, so an edge
48+
/// between them is genuinely cross-shard. Deterministic: `VShardId::from_key` is
49+
/// a pure function of the key bytes.
50+
fn distinct_vshard_node_keys() -> (String, String) {
51+
let dst = "sncalvin_wv_hub".to_string();
52+
let vdst = VShardId::from_key(dst.as_bytes()).as_u32();
53+
for i in 0u32..4096 {
54+
let src = format!("sncalvin_wv_src_{i}");
55+
if VShardId::from_key(src.as_bytes()).as_u32() != vdst {
56+
return (src, dst);
57+
}
58+
}
59+
panic!("could not find a node key on a distinct vShard from the hub in 4096 tries");
60+
}
61+
62+
/// A cross-shard (dual-home edge) Calvin-committed write increments the
63+
/// node-global write-version-recorded counter — i.e. its keys' versions are
64+
/// recorded at the CalvinApplied WAL LSN instead of being silently dropped.
65+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
66+
async fn cross_shard_calvin_write_records_write_version() {
67+
// 4 Data-Plane cores so distinct vShards land on distinct cores — a genuine
68+
// cross-core transaction routed through the Calvin scheduler.
69+
let node = TestClusterNode::spawn_single_node_calvin(4)
70+
.await
71+
.expect("spawn standalone single-node-calvin server");
72+
73+
// The lone sequencer voter self-elects; wait for it before submitting.
74+
wait_for(
75+
"single-node sequencer leader elected",
76+
Duration::from_secs(10),
77+
Duration::from_millis(50),
78+
|| sequencer_leader(&node) == node.node_id,
79+
)
80+
.await;
81+
82+
node.client
83+
.simple_query("CREATE COLLECTION sncalvin_wv_graph")
84+
.await
85+
.expect("CREATE COLLECTION sncalvin_wv_graph");
86+
wait_for(
87+
"collection visible on the node",
88+
Duration::from_secs(10),
89+
Duration::from_millis(50),
90+
|| node.cached_collection_count() >= 1,
91+
)
92+
.await;
93+
94+
let (src, dst) = distinct_vshard_node_keys();
95+
let recorded_before = write_versions_recorded(&node);
96+
97+
// AUTOCOMMIT cross-shard edge insert: because the endpoints home to distinct
98+
// vShards, the edge dual-homes atomically through the Calvin scheduler
99+
// (`submit_calvin_routed`), not the single-home fast path. On commit each
100+
// participating vShard's scheduler records its slice's write versions.
101+
node.client
102+
.simple_query(&format!(
103+
"GRAPH INSERT EDGE IN 'sncalvin_wv_graph' FROM '{src}' TO '{dst}' TYPE 'l'"
104+
))
105+
.await
106+
.expect("cross-shard edge insert must commit via the single-node Calvin path");
107+
108+
// Recording is dispatched fire-and-forget after the CalvinApplied WAL LSN is
109+
// obtained, so wait for the counter to advance.
110+
wait_for(
111+
"calvin write-version recording fired for the cross-shard commit",
112+
Duration::from_secs(10),
113+
Duration::from_millis(25),
114+
|| write_versions_recorded(&node) > recorded_before,
115+
)
116+
.await;
117+
118+
let recorded_after = write_versions_recorded(&node);
119+
assert!(
120+
recorded_after > recorded_before,
121+
"a cross-shard Calvin-committed write must record its write version \
122+
(before={recorded_before}, after={recorded_after})"
123+
);
124+
125+
node.shutdown().await;
126+
}

nodedb-physical/src/physical_plan/meta.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,4 +479,22 @@ pub enum MetaOp {
479479
value_marker: u64,
480480
graph_marker: u64,
481481
},
482+
483+
/// Record the per-key / per-collection write versions of a committed
484+
/// Calvin transaction's locally-applied write plans.
485+
///
486+
/// A Calvin apply's committed WAL LSN is known only after the apply
487+
/// succeeds, so the apply itself cannot advance the version index. The
488+
/// scheduler stamps that LSN onto this op's `wal_lsn` and dispatches it back
489+
/// to the same core, which funnels `plans` through the shared write-version
490+
/// recorder at that LSN — landing in the same shard-local WAL-LSN space the
491+
/// single-shard fast path and read watermarks use. Records only: no base
492+
/// mutation, no WAL append, no event emission. Wire-additive (appended last)
493+
/// so older log entries decode unchanged.
494+
RecordCalvinWriteVersions {
495+
/// Tenant scope for all plans.
496+
tenant_id: TenantId,
497+
/// The locally-applied write plans whose keys' versions are recorded.
498+
plans: Vec<super::PhysicalPlan>,
499+
},
482500
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ impl Scheduler {
128128
mr.vshard_role_is_leader(self.vshard_id)
129129
}
130130

131-
fn local_calvin_plans(
131+
pub(in crate::control::cluster::calvin::scheduler::driver::core) fn local_calvin_plans(
132132
&self,
133133
plans: Vec<PhysicalPlan>,
134134
epoch: u64,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
//! - [`dispatch`] — static / active dispatch to the Data Plane executor.
1616
//! - [`read_result`] — `CalvinReadResult` handling and barrier timeouts.
1717
//! - [`propose`] — propose `CalvinReadResult` Raft entries.
18+
//! - [`write_version_record`] — post-apply write-version recording for
19+
//! committed Calvin transactions (at the CalvinApplied WAL LSN).
1820
//!
1921
//! # Determinism
2022
//!
@@ -34,6 +36,7 @@ pub mod process;
3436
pub mod propose;
3537
pub mod read_result;
3638
pub mod scheduler;
39+
pub mod write_version_record;
3740

3841
#[cfg(test)]
3942
mod tests;

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -375,18 +375,25 @@ impl Scheduler {
375375
}
376376
}
377377
}
378-
if let Err(e) = self.shared.wal.append_calvin_applied(
378+
match self.shared.wal.append_calvin_applied(
379379
crate::types::VShardId::new(self.vshard_id),
380380
txn_id.epoch,
381381
txn_id.position,
382382
) {
383-
tracing::error!(
384-
vshard_id = self.vshard_id,
385-
epoch = txn_id.epoch,
386-
position = txn_id.position,
387-
error = %e,
388-
"calvin: failed to write CalvinApplied WAL record"
389-
);
383+
// The CalvinApplied WAL LSN is the committed write-LSN for this
384+
// apply — the SAME shard-local WAL-LSN space fast-path writes and
385+
// read watermarks use. Record the apply's per-key write versions
386+
// at it now that it exists (it did not at dispatch time).
387+
Ok(applied_lsn) => self.record_calvin_write_versions(txn_id, applied_lsn),
388+
Err(e) => {
389+
tracing::error!(
390+
vshard_id = self.vshard_id,
391+
epoch = txn_id.epoch,
392+
position = txn_id.position,
393+
error = %e,
394+
"calvin: failed to write CalvinApplied WAL record"
395+
);
396+
}
390397
}
391398
self.propose_sequencer_entry(
392399
SequencerEntry::CompletionAck {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Post-apply write-version recording for committed Calvin transactions.
4+
//!
5+
//! A Calvin apply's committed WAL LSN is allocated only AFTER the apply
6+
//! succeeds — the `CalvinApplied` WAL record is appended on the Control Plane
7+
//! once the executor response returns — so the apply itself carries no
8+
//! committed LSN and the per-core write-version index cannot be advanced in
9+
//! place (the dispatch stamps `wal_lsn: None`). Once the scheduler has that
10+
//! LSN it dispatches a one-way, record-only op back to the same core, which
11+
//! funnels the transaction's locally-applied write plans through the shared
12+
//! write-version recorder at that LSN — the same shard-local WAL-LSN space the
13+
//! single-shard fast path and read watermarks use.
14+
15+
use std::sync::atomic::Ordering;
16+
use std::time::Instant;
17+
18+
use super::scheduler::Scheduler;
19+
use crate::bridge::envelope::{Admission, ExemptReason, Priority, Request};
20+
use crate::control::cluster::calvin::scheduler::lock_manager::TxnId;
21+
use crate::types::{DatabaseId, Lsn, ReadConsistency, VShardId};
22+
use nodedb_physical::physical_plan::PhysicalPlan;
23+
use nodedb_physical::physical_plan::meta::MetaOp;
24+
25+
impl Scheduler {
26+
/// Record the per-key write versions of a just-committed Calvin
27+
/// transaction's locally-applied write plans at its CalvinApplied WAL
28+
/// `applied_lsn`.
29+
///
30+
/// Dispatches a record-only [`MetaOp::RecordCalvinWriteVersions`] op back to
31+
/// this vShard's core with `applied_lsn` stamped on the request envelope's
32+
/// `wal_lsn`; the core funnels the plans through the shared write-version
33+
/// recorder at that LSN. The recorded version therefore lands in the same
34+
/// WAL-LSN space as fast-path writes, so a later read-set validation against
35+
/// these keys is not a false-Valid serializability hole.
36+
///
37+
/// Fire-and-forget: the recorded version is not needed to complete the
38+
/// transaction, so the response is drained and discarded. A brief index-lag
39+
/// window before the record op lands is harmless — nothing enforces read-set
40+
/// validation against these versions yet. A dropped record (decode failure,
41+
/// no local write plan, or dispatch backpressure) simply leaves the version
42+
/// unrecorded and never blocks the commit.
43+
pub(in crate::control::cluster::calvin::scheduler::driver::core) fn record_calvin_write_versions(
44+
&self,
45+
txn_id: TxnId,
46+
applied_lsn: Lsn,
47+
) {
48+
let epoch = txn_id.epoch;
49+
let position = txn_id.position;
50+
51+
let Some(pending) = self.pending.get(&txn_id) else {
52+
return;
53+
};
54+
let tenant_id = pending.txn.tx_class.tenant_id;
55+
let plans = match super::super::helpers::decode_plans(&pending.txn.tx_class.plans) {
56+
Ok(p) => p,
57+
Err(e) => {
58+
tracing::warn!(
59+
vshard_id = self.vshard_id,
60+
epoch,
61+
position,
62+
error = %e,
63+
"calvin: write-version recording skipped — plan decode failed"
64+
);
65+
return;
66+
}
67+
};
68+
// The locally-applied slice this vShard committed. `local_calvin_plans`
69+
// guarantees a non-empty set (this completion path only fires for a
70+
// write participant); the recorder no-ops any plan without a per-key or
71+
// collection version, so no gate on plan kind is applied here — gating
72+
// on a narrower write predicate would silently skip recordable writes
73+
// (e.g. a CRDT apply) and reopen the version gap.
74+
let local = match self.local_calvin_plans(plans, epoch, position) {
75+
Ok(p) => p,
76+
Err(e) => {
77+
tracing::warn!(
78+
vshard_id = self.vshard_id,
79+
epoch,
80+
position,
81+
error = %e,
82+
"calvin: write-version recording skipped — local plan routing failed"
83+
);
84+
return;
85+
}
86+
};
87+
88+
let request_id = self.next_request_id();
89+
let deadline = Instant::now()
90+
+ std::time::Duration::from_millis(
91+
self.config.epoch_duration_ms * u64::from(self.config.txn_deadline_multiplier),
92+
);
93+
let request = Request {
94+
request_id,
95+
tenant_id,
96+
database_id: DatabaseId::DEFAULT,
97+
vshard_id: VShardId::new(self.vshard_id),
98+
plan: PhysicalPlan::Meta(MetaOp::RecordCalvinWriteVersions {
99+
tenant_id,
100+
plans: local,
101+
}),
102+
deadline,
103+
priority: Priority::Normal,
104+
trace_id: nodedb_types::TraceId([0u8; 16]),
105+
consistency: ReadConsistency::Strong,
106+
idempotency_key: None,
107+
event_source: crate::event::EventSource::User,
108+
user_roles: Vec::new(),
109+
user_id: None,
110+
statement_digest: None,
111+
txn_id: None,
112+
// The committed write-LSN for this Calvin apply — recorded against
113+
// every key the plans wrote, in the same WAL-LSN space as fast-path.
114+
wal_lsn: Some(applied_lsn),
115+
// Recording only — the scheduler already held (and is releasing) this
116+
// transaction's locks; the write-admission gate must not re-acquire.
117+
admission: Admission::Exempt(ExemptReason::AlreadyOrdered),
118+
};
119+
120+
// Register so the response routes to a real receiver (not the
121+
// unknown-request warning path), then discard it — the recording is
122+
// one-way.
123+
let resp_rx = self.shared.tracker.register(request_id);
124+
let dispatch_result = match self.shared.dispatcher.lock() {
125+
Ok(mut d) => d.dispatch(request),
126+
Err(poisoned) => poisoned.into_inner().dispatch(request),
127+
};
128+
if let Err(e) = dispatch_result {
129+
self.shared.tracker.cancel(&request_id);
130+
tracing::warn!(
131+
vshard_id = self.vshard_id,
132+
epoch,
133+
position,
134+
error = %e,
135+
"calvin: write-version record dispatch failed"
136+
);
137+
return;
138+
}
139+
tokio::spawn(async move {
140+
let mut rx = resp_rx;
141+
let _ = rx.recv().await;
142+
});
143+
self.shared
144+
.calvin_write_versions_recorded
145+
.fetch_add(1, Ordering::Relaxed);
146+
}
147+
}

nodedb/src/control/security/identity/plan_permission.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,8 @@ pub fn required_permission(plan: &crate::bridge::envelope::PhysicalPlan) -> Perm
313313
PhysicalPlan::Meta(
314314
MetaOp::CalvinExecuteStatic { .. }
315315
| MetaOp::CalvinExecutePassive { .. }
316-
| MetaOp::CalvinExecuteActive { .. },
316+
| MetaOp::CalvinExecuteActive { .. }
317+
| MetaOp::RecordCalvinWriteVersions { .. },
317318
) => Permission::Write,
318319

319320
// Synonym group DDL: Alter permission (same tier as CREATE/DROP other DDL objects).

nodedb/src/control/server/exchange/all_cores/dispatch.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ pub async fn execute_plan_all_local_cores(
180180
| MetaOp::StageWrite { .. }
181181
| MetaOp::DropTxnOverlay { .. }
182182
| MetaOp::MarkSavepoint { .. }
183-
| MetaOp::RollbackToSavepoint { .. } => {
183+
| MetaOp::RollbackToSavepoint { .. }
184+
| MetaOp::RecordCalvinWriteVersions { .. } => {
184185
generic_gather(state, tenant_id, database_id, plan, trace_id).await
185186
}
186187
},

nodedb/src/control/state/fields.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,15 @@ pub struct SharedState {
375375
/// the schedulers (which hold `Arc<SharedState>`) advance the same counter
376376
/// the session reads. 0 in single-node / no-Calvin deployments.
377377
pub last_applied_calvin_epoch: Arc<AtomicU64>,
378+
/// Count of committed Calvin applies whose write versions were recorded into
379+
/// the per-core write-version index — incremented once per apply for which a
380+
/// per-vShard scheduler dispatched a write-version record op after obtaining
381+
/// the CalvinApplied WAL LSN. Node-global observability so tests and metrics
382+
/// can confirm cross-shard-committed writes advance the version index without
383+
/// reaching into the `!Send` Data-Plane index. `Arc` so the schedulers (which
384+
/// hold `Arc<SharedState>`) increment the same counter a reader observes. 0 in
385+
/// single-node / no-Calvin deployments and until the first Calvin write apply.
386+
pub calvin_write_versions_recorded: Arc<AtomicU64>,
378387
/// Local, in-process sidecar carrying the applied Data-Plane [`Response`]
379388
/// (including RETURNING rows) of a completed Calvin transaction, keyed by
380389
/// its sequencer-assigned `TxnId`.

0 commit comments

Comments
 (0)