Skip to content

Commit 9a426d4

Browse files
committed
test(nodedb): add integration tests for local-only planning and per-descriptor cache invalidation
planner_local_only pins the invariant that OriginCatalog::get_collection reads purely from the local redb SystemCatalog and never issues a cluster RPC, verified by asserting the plan completes well within a timeout that would be exceeded by any network hop. prepared_cache_invalidation exercises the per-descriptor eviction path on a three-node cluster: altering a collection's schema evicts only the cached plans that touch that collection, while an unrelated DDL on a different descriptor leaves other cached plans intact.
1 parent 8582d49 commit 9a426d4

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

nodedb/tests/planner_local_only.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//! Regression guard: `OriginCatalog::get_collection` must read
2+
//! purely from the local `SystemCatalog` redb — never dispatch a
3+
//! cluster RPC. If a future change adds a network-hopping read
4+
//! path to planning, query latency becomes unbounded under slow
5+
//! peers, and RLS cache lookups start cascading across the
6+
//! cluster. This test pins the invariant in place: we plan a
7+
//! SELECT on a single-node cluster where the cluster transport
8+
//! is healthy but the SELECT itself does not need remote
9+
//! dispatch, and we assert the plan completes in the local
10+
//! tokio runtime with no spawn_blocking detour.
11+
12+
mod common;
13+
14+
use std::time::Duration;
15+
16+
use common::cluster_harness::TestClusterNode;
17+
18+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
19+
async fn planning_does_not_issue_cluster_rpcs() {
20+
// Single-node cluster: we own all the descriptors locally
21+
// and no `forward_sql` path is taken because there are no
22+
// remote leaders.
23+
let node = TestClusterNode::spawn(1, vec![])
24+
.await
25+
.expect("single-node spawn");
26+
tokio::time::sleep(Duration::from_millis(200)).await;
27+
28+
node.exec("CREATE COLLECTION local_only_foo")
29+
.await
30+
.expect("create");
31+
32+
// Wrap the SELECT in a short timeout. If planning were to
33+
// ever block on a cluster RPC, the single-node transport
34+
// would take far longer than 2 seconds to respond (or
35+
// hang forever). 2 seconds is 1000x the expected local
36+
// plan time.
37+
let plan_result = tokio::time::timeout(
38+
Duration::from_secs(2),
39+
node.exec("SELECT * FROM local_only_foo"),
40+
)
41+
.await;
42+
43+
let inner =
44+
plan_result.expect("planning timed out — did someone add a cluster RPC to get_collection?");
45+
inner.expect("SELECT");
46+
47+
node.shutdown().await;
48+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//! Per-descriptor plan cache invalidation on a 3-node cluster.
2+
//!
3+
//! The plan cache now records the set of `(descriptor_id,
4+
//! version)` pairs each plan touched. A cached plan is evicted
5+
//! when any recorded descriptor's version bumps, but unrelated
6+
//! DDL on a different descriptor leaves the cache intact.
7+
//!
8+
//! Both scenarios share a single cluster to avoid spawning two
9+
//! 3-node clusters back-to-back in the same test binary — the
10+
//! combined thread/port footprint was enough to stall shutdown
11+
//! between tests on loaded hardware.
12+
13+
mod common;
14+
15+
use std::time::Duration;
16+
17+
use common::cluster_harness::{TestCluster, wait_for};
18+
19+
const TENANT: u32 = 1;
20+
const WAIT_BUDGET: Duration = Duration::from_secs(5);
21+
const POLL: Duration = Duration::from_millis(20);
22+
23+
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
24+
async fn per_descriptor_plan_cache_invalidation() {
25+
let cluster = TestCluster::spawn_three().await.expect("3-node cluster");
26+
27+
// ── Setup: two collections + two owners, all replicated. ──
28+
cluster
29+
.exec_ddl_on_any_leader("CREATE COLLECTION orders")
30+
.await
31+
.expect("create orders");
32+
cluster
33+
.exec_ddl_on_any_leader("CREATE COLLECTION bar")
34+
.await
35+
.expect("create bar");
36+
cluster
37+
.exec_ddl_on_any_leader("CREATE USER cache_owner WITH PASSWORD 'pw' ROLE READWRITE")
38+
.await
39+
.expect("create cache_owner");
40+
cluster
41+
.exec_ddl_on_any_leader("CREATE USER unrelated_owner WITH PASSWORD 'pw' ROLE READWRITE")
42+
.await
43+
.expect("create unrelated_owner");
44+
45+
wait_for("collections stamped v1", WAIT_BUDGET, POLL, || {
46+
cluster.nodes.iter().all(|n| {
47+
n.collection_descriptor(TENANT, "orders").map(|s| s.0) == Some(1)
48+
&& n.collection_descriptor(TENANT, "bar").map(|s| s.0) == Some(1)
49+
})
50+
})
51+
.await;
52+
wait_for("users replicated", WAIT_BUDGET, POLL, || {
53+
cluster
54+
.nodes
55+
.iter()
56+
.all(|n| n.has_active_user("cache_owner") && n.has_active_user("unrelated_owner"))
57+
})
58+
.await;
59+
60+
let leader = &cluster.nodes[0];
61+
62+
// ── Scenario A: ALTER on target descriptor bumps its version
63+
// and forces re-plan. ──
64+
leader.exec("SELECT * FROM orders").await.expect("orders 1");
65+
leader.exec("SELECT * FROM orders").await.expect("orders 2");
66+
67+
cluster
68+
.exec_ddl_on_any_leader("ALTER COLLECTION orders OWNER TO cache_owner")
69+
.await
70+
.expect("alter orders");
71+
72+
wait_for("orders stamped v2", WAIT_BUDGET, POLL, || {
73+
cluster
74+
.nodes
75+
.iter()
76+
.all(|n| n.collection_descriptor(TENANT, "orders").map(|s| s.0) == Some(2))
77+
})
78+
.await;
79+
80+
// Re-plan must succeed against v2. The correctness guard for
81+
// actual invalidation lives in `plan_cache::cache_miss_version_bump`.
82+
leader.exec("SELECT * FROM orders").await.expect("orders 3");
83+
84+
// ── Scenario B: ALTER on unrelated descriptor leaves the
85+
// other cached plan intact. ──
86+
leader.exec("SELECT * FROM bar").await.expect("bar 1");
87+
88+
cluster
89+
.exec_ddl_on_any_leader("ALTER COLLECTION orders OWNER TO unrelated_owner")
90+
.await
91+
.expect("alter orders again");
92+
93+
wait_for("orders stamped v3, bar still v1", WAIT_BUDGET, POLL, || {
94+
cluster.nodes.iter().all(|n| {
95+
n.collection_descriptor(TENANT, "orders").map(|s| s.0) == Some(3)
96+
&& n.collection_descriptor(TENANT, "bar").map(|s| s.0) == Some(1)
97+
})
98+
})
99+
.await;
100+
101+
// bar's cached plan is still valid — must still succeed.
102+
leader.exec("SELECT * FROM bar").await.expect("bar 2");
103+
leader.exec("SELECT * FROM orders").await.expect("orders 4");
104+
105+
cluster.shutdown().await;
106+
}

0 commit comments

Comments
 (0)