|
| 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 | +} |
0 commit comments