|
| 1 | +// SPDX-License-Identifier: BUSL-1.1 |
| 2 | + |
| 3 | +//! Cross-shard (dual-home) graph edge STAGING inside an explicit transaction on |
| 4 | +//! a single-node-calvin server. |
| 5 | +//! |
| 6 | +//! Before this coverage, a `GRAPH INSERT EDGE` whose endpoints home to DISTINCT |
| 7 | +//! vShards, issued inside a `BEGIN..COMMIT` block, was REJECTED with |
| 8 | +//! `CrossShardInExplicitTransaction`: only single-home edges could stage. A |
| 9 | +//! cross-shard edge lives on BOTH endpoints (forward row on `from_key(src)`, |
| 10 | +//! reverse row on `from_key(dst)`), and a read scatters to the home vShard of |
| 11 | +//! the node it starts from — so each Data-Plane core merges only its OWN |
| 12 | +//! per-transaction overlay. To make read-your-own-writes work from either |
| 13 | +//! endpoint the edge must be staged into BOTH overlays. |
| 14 | +//! |
| 15 | +//! `dual_home_edge_stages_both_overlays_and_rollback_tears_down` proves the new |
| 16 | +//! behavior end to end on a `single_node_calvin` server (where `calvin_available` |
| 17 | +//! is true so a cross-shard edge is genuinely dual-home, not forced single-home): |
| 18 | +//! |
| 19 | +//! 1. `BEGIN`; `GRAPH INSERT EDGE` across two distinct vShards is ACCEPTED (no |
| 20 | +//! `CrossShardInExplicitTransaction`) and staged. |
| 21 | +//! 2. In-txn RYOW from BOTH endpoints: `GRAPH NEIGHBORS ... DIRECTION out` from |
| 22 | +//! the SRC endpoint sees the edge (forward overlay on `vsrc`), and |
| 23 | +//! `GRAPH NEIGHBORS ... DIRECTION in` from the DST endpoint ALSO sees it |
| 24 | +//! (reverse overlay on `vdst`) — proving both overlays hold the staged edge. |
| 25 | +//! 3. `ROLLBACK` fans `DropTxnOverlay` to both touched vShards, so a post-rollback |
| 26 | +//! read from each endpoint sees the edge gone. |
| 27 | +
|
| 28 | +mod common; |
| 29 | + |
| 30 | +use std::time::Duration; |
| 31 | + |
| 32 | +use nodedb_types::id::VShardId; |
| 33 | + |
| 34 | +use common::cluster_harness::{TestClusterNode, wait_for}; |
| 35 | + |
| 36 | +/// The lone sequencer voter's observed leader id, or `0` if none known yet. |
| 37 | +/// Mirrors the wait used by the sibling `single_node_calvin` suite so the |
| 38 | +/// Calvin stack is up before the transaction runs. |
| 39 | +fn sequencer_leader(node: &TestClusterNode) -> u64 { |
| 40 | + let Some(status_fn) = node.shared.raft_status_fn.get() else { |
| 41 | + return 0; |
| 42 | + }; |
| 43 | + status_fn() |
| 44 | + .into_iter() |
| 45 | + .find(|g| g.group_id == nodedb_cluster::calvin::SEQUENCER_GROUP_ID) |
| 46 | + .map(|g| g.leader_id) |
| 47 | + .unwrap_or(0) |
| 48 | +} |
| 49 | + |
| 50 | +/// A `(src, dst)` pair of graph node keys whose home vShards differ, so an edge |
| 51 | +/// between them is genuinely cross-shard. Deterministic: `VShardId::from_key` is |
| 52 | +/// a pure function of the key bytes, and it is how `insert_edge` homes each |
| 53 | +/// endpoint. Same key-picking approach as the sibling `single_node_calvin` suite. |
| 54 | +fn distinct_vshard_node_keys() -> (String, String) { |
| 55 | + let dst = "sncgtx_hub".to_string(); |
| 56 | + let vdst = VShardId::from_key(dst.as_bytes()).as_u32(); |
| 57 | + for i in 0u32..4096 { |
| 58 | + let src = format!("sncgtx_src_{i}"); |
| 59 | + if VShardId::from_key(src.as_bytes()).as_u32() != vdst { |
| 60 | + return (src, dst); |
| 61 | + } |
| 62 | + } |
| 63 | + panic!("could not find a node key on a distinct vShard from the hub in 4096 tries"); |
| 64 | +} |
| 65 | + |
| 66 | +/// Run `GRAPH NEIGHBORS OF '<node>' LABEL '<label>' DIRECTION <dir>` and return |
| 67 | +/// the neighbor node ids from the single-row `[{"label":..,"node":..}, ...]` |
| 68 | +/// JSON payload in the first column. |
| 69 | +async fn neighbors( |
| 70 | + client: &tokio_postgres::Client, |
| 71 | + node: &str, |
| 72 | + label: &str, |
| 73 | + dir: &str, |
| 74 | +) -> Vec<String> { |
| 75 | + let sql = format!("GRAPH NEIGHBORS OF '{node}' LABEL '{label}' DIRECTION {dir}"); |
| 76 | + let msgs = client.simple_query(&sql).await.expect("GRAPH NEIGHBORS"); |
| 77 | + let mut out = Vec::new(); |
| 78 | + for msg in msgs { |
| 79 | + if let tokio_postgres::SimpleQueryMessage::Row(row) = msg { |
| 80 | + let raw = row.get(0).unwrap_or(""); |
| 81 | + let parsed: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap_or_default(); |
| 82 | + for entry in parsed { |
| 83 | + if let Some(n) = entry.get("node").and_then(|v| v.as_str()) { |
| 84 | + out.push(n.to_string()); |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + } |
| 89 | + out |
| 90 | +} |
| 91 | + |
| 92 | +/// A cross-shard edge inserted inside a transaction stages into BOTH endpoint |
| 93 | +/// overlays (RYOW from either direction) and ROLLBACK tears both down. |
| 94 | +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
| 95 | +async fn dual_home_edge_stages_both_overlays_and_rollback_tears_down() { |
| 96 | + // 4 Data-Plane cores so distinct vShards land on distinct cores — a genuine |
| 97 | + // cross-core (dual-home) edge. |
| 98 | + let node = TestClusterNode::spawn_single_node_calvin(4) |
| 99 | + .await |
| 100 | + .expect("spawn standalone single-node-calvin server"); |
| 101 | + |
| 102 | + // The lone sequencer voter self-elects; wait for it so `calvin_available` is |
| 103 | + // genuinely operational (a cross-shard edge is dual-home, not forced |
| 104 | + // single-home). |
| 105 | + wait_for( |
| 106 | + "single-node sequencer leader elected", |
| 107 | + Duration::from_secs(10), |
| 108 | + Duration::from_millis(50), |
| 109 | + || sequencer_leader(&node) == node.node_id, |
| 110 | + ) |
| 111 | + .await; |
| 112 | + assert!( |
| 113 | + node.shared.cluster_transport.is_some() && node.shared.sequencer_inbox.get().is_some(), |
| 114 | + "single-node calvin must wire calvin_available (cluster_transport + sequencer_inbox)" |
| 115 | + ); |
| 116 | + |
| 117 | + node.client |
| 118 | + .simple_query("CREATE COLLECTION sncgtx_graph") |
| 119 | + .await |
| 120 | + .expect("CREATE COLLECTION sncgtx_graph"); |
| 121 | + wait_for( |
| 122 | + "collection visible on the node", |
| 123 | + Duration::from_secs(10), |
| 124 | + Duration::from_millis(50), |
| 125 | + || node.cached_collection_count() >= 1, |
| 126 | + ) |
| 127 | + .await; |
| 128 | + |
| 129 | + let (src, dst) = distinct_vshard_node_keys(); |
| 130 | + let label = "l"; |
| 131 | + |
| 132 | + node.client.simple_query("BEGIN").await.expect("BEGIN"); |
| 133 | + |
| 134 | + // Cross-shard edge insert INSIDE the transaction. Pre-change this returned |
| 135 | + // `CrossShardInExplicitTransaction`; now it stages into both endpoint |
| 136 | + // overlays. |
| 137 | + node.client |
| 138 | + .simple_query(&format!( |
| 139 | + "GRAPH INSERT EDGE IN 'sncgtx_graph' FROM '{src}' TO '{dst}' TYPE '{label}'" |
| 140 | + )) |
| 141 | + .await |
| 142 | + .expect( |
| 143 | + "cross-shard edge insert inside a transaction must STAGE (dual-home), \ |
| 144 | + not reject with CrossShardInExplicitTransaction", |
| 145 | + ); |
| 146 | + |
| 147 | + // RYOW from the SRC endpoint: forward overlay on `vsrc` holds the edge. |
| 148 | + let out_src = neighbors(&node.client, &src, label, "out").await; |
| 149 | + assert!( |
| 150 | + out_src.contains(&dst), |
| 151 | + "in-tx forward NEIGHBORS from src '{src}' must observe the staged edge to \ |
| 152 | + '{dst}' (vsrc overlay), got: {out_src:?}" |
| 153 | + ); |
| 154 | + |
| 155 | + // RYOW from the DST endpoint: reverse overlay on `vdst` ALSO holds the edge. |
| 156 | + // This is the dual-home proof — a single-home stage would leave `vdst` empty. |
| 157 | + let in_dst = neighbors(&node.client, &dst, label, "in").await; |
| 158 | + assert!( |
| 159 | + in_dst.contains(&src), |
| 160 | + "in-tx reverse NEIGHBORS from dst '{dst}' must observe the staged edge from \ |
| 161 | + '{src}' (vdst overlay) — proves BOTH overlays hold it, got: {in_dst:?}" |
| 162 | + ); |
| 163 | + |
| 164 | + node.client |
| 165 | + .simple_query("ROLLBACK") |
| 166 | + .await |
| 167 | + .expect("ROLLBACK"); |
| 168 | + |
| 169 | + // Post-rollback: the edge is gone from BOTH endpoints (DropTxnOverlay fanned |
| 170 | + // to both touched vShards). |
| 171 | + let out_src_after = neighbors(&node.client, &src, label, "out").await; |
| 172 | + assert!( |
| 173 | + !out_src_after.contains(&dst), |
| 174 | + "after ROLLBACK the staged edge must be gone from src '{src}' (vsrc \ |
| 175 | + overlay dropped), got: {out_src_after:?}" |
| 176 | + ); |
| 177 | + let in_dst_after = neighbors(&node.client, &dst, label, "in").await; |
| 178 | + assert!( |
| 179 | + !in_dst_after.contains(&src), |
| 180 | + "after ROLLBACK the staged edge must be gone from dst '{dst}' (vdst \ |
| 181 | + overlay dropped), got: {in_dst_after:?}" |
| 182 | + ); |
| 183 | + |
| 184 | + node.shutdown().await; |
| 185 | +} |
0 commit comments