Skip to content

Commit 6a711b6

Browse files
committed
feat(control): add flag-gated single-node Calvin deployment
Standalone servers can now synthesize a one-node cluster (self-seeded, replication factor 1) via `server.single_node_calvin`, driving the same cluster startup a real deployment uses so the sequencer Raft group and per-vShard schedulers come up and cross-core transactions take the deterministic Calvin path without a multi-node cluster. Off by default, leaving the existing standalone boot path unchanged. Extends the cluster test harness to spawn nodes through this path and adds coverage asserting the flag toggles calvin_available and that a cross-shard write is admitted through the sequencer while single-shard writes keep working with the flag off.
1 parent bceb877 commit 6a711b6

9 files changed

Lines changed: 430 additions & 40 deletions

File tree

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

nodedb-test-support/src/cluster_harness/cluster/bringup.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ impl TestCluster {
3434
num_cores,
3535
log_compaction_threshold,
3636
replication_factor,
37+
single_node_calvin: false,
3738
};
3839

3940
let node1 = TestClusterNode::spawn_with_full_config(1, vec![], &config).await?;

nodedb-test-support/src/cluster_harness/cluster/types.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ pub(crate) struct ClusterSpawnConfig {
2525
/// `min(replication_factor, node_count)`). Defaults to 3 for every
2626
/// spawn entry point except [`TestCluster::spawn_three_with_compaction_threshold_and_rf`].
2727
pub(crate) replication_factor: usize,
28+
/// When `true`, the node acquires its cluster handle from
29+
/// `init_single_node_calvin` (the flag-gated standalone Calvin
30+
/// synthesis) instead of building explicit `ClusterSettings` and
31+
/// calling `init_cluster_with_transport`. Used only by
32+
/// [`TestClusterNode::spawn_single_node_calvin`]. Defaults to `false`
33+
/// for every multi-node spawn path.
34+
pub(crate) single_node_calvin: bool,
2835
}
2936

3037
/// An in-process cluster of `TestClusterNode`s.

nodedb-test-support/src/cluster_harness/node/lifecycle/spawn_full.rs

Lines changed: 51 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -58,37 +58,6 @@ impl TestClusterNode {
5858
let data_dir = tempfile::tempdir()?;
5959
let data_dir_path: PathBuf = data_dir.path().to_path_buf();
6060

61-
// Pre-bind the QUIC transport on a random port so we know the
62-
// listen address before wiring seeds / cluster settings.
63-
let transport = Arc::new(nodedb_cluster::NexarTransport::new(
64-
node_id,
65-
"127.0.0.1:0".parse()?,
66-
nodedb_cluster::TransportCredentials::Insecure,
67-
)?);
68-
let listen_addr = transport.local_addr();
69-
70-
// Build cluster settings. Empty `seed_nodes` → single-node
71-
// bootstrap by listing only our own address.
72-
let seeds = if seed_nodes.is_empty() {
73-
vec![listen_addr]
74-
} else {
75-
seed_nodes
76-
};
77-
let cluster_settings = ClusterSettings {
78-
node_id,
79-
listen: listen_addr,
80-
seed_nodes: seeds,
81-
num_groups: 2,
82-
replication_factor,
83-
force_bootstrap: false,
84-
tls: None,
85-
max_active_sessions: 0,
86-
login_attempts_per_ip_per_min: 30,
87-
login_attempts_per_user_per_min: 10,
88-
insecure_transport: true,
89-
log_compaction_threshold,
90-
};
91-
9261
// Open WAL + dispatcher + event bus.
9362
let wal = Arc::new(WalManager::open_for_testing(
9463
&data_dir_path.join("test.wal"),
@@ -106,14 +75,57 @@ impl TestClusterNode {
10675
let mut shared =
10776
SharedState::new_with_credentials(dispatcher, Arc::clone(&wal), credentials)?;
10877

109-
// Initialise the cluster using the pre-bound transport.
110-
let handle = nodedb::control::cluster::init_cluster_with_transport(
111-
&cluster_settings,
112-
transport.clone(),
113-
&data_dir_path,
114-
tuning,
115-
)
116-
.await?;
78+
// Acquire the cluster handle. The single-node-Calvin path drives the
79+
// production `init_single_node_calvin` synthesis (which binds its own
80+
// loopback transport); every multi-node path pre-binds a transport and
81+
// builds explicit `ClusterSettings`.
82+
let (handle, listen_addr) = if config.single_node_calvin {
83+
let handle =
84+
nodedb::control::cluster::init_single_node_calvin(&data_dir_path, tuning).await?;
85+
let listen_addr = handle.transport.local_addr();
86+
(handle, listen_addr)
87+
} else {
88+
// Pre-bind the QUIC transport on a random port so we know the
89+
// listen address before wiring seeds / cluster settings.
90+
let transport = Arc::new(nodedb_cluster::NexarTransport::new(
91+
node_id,
92+
"127.0.0.1:0".parse()?,
93+
nodedb_cluster::TransportCredentials::Insecure,
94+
)?);
95+
let listen_addr = transport.local_addr();
96+
97+
// Build cluster settings. Empty `seed_nodes` → single-node
98+
// bootstrap by listing only our own address.
99+
let seeds = if seed_nodes.is_empty() {
100+
vec![listen_addr]
101+
} else {
102+
seed_nodes
103+
};
104+
let cluster_settings = ClusterSettings {
105+
node_id,
106+
listen: listen_addr,
107+
seed_nodes: seeds,
108+
num_groups: 2,
109+
replication_factor,
110+
force_bootstrap: false,
111+
tls: None,
112+
max_active_sessions: 0,
113+
login_attempts_per_ip_per_min: 30,
114+
login_attempts_per_user_per_min: 10,
115+
insecure_transport: true,
116+
log_compaction_threshold,
117+
};
118+
119+
// Initialise the cluster using the pre-bound transport.
120+
let handle = nodedb::control::cluster::init_cluster_with_transport(
121+
&cluster_settings,
122+
transport.clone(),
123+
&data_dir_path,
124+
tuning,
125+
)
126+
.await?;
127+
(handle, listen_addr)
128+
};
117129

118130
// Wire cluster handles into SharedState (mirrors main.rs).
119131
// `Arc::get_mut` is valid here: `shared` has not been cloned.

nodedb-test-support/src/cluster_harness/node/lifecycle/spawn_variants.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,39 @@ impl TestClusterNode {
102102
num_cores,
103103
log_compaction_threshold: None,
104104
replication_factor: 3,
105+
single_node_calvin: false,
105106
};
106107
Self::spawn_with_full_config(node_id, seed_nodes, &config).await
107108
}
109+
110+
/// Spawn a standalone node with the flag-gated single-node Calvin stack
111+
/// (`server.single_node_calvin = true`), exercising the same
112+
/// `init_single_node_calvin` synthesis the production standalone boot uses.
113+
///
114+
/// The node stands up its own sequencer Raft group and per-vShard
115+
/// schedulers, so `calvin_available` becomes true and a cross-vShard
116+
/// transaction traverses the deterministic Calvin path — all on one node.
117+
/// `num_cores` should be `>= 2` so distinct vShards map to distinct cores.
118+
pub async fn spawn_single_node_calvin(
119+
num_cores: usize,
120+
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
121+
// Sub-second election so the lone sequencer voter self-elects quickly
122+
// under the shared CI CPU pool without spurious re-elections.
123+
let tuning = ClusterTransportTuning {
124+
health_ping_interval_secs: 1,
125+
election_timeout_min_ms: 500,
126+
election_timeout_max_ms: 1000,
127+
..ClusterTransportTuning::default()
128+
};
129+
let config = ClusterSpawnConfig {
130+
tuning,
131+
graph_tuning: nodedb_types::config::tuning::GraphTuning::default(),
132+
query_tuning: nodedb_types::config::tuning::QueryTuning::default(),
133+
num_cores,
134+
log_compaction_threshold: None,
135+
replication_factor: 1,
136+
single_node_calvin: true,
137+
};
138+
Self::spawn_with_full_config(1, vec![], &config).await
139+
}
108140
}

0 commit comments

Comments
 (0)