Skip to content

Commit d5c45c2

Browse files
committed
fix: single-core L1 file reclaim and distinguish retry-owned purge failures
Route shared on-disk L1 checkpoint/partition reclaim (vector, spatial, sparse-vector, timeseries) to a single homing core per collection during the all-cores UnregisterCollection fan-out, since those paths are keyed by (database, tenant, collection) rather than by core and concurrent cores racing remove_dir_all/unlink on the same tree could turn a benign concurrent-removal errno into a fatal barrier failure. Other cores still evict their own per-core in-memory state. Replace the untyped reclaim error with ReclaimFailure, which records whether a durable pending_reclaim retry record was persisted. Callers now disarm their lifecycle guard only when a retry owner exists; otherwise the guard's unwind Drop releases the in-memory drain so a same-name CREATE can self-heal off the durable inactive catalog row instead of wedging behind an orphaned hold. Also make catalog existence checks in collection/materialized-view CREATE abort on a read fault rather than silently treating it as "does not exist".
1 parent 0481b20 commit d5c45c2

16 files changed

Lines changed: 277 additions & 165 deletions

File tree

nodedb-physical/src/physical_plan/meta.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ pub enum MetaOp {
131131
tenant_id: u64,
132132
name: String,
133133
purge_lsn: u64,
134+
/// Whether this core must reclaim the collection's shared on-disk L1
135+
/// checkpoint/partition files. Those paths are keyed by
136+
/// `(database, tenant, collection)` — not by core — so the Control
137+
/// Plane sets this `true` for exactly one core (the collection's
138+
/// homing vshard) in the all-cores fan-out. Every core still evicts
139+
/// its own per-core in-memory state; only the shared-path unlink /
140+
/// `remove_dir_all` is single-cored, so concurrent cores cannot race
141+
/// on the same tree.
142+
reclaim_l1_files: bool,
134143
},
135144

136145
/// Reclaim every local Data Plane resource for a single

nodedb/src/bridge/quiesce/drain.rs

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -153,27 +153,6 @@ impl CollectionQuiesce {
153153
}
154154
}
155155

156-
/// Wait until no lifecycle drain owns `(database, tenant, collection)`.
157-
pub async fn wait_until_not_draining(
158-
&self,
159-
database_id: u64,
160-
tenant_id: u64,
161-
collection: &str,
162-
) {
163-
loop {
164-
let notified = self.notify.notified();
165-
tokio::pin!(notified);
166-
// Register before checking the state. `notify_waiters` does not
167-
// store a permit for a future waiter, so checking first would lose
168-
// a clear/forget notification in the check-to-poll gap.
169-
notified.as_mut().enable();
170-
if !self.is_draining(database_id, tenant_id, collection) {
171-
return;
172-
}
173-
notified.await;
174-
}
175-
}
176-
177156
/// Returns a future that resolves once every open scan against
178157
/// `(tenant_id, collection)` has completed. Safe to await from the
179158
/// Control Plane (tokio) — internally uses [`tokio::sync::Notify`]
@@ -314,19 +293,13 @@ mod tests {
314293
}
315294

316295
#[tokio::test]
317-
async fn create_waiter_resumes_after_lifecycle_drain_is_forgotten() {
296+
async fn single_lifecycle_holder_clears_on_forget() {
318297
let q = CollectionQuiesce::new();
319298
q.begin_drain(DB, 1, "c");
320-
321-
let q_clone = Arc::clone(&q);
322-
let waiter = tokio::spawn(async move {
323-
q_clone.wait_until_not_draining(DB, 1, "c").await;
324-
});
325-
tokio::task::yield_now().await;
326-
assert!(!waiter.is_finished());
299+
assert!(q.is_draining(DB, 1, "c"));
327300

328301
q.forget(DB, 1, "c");
329-
waiter.await.unwrap();
302+
assert!(!q.is_draining(DB, 1, "c"));
330303
}
331304

332305
#[tokio::test]
@@ -346,23 +319,18 @@ mod tests {
346319
}
347320

348321
#[tokio::test]
349-
async fn create_waiter_requires_every_lifecycle_holder_to_finish() {
322+
async fn is_draining_until_every_lifecycle_holder_forgets() {
350323
let q = CollectionQuiesce::new();
351324
q.begin_drain(DB, 1, "c");
352325
q.begin_drain(DB, 1, "c");
353326

354-
let q_clone = Arc::clone(&q);
355-
let waiter = tokio::spawn(async move {
356-
q_clone.wait_until_not_draining(DB, 1, "c").await;
357-
});
358-
tokio::task::yield_now().await;
359-
327+
// One holder released — still draining while the other holds.
360328
q.forget(DB, 1, "c");
361-
tokio::task::yield_now().await;
362-
assert!(!waiter.is_finished());
329+
assert!(q.is_draining(DB, 1, "c"));
363330

331+
// Last holder released — drain clears.
364332
q.forget(DB, 1, "c");
365-
waiter.await.unwrap();
333+
assert!(!q.is_draining(DB, 1, "c"));
366334
}
367335

368336
#[tokio::test]

nodedb/src/control/catalog_entry/post_apply/async_dispatch/collection.rs

Lines changed: 109 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,49 @@ pub async fn put_async(stored: StoredCollection, shared: Arc<SharedState>) {
1919
collection::put_async(stored, shared).await;
2020
}
2121

22+
/// Failure outcome of [`reclaim_collection_storage`].
23+
///
24+
/// `retry_queued` distinguishes the two failure shapes a lifecycle-guard
25+
/// holder must handle differently:
26+
///
27+
/// - `true` — a durable `_system.pending_reclaim` record was persisted, so a
28+
/// worker (and the boot-time drain) owns the retry and will release the
29+
/// lifecycle drain via `forget` once it completes. The holder must `disarm`
30+
/// its guard so it does NOT also release the drain.
31+
/// - `false` — no durable owner exists for a retry (the WAL/redb tombstone
32+
/// writes failed before any record was queued, or queuing the record itself
33+
/// failed). The holder must let its guard `Drop` release the in-memory drain
34+
/// so a same-name CREATE can re-acquire the lifecycle and self-heal off the
35+
/// durable inactive catalog row. Leaking the drain here would wedge every
36+
/// future same-name CREATE (and the GC sweeper) until the node restarts.
37+
#[derive(Debug)]
38+
pub(crate) struct ReclaimFailure {
39+
pub(crate) error: crate::Error,
40+
pub(crate) retry_queued: bool,
41+
}
42+
43+
impl ReclaimFailure {
44+
pub(crate) fn no_retry(error: impl Into<crate::Error>) -> Self {
45+
Self {
46+
error: error.into(),
47+
retry_queued: false,
48+
}
49+
}
50+
51+
fn retry_queued(error: crate::Error) -> Self {
52+
Self {
53+
error,
54+
retry_queued: true,
55+
}
56+
}
57+
}
58+
59+
impl std::fmt::Display for ReclaimFailure {
60+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61+
write!(f, "{}", self.error)
62+
}
63+
}
64+
2265
/// Reclaim every engine's storage for `(tenant_id, name)` on this node — WAL
2366
/// tombstone, redb tombstone, optional L2 cleanup enqueue, quiesce drain,
2467
/// `MetaOp::UnregisterCollection` dispatch to the local Data Plane, and Lite
@@ -35,20 +78,27 @@ pub(crate) async fn reclaim_collection_storage(
3578
name: &str,
3679
purge_lsn: u64,
3780
drain_already_held: bool,
38-
) -> crate::Result<()> {
39-
// 1. Persist to redb (every node has its own catalog).
81+
) -> Result<(), ReclaimFailure> {
82+
// 1. Persist to redb (every node has its own catalog). A failure here
83+
// leaves no durable retry owner, so it is a `no_retry` failure: the caller
84+
// releases its lifecycle guard rather than leaking the drain.
4085
let catalog = shared.credentials.catalog();
41-
catalog.record_wal_tombstone(database_id, tenant_id, name, purge_lsn)?;
86+
catalog
87+
.record_wal_tombstone(database_id, tenant_id, name, purge_lsn)
88+
.map_err(ReclaimFailure::no_retry)?;
4289

4390
// 2. Append to local WAL. Both durable tombstone surfaces are required
4491
// before storage reclaim; otherwise truncation or catalog loss can replay
4592
// predecessor writes after a same-name CREATE.
46-
shared.wal.append_collection_tombstone(
47-
TenantId::new(tenant_id),
48-
DatabaseId::new(database_id),
49-
name,
50-
purge_lsn,
51-
)?;
93+
shared
94+
.wal
95+
.append_collection_tombstone(
96+
TenantId::new(tenant_id),
97+
DatabaseId::new(database_id),
98+
name,
99+
purge_lsn,
100+
)
101+
.map_err(ReclaimFailure::no_retry)?;
52102

53103
// 2b. Enqueue an L2 cleanup entry if cold storage is configured.
54104
// Recorded even when `bytes_pending` is unknown (0) — the worker
@@ -123,49 +173,59 @@ pub(crate) async fn reclaim_collection_storage(
123173
)
124174
});
125175

126-
if let Err(e) = &purge_result {
127-
// Keep the lifecycle drain marker set. A same-name CREATE waits until
128-
// the durable retry succeeds; releasing it here would let that retry
129-
// erase the replacement because engine keys are name-scoped.
130-
if let Err(record_error) = record_pending_reclaim(
131-
shared,
132-
database_id,
133-
tenant_id,
134-
name,
135-
purge_lsn,
136-
&e.to_string(),
137-
) {
138-
return Err(crate::Error::Storage {
139-
engine: "pending-reclaim".into(),
140-
detail: format!(
141-
"collection reclaim failed ({e}); durable retry record also failed: {record_error}"
142-
),
143-
});
176+
match purge_result {
177+
Err(e) => {
178+
// Keep the lifecycle drain marker set ONLY when a durable retry
179+
// record is persisted: a worker then owns the retry and releases
180+
// the drain via `forget`. A same-name CREATE waits until that
181+
// retry succeeds, because engine keys are name-scoped. If recording
182+
// the durable entry itself fails there is no owner to release the
183+
// drain, so this is a `no_retry` failure and the caller must let
184+
// its guard release the in-memory hold.
185+
match record_pending_reclaim(
186+
shared,
187+
database_id,
188+
tenant_id,
189+
name,
190+
purge_lsn,
191+
&e.to_string(),
192+
) {
193+
Ok(()) => Err(ReclaimFailure::retry_queued(e)),
194+
Err(record_error) => Err(ReclaimFailure::no_retry(crate::Error::Storage {
195+
engine: "pending-reclaim".into(),
196+
detail: format!(
197+
"collection reclaim failed ({e}); durable retry record also failed: {record_error}"
198+
),
199+
})),
200+
}
144201
}
145-
} else {
146-
// Broadcast only after every core reclaimed the old incarnation.
147-
// Saturated per-session channels may drop the notification; offline
148-
// replay remains the fallback.
149-
shared
150-
.crdt_sync_delivery
151-
.broadcast_collection_purged(tenant_id, name, purge_lsn);
152-
153-
// A prior failed attempt may have left a durable entry; a succeeding
154-
// purge clears it, then releases CREATE waiters.
155-
let catalog = shared.credentials.catalog();
156-
catalog.remove_pending_reclaim(database_id, tenant_id, name)?;
157-
if !drain_already_held {
158-
shared.quiesce.forget(database_id, tenant_id, name);
202+
Ok(()) => {
203+
// Broadcast only after every core reclaimed the old incarnation.
204+
// Saturated per-session channels may drop the notification; offline
205+
// replay remains the fallback.
206+
shared
207+
.crdt_sync_delivery
208+
.broadcast_collection_purged(tenant_id, name, purge_lsn);
209+
210+
// A prior failed attempt may have left a durable entry; a
211+
// succeeding purge clears it, then releases CREATE waiters.
212+
shared
213+
.credentials
214+
.catalog()
215+
.remove_pending_reclaim(database_id, tenant_id, name)
216+
.map_err(ReclaimFailure::no_retry)?;
217+
if !drain_already_held {
218+
shared.quiesce.forget(database_id, tenant_id, name);
219+
}
220+
debug!(
221+
collection = %name,
222+
tenant = tenant_id,
223+
purge_lsn,
224+
"catalog_entry: UnregisterCollection reclaimed on local Data Plane"
225+
);
226+
Ok(())
159227
}
160-
debug!(
161-
collection = %name,
162-
tenant = tenant_id,
163-
purge_lsn,
164-
"catalog_entry: UnregisterCollection reclaimed on local Data Plane"
165-
);
166228
}
167-
168-
purge_result
169229
}
170230

171231
/// Persist a durable `_system.pending_reclaim` entry so the failed

nodedb/src/control/catalog_entry/post_apply/async_dispatch/materialized_view.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub async fn delete_async(
2424
name: String,
2525
purge_lsn: u64,
2626
shared: Arc<SharedState>,
27-
) -> crate::Result<()> {
27+
) -> Result<(), super::collection::ReclaimFailure> {
2828
super::collection::reclaim_collection_storage(&shared, 0, tenant_id, &name, purge_lsn, false)
2929
.await
3030
}

nodedb/src/control/catalog_entry/post_apply/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,6 @@ mod async_dispatch;
4141
pub(crate) mod gateway_invalidation;
4242
mod sync;
4343

44-
pub(crate) use async_dispatch::collection::reclaim_collection_storage;
44+
pub(crate) use async_dispatch::collection::{ReclaimFailure, reclaim_collection_storage};
4545
pub use async_dispatch::spawn_post_apply_async_side_effects;
4646
pub use sync::apply_post_apply_side_effects_sync;

nodedb/src/control/server/shared/ddl/neutral/collection/create/build.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,13 @@ pub async fn build_and_persist(
152152
));
153153
}
154154

155-
// Check if the object already exists.
156-
if let Ok(Some(existing)) = catalog.get_collection(database_id, tenant_id.as_u64(), name) {
155+
// Check if the object already exists. A catalog-read fault must abort the
156+
// CREATE — proceeding as if no row exists could build a fresh collection
157+
// over a soft-deleted incarnation's still-present storage.
158+
let existing = catalog
159+
.get_collection(database_id, tenant_id.as_u64(), name)
160+
.map_err(|error| err("XX000", error.to_string()))?;
161+
if let Some(existing) = existing {
157162
if existing.is_active {
158163
return Err(err(
159164
"42P07",
@@ -189,11 +194,16 @@ pub async fn build_and_persist(
189194
local_lifecycle.is_some(),
190195
)
191196
.await;
192-
if let Err(error) = purge_result {
193-
if let Some(guard) = local_lifecycle.take() {
197+
if let Err(failure) = purge_result {
198+
// Only disarm when a durable retry record owns the drain. Otherwise
199+
// let the guard release the in-memory hold so this same-name CREATE
200+
// can be retried against the durable inactive catalog row.
201+
if failure.retry_queued
202+
&& let Some(guard) = local_lifecycle.take()
203+
{
194204
guard.disarm();
195205
}
196-
return Err(err("XX000", error.to_string()));
206+
return Err(err("XX000", failure.error.to_string()));
197207
}
198208
}
199209

nodedb/src/control/server/shared/ddl/neutral/collection/drop.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,16 @@ pub fn drop_collection(
283283
.await
284284
})
285285
});
286-
if let Err(error) = purge_result {
287-
if let Some(guard) = local_lifecycle.take() {
286+
if let Err(failure) = purge_result {
287+
// Disarm only when a durable retry record owns the drain; on a
288+
// no-retry failure the guard's unwind Drop releases the hold so
289+
// a same-name CREATE is not wedged behind an orphaned drain.
290+
if failure.retry_queued
291+
&& let Some(guard) = local_lifecycle.take()
292+
{
288293
guard.disarm();
289294
}
290-
panic!("local collection reclaim failed: {error}");
295+
panic!("local collection reclaim failed: {}", failure.error);
291296
}
292297
state
293298
.permissions

nodedb/src/control/server/shared/ddl/neutral/collection/purge/dispatch.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ pub async fn dispatch_unregister_collection(
4646
.dispatcher
4747
.lock()
4848
.unwrap_or_else(|poisoned| poisoned.into_inner());
49+
// The shared on-disk L1 files are keyed by (database, tenant,
50+
// collection), so exactly one core reclaims them. Route to the
51+
// collection's homing vshard; fall back to core 0 if the router
52+
// cannot resolve it, so the files are never orphaned.
53+
let homing_core = dispatcher
54+
.router()
55+
.resolve(VShardId::from_collection_in_database(database, name))
56+
.unwrap_or(0);
4957
for core_id in 0..num_cores {
5058
let request_id = state.next_request_id();
5159
let request = Request {
@@ -57,6 +65,7 @@ pub async fn dispatch_unregister_collection(
5765
tenant_id,
5866
name: name.to_string(),
5967
purge_lsn,
68+
reclaim_l1_files: core_id == homing_core,
6069
}),
6170
deadline: Instant::now() + timeout,
6271
priority: Priority::Background,

0 commit comments

Comments
 (0)