Skip to content

Commit f8f019d

Browse files
committed
feat(cluster): route best-effort cross-shard COMMIT through Calvin sequencer
Each per-vShard sub-batch of a best-effort cross-shard COMMIT now submits as an independent single-vShard Calvin transaction (build_single_vshard_tx_class + submit_calvin_routed) instead of a raw TransactionBatch dispatch, so every group gets an epoch-anchored bitemporal stamp and a replayable TransactionRedo record, matching the durability guarantees of the strict path while staying non-atomic across vShards.
1 parent 89898db commit f8f019d

4 files changed

Lines changed: 357 additions & 74 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! A best-effort cross-shard `BEGIN; <cross-shard bitemporal INSERTs>; COMMIT`
4+
//! block must be just as version-stable across a WAL-only restart as the strict
5+
//! path: even though the vShards commit independently (no cross-shard vote binds
6+
//! them), each per-vShard sub-batch still routes through the deterministic
7+
//! Calvin sequencer, so it earns an epoch-anchored system-time stamp and a
8+
//! replayable redo record. Replay must reuse that committed stamp and write
9+
//! exactly one version per row, never a wall-clock-divergent duplicate. This is
10+
//! the best-effort analogue of `calvin_multi_shard_bitemporal_restart.rs`.
11+
//!
12+
//! 1. Two `document_schemaless` collections, each created `WITH
13+
//! (bitemporal=true)`, are placed on DIFFERENT vShards
14+
//! (`distinct_vshard_bitemporal_collections`, same technique as
15+
//! `calvin_multi_shard_redo_restart.rs`).
16+
//! 2. `BEGIN; INSERT INTO <a>; INSERT INTO <b>; COMMIT` is sent as ONE
17+
//! `simple_query` call, so both writes are buffered inside the block and,
18+
//! on COMMIT, `classify_dispatch` sees writes on two vShards → MultiShard.
19+
//! In best-effort mode that fans out into TWO independent single-vShard
20+
//! Calvin transactions, one per vShard.
21+
//! 3. Before restart, each inserted row has exactly one system-time version
22+
//! (no double-stamp from the commit-time resolver).
23+
//! 4. A WAL-only restart (no checkpoint) reopens the same data directory. The
24+
//! current read must still return both rows, and the audit log (`AS OF
25+
//! SYSTEM TIME NULL`) must still hold exactly one version per row, at the
26+
//! SAME `_ts_system` stamp observed before restart — proving each vShard's
27+
//! replay reused its committed stamp instead of re-deriving one from the
28+
//! replay-time clock and appending a second version.
29+
30+
mod common;
31+
32+
use std::sync::atomic::Ordering;
33+
use std::time::Duration;
34+
35+
use nodedb::types::{DatabaseId, VShardId};
36+
use tokio_postgres::SimpleQueryMessage;
37+
38+
use common::cluster_harness::{TestClusterNode, wait_for};
39+
40+
/// Observed sequencer-group leader id from a node's local Raft status, or `0`
41+
/// if no leader is known yet. Same shape as the sibling `single_node_calvin_*`
42+
/// suite.
43+
fn sequencer_leader(node: &TestClusterNode) -> u64 {
44+
let Some(status_fn) = node.shared.raft_status_fn.get() else {
45+
return 0;
46+
};
47+
status_fn()
48+
.into_iter()
49+
.find(|g| g.group_id == nodedb_cluster::calvin::SEQUENCER_GROUP_ID)
50+
.map(|g| g.leader_id)
51+
.unwrap_or(0)
52+
}
53+
54+
/// Count of transactions the single-node sequencer has admitted to an epoch,
55+
/// or `0` if the sequencer metrics handle is not installed yet.
56+
fn admitted_total(node: &TestClusterNode) -> u64 {
57+
node.shared
58+
.sequencer_metrics
59+
.get()
60+
.map(|m| m.admitted_total.load(Ordering::Relaxed))
61+
.unwrap_or(0)
62+
}
63+
64+
/// A `(coll_a, coll_b)` pair of bitemporal collection names whose vShard ids
65+
/// differ, so a transaction writing to both is genuinely multi-shard.
66+
/// Deterministic: `VShardId::from_collection_in_database` is a pure function
67+
/// of the database id + collection name bytes. Same technique as
68+
/// `calvin_multi_shard_redo_restart.rs::distinct_vshard_collections`.
69+
fn distinct_vshard_bitemporal_collections() -> (String, String) {
70+
let a_name = "bt_a".to_string();
71+
let va = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &a_name).as_u32();
72+
for i in 0u32..512 {
73+
let b_name = format!("bt_b_{i}");
74+
if VShardId::from_collection_in_database(DatabaseId::DEFAULT, &b_name).as_u32() != va {
75+
return (a_name, b_name);
76+
}
77+
}
78+
panic!(
79+
"could not find a second bitemporal collection name on a distinct vShard \
80+
from the first in 512 tries"
81+
);
82+
}
83+
84+
/// Current `value` for `id` in a bitemporal document collection, or `None` if
85+
/// not visible.
86+
async fn current_value(client: &tokio_postgres::Client, coll: &str, id: &str) -> Option<String> {
87+
let msgs = client
88+
.simple_query(&format!("SELECT value FROM {coll} WHERE id = '{id}'"))
89+
.await
90+
.expect("SELECT current value by id");
91+
msgs.iter().find_map(|m| match m {
92+
SimpleQueryMessage::Row(r) => r.get("value").map(str::to_owned),
93+
_ => None,
94+
})
95+
}
96+
97+
/// Sorted `_ts_system` stamps of every audit-log version (`AS OF SYSTEM TIME
98+
/// NULL`) belonging to `id` in `coll`. Filters by id in Rust rather than in
99+
/// SQL — `AS OF SYSTEM TIME NULL` combined with `WHERE` is not exercised
100+
/// elsewhere in the suite, so this avoids depending on unverified parser
101+
/// support. Exactly one element means exactly one system-time version.
102+
async fn audit_system_stamps(client: &tokio_postgres::Client, coll: &str, id: &str) -> Vec<String> {
103+
let msgs = client
104+
.simple_query(&format!(
105+
"SELECT id, _ts_system FROM {coll} AS OF SYSTEM TIME NULL"
106+
))
107+
.await
108+
.expect("SELECT audit log (all versions)");
109+
let mut stamps: Vec<String> = msgs
110+
.iter()
111+
.filter_map(|m| match m {
112+
SimpleQueryMessage::Row(r) => {
113+
if r.get("id") == Some(id) {
114+
r.get("_ts_system").map(str::to_owned)
115+
} else {
116+
None
117+
}
118+
}
119+
_ => None,
120+
})
121+
.collect();
122+
stamps.sort();
123+
stamps
124+
}
125+
126+
/// A best-effort Calvin cross-shard COMMIT into two `bitemporal=true` document
127+
/// collections on distinct vShards is version-stable across a WAL-only restart:
128+
/// each row keeps exactly one system-time version, stamped identically before
129+
/// and after replay, even though the two vShards commit as independent
130+
/// single-vShard Calvin transactions.
131+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
132+
async fn calvin_multi_shard_bitemporal_best_effort_commit_survives_wal_only_restart() {
133+
// The node's own data directory (kept alive across both bring-ups).
134+
let data_dir = tempfile::tempdir().expect("tempdir");
135+
let data_dir_path = data_dir.path().to_path_buf();
136+
137+
// 4 Data-Plane cores so distinct vShards land on distinct cores — a
138+
// genuine cross-core, cross-shard write.
139+
let node = TestClusterNode::spawn_single_node_calvin_on_path(4, data_dir_path.clone())
140+
.await
141+
.expect("spawn standalone single-node-calvin server on path");
142+
143+
wait_for(
144+
"single-node sequencer leader elected",
145+
Duration::from_secs(10),
146+
Duration::from_millis(50),
147+
|| sequencer_leader(&node) == node.node_id,
148+
)
149+
.await;
150+
151+
let (coll_a, coll_b) = distinct_vshard_bitemporal_collections();
152+
153+
node.client
154+
.simple_query(&format!(
155+
"CREATE COLLECTION {coll_a} (id STRING PRIMARY KEY, value STRING) \
156+
WITH (engine='document_schemaless', bitemporal=true)"
157+
))
158+
.await
159+
.expect("CREATE COLLECTION bitemporal a");
160+
node.client
161+
.simple_query(&format!(
162+
"CREATE COLLECTION {coll_b} (id STRING PRIMARY KEY, value STRING) \
163+
WITH (engine='document_schemaless', bitemporal=true)"
164+
))
165+
.await
166+
.expect("CREATE COLLECTION bitemporal b");
167+
wait_for(
168+
"both bitemporal collections visible on the node",
169+
Duration::from_secs(10),
170+
Duration::from_millis(50),
171+
|| node.cached_collection_count() >= 2,
172+
)
173+
.await;
174+
175+
// Best-effort cross-shard mode so COMMIT's multi-shard path fans out into
176+
// one independent single-vShard Calvin transaction per vShard.
177+
node.client
178+
.simple_query("SET cross_shard_txn = 'best_effort_non_atomic'")
179+
.await
180+
.expect("SET cross_shard_txn = best_effort_non_atomic");
181+
182+
let admitted_before = admitted_total(&node);
183+
184+
// ONE `simple_query` call carrying the whole transaction: the two INSERTs
185+
// are buffered during the block, and on COMMIT `classify_dispatch` sees
186+
// the full task set spanning two vShards → MultiShard → best-effort fans
187+
// out into two independent single-vShard Calvin flushes.
188+
let txn_sql = format!(
189+
"BEGIN; \
190+
INSERT INTO {coll_a} (id, value) VALUES ('a1', 'va'); \
191+
INSERT INTO {coll_b} (id, value) VALUES ('b1', 'vb'); \
192+
COMMIT"
193+
);
194+
node.client
195+
.simple_query(&txn_sql)
196+
.await
197+
.expect("interactive best-effort cross-shard bitemporal COMMIT must succeed per vShard");
198+
199+
// Both per-vShard sub-batches reached the sequencer inbox — best-effort
200+
// submits TWO independent single-vShard Calvin txns, so admitted advances
201+
// by at least 2 past baseline (strict would advance by 1).
202+
wait_for(
203+
"calvin admitted both per-vShard best-effort bitemporal transactions",
204+
Duration::from_secs(10),
205+
Duration::from_millis(25),
206+
|| admitted_total(&node) >= admitted_before + 2,
207+
)
208+
.await;
209+
210+
// Pre-restart: both rows are visible and each carries exactly one
211+
// system-time version. The Calvin flush lands asynchronously after the
212+
// completion ack, so poll.
213+
let (stamp_a, stamp_b);
214+
let deadline = std::time::Instant::now() + Duration::from_secs(10);
215+
loop {
216+
let val_a = current_value(&node.client, &coll_a, "a1").await;
217+
let val_b = current_value(&node.client, &coll_b, "b1").await;
218+
let stamps_a = audit_system_stamps(&node.client, &coll_a, "a1").await;
219+
let stamps_b = audit_system_stamps(&node.client, &coll_b, "b1").await;
220+
if val_a.as_deref() == Some("va")
221+
&& val_b.as_deref() == Some("vb")
222+
&& stamps_a.len() == 1
223+
&& stamps_b.len() == 1
224+
{
225+
stamp_a = stamps_a[0].clone();
226+
stamp_b = stamps_b[0].clone();
227+
break;
228+
}
229+
if std::time::Instant::now() >= deadline {
230+
panic!(
231+
"pre-restart committed bitemporal rows not stable within 10s: \
232+
val_a={val_a:?} val_b={val_b:?} stamps_a={stamps_a:?} stamps_b={stamps_b:?}"
233+
);
234+
}
235+
tokio::time::sleep(Duration::from_millis(25)).await;
236+
}
237+
238+
// WAL-only restart: shut down cleanly (flushing the WAL, releasing every
239+
// redb handle) and reopen against the SAME directory — no checkpoint, so
240+
// both rows must be reconstructed purely from each vShard's replayed
241+
// bitemporal stamp.
242+
node.graceful_shutdown_wal_only().await;
243+
let node = TestClusterNode::spawn_single_node_calvin_on_path(4, data_dir_path.clone())
244+
.await
245+
.expect("reopen standalone single-node-calvin server on the same path");
246+
wait_for(
247+
"both bitemporal collections visible after WAL-only restart",
248+
Duration::from_secs(10),
249+
Duration::from_millis(50),
250+
|| node.cached_collection_count() >= 2,
251+
)
252+
.await;
253+
254+
// Post-restart: current reads return both rows, and each id's audit log
255+
// holds EXACTLY the SAME single stamp observed pre-restart — proving each
256+
// vShard's replay reused its committed system-time stamp rather than
257+
// re-deriving one from the replay-time clock and appending a second version.
258+
let deadline = std::time::Instant::now() + Duration::from_secs(10);
259+
loop {
260+
let val_a = current_value(&node.client, &coll_a, "a1").await;
261+
let val_b = current_value(&node.client, &coll_b, "b1").await;
262+
let stamps_a = audit_system_stamps(&node.client, &coll_a, "a1").await;
263+
let stamps_b = audit_system_stamps(&node.client, &coll_b, "b1").await;
264+
if val_a.as_deref() == Some("va")
265+
&& val_b.as_deref() == Some("vb")
266+
&& stamps_a == [stamp_a.as_str()]
267+
&& stamps_b == [stamp_b.as_str()]
268+
{
269+
break;
270+
}
271+
if std::time::Instant::now() >= deadline {
272+
panic!(
273+
"post-restart bitemporal version stability check failed within 10s: \
274+
val_a={val_a:?} val_b={val_b:?} \
275+
stamps_a={stamps_a:?} (expected [{stamp_a}]) \
276+
stamps_b={stamps_b:?} (expected [{stamp_b}])"
277+
);
278+
}
279+
tokio::time::sleep(Duration::from_millis(25)).await;
280+
}
281+
282+
node.shutdown().await;
283+
}

nodedb/src/control/server/shared/session/commit.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ pub async fn run_commit(
8484
// and returns a serialization abort (SQLSTATE 40001) on an ABORT
8585
// verdict.
8686
if let Some(reason) = super::commit_calvin::run_commit_calvin(
87-
sessions, addr, state, dp, &buffered, tenant_id, &read_set,
87+
sessions, addr, state, &buffered, tenant_id, &read_set,
8888
)
8989
.await
9090
{

0 commit comments

Comments
 (0)