Skip to content

Commit 17bf6eb

Browse files
committed
catalog: reclaim storage metadata for ephemeral items on crash restart (SQL-150)
When a process crash-restarted, shards from temp tables were never cleaned up. However, on graceful close, we do clean up these shards. This was an existing bug from before the durable temporary objects change.
1 parent de0a1f8 commit 17bf6eb

3 files changed

Lines changed: 226 additions & 12 deletions

File tree

src/catalog/src/durable/transaction.rs

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -802,19 +802,91 @@ impl<'a> Transaction<'a> {
802802
}
803803
}
804804

805-
/// Removes every item owned by an ephemeral session from the transaction.
805+
/// Removes every item owned by an ephemeral session from the transaction,
806+
/// along with the durable state a graceful drop would have removed with
807+
/// it: storage collection metadata (moving the backing shards to the
808+
/// finalization WAL), comments, and source references.
806809
///
807810
/// Used to reclaim temporary items when the catalog is opened with write
808811
/// intent, at which point every session that could own one is dead.
812+
///
813+
/// This must mirror everything the graceful `Op::DropObjects` path
814+
/// persists for a temporary item, because nothing revisits the leftovers:
815+
/// bootstrap only ever inserts collection metadata for items present in
816+
/// the catalog, and shard finalization is driven solely by the
817+
/// `unfinalized_shards` collection, so a metadata row that outlives its
818+
/// item leaks the persist shard permanently.
809819
pub fn remove_ephemeral_items(&mut self) {
810-
let keys: Vec<_> = self
811-
.items
812-
.items()
820+
let mut keys = Vec::new();
821+
let mut item_ids = BTreeSet::new();
822+
let mut global_ids = BTreeSet::new();
823+
for (key, value) in self.items.items() {
824+
if value.ephemeral_owner_session.is_none() {
825+
continue;
826+
}
827+
item_ids.insert(key.id);
828+
global_ids.insert(value.global_id);
829+
global_ids.extend(value.extra_versions.values().copied());
830+
keys.push(key.clone());
831+
}
832+
self.items.delete_by_keys(keys, self.op_id);
833+
834+
// Move the items' storage mappings to the finalization WAL, like
835+
// `StorageCollections::prepare_state` does for a graceful drop. Every
836+
// version of a table maps to the same shard, and a shard that a
837+
// remaining mapping still references must not be finalized. No
838+
// remaining mapping can reference one today (only replacement
839+
// materialized views share shards, and those cannot be temporary),
840+
// so this mirrors `prepare_state`'s guard defensively.
841+
let dropped_mappings = self.delete_collection_metadata(global_ids);
842+
let mut dropped_shards: BTreeSet<_> = dropped_mappings
813843
.into_iter()
814-
.filter(|(_, value)| value.ephemeral_owner_session.is_some())
815-
.map(|(key, _)| key.clone())
844+
.map(|(_, shard)| shard)
816845
.collect();
817-
self.items.delete_by_keys(keys, self.op_id);
846+
let live_shards: BTreeSet<_> = self.get_collection_metadata().into_values().collect();
847+
dropped_shards.retain(|shard| {
848+
let live = live_shards.contains(shard);
849+
if live {
850+
soft_panic_or_log!(
851+
"shard {shard} of a reclaimed ephemeral item is still referenced by a \
852+
live collection, not finalizing it"
853+
);
854+
}
855+
!live
856+
});
857+
self.insert_unfinalized_shards(dropped_shards).expect(
858+
"inserting unfinalized shards only fails on duplicate values, which it ignores",
859+
);
860+
861+
// Comments on ephemeral items would otherwise dangle and, because
862+
// item ids are reused, could later re-attach to an unrelated object.
863+
self.comments.delete(
864+
|key, _value| match key.object_id {
865+
CommentObjectId::Table(item_id)
866+
| CommentObjectId::View(item_id)
867+
| CommentObjectId::MaterializedView(item_id)
868+
| CommentObjectId::Source(item_id)
869+
| CommentObjectId::Sink(item_id)
870+
| CommentObjectId::MetricSink(item_id)
871+
| CommentObjectId::Index(item_id)
872+
| CommentObjectId::Func(item_id)
873+
| CommentObjectId::Connection(item_id)
874+
| CommentObjectId::Type(item_id)
875+
| CommentObjectId::Secret(item_id) => item_ids.contains(&item_id),
876+
CommentObjectId::Role(_)
877+
| CommentObjectId::Database(_)
878+
| CommentObjectId::Schema(_)
879+
| CommentObjectId::Cluster(_)
880+
| CommentObjectId::ClusterReplica(_)
881+
| CommentObjectId::NetworkPolicy(_) => false,
882+
},
883+
self.op_id,
884+
);
885+
886+
// Only sources hold source references and sources cannot be temporary
887+
// today, so this is defensive.
888+
self.source_references
889+
.delete(|key, _value| item_ids.contains(&key.source_id), self.op_id);
818890
}
819891

820892
pub fn get_and_increment_id(&mut self, key: String) -> Result<u64, CatalogError> {

src/catalog/tests/read-write.rs

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99

1010
#![recursion_limit = "256"]
1111

12-
use std::collections::BTreeMap;
12+
use std::collections::{BTreeMap, BTreeSet};
1313
use std::sync::Arc;
1414

1515
use insta::assert_debug_snapshot;
1616
use itertools::Itertools;
1717
use mz_audit_log::{EventDetails, EventType, EventV1, IdNameV1, VersionedEvent};
1818
use mz_catalog::durable::objects::serialization::proto;
19-
use mz_catalog::durable::objects::{DurableType, IdAlloc};
19+
use mz_catalog::durable::objects::{Comment, DurableType, IdAlloc};
2020
use mz_catalog::durable::{
2121
CatalogError, Database, DurableCatalogError, FenceError, Item, Metrics,
2222
TestCatalogStateBuilder, USER_ITEM_ALLOC_KEY, test_bootstrap_args,
@@ -25,12 +25,13 @@ use mz_ore::assert_ok;
2525
use mz_ore::collections::HashSet;
2626
use mz_ore::metrics::MetricsRegistry;
2727
use mz_ore::now::SYSTEM_TIME;
28-
use mz_persist_client::PersistClient;
28+
use mz_persist_client::{PersistClient, ShardId};
2929
use mz_proto::RustType;
3030
use mz_repr::role_id::RoleId;
31-
use mz_repr::{CatalogItemId, GlobalId};
31+
use mz_repr::{CatalogItemId, GlobalId, RelationVersion};
3232
use mz_sql::catalog::{RoleAttributesRaw, RoleMembership, RoleVars};
33-
use mz_sql::names::{DatabaseId, ResolvedDatabaseSpecifier, SchemaId};
33+
use mz_sql::names::{CommentObjectId, DatabaseId, ResolvedDatabaseSpecifier, SchemaId};
34+
use mz_storage_client::controller::StorageTxn;
3435
use uuid::Uuid;
3536

3637
#[mz_ore::test(tokio::test)]
@@ -580,6 +581,49 @@ async fn test_ephemeral_items(state_builder: TestCatalogStateBuilder) {
580581
insert(&mut txn, 200, temp_schema, "tt", Some(session_a)).unwrap();
581582
insert(&mut txn, 300, temp_schema, "tt", Some(session_b)).unwrap();
582583

584+
// A temporary item with an ALTER history: two global ids, one shard.
585+
txn.insert_item(
586+
CatalogItemId::User(500),
587+
20_500,
588+
GlobalId::User(500),
589+
temp_schema,
590+
"versioned",
591+
"CREATE TABLE versioned (a int)".to_string(),
592+
RoleId::User(1),
593+
vec![],
594+
BTreeMap::from([(RelationVersion::root().bump(), GlobalId::User(501))]),
595+
Some(session_a),
596+
)
597+
.unwrap();
598+
599+
// Storage mappings like the ones `prepare_state` writes at CREATE, for
600+
// the normal item, one plain temporary item, and both versions of the
601+
// versioned one.
602+
let keep_shard = ShardId::new();
603+
let temp_shard = ShardId::new();
604+
let versioned_shard = ShardId::new();
605+
txn.insert_collection_metadata(BTreeMap::from([
606+
(GlobalId::User(100), keep_shard),
607+
(GlobalId::User(200), temp_shard),
608+
(GlobalId::User(500), versioned_shard),
609+
(GlobalId::User(501), versioned_shard),
610+
]))
611+
.unwrap();
612+
613+
// Comments on a temporary and a non-temporary item.
614+
txn.update_comment(
615+
CommentObjectId::View(CatalogItemId::User(100)),
616+
None,
617+
Some("keep comment".into()),
618+
)
619+
.unwrap();
620+
txn.update_comment(
621+
CommentObjectId::View(CatalogItemId::User(200)),
622+
None,
623+
Some("temp comment".into()),
624+
)
625+
.unwrap();
626+
583627
// One session may not hold the same name twice, though.
584628
let err = insert(&mut txn, 400, temp_schema, "tt", Some(session_a)).unwrap_err();
585629
assert!(
@@ -627,6 +671,41 @@ async fn test_ephemeral_items(state_builder: TestCatalogStateBuilder) {
627671
"non-ephemeral item was removed: {snapshot_items:?}"
628672
);
629673

674+
// Only the non-ephemeral item's comment survives.
675+
let snapshot_comments: Vec<Comment> = state
676+
.snapshot()
677+
.await
678+
.unwrap()
679+
.comments
680+
.into_iter()
681+
.map(RustType::from_proto)
682+
.map_ok(|(k, v)| Comment::from_key_value(k, v))
683+
.collect::<Result<_, _>>()
684+
.unwrap();
685+
assert_eq!(
686+
snapshot_comments
687+
.iter()
688+
.map(|c| c.object_id.clone())
689+
.collect::<Vec<_>>(),
690+
vec![CommentObjectId::View(CatalogItemId::User(100))],
691+
"comments on ephemeral items survived: {snapshot_comments:?}"
692+
);
693+
694+
// The ephemeral items' storage mappings moved to the finalization WAL,
695+
// deduped to one shard per item. The non-ephemeral mapping is untouched.
696+
let txn = state.transaction().await.unwrap();
697+
assert_eq!(
698+
txn.get_collection_metadata(),
699+
BTreeMap::from([(GlobalId::User(100), keep_shard)]),
700+
"ephemeral collection metadata survived"
701+
);
702+
assert_eq!(
703+
txn.get_unfinalized_shards(),
704+
BTreeSet::from([temp_shard, versioned_shard]),
705+
"ephemeral shards were not enqueued for finalization"
706+
);
707+
drop(txn);
708+
630709
Box::new(state).expire().await;
631710
}
632711

test/restart/mzcompose.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,36 @@ def wait_for(sql: str, expected: list[tuple], what: str) -> None:
13341334
cur_b.execute("SELECT count(*) FROM tv")
13351335
assert cur_b.fetchall() == [(1,)], "session b lost its own temporary items"
13361336

1337+
# A comment on a temporary item is a durable catalog row too, and item ids
1338+
# are reused, so reclamation must drop it or it can re-attach to an
1339+
# unrelated later object.
1340+
cur_b.execute("COMMENT ON TABLE tt IS 'crash victim'")
1341+
temp_comment_count = """
1342+
SELECT count(*) FROM mz_internal.mz_catalog_raw
1343+
WHERE data->>'kind' = 'Comment'
1344+
AND data->'value'->>'comment' = 'crash victim'
1345+
"""
1346+
comments = c.sql_query(temp_comment_count, port=6877, user="mz_system")
1347+
assert comments == [(1,)], f"the temp table's comment was not written: {comments}"
1348+
1349+
# Capture the shard backing session b's temp table: the metadata row of
1350+
# the one remaining ephemeral item that has storage (the temp view has
1351+
# none). It is what boot-time reclamation must clean up after the kill.
1352+
shards = c.sql_query(
1353+
"""SELECT m.data->'value'->>'shard'
1354+
FROM mz_internal.mz_catalog_raw m
1355+
WHERE m.data->>'kind' = 'StorageCollectionMetadata'
1356+
AND m.data->'key'->'id' IN (
1357+
SELECT i.data->'value'->'global_id'
1358+
FROM mz_internal.mz_catalog_raw i
1359+
WHERE i.data->>'kind' = 'Item'
1360+
AND i.data->'value'->>'ephemeral_owner_session' IS NOT NULL)""",
1361+
port=6877,
1362+
user="mz_system",
1363+
)
1364+
assert len(shards) == 1, f"expected one ephemeral storage mapping: {shards}"
1365+
temp_shard = shards[0][0]
1366+
13371367
# --- kill -9, with session b's items still live ---------------------------
13381368

13391369
c.kill("materialized")
@@ -1366,6 +1396,39 @@ def wait_for(sql: str, expected: list[tuple], what: str) -> None:
13661396
(0,)
13671397
], f"ephemeral catalog items survived the restart: {ephemeral}"
13681398

1399+
# The temp table's storage mapping must have moved to the finalization
1400+
# WAL in the same reclamation, else the metadata row and its persist
1401+
# shard would leak forever. Both rows are stable to assert on here: the
1402+
# metadata deletion is permanent, and the WAL row survives until the
1403+
# next committed catalog transaction, which cannot have happened because
1404+
# nothing has run DDL since the restart.
1405+
metadata = c.sql_query(
1406+
f"""SELECT count(*) FROM mz_internal.mz_catalog_raw
1407+
WHERE data->>'kind' = 'StorageCollectionMetadata'
1408+
AND data->'value'->>'shard' = '{temp_shard}'""",
1409+
port=6877,
1410+
user="mz_system",
1411+
)
1412+
assert metadata == [
1413+
(0,)
1414+
], f"temp table's storage metadata survived the restart: {temp_shard}"
1415+
unfinalized = c.sql_query(
1416+
f"""SELECT count(*) FROM mz_internal.mz_catalog_raw
1417+
WHERE data->>'kind' = 'UnfinalizedShard'
1418+
AND data->'key'->>'shard' = '{temp_shard}'""",
1419+
port=6877,
1420+
user="mz_system",
1421+
)
1422+
assert unfinalized == [
1423+
(1,)
1424+
], f"temp table's shard was not enqueued for finalization: {temp_shard}"
1425+
1426+
# The comment row dies with its item.
1427+
comments = c.sql_query(temp_comment_count, port=6877, user="mz_system")
1428+
assert comments == [
1429+
(0,)
1430+
], f"the temp table's comment survived the restart: {comments}"
1431+
13691432
# conn_b's socket died with the process; closing is bookkeeping only.
13701433
try:
13711434
conn_b.close()

0 commit comments

Comments
 (0)