|
| 1 | +// SPDX-License-Identifier: BUSL-1.1 |
| 2 | + |
| 3 | +//! Single-node Calvin always-on: a STANDALONE (non-cluster) server can run the |
| 4 | +//! full Calvin stack — sequencer Raft group + per-vShard schedulers — so a |
| 5 | +//! cross-core (cross-vShard) transaction traverses the SAME deterministic |
| 6 | +//! Calvin path it would in a multi-node cluster. |
| 7 | +//! |
| 8 | +//! Gated behind `server.single_node_calvin` (default `false`). When the flag is |
| 9 | +//! off, the standalone server starts no Calvin stack and every write stays on |
| 10 | +//! the existing single-node path; when it is on, the server synthesizes a |
| 11 | +//! one-node cluster (self-seeded, replication factor 1) via |
| 12 | +//! `init_single_node_calvin`. |
| 13 | +//! |
| 14 | +//! ## What each test proves |
| 15 | +//! |
| 16 | +//! - `flag_on`: with the flag set, `calvin_available` becomes true |
| 17 | +//! (`cluster_transport` + `sequencer_inbox` are both wired) and an AUTOCOMMIT |
| 18 | +//! cross-shard write — a `GRAPH INSERT EDGE` whose endpoints home to DISTINCT |
| 19 | +//! vShards — is admitted to a Calvin epoch (the sequencer's `admitted_total` |
| 20 | +//! advances) and dual-homes atomically (a reverse/IN traversal from the |
| 21 | +//! destination reaches the source). That is the sequencer→scheduler path, not |
| 22 | +//! the single-home fast path and not a `SequencerUnavailable` error. |
| 23 | +//! - `flag_off`: a standalone server with no Calvin stack reports |
| 24 | +//! `calvin_available == false` and single-shard writes still commit on the |
| 25 | +//! fast path. |
| 26 | +
|
| 27 | +mod common; |
| 28 | + |
| 29 | +use std::collections::HashSet; |
| 30 | +use std::sync::atomic::Ordering; |
| 31 | +use std::time::Duration; |
| 32 | + |
| 33 | +use nodedb_cluster::calvin::SEQUENCER_GROUP_ID; |
| 34 | +use nodedb_types::id::VShardId; |
| 35 | + |
| 36 | +use common::cluster_harness::{TestClusterNode, wait_for}; |
| 37 | +use common::pgwire_harness::TestServer; |
| 38 | + |
| 39 | +/// Observed sequencer-group leader id from a node's local Raft status, or `0` |
| 40 | +/// if no leader is known yet. |
| 41 | +fn sequencer_leader(node: &TestClusterNode) -> u64 { |
| 42 | + let Some(status_fn) = node.shared.raft_status_fn.get() else { |
| 43 | + return 0; |
| 44 | + }; |
| 45 | + status_fn() |
| 46 | + .into_iter() |
| 47 | + .find(|g| g.group_id == SEQUENCER_GROUP_ID) |
| 48 | + .map(|g| g.leader_id) |
| 49 | + .unwrap_or(0) |
| 50 | +} |
| 51 | + |
| 52 | +/// Count of transactions the single-node sequencer has admitted to an epoch, or |
| 53 | +/// `0` if the sequencer metrics handle is not installed yet. |
| 54 | +fn sequencer_admitted(node: &TestClusterNode) -> u64 { |
| 55 | + node.shared |
| 56 | + .sequencer_metrics |
| 57 | + .get() |
| 58 | + .map(|m| m.admitted_total.load(Ordering::Relaxed)) |
| 59 | + .unwrap_or(0) |
| 60 | +} |
| 61 | + |
| 62 | +/// A `(src, dst)` pair of graph node keys whose home vShards differ, so an edge |
| 63 | +/// between them is genuinely cross-shard (`VShardId::from_key` is how |
| 64 | +/// `insert_edge` homes each endpoint). Deterministic: the mapping is a pure |
| 65 | +/// function of the key bytes. |
| 66 | +fn distinct_vshard_node_keys() -> (String, String) { |
| 67 | + let dst = "sncalvin_hub".to_string(); |
| 68 | + let vdst = VShardId::from_key(dst.as_bytes()).as_u32(); |
| 69 | + for i in 0u32..4096 { |
| 70 | + let src = format!("sncalvin_src_{i}"); |
| 71 | + if VShardId::from_key(src.as_bytes()).as_u32() != vdst { |
| 72 | + return (src, dst); |
| 73 | + } |
| 74 | + } |
| 75 | + panic!("could not find a node key on a distinct vShard from the hub in 4096 tries"); |
| 76 | +} |
| 77 | + |
| 78 | +/// Run a `GRAPH TRAVERSE` query that yields a single `result` JSON row. |
| 79 | +async fn query_graph_json(client: &tokio_postgres::Client, sql: &str) -> serde_json::Value { |
| 80 | + let msgs = client.simple_query(sql).await.expect("simple_query"); |
| 81 | + let row = msgs |
| 82 | + .iter() |
| 83 | + .find_map(|m| match m { |
| 84 | + tokio_postgres::SimpleQueryMessage::Row(r) => Some(r), |
| 85 | + _ => None, |
| 86 | + }) |
| 87 | + .expect("graph query returned no result row"); |
| 88 | + let raw = row.get("result").expect("result column present"); |
| 89 | + serde_json::from_str(raw).expect("result column is valid JSON") |
| 90 | +} |
| 91 | + |
| 92 | +/// The set of reached node ids from a `GRAPH TRAVERSE` result JSON. |
| 93 | +fn traversed_node_ids(v: &serde_json::Value) -> HashSet<String> { |
| 94 | + v.get("nodes") |
| 95 | + .and_then(|n| n.as_array()) |
| 96 | + .expect("traverse result has a nodes array") |
| 97 | + .iter() |
| 98 | + .map(|n| { |
| 99 | + n.get("id") |
| 100 | + .and_then(|id| id.as_str()) |
| 101 | + .expect("node has string id") |
| 102 | + .to_string() |
| 103 | + }) |
| 104 | + .collect() |
| 105 | +} |
| 106 | + |
| 107 | +/// Flag ON: a standalone server with `single_node_calvin = true` runs the |
| 108 | +/// Calvin stack and routes an autocommit cross-shard edge insert through the |
| 109 | +/// single-node sequencer→scheduler path. |
| 110 | +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
| 111 | +async fn single_node_calvin_flag_on_routes_cross_shard_write_through_sequencer() { |
| 112 | + // 4 Data-Plane cores so distinct vShards land on distinct cores — a genuine |
| 113 | + // cross-core transaction. |
| 114 | + let node = TestClusterNode::spawn_single_node_calvin(4) |
| 115 | + .await |
| 116 | + .expect("spawn standalone single-node-calvin server"); |
| 117 | + |
| 118 | + // The lone sequencer voter self-elects; wait for it before submitting. |
| 119 | + wait_for( |
| 120 | + "single-node sequencer leader elected", |
| 121 | + Duration::from_secs(10), |
| 122 | + Duration::from_millis(50), |
| 123 | + || sequencer_leader(&node) == node.node_id, |
| 124 | + ) |
| 125 | + .await; |
| 126 | + |
| 127 | + // calvin_available == true: both fields the neutral gate checks are wired. |
| 128 | + assert!( |
| 129 | + node.shared.cluster_transport.is_some(), |
| 130 | + "single-node calvin must install cluster_transport" |
| 131 | + ); |
| 132 | + assert!( |
| 133 | + node.shared.sequencer_inbox.get().is_some(), |
| 134 | + "single-node calvin must install sequencer_inbox → calvin_available" |
| 135 | + ); |
| 136 | + |
| 137 | + // A graph-capable collection. |
| 138 | + node.client |
| 139 | + .simple_query("CREATE COLLECTION sncalvin_graph") |
| 140 | + .await |
| 141 | + .expect("CREATE COLLECTION sncalvin_graph"); |
| 142 | + wait_for( |
| 143 | + "collection visible on the node", |
| 144 | + Duration::from_secs(10), |
| 145 | + Duration::from_millis(50), |
| 146 | + || node.cached_collection_count() >= 1, |
| 147 | + ) |
| 148 | + .await; |
| 149 | + |
| 150 | + let (src, dst) = distinct_vshard_node_keys(); |
| 151 | + let admitted_before = sequencer_admitted(&node); |
| 152 | + |
| 153 | + // AUTOCOMMIT cross-shard edge insert. Because `calvin_available` is true and |
| 154 | + // the endpoints home to distinct vShards, `insert_edge` dual-homes the edge |
| 155 | + // atomically through Calvin (`submit_calvin_routed`) instead of taking the |
| 156 | + // single-home fast path. If the single-node sequencer stack were not |
| 157 | + // operational this would error (`SequencerUnavailable`) or time out. |
| 158 | + node.client |
| 159 | + .simple_query(&format!( |
| 160 | + "GRAPH INSERT EDGE IN 'sncalvin_graph' FROM '{src}' TO '{dst}' TYPE 'l'" |
| 161 | + )) |
| 162 | + .await |
| 163 | + .expect("cross-shard edge insert must commit via the single-node Calvin path"); |
| 164 | + |
| 165 | + // Proof it traversed the sequencer→scheduler path: the transaction was |
| 166 | + // admitted to a Calvin epoch. |
| 167 | + let admitted_after = sequencer_admitted(&node); |
| 168 | + assert!( |
| 169 | + admitted_after > admitted_before, |
| 170 | + "cross-shard edge must be admitted to a Calvin epoch (before={admitted_before}, \ |
| 171 | + after={admitted_after})" |
| 172 | + ); |
| 173 | + |
| 174 | + // Functional proof the edge dual-homed atomically: a reverse (IN) traversal |
| 175 | + // from the destination reaches the source, so the REVERSE_EDGES row landed |
| 176 | + // on the destination's home vShard. |
| 177 | + let v = query_graph_json( |
| 178 | + &node.client, |
| 179 | + &format!("GRAPH TRAVERSE FROM '{dst}' DEPTH 1 LABEL 'l' DIRECTION in"), |
| 180 | + ) |
| 181 | + .await; |
| 182 | + let reached = traversed_node_ids(&v); |
| 183 | + assert!( |
| 184 | + reached.contains(&src), |
| 185 | + "reverse traversal from '{dst}' must reach '{src}' (edge dual-homed via Calvin); \ |
| 186 | + got {reached:?}" |
| 187 | + ); |
| 188 | + |
| 189 | + node.shutdown().await; |
| 190 | +} |
| 191 | + |
| 192 | +/// Flag OFF: a standalone server starts no Calvin stack, so `calvin_available` |
| 193 | +/// is false and single-shard writes commit on the existing fast path. |
| 194 | +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| 195 | +async fn single_node_calvin_flag_off_keeps_fast_path() { |
| 196 | + let server = TestServer::start().await; |
| 197 | + |
| 198 | + // No cluster/Calvin stack was started on a standalone server. |
| 199 | + assert!( |
| 200 | + server.shared.cluster_transport.is_none(), |
| 201 | + "standalone server (flag off) must not install cluster_transport" |
| 202 | + ); |
| 203 | + assert!( |
| 204 | + server.shared.sequencer_inbox.get().is_none(), |
| 205 | + "standalone server (flag off) must leave sequencer_inbox unset → calvin_available false" |
| 206 | + ); |
| 207 | + |
| 208 | + // A single-shard write commits and reads back on the fast path. |
| 209 | + server |
| 210 | + .client |
| 211 | + .simple_query("CREATE COLLECTION sncalvin_fastpath (id TEXT PRIMARY KEY, v TEXT)") |
| 212 | + .await |
| 213 | + .expect("CREATE COLLECTION sncalvin_fastpath"); |
| 214 | + server |
| 215 | + .client |
| 216 | + .simple_query("INSERT INTO sncalvin_fastpath (id, v) VALUES ('k1', 'hello')") |
| 217 | + .await |
| 218 | + .expect("single-shard INSERT on the fast path"); |
| 219 | + |
| 220 | + let rows = server |
| 221 | + .client |
| 222 | + .simple_query("SELECT v FROM sncalvin_fastpath WHERE id = 'k1'") |
| 223 | + .await |
| 224 | + .expect("SELECT sncalvin_fastpath"); |
| 225 | + let count = rows |
| 226 | + .iter() |
| 227 | + .filter(|m| matches!(m, tokio_postgres::SimpleQueryMessage::Row(_))) |
| 228 | + .count(); |
| 229 | + assert_eq!( |
| 230 | + count, 1, |
| 231 | + "single-shard row must be present after the fast-path write" |
| 232 | + ); |
| 233 | +} |
0 commit comments