Skip to content

Commit b88c8ee

Browse files
committed
test: add integration tests for gateway, startup gates, and shutdown phases
New test suites cover the gateway and shutdown work introduced in this branch: Gateway: - gateway_execute: basic execute roundtrip through the Gateway - cluster_execute_request: ExecuteRequest routing over QUIC in a 3-node cluster - {http,ilp,native,pgwire,resp}_gateway_migration: verify each protocol handler falls back correctly from direct dispatch to gateway routing - listeners_gateway_smoke: all listeners accept queries after gateway enable - listeners_typed_not_leader: non-leader nodes return NOT_LEADER via gateway - catalog_recovery_check: diverged in-memory registry is repaired on startup Startup gates: - startup_gate_{pgwire,http,native,ilp,resp}: each listener blocks connections before GatewayEnable and accepts them immediately after - startup_failure: node in Failed state rejects all connections Shutdown phases: - shutdown_{idempotent,budget,in_flight,abort_offender,event_plane}: phased drain correctness, per-phase budget enforcement, and offender abort Existing tests updated for renamed harness helpers and new startup/shutdown APIs.
1 parent 9875e13 commit b88c8ee

32 files changed

Lines changed: 4551 additions & 38 deletions

nodedb/tests/catalog_recovery_check.rs

Lines changed: 521 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
//! Integration tests for `ExecuteRequest` / `ExecuteResponse` cross-node RPC.
2+
//!
3+
//! Tests the C-β physical-plan forwarding path end-to-end:
4+
//! 1. Happy path: encode a `PhysicalPlan`, ship it via `ExecuteRequest`,
5+
//! get payloads back.
6+
//! 2. DescriptorMismatch: caller passes a stale version, receiver returns
7+
//! `TypedClusterError::DescriptorMismatch`.
8+
//! 3. DeadlineExceeded: caller passes `deadline_remaining_ms = 0`, receiver
9+
//! returns `DeadlineExceeded` immediately — no dispatch to Data Plane.
10+
//!
11+
//! These tests run in the `cluster` nextest group (max-threads = 1,
12+
//! threads-required = num-test-threads) because they bring up 3-node clusters.
13+
14+
mod common;
15+
16+
use std::time::Duration;
17+
18+
use common::cluster_harness::TestCluster;
19+
use nodedb::bridge::physical_plan::wire as plan_wire;
20+
use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan};
21+
use nodedb_cluster::rpc_codec::{
22+
DescriptorVersionEntry, ExecuteRequest, RaftRpc, TypedClusterError,
23+
};
24+
25+
/// Build an `ExecuteRequest` wrapping a trivial `KvOp::Put`.
26+
fn make_kv_put_request(
27+
collection: &str,
28+
descriptor_version: u64,
29+
deadline_remaining_ms: u64,
30+
) -> ExecuteRequest {
31+
// KvOp::Put expects binary-encoded value bytes (Binary Tuple / msgpack).
32+
// Use a minimal msgpack-encoded string via zerompk.
33+
let value_bytes = zerompk::to_msgpack_vec(&nodedb_types::Value::String("hello".into()))
34+
.expect("encode value");
35+
let plan = PhysicalPlan::Kv(KvOp::Put {
36+
collection: collection.into(),
37+
key: b"test-key".to_vec(),
38+
value: value_bytes,
39+
ttl_ms: 0,
40+
});
41+
42+
let plan_bytes = plan_wire::encode(&plan).expect("encode plan");
43+
44+
ExecuteRequest {
45+
plan_bytes,
46+
tenant_id: 0,
47+
deadline_remaining_ms,
48+
trace_id: 0xDEAD_CAFE_1234,
49+
descriptor_versions: vec![DescriptorVersionEntry {
50+
collection: collection.into(),
51+
version: descriptor_version,
52+
}],
53+
}
54+
}
55+
56+
/// Send an `ExecuteRequest` to a specific node and decode the response.
57+
///
58+
/// Uses `send_rpc_to_addr` so the test doesn't need to know a node's ID in the
59+
/// transport routing table — it just sends directly to the QUIC listen address.
60+
async fn send_execute_request(
61+
transport: &nodedb_cluster::NexarTransport,
62+
target_addr: std::net::SocketAddr,
63+
req: ExecuteRequest,
64+
) -> nodedb_cluster::rpc_codec::ExecuteResponse {
65+
let rpc = RaftRpc::ExecuteRequest(req);
66+
match transport.send_rpc_to_addr(target_addr, rpc).await {
67+
Ok(RaftRpc::ExecuteResponse(resp)) => resp,
68+
Ok(other) => panic!("expected ExecuteResponse, got {other:?}"),
69+
Err(e) => panic!("transport error: {e}"),
70+
}
71+
}
72+
73+
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
74+
async fn execute_request_deadline_exceeded_immediate() {
75+
// Simple test that doesn't need a 3-node cluster: a single node already
76+
// has `LocalPlanExecutor` wired. Send with deadline_remaining_ms=0 and
77+
// verify the receiver returns DeadlineExceeded without touching storage.
78+
let node1 = common::cluster_harness::TestClusterNode::spawn(1, vec![])
79+
.await
80+
.expect("spawn node 1");
81+
82+
// Give the node a moment to finish startup.
83+
tokio::time::sleep(Duration::from_millis(200)).await;
84+
85+
let transport = node1
86+
.shared
87+
.cluster_transport
88+
.as_ref()
89+
.expect("cluster_transport");
90+
let req = make_kv_put_request("deadlines_test", 1, 0 /* deadline = 0 */);
91+
let resp = send_execute_request(transport, node1.listen_addr, req).await;
92+
93+
assert!(!resp.success, "expected failure for expired deadline");
94+
match resp.error {
95+
Some(TypedClusterError::DeadlineExceeded { .. }) => {}
96+
other => panic!("expected DeadlineExceeded, got {other:?}"),
97+
}
98+
99+
node1.shutdown().await;
100+
}
101+
102+
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
103+
async fn execute_request_descriptor_mismatch() {
104+
// Single-node: create a collection, then send an ExecuteRequest with
105+
// a stale descriptor_version and verify DescriptorMismatch is returned.
106+
let node1 = common::cluster_harness::TestClusterNode::spawn(1, vec![])
107+
.await
108+
.expect("spawn node 1");
109+
tokio::time::sleep(Duration::from_millis(200)).await;
110+
111+
// Create the collection so the node has a real descriptor (version ≥ 1).
112+
node1
113+
.exec("CREATE COLLECTION schema_check_test KEY TEXT")
114+
.await
115+
.expect("create collection");
116+
117+
// Give the metadata applier a moment to commit.
118+
tokio::time::sleep(Duration::from_millis(300)).await;
119+
120+
let transport = node1
121+
.shared
122+
.cluster_transport
123+
.as_ref()
124+
.expect("cluster_transport");
125+
126+
// Version 999 is deliberately stale — the actual version will be 1.
127+
let req = make_kv_put_request("schema_check_test", 999, 5000);
128+
let resp = send_execute_request(transport, node1.listen_addr, req).await;
129+
130+
assert!(!resp.success, "expected failure for stale descriptor");
131+
match resp.error {
132+
Some(TypedClusterError::DescriptorMismatch {
133+
collection,
134+
expected_version,
135+
..
136+
}) => {
137+
assert_eq!(collection, "schema_check_test");
138+
assert_eq!(expected_version, 999);
139+
}
140+
other => panic!("expected DescriptorMismatch, got {other:?}"),
141+
}
142+
143+
node1.shutdown().await;
144+
}
145+
146+
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
147+
async fn execute_request_cross_node_dispatch() {
148+
// 3-node cluster: create a collection on the leader, then send an
149+
// ExecuteRequest from node 2's transport directly to node 1 (the bootstrap
150+
// leader). Verify the response indicates success or a known dispatch error.
151+
//
152+
// We use version 0 in the descriptor_versions list so any version matches
153+
// (the catalog check only rejects when expected ≠ actual AND actual > 0).
154+
// This lets the test succeed even if the applier hasn't flushed yet.
155+
let cluster = TestCluster::spawn_three()
156+
.await
157+
.expect("3-node cluster spawn");
158+
159+
// Create a KV collection on whatever node is the DDL leader.
160+
cluster
161+
.exec_ddl_on_any_leader("CREATE COLLECTION cross_node_kv KEY TEXT")
162+
.await
163+
.expect("create collection");
164+
165+
// Give the metadata applier on all nodes a moment to replicate.
166+
tokio::time::sleep(Duration::from_millis(400)).await;
167+
168+
// Node 2 sends the request; node 1 (bootstrap leader) receives it.
169+
let sender_transport = cluster.nodes[1]
170+
.shared
171+
.cluster_transport
172+
.as_ref()
173+
.expect("node 2 transport");
174+
let target_addr = cluster.nodes[0].listen_addr;
175+
176+
// Use version 0 to bypass the descriptor check (pre-bootstrap sentinel).
177+
let req = ExecuteRequest {
178+
plan_bytes: {
179+
let value_bytes = zerompk::to_msgpack_vec(&nodedb_types::Value::String("v1".into()))
180+
.expect("encode value");
181+
let plan = PhysicalPlan::Kv(KvOp::Put {
182+
collection: "cross_node_kv".into(),
183+
key: b"k1".to_vec(),
184+
value: value_bytes,
185+
ttl_ms: 0,
186+
});
187+
plan_wire::encode(&plan).expect("encode plan")
188+
},
189+
tenant_id: 0,
190+
deadline_remaining_ms: 5000,
191+
trace_id: 0xBEEF_FACE,
192+
descriptor_versions: vec![DescriptorVersionEntry {
193+
collection: "cross_node_kv".into(),
194+
version: 0, // Accept any version (pre-B.1 sentinel bypass)
195+
}],
196+
};
197+
198+
let resp = send_execute_request(sender_transport, target_addr, req).await;
199+
200+
// The response is either success (Data Plane executed the put) or an
201+
// Internal error from the dispatcher (e.g. if no Data Plane core is
202+
// registered for this vshard in the test harness). Both are acceptable
203+
// outcomes for this path test — we're validating the RPC codec and
204+
// handler wiring, not Data Plane correctness.
205+
//
206+
// What must NOT happen: an unexpected panic, a codec error, or a
207+
// DescriptorMismatch (version 0 bypasses that check).
208+
match resp.error {
209+
Some(TypedClusterError::DescriptorMismatch { .. }) => {
210+
panic!("DescriptorMismatch should not fire for version 0");
211+
}
212+
Some(TypedClusterError::DeadlineExceeded { .. }) => {
213+
panic!("DeadlineExceeded should not fire with 5s deadline");
214+
}
215+
_ => {
216+
// success or Internal — both acceptable
217+
}
218+
}
219+
220+
cluster.shutdown().await;
221+
}

nodedb/tests/common/cluster_harness/node.rs

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ pub struct TestClusterNode {
4747
pub shared: Arc<SharedState>,
4848
_data_dir: tempfile::TempDir,
4949
_conn_handle: tokio::task::JoinHandle<()>,
50-
pg_shutdown_tx: tokio::sync::watch::Sender<bool>,
50+
pg_shutdown_bus: nodedb::control::shutdown::ShutdownBus,
5151
poller_shutdown_tx: tokio::sync::watch::Sender<bool>,
5252
cluster_shutdown_tx: tokio::sync::watch::Sender<bool>,
5353
core_stop_tx: std::sync::mpsc::Sender<()>,
@@ -201,6 +201,7 @@ impl TestClusterNode {
201201
Arc::clone(&shared),
202202
trigger_dlq,
203203
Arc::clone(&shared.cdc_router),
204+
Arc::clone(&shared.shutdown),
204205
);
205206

206207
// Start Raft + install MetadataCommitApplier.
@@ -224,19 +225,54 @@ impl TestClusterNode {
224225
cluster_shutdown_rx,
225226
);
226227

228+
// Construct the gateway and install it (plus its DDL invalidator) on
229+
// SharedState, mirroring what main.rs does before listeners bind.
230+
//
231+
// We use a raw-pointer write because `shared` has already been cloned
232+
// by the response poller task, making `Arc::get_mut` return None.
233+
// This is sound at this point in setup because:
234+
// 1. The response poller only calls `poll_and_route_responses()`,
235+
// which never touches the `gateway` or `gateway_invalidator` fields.
236+
// 2. No other concurrent task reads those fields before the pgwire
237+
// listener binds (a few lines below).
238+
// 3. The write completes before the pgwire listener spawns, so the
239+
// happens-before relationship is guaranteed.
240+
{
241+
let shared_for_gw = Arc::clone(&shared);
242+
let gateway = Arc::new(nodedb::control::gateway::Gateway::new(shared_for_gw));
243+
let invalidator = Arc::new(nodedb::control::gateway::PlanCacheInvalidator::new(
244+
&gateway.plan_cache,
245+
));
246+
// SAFETY: no concurrent reads of `gateway` / `gateway_invalidator`
247+
// at this point (see comment above). Fields start as `None` and
248+
// are written once here before any listener starts.
249+
unsafe {
250+
let state = Arc::as_ptr(&shared) as *mut nodedb::control::state::SharedState;
251+
(*state).gateway = Some(Arc::clone(&gateway));
252+
(*state).gateway_invalidator = Some(invalidator);
253+
}
254+
}
255+
227256
// pgwire listener.
257+
// In the test harness, use the startup gate already on SharedState
258+
// (a pre-fired placeholder from `new_inner`). This means the listener
259+
// accepts immediately without a startup-phase delay.
228260
let pg_listener = PgListener::bind("127.0.0.1:0".parse()?).await?;
229261
let pg_addr = pg_listener.local_addr();
230-
let (pg_shutdown_tx, pg_shutdown_rx) = tokio::sync::watch::channel(false);
262+
let (pg_shutdown_bus, _) =
263+
nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown));
231264
let shared_pg = Arc::clone(&shared);
265+
let test_startup_gate = Arc::clone(&shared.startup);
266+
let bus_pg = pg_shutdown_bus.clone();
232267
let pg_handle = tokio::spawn(async move {
233268
let _ = pg_listener
234269
.run(
235270
shared_pg,
236271
AuthMode::Trust,
237272
None,
238273
Arc::new(tokio::sync::Semaphore::new(128)),
239-
pg_shutdown_rx,
274+
test_startup_gate,
275+
bus_pg,
240276
)
241277
.await;
242278
});
@@ -264,7 +300,7 @@ impl TestClusterNode {
264300
shared,
265301
_data_dir: data_dir,
266302
_conn_handle: conn_handle,
267-
pg_shutdown_tx,
303+
pg_shutdown_bus,
268304
poller_shutdown_tx,
269305
cluster_shutdown_tx,
270306
core_stop_tx,
@@ -633,6 +669,35 @@ impl TestClusterNode {
633669
.unwrap_or(false)
634670
}
635671

672+
/// Force the routing table on this node to point `group_id` at `fake_leader`,
673+
/// creating a stale route.
674+
///
675+
/// When the gateway on this node next dispatches to `group_id`, it will send
676+
/// the request to `fake_leader` instead of the real leader. The remote node
677+
/// (which is NOT the leader for that group) will return `TypedClusterError::NotLeader`,
678+
/// causing `retry_not_leader` to update the routing table and retry against
679+
/// the real leader. This is the canonical way to exercise the NotLeader retry
680+
/// path in tests without needing a real leadership change (which is slow and
681+
/// flaky).
682+
pub fn force_stale_route_for_test(&self, group_id: u64, fake_leader: u64) {
683+
if let Some(ref routing) = self.shared.cluster_routing {
684+
let mut table = routing.write().unwrap_or_else(|p| p.into_inner());
685+
table.set_leader(group_id, fake_leader);
686+
}
687+
}
688+
689+
/// Read the current `not_leader_retry_count` from this node's shared gateway.
690+
///
691+
/// Returns 0 if the gateway has not been constructed yet (shouldn't happen
692+
/// in tests since the harness wires the gateway during spawn).
693+
pub fn not_leader_retry_count(&self) -> u64 {
694+
self.shared
695+
.gateway
696+
.as_ref()
697+
.map(|gw| gw.not_leader_retry_count())
698+
.unwrap_or(0)
699+
}
700+
636701
/// Execute a simple query; returns an error message on SQL error.
637702
pub async fn exec(&self, sql: &str) -> Result<(), String> {
638703
match self.client.simple_query(sql).await {
@@ -643,7 +708,7 @@ impl TestClusterNode {
643708

644709
/// Cooperatively shut down every background task this node owns.
645710
pub async fn shutdown(self) {
646-
let _ = self.pg_shutdown_tx.send(true);
711+
self.pg_shutdown_bus.initiate();
647712
let _ = self.cluster_shutdown_tx.send(true);
648713
let _ = self.poller_shutdown_tx.send(true);
649714
let _ = self.core_stop_tx.send(());
@@ -678,7 +743,7 @@ impl TestClusterNode {
678743
/// in milliseconds instead of minutes.
679744
impl Drop for TestClusterNode {
680745
fn drop(&mut self) {
681-
let _ = self.pg_shutdown_tx.send(true);
746+
self.pg_shutdown_bus.initiate();
682747
let _ = self.cluster_shutdown_tx.send(true);
683748
let _ = self.poller_shutdown_tx.send(true);
684749
// `core_stop_tx` is a std mpsc Sender; dropping it disconnects

0 commit comments

Comments
 (0)