Skip to content

Commit 26ac75c

Browse files
authored
Merge pull request #205 from NodeDB-Lab/fix/purge-teardown-and-cluster-harness-superuser
Fix cluster harness superuser identity and single-node tenant teardown storage reclaim
2 parents bece881 + df7f3cf commit 26ac75c

7 files changed

Lines changed: 100 additions & 31 deletions

File tree

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ fluxbench = "0.1"
265265

266266
[profile.dev]
267267
debug = "line-tables-only"
268+
# Strip symbol tables from dev/test binaries. With --all-features every test
269+
# binary statically links the full dependency closure, and there are hundreds of
270+
# them, so an unstripped target/ runs to ~100 GB. Stripping removes better than a
271+
# third of each binary. This unsymbolises panics/backtraces on ordinary dev
272+
# builds; when you need to debug, build with `--profile debugging`, which keeps
273+
# full symbols (see below).
274+
strip = true
268275

269276
[profile.dev.package."*"]
270277
debug = false
@@ -286,3 +293,5 @@ strip = true
286293
[profile.debugging]
287294
inherits = "dev"
288295
debug = true
296+
# dev strips symbols; override it here so the debugging profile stays symbolised.
297+
strip = false

nodedb-cluster-tests/tests/native_gather_join_in_txn_occ.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ fn pinned_native_client(node: &TestClusterNode) -> NativeClient {
9292
NativeClient::new(PoolConfig {
9393
addr: format!("127.0.0.1:{}", node.native_port),
9494
max_size: 1,
95+
// The cluster harness runs trust auth with `nodedb` as the sole
96+
// materialized superuser; the `PoolConfig` default user is `admin`,
97+
// which trust mode rejects as a non-existent identity. Authenticate as
98+
// the harness superuser, matching the pgwire path (`user=nodedb`).
99+
auth: nodedb_types::protocol::AuthMethod::Trust {
100+
username: "nodedb".into(),
101+
},
95102
..Default::default()
96103
})
97104
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ impl TestClusterNode {
107107
&data_dir_path.join("system.redb"),
108108
)?,
109109
);
110+
// Mirror production/pgwire-harness bootstrap: both listeners run in
111+
// `AuthMode::Trust`, which resolves — but never fabricates — a durable
112+
// stored identity. Without this every node rejects the harness connect
113+
// with `trust auth: user 'nodedb' does not exist`.
114+
credentials.bootstrap_trust_superuser("nodedb")?;
110115
let mut shared =
111116
SharedState::new_with_credentials(dispatcher, Arc::clone(&wal), credentials)?;
112117

nodedb/src/control/catalog_entry/tests/collection.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ fn purge_collection_is_scoped_to_database() {
102102
apply_to(&CatalogEntry::PutCollection(Box::new(default)), catalog);
103103
apply_to(&CatalogEntry::PutCollection(Box::new(other)), catalog);
104104

105+
// A `PurgeCollection` apply only deactivates the catalog row (the
106+
// crash-durable same-name barrier); the row/owner/surrogate deletion is the
107+
// `finalize_purge` half that runs once storage reclaim succeeds. Drive both
108+
// to assert the delete is scoped to the target database.
105109
apply_to(
106110
&CatalogEntry::PurgeCollection {
107111
database_id: 9,
@@ -110,6 +114,8 @@ fn purge_collection_is_scoped_to_database() {
110114
},
111115
catalog,
112116
);
117+
crate::control::catalog_entry::apply::collection::finalize_purge(9, 1, "shared", catalog)
118+
.expect("finalize purge for database 9");
113119

114120
assert!(
115121
catalog

nodedb/src/control/server/shared/ddl/neutral/user/tenant_purge.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,34 @@ pub(super) fn purge_owned_for_tenant_teardown(
4141
crate::control::catalog_entry::apply::local::apply_locally_if_needed(
4242
state, &entry, log_index,
4343
);
44+
// A `PurgeCollection` apply only deactivates the catalog row — the
45+
// durable owner/collection deletion and storage reclaim are the
46+
// post-apply half. On the clustered path the metadata applier schedules
47+
// that reclaim on every node; on the single-node path (`log_index == 0`)
48+
// there is no applier, so drive the same reclaim `drop.rs` runs inline.
49+
// Without this the teardown leaves the owner row and never reclaims the
50+
// collection's storage. Other owner kinds delete fully in their apply.
51+
if kind == OwnerKind::Collection && log_index == 0 {
52+
let purge_lsn = state.wal.next_lsn().as_u64();
53+
let reclaim = tokio::task::block_in_place(|| {
54+
tokio::runtime::Handle::current().block_on(
55+
crate::control::server::shared::ddl::neutral::collection::purge::hard_purge_collection(
56+
state,
57+
owner.database_id,
58+
tenant.as_u64(),
59+
&owner.object_name,
60+
purge_lsn,
61+
false,
62+
),
63+
)
64+
});
65+
reclaim.map_err(|failure| {
66+
ddl_err(format!(
67+
"tenant teardown collection reclaim failed for '{}': {}",
68+
owner.object_name, failure.error
69+
))
70+
})?;
71+
}
4472
if log_index == 0 {
4573
state
4674
.permissions

nodedb/tests/crash_harness/mod.rs

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -233,34 +233,54 @@ impl CrashHarness {
233233
self.wait_ready(Duration::from_secs(20));
234234
}
235235

236+
/// Open a fresh pgwire connection, run one statement, and return the
237+
/// resulting messages. Panics on connect/exec error.
238+
///
239+
/// Retries the transient "no sequencer leader elected yet" startup
240+
/// condition: `/healthz` intentionally reports ready before the Calvin
241+
/// sequencer group has elected a leader (the sequencer is deliberately not
242+
/// a data group in the readiness gate), so a cross-shard write issued in
243+
/// the first moments of uptime can race the election and get a clean,
244+
/// retryable error. On a loaded machine that window is wide enough to lose.
245+
/// A real client retries; so does the harness, bounded, before writing.
246+
async fn simple_query_ready(&self, sql: &str) -> Vec<tokio_postgres::SimpleQueryMessage> {
247+
let deadline = Instant::now() + Duration::from_secs(20);
248+
loop {
249+
let (client, connection) =
250+
tokio_postgres::connect(&self.pgwire_conn_str(), tokio_postgres::NoTls)
251+
.await
252+
.expect("connect for exec");
253+
let conn_handle = tokio::spawn(async move {
254+
let _ = connection.await;
255+
});
256+
let result = client.simple_query(sql).await;
257+
drop(client);
258+
let _ = conn_handle.await;
259+
match result {
260+
Ok(messages) => return messages,
261+
Err(e)
262+
if Instant::now() < deadline
263+
&& e.as_db_error().is_some_and(|db| {
264+
db.message().contains("no sequencer leader elected yet")
265+
}) =>
266+
{
267+
tokio::time::sleep(Duration::from_millis(100)).await;
268+
}
269+
Err(e) => panic!("exec: {e}"),
270+
}
271+
}
272+
}
273+
236274
/// Open a fresh pgwire connection, run one statement, and drop the
237275
/// connection. Panics on connect/exec error.
238276
pub async fn exec(&self, sql: &str) {
239-
let (client, connection) =
240-
tokio_postgres::connect(&self.pgwire_conn_str(), tokio_postgres::NoTls)
241-
.await
242-
.expect("connect for exec");
243-
let conn_handle = tokio::spawn(async move {
244-
let _ = connection.await;
245-
});
246-
client.simple_query(sql).await.expect("exec");
247-
drop(client);
248-
let _ = conn_handle.await;
277+
let _ = self.simple_query_ready(sql).await;
249278
}
250279

251280
/// Run a query and return column `col` from every returned row, as text
252281
/// (via `simple_query`, so the value survives regardless of its type OID).
253282
pub async fn query_col(&self, sql: &str, col: &str) -> Vec<String> {
254-
let (client, connection) =
255-
tokio_postgres::connect(&self.pgwire_conn_str(), tokio_postgres::NoTls)
256-
.await
257-
.expect("connect for query");
258-
let conn_handle = tokio::spawn(async move {
259-
let _ = connection.await;
260-
});
261-
let messages = client.simple_query(sql).await.expect("query");
262-
drop(client);
263-
let _ = conn_handle.await;
283+
let messages = self.simple_query_ready(sql).await;
264284
messages
265285
.iter()
266286
.filter_map(|m| match m {
@@ -278,16 +298,7 @@ impl CrashHarness {
278298
/// `COUNT(*) AS n` — does not surface a usable column name through the
279299
/// pgwire row description, so aggregates must be read by index.
280300
pub async fn query_col_idx(&self, sql: &str, idx: usize) -> Vec<String> {
281-
let (client, connection) =
282-
tokio_postgres::connect(&self.pgwire_conn_str(), tokio_postgres::NoTls)
283-
.await
284-
.expect("connect for query");
285-
let conn_handle = tokio::spawn(async move {
286-
let _ = connection.await;
287-
});
288-
let messages = client.simple_query(sql).await.expect("query");
289-
drop(client);
290-
let _ = conn_handle.await;
301+
let messages = self.simple_query_ready(sql).await;
291302
messages
292303
.iter()
293304
.filter_map(|m| match m {

nodedb/tests/graph_drop_hides_edges.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ async fn soft_drop_named_collection_stats_still_deactivated() {
105105

106106
/// `DROP ... PURGE` must still fully remove edges and stats — the hard-purge
107107
/// path is untouched by this fix and must keep working exactly as before.
108-
#[tokio::test]
108+
///
109+
/// Multi-threaded runtime: the single-node purge path reclaims storage inline
110+
/// via `block_in_place`, which panics on the current-thread runtime.
111+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
109112
async fn hard_purge_removes_edges_and_stats() {
110113
let srv = TestServer::start().await;
111114
srv.exec("CREATE COLLECTION g_hard_purge").await.unwrap();

0 commit comments

Comments
 (0)