Skip to content

Commit 5e3063d

Browse files
committed
feat(control): stage cross-shard graph edges inside transactions
A GRAPH INSERT/DELETE EDGE whose endpoints home to distinct vShards was previously rejected inside an explicit transaction block with CrossShardInExplicitTransaction, since only single-home edges could stage into the per-transaction overlay. An edge is reachable from either endpoint (forward row on vsrc, reverse row on vdst), and each Data-Plane core merges only its own overlay on read, so a dual-home edge now stages the same GraphOp into both endpoint overlays via the new stage_edge_dual_home helper, giving read-your-own-writes from either direction; ROLLBACK fans DropTxnOverlay to both touched vShards. Single-home edges keep staging once as before. Adds cluster-test coverage on a single-node-calvin server proving the cross-shard edge is accepted, observed from both endpoints in-txn, and torn down from both on rollback.
1 parent 6a711b6 commit 5e3063d

3 files changed

Lines changed: 299 additions & 49 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
}

nodedb/src/control/server/shared/ddl/neutral/graph_ops/edge.rs

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -150,32 +150,26 @@ pub async fn insert_edge(
150150
state.cluster_transport.is_some() && state.sequencer_inbox.get().is_some();
151151
let single_home = vsrc == vdst || !calvin_available;
152152

153-
// Inside an explicit transaction block a single-home edge insert stages into
154-
// the per-transaction `GraphTxnOverlay` through the neutral gate instead of
153+
// Inside an explicit transaction block an edge insert stages into the
154+
// per-transaction `GraphTxnOverlay` through the neutral gate instead of
155155
// applying durably now: an in-transaction `MATCH` / `GRAPH NEIGHBORS` then
156156
// observes the edge as present (read-your-own-writes), COMMIT replays the
157-
// buffered `EdgePut` via the single-shard WAL + `TransactionBatch` path, and
158-
// ROLLBACK discards the overlay. This is the write-side complement to the
159-
// `delete_edge` staging above; both reuse `stage_edge_write_in_txn`.
160-
// Autocommit is untouched.
157+
// buffered `EdgePut`, and ROLLBACK discards the overlay. A cross-shard
158+
// (dual-home) edge stages into BOTH endpoint overlays via
159+
// `stage_edge_dual_home` so RYOW works from either endpoint; a single-home
160+
// edge stages once. This is the write-side complement to the `delete_edge`
161+
// staging below. Autocommit is untouched.
161162
if txn_ctx.sessions.transaction_state(txn_ctx.addr) == TransactionState::InBlock {
162-
if !single_home {
163-
// A cross-shard (dual-home) edge insert inside an explicit
164-
// transaction needs the cross-shard-commit machinery to stage both
165-
// homes atomically across the outer transaction boundary — out of
166-
// scope for single-home staging. Reject with the same signal a
167-
// cross-shard predicate write inside a transaction produces.
168-
return Err(ddl_err(
169-
"XX000",
170-
crate::Error::CrossShardInExplicitTransaction.to_string(),
171-
));
172-
}
173-
super::edge_stage::stage_edge_write_in_txn(
163+
super::edge_stage::stage_edge_dual_home(
174164
state,
175165
tenant_id,
176166
database_id,
177-
vsrc,
178-
PhysicalPlan::Graph(edge_put),
167+
EdgeHomes {
168+
vsrc,
169+
vdst,
170+
single_home,
171+
},
172+
edge_put,
179173
txn_ctx,
180174
)
181175
.await?;
@@ -239,6 +233,19 @@ pub struct EdgeRef {
239233
pub label: String,
240234
}
241235

236+
/// The home vShard(s) an edge resolves to. An edge is reachable from BOTH
237+
/// endpoints (forward from `src`, reverse from `dst`), so a cross-shard edge
238+
/// (`!single_home`) has two distinct homes: `vsrc` holds the forward row and
239+
/// `vdst` holds the reverse row. `single_home` is true when both endpoints share
240+
/// one vShard, or when Calvin is unavailable (single-node) so one write covers
241+
/// both. Bundled so [`stage_edge_dual_home`](super::edge_stage::stage_edge_dual_home)
242+
/// stays within the argument budget.
243+
pub struct EdgeHomes {
244+
pub vsrc: VShardId,
245+
pub vdst: VShardId,
246+
pub single_home: bool,
247+
}
248+
242249
/// `GRAPH DELETE EDGE IN '<collection>' FROM '<src>' TO '<dst>' TYPE '<label>'`
243250
pub async fn delete_edge(
244251
state: &SharedState,
@@ -316,30 +323,23 @@ pub async fn delete_edge(
316323
state.cluster_transport.is_some() && state.sequencer_inbox.get().is_some();
317324
let single_home = vsrc == vdst || !calvin_available;
318325

319-
// Inside an explicit transaction block a single-home edge delete stages into
320-
// the per-transaction `GraphTxnOverlay` through the neutral gate instead of
326+
// Inside an explicit transaction block an edge delete stages into the
327+
// per-transaction `GraphTxnOverlay` through the neutral gate instead of
321328
// applying durably now: an in-transaction `MATCH` / `GRAPH NEIGHBORS` then
322329
// observes the edge as removed (read-your-own-writes), COMMIT replays the
323-
// buffered `EdgeDelete` via the single-shard WAL + `TransactionBatch` path,
324-
// and ROLLBACK discards the overlay. Autocommit is untouched.
330+
// buffered `EdgeDelete`, and ROLLBACK discards the overlay. Autocommit is
331+
// untouched.
325332
if txn_ctx.sessions.transaction_state(txn_ctx.addr) == TransactionState::InBlock {
326-
if !single_home {
327-
// A cross-shard (dual-home) edge delete inside an explicit
328-
// transaction needs the cross-shard-commit machinery to stage both
329-
// homes atomically across the outer transaction boundary — out of
330-
// scope for single-home staging. Reject with the same signal a
331-
// cross-shard predicate write inside a transaction produces.
332-
return Err(ddl_err(
333-
"XX000",
334-
crate::Error::CrossShardInExplicitTransaction.to_string(),
335-
));
336-
}
337-
super::edge_stage::stage_edge_write_in_txn(
333+
super::edge_stage::stage_edge_dual_home(
338334
state,
339335
tenant_id,
340336
database_id,
341-
vsrc,
342-
PhysicalPlan::Graph(edge_delete),
337+
EdgeHomes {
338+
vsrc,
339+
vdst,
340+
single_home,
341+
},
342+
edge_delete,
343343
txn_ctx,
344344
)
345345
.await?;

0 commit comments

Comments
 (0)