Skip to main content

cobble_table/catalog/
file_catalog.rs

1use super::store::CatalogStore;
2use crate::catalog::{
3    Catalog, CatalogError, CatalogResult, CatalogSchemaId, CatalogTable, SchemaChange, TableId,
4    TableIdentifier,
5};
6use crate::evolution::{
7    FieldTransform, apply_schema_changes, compile_column_evolution, schema_field_ids,
8};
9use crate::metadata::TableMetadata;
10use crate::snapshot::TableSnapshotCommitter;
11use crate::write::{TABLE_WRITE_PLAN_FORMAT, TABLE_WRITE_PLAN_VERSION};
12use crate::{
13    FieldId, ReadOnlyTableBuilder, Table, TableError, TableReaderBuilder, TableSchema,
14    TableWriteBuilder, TableWritePlan, TableWriterBuilder,
15};
16use cobble::{
17    ColumnFamilyOptions, Config, CoordinatorConfig, Db, DbCoordinator, VolumeDescriptor,
18    VolumeUsageKind,
19};
20use serde::{Deserialize, Serialize, de::DeserializeOwned};
21use sha2::{Digest, Sha256};
22use std::collections::{HashMap, HashSet};
23use std::fmt::Write as _;
24use std::path::Path;
25use std::sync::{Arc, Mutex, OnceLock, Weak};
26use url::Url;
27use uuid::Uuid;
28
29#[cfg(test)]
30#[path = "../../tests/unit/catalog_storage.rs"]
31mod storage_tests;
32
33impl From<cobble::Error> for CatalogError {
34    fn from(error: cobble::Error) -> Self {
35        Self::Backend(Box::new(error))
36    }
37}
38
39const CATALOG_FORMAT: &str = "cobble-table-catalog";
40const CATALOG_VERSION: u32 = 1;
41
42/// Runtime storage association captured while opening a file catalog.
43///
44/// The configuration is process-local and intentionally never serialized into catalog metadata.
45pub(crate) struct CatalogRuntimeContext {
46    config: Config,
47    storage_id: String,
48}
49
50impl CatalogRuntimeContext {
51    fn scoped_config(&self, runtime: Config, table_id: TableId) -> Config {
52        scoped_table_config(&self.config.volumes, &self.storage_id, table_id, runtime)
53    }
54}
55
56fn scoped_table_config(
57    shared_volumes: &[VolumeDescriptor],
58    storage_id: &str,
59    table_id: TableId,
60    mut runtime: Config,
61) -> Config {
62    let relative_root = format!("{storage_id}/tables/TABLE-{table_id}");
63    let mut volumes = shared_volumes
64        .iter()
65        .filter_map(|volume| shared_volume(volume, &relative_root))
66        .collect::<Vec<_>>();
67    volumes.extend(
68        runtime
69            .volumes
70            .iter()
71            .flat_map(|volume| runtime_volumes(volume, &relative_root)),
72    );
73    runtime.volumes = volumes;
74    runtime
75}
76
77fn shared_volume(source: &VolumeDescriptor, relative_root: &str) -> Option<VolumeDescriptor> {
78    plan_shared_volume(source).map(|mut volume| {
79        volume.base_dir = append_relative_path(&volume.base_dir, relative_root);
80        volume
81    })
82}
83
84fn plan_shared_volume(source: &VolumeDescriptor) -> Option<VolumeDescriptor> {
85    let mut volume = source.clone();
86    volume.kinds = 0;
87    for kind in [
88        VolumeUsageKind::Meta,
89        VolumeUsageKind::Snapshot,
90        VolumeUsageKind::Wal,
91    ] {
92        if source.supports(kind) {
93            volume.set_usage(kind);
94        }
95    }
96    (volume.kinds != 0).then_some(volume)
97}
98
99fn runtime_volumes(source: &VolumeDescriptor, relative_root: &str) -> Vec<VolumeDescriptor> {
100    let mut owned = source.clone();
101    owned.kinds = 0;
102    for kind in [
103        VolumeUsageKind::PrimaryDataPriorityHigh,
104        VolumeUsageKind::PrimaryDataPriorityMedium,
105        VolumeUsageKind::PrimaryDataPriorityLow,
106        VolumeUsageKind::Cache,
107    ] {
108        if source.supports(kind) {
109            owned.set_usage(kind);
110        }
111    }
112    let mut volumes = Vec::new();
113    if owned.kinds != 0 {
114        owned.base_dir = append_relative_path(&owned.base_dir, relative_root);
115        volumes.push(owned);
116    }
117    if source.supports(VolumeUsageKind::Readonly) {
118        let mut readonly = source.clone();
119        readonly.kinds = 0;
120        readonly.set_usage(VolumeUsageKind::Readonly);
121        volumes.push(readonly);
122    }
123    volumes
124}
125
126fn append_relative_path(base: &str, relative: &str) -> String {
127    if let Ok(mut url) = Url::parse(base) {
128        let parent = url.path().trim_end_matches('/');
129        let child = relative.trim_matches('/');
130        let path = match (parent, child) {
131            ("", child) => format!("/{child}"),
132            (parent, "") => parent.to_string(),
133            (parent, child) => format!("{parent}/{child}"),
134        };
135        url.set_path(&path);
136        return url.to_string();
137    }
138    Path::new(base)
139        .join(relative)
140        .to_string_lossy()
141        .into_owned()
142}
143
144/// Runtime-only configuration for a file catalog.
145///
146/// Volume descriptors and credentials remain in [`cobble::Config`] and are never written into
147/// catalog metadata.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct FileCatalogConfig {
150    storage_id: String,
151}
152
153impl FileCatalogConfig {
154    pub fn new(storage_id: impl Into<String>) -> Self {
155        Self {
156            storage_id: storage_id.into(),
157        }
158    }
159
160    pub fn storage_id(&self) -> &str {
161        &self.storage_id
162    }
163}
164
165/// One catalog schema version materialized into a shard's core schema.
166#[derive(Clone, Debug, PartialEq, Eq)]
167pub struct ShardSchemaMapping {
168    table_id: TableId,
169    db_id: String,
170    catalog_schema_id: CatalogSchemaId,
171    core_schema_id: u64,
172}
173
174impl ShardSchemaMapping {
175    pub fn table_id(&self) -> TableId {
176        self.table_id
177    }
178
179    pub fn db_id(&self) -> &str {
180        &self.db_id
181    }
182
183    pub fn catalog_schema_id(&self) -> CatalogSchemaId {
184        self.catalog_schema_id
185    }
186
187    pub fn core_schema_id(&self) -> u64 {
188        self.core_schema_id
189    }
190}
191#[derive(Clone, Debug, Serialize, Deserialize)]
192struct CurrentPointer {
193    format: String,
194    version: u32,
195    generation: u64,
196}
197
198#[derive(Clone, Debug, Serialize, Deserialize)]
199struct CatalogManifest {
200    format: String,
201    version: u32,
202    generation: u64,
203    next_table_id: u32,
204    namespaces: Vec<NamespaceEntry>,
205}
206
207impl CatalogManifest {
208    fn empty() -> Self {
209        Self {
210            format: CATALOG_FORMAT.to_string(),
211            version: CATALOG_VERSION,
212            generation: 0,
213            next_table_id: 1,
214            namespaces: Vec::new(),
215        }
216    }
217}
218
219#[derive(Clone, Debug, Serialize, Deserialize)]
220struct NamespaceEntry {
221    namespace: Vec<String>,
222    namespace_id: Uuid,
223}
224
225#[derive(Clone, Debug, Serialize, Deserialize)]
226struct NamespaceManifest {
227    format: String,
228    version: u32,
229    generation: u64,
230    namespace: Vec<String>,
231    tables: Vec<TableEntry>,
232}
233
234#[derive(Clone, Debug, Serialize, Deserialize)]
235struct TableEntry {
236    name: String,
237    table_id: TableId,
238    catalog_schema_id: CatalogSchemaId,
239}
240
241#[derive(Clone, Debug, Serialize, Deserialize)]
242struct TableIdentity {
243    format: String,
244    version: u32,
245    table_id: TableId,
246    physical_name: String,
247}
248
249#[derive(Clone, Debug, Serialize, Deserialize)]
250struct TableSchemaRecord {
251    format: String,
252    version: u32,
253    table_id: TableId,
254    catalog_schema_id: CatalogSchemaId,
255    schema: TableSchema,
256    used_field_ids: Vec<FieldId>,
257    field_transforms: Vec<FieldTransform>,
258}
259
260#[derive(Clone, Debug, Serialize, Deserialize)]
261struct ShardSchemaMappingFile {
262    format: String,
263    version: u32,
264    table_id: TableId,
265    db_id: String,
266    catalog_schema_id: CatalogSchemaId,
267    core_schema_id: u64,
268}
269
270/// File-backed table catalog.
271///
272/// Operations are serialized across handles in this process and refresh CURRENT before every
273/// read or mutation. One active catalog mutator is still required across processes; distributed
274/// locking is intentionally outside this implementation.
275pub struct FileCatalog {
276    store: CatalogStore,
277    runtime_context: Arc<CatalogRuntimeContext>,
278    state: Mutex<CatalogManifest>,
279    operation_lock: Arc<Mutex<()>>,
280}
281
282impl FileCatalog {
283    pub fn open(config: &Config, catalog_config: FileCatalogConfig) -> CatalogResult<Self> {
284        let store = CatalogStore::open(config, catalog_config.storage_id())?;
285        let manifest = load_catalog_manifest(&store)?;
286        let operation_lock = process_operation_lock(catalog_config.storage_id())?;
287        let runtime_context = Arc::new(CatalogRuntimeContext {
288            config: config.clone(),
289            storage_id: catalog_config.storage_id().to_string(),
290        });
291        Ok(Self {
292            store,
293            runtime_context,
294            state: Mutex::new(manifest),
295            operation_lock,
296        })
297    }
298
299    /// Materialize the current catalog schema into one writable shard.
300    ///
301    /// Calls for a shard must not run concurrently with other core schema updates.
302    pub fn materialize_table(
303        &self,
304        db: Arc<Db>,
305        identifier: &TableIdentifier,
306    ) -> CatalogResult<Table> {
307        let table = self.load_table(identifier)?;
308        let (physical_name, target) = materialize_loaded_table(&self.store, db.as_ref(), &table)?;
309        Table::from_metadata(db, physical_name, target).map_err(Into::into)
310    }
311
312    fn with_current<T>(
313        &self,
314        operation: impl FnOnce(&mut CatalogManifest) -> CatalogResult<T>,
315    ) -> CatalogResult<T> {
316        let _operation = lock(&self.operation_lock)?;
317        let mut manifest = lock(&self.state)?;
318        *manifest = load_catalog_manifest(&self.store)?;
319        operation(&mut manifest)
320    }
321
322    fn namespace_entry<'a>(
323        manifest: &'a CatalogManifest,
324        namespace: &[String],
325    ) -> Option<&'a NamespaceEntry> {
326        manifest
327            .namespaces
328            .iter()
329            .find(|entry| entry.namespace == namespace)
330    }
331
332    fn required_namespace<'a>(
333        manifest: &'a CatalogManifest,
334        namespace: &[String],
335    ) -> CatalogResult<&'a NamespaceEntry> {
336        Self::namespace_entry(manifest, namespace)
337            .ok_or_else(|| CatalogError::NamespaceNotFound(namespace.to_vec()))
338    }
339
340    fn load_namespace(&self, entry: &NamespaceEntry) -> CatalogResult<NamespaceManifest> {
341        let prefix = namespace_prefix(entry.namespace_id);
342        let current: CurrentPointer = read_json(&self.store, &format!("{prefix}/CURRENT"))?;
343        validate_header(&current.format, current.version)?;
344        let manifest: NamespaceManifest = read_json(
345            &self.store,
346            &format!("{prefix}/NAMESPACE-{}", current.generation),
347        )?;
348        validate_header(&manifest.format, manifest.version)?;
349        if manifest.generation != current.generation || manifest.namespace != entry.namespace {
350            return Err(CatalogError::InvalidMetadata(
351                "namespace manifest does not match CURRENT or catalog entry".to_string(),
352            ));
353        }
354        Ok(manifest)
355    }
356
357    fn load_identity(&self, table_id: TableId) -> CatalogResult<TableIdentity> {
358        let identity: TableIdentity = read_json(&self.store, &table_identity_path(table_id))?;
359        validate_header(&identity.format, identity.version)?;
360        debug_assert_eq!(identity.table_id, table_id);
361        debug_assert_eq!(identity.physical_name, physical_table_name(table_id));
362        Ok(identity)
363    }
364
365    fn load_schema(
366        &self,
367        table_id: TableId,
368        catalog_schema_id: CatalogSchemaId,
369    ) -> CatalogResult<TableSchemaRecord> {
370        load_table_schema_record(&self.store, table_id, catalog_schema_id)
371    }
372
373    fn catalog_table(
374        &self,
375        identifier: TableIdentifier,
376        identity: TableIdentity,
377        schema: TableSchemaRecord,
378    ) -> CatalogTable {
379        CatalogTable {
380            identifier,
381            table_id: identity.table_id,
382            catalog_schema_id: schema.catalog_schema_id,
383            schema: schema.schema,
384            runtime_context: Arc::clone(&self.runtime_context),
385        }
386    }
387
388    fn commit_catalog(
389        &self,
390        current: &mut CatalogManifest,
391        next: CatalogManifest,
392    ) -> CatalogResult<()> {
393        write_json(&self.store, &format!("CATALOG-{}", next.generation), &next)?;
394        write_json(
395            &self.store,
396            "CURRENT",
397            &CurrentPointer {
398                format: CATALOG_FORMAT.to_string(),
399                version: CATALOG_VERSION,
400                generation: next.generation,
401            },
402        )?;
403        *current = next;
404        Ok(())
405    }
406
407    fn commit_namespace(
408        &self,
409        entry: &NamespaceEntry,
410        manifest: &NamespaceManifest,
411    ) -> CatalogResult<()> {
412        let prefix = namespace_prefix(entry.namespace_id);
413        write_json(
414            &self.store,
415            &format!("{prefix}/NAMESPACE-{}", manifest.generation),
416            manifest,
417        )?;
418        write_json(
419            &self.store,
420            &format!("{prefix}/CURRENT"),
421            &CurrentPointer {
422                format: CATALOG_FORMAT.to_string(),
423                version: CATALOG_VERSION,
424                generation: manifest.generation,
425            },
426        )
427    }
428}
429
430fn materialize_loaded_table(
431    store: &CatalogStore,
432    db: &Db,
433    table: &CatalogTable,
434) -> CatalogResult<(String, TableMetadata)> {
435    materialize_table_definition(
436        store,
437        db,
438        table.table_id,
439        table.catalog_schema_id,
440        &table.schema,
441    )
442}
443
444pub(crate) fn materialize_write_plan(
445    store_config: &Config,
446    db: &Db,
447    plan: &TableWritePlan,
448) -> crate::Result<(String, TableMetadata)> {
449    plan.validate()?;
450    let store = CatalogStore::open(store_config, &plan.storage_id)
451        .map_err(|error| TableError::internal(error.to_string()))?;
452    materialize_table_definition(
453        &store,
454        db,
455        plan.table_id,
456        plan.catalog_schema_id,
457        &plan.schema,
458    )
459    .map_err(|error| TableError::internal(error.to_string()))
460}
461
462fn materialize_table_definition(
463    store: &CatalogStore,
464    db: &Db,
465    table_id: TableId,
466    catalog_schema_id: CatalogSchemaId,
467    schema: &TableSchema,
468) -> CatalogResult<(String, TableMetadata)> {
469    let physical_name = physical_table_name(table_id);
470    let target = TableMetadata::compile_catalog(schema.clone(), table_id, catalog_schema_id)?;
471    let current = db.current_schema();
472    let materialized = if let Some(column_family_id) =
473        current.column_family_ids().get(&physical_name).copied()
474    {
475        let options = current.column_family_options_in_family(column_family_id);
476        let existing = options.metadata.as_ref().ok_or_else(|| {
477            TableError::InvalidSchema(format!(
478                "column family '{physical_name}' is not a catalog table"
479            ))
480        })?;
481        let existing = TableMetadata::from_value(existing)?;
482        let binding = existing.catalog_binding.ok_or_else(|| {
483            TableError::InvalidSchema(format!(
484                "column family '{physical_name}' is not a catalog table"
485            ))
486        })?;
487        if binding.table_id != table_id {
488            return Err(TableError::InvalidSchema(format!(
489                "column family '{physical_name}' belongs to another catalog table"
490            ))
491            .into());
492        }
493        if binding.catalog_schema_id > catalog_schema_id {
494            return Err(TableError::InvalidSchema(format!(
495                "catalog schema {} cannot replace newer materialized schema {}",
496                catalog_schema_id, binding.catalog_schema_id
497            ))
498            .into());
499        }
500        let source_record = load_table_schema_record(store, table_id, binding.catalog_schema_id)?;
501        let mut materialized = TableMetadata::compile_catalog(
502            source_record.schema,
503            table_id,
504            binding.catalog_schema_id,
505        )?;
506        if existing != materialized
507            || current.num_columns_in_family(column_family_id)
508                != Some(materialized.layout.value_columns.len().max(1))
509        {
510            return Err(TableError::InvalidSchema(
511                "materialized table metadata does not match the catalog".to_string(),
512            )
513            .into());
514        }
515        write_schema_mapping(
516            store,
517            shard_schema_mapping(table_id, db, binding.catalog_schema_id, current.version()),
518        )?;
519        let mut materialized_catalog_schema_id = binding.catalog_schema_id;
520        while materialized_catalog_schema_id < catalog_schema_id {
521            let next_catalog_schema_id =
522                materialized_catalog_schema_id.next().ok_or_else(|| {
523                    CatalogError::InvalidSchemaEvolution(
524                        "catalog schema id space exhausted".to_string(),
525                    )
526                })?;
527            let next_record = load_table_schema_record(store, table_id, next_catalog_schema_id)?;
528            let next = TableMetadata::compile_catalog(
529                next_record.schema,
530                table_id,
531                next_catalog_schema_id,
532            )?;
533            let remap =
534                compile_column_evolution(&materialized, &next, &next_record.field_transforms)?;
535            let mut builder = db.update_schema();
536            builder.remap_columns(Some(physical_name.clone()), remap)?;
537            builder.set_column_family_options(
538                Some(physical_name.clone()),
539                ColumnFamilyOptions {
540                    metadata: Some(next.to_value()?),
541                    ..ColumnFamilyOptions::default()
542                },
543            )?;
544            let core_schema_id = builder.commit().version();
545            write_schema_mapping(
546                store,
547                shard_schema_mapping(table_id, db, next_catalog_schema_id, core_schema_id),
548            )?;
549            materialized = next;
550            materialized_catalog_schema_id = next_catalog_schema_id;
551        }
552        materialized
553    } else {
554        let mut builder = db.update_schema();
555        builder.ensure_column_family_exists(physical_name.clone())?;
556        for column in 0..target.layout.value_columns.len().max(1) {
557            builder.add_column(column, None, None, Some(physical_name.clone()))?;
558        }
559        builder.set_column_family_options(
560            Some(physical_name.clone()),
561            ColumnFamilyOptions {
562                metadata: Some(target.to_value()?),
563                ..ColumnFamilyOptions::default()
564            },
565        )?;
566        let core_schema_id = builder.commit().version();
567        write_schema_mapping(
568            store,
569            shard_schema_mapping(table_id, db, catalog_schema_id, core_schema_id),
570        )?;
571        return Ok((physical_name, target));
572    };
573    if materialized != target {
574        return Err(TableError::InvalidSchema(
575            "materialized table metadata does not match the requested catalog schema".to_string(),
576        )
577        .into());
578    }
579    Ok((physical_name, materialized))
580}
581
582fn shard_schema_mapping(
583    table_id: TableId,
584    db: &Db,
585    catalog_schema_id: CatalogSchemaId,
586    core_schema_id: u64,
587) -> ShardSchemaMappingFile {
588    ShardSchemaMappingFile {
589        format: CATALOG_FORMAT.to_string(),
590        version: CATALOG_VERSION,
591        table_id,
592        db_id: db.id().to_string(),
593        catalog_schema_id,
594        core_schema_id,
595    }
596}
597
598fn write_schema_mapping(
599    store: &CatalogStore,
600    mapping: ShardSchemaMappingFile,
601) -> CatalogResult<()> {
602    let path = schema_mapping_path(mapping.table_id, &mapping.db_id, mapping.catalog_schema_id);
603    if store.exists(&path)? {
604        let existing: ShardSchemaMappingFile = read_json(store, &path)?;
605        validate_header(&existing.format, existing.version)?;
606        validate_schema_mapping_key(
607            &existing,
608            mapping.table_id,
609            &mapping.db_id,
610            mapping.catalog_schema_id,
611        )?;
612        if existing.core_schema_id <= mapping.core_schema_id {
613            return Ok(());
614        }
615    }
616    write_json(store, &path, &mapping)
617}
618
619fn load_table_schema_record(
620    store: &CatalogStore,
621    table_id: TableId,
622    catalog_schema_id: CatalogSchemaId,
623) -> CatalogResult<TableSchemaRecord> {
624    let record: TableSchemaRecord =
625        read_json(store, &table_schema_path(table_id, catalog_schema_id))?;
626    validate_header(&record.format, record.version)?;
627    if record.table_id != table_id || record.catalog_schema_id != catalog_schema_id {
628        return Err(CatalogError::InvalidMetadata(
629            "table schema record does not match its lookup key".to_string(),
630        ));
631    }
632    #[cfg(debug_assertions)]
633    {
634        debug_assert!(record.schema.validate().is_ok());
635        debug_assert!(
636            record
637                .used_field_ids
638                .windows(2)
639                .all(|window| window[0] < window[1])
640        );
641        debug_assert!(
642            schema_field_ids(&record.schema)
643                .iter()
644                .all(|field_id| record.used_field_ids.binary_search(field_id).is_ok())
645        );
646    }
647    Ok(record)
648}
649
650impl CatalogTable {
651    /// Return the stable physical column-family name for this catalog table.
652    #[cfg(feature = "ffi")]
653    #[doc(hidden)]
654    pub fn physical_name(&self) -> String {
655        physical_table_name(self.table_id)
656    }
657
658    /// Materialize this captured catalog schema into one writable shard.
659    ///
660    /// Calls for a shard must not run concurrently with other core schema updates.
661    pub fn materialize_table(&self, db: Arc<Db>) -> CatalogResult<Table> {
662        let store = CatalogStore::open(
663            &self.runtime_context.config,
664            &self.runtime_context.storage_id,
665        )?;
666        let (physical_name, target) = materialize_loaded_table(&store, db.as_ref(), self)?;
667        Table::from_metadata(db, physical_name, target).map_err(Into::into)
668    }
669
670    /// Start building a portable writer initialization plan for this table.
671    pub fn new_write_builder(&self) -> TableWriteBuilder {
672        TableWriteBuilder::new(self.clone(), self.runtime_context.config.total_buckets)
673    }
674
675    /// Build an owned writer for this table using its catalog-managed shared storage.
676    pub fn writer_builder(&self, runtime: Config) -> CatalogResult<TableWriterBuilder> {
677        self.new_write_builder()
678            .total_buckets(runtime.total_buckets)
679            .build()?
680            .writer_builder(runtime)
681    }
682
683    /// Materialize this loaded catalog version into a writable table and refresh its local layout.
684    ///
685    /// The caller controls which catalog version is loaded; this method never follows catalog
686    /// CURRENT implicitly.
687    pub fn refresh_writer(&self, table: &mut Table) -> CatalogResult<bool> {
688        let physical_name = physical_table_name(self.table_id);
689        if table.name() != physical_name {
690            return Err(TableError::InvalidSchema(
691                "Table does not belong to this catalog table".to_string(),
692            )
693            .into());
694        }
695        materialize_loaded_table(
696            &CatalogStore::open(
697                &self.runtime_context.config,
698                &self.runtime_context.storage_id,
699            )?,
700            table.db(),
701            self,
702        )?;
703        table.refresh_schema().map_err(Into::into)
704    }
705
706    /// Build an owned snapshot reader for this table using its catalog-managed shared storage.
707    pub fn reader_builder(&self, runtime: Config) -> CatalogResult<TableReaderBuilder> {
708        let context = &self.runtime_context;
709        let config = context.scoped_config(runtime, self.table_id);
710        Ok(TableReaderBuilder::from_catalog(
711            config,
712            physical_table_name(self.table_id),
713            self.table_id,
714        ))
715    }
716
717    /// Build an owned shard snapshot table for this catalog table.
718    pub fn readonly_table_builder(&self, runtime: Config) -> CatalogResult<ReadOnlyTableBuilder> {
719        let context = &self.runtime_context;
720        let config = context.scoped_config(runtime, self.table_id);
721        Ok(ReadOnlyTableBuilder::from_catalog(
722            config,
723            physical_table_name(self.table_id),
724            self.table_id,
725        ))
726    }
727
728    /// Build an in-process committer in this table's global snapshot namespace.
729    pub fn snapshot_committer(
730        &self,
731        runtime: Config,
732        max_pending_commits: usize,
733    ) -> CatalogResult<TableSnapshotCommitter> {
734        let total_buckets = runtime.total_buckets;
735        let coordinator = Arc::new(self.coordinator(runtime)?);
736        Ok(TableSnapshotCommitter::new(
737            coordinator,
738            total_buckets,
739            max_pending_commits,
740        )?)
741    }
742
743    /// Open the core coordinator in this table's global snapshot namespace.
744    pub fn coordinator(&self, runtime: Config) -> CatalogResult<DbCoordinator> {
745        let context = &self.runtime_context;
746        let config = context.scoped_config(runtime, self.table_id);
747        Ok(DbCoordinator::open(CoordinatorConfig::from_config(
748            &config,
749        ))?)
750    }
751}
752
753pub(crate) fn build_write_plan(
754    table: CatalogTable,
755    total_buckets: u32,
756) -> CatalogResult<TableWritePlan> {
757    let context = &table.runtime_context;
758    let shared_volumes = context
759        .config
760        .volumes
761        .iter()
762        .filter_map(plan_shared_volume)
763        .map(|volume| volume.without_credentials())
764        .collect();
765    let plan = TableWritePlan {
766        format: TABLE_WRITE_PLAN_FORMAT.to_string(),
767        version: TABLE_WRITE_PLAN_VERSION,
768        identifier: table.identifier,
769        table_id: table.table_id,
770        catalog_schema_id: table.catalog_schema_id,
771        schema: table.schema,
772        storage_id: context.storage_id.clone(),
773        shared_volumes,
774        total_buckets,
775        auth_source: Some(context.config.clone()),
776    };
777    plan.validate()?;
778    Ok(plan)
779}
780
781pub(crate) fn writer_builder_from_write_plan(
782    plan: &TableWritePlan,
783    runtime: Config,
784) -> CatalogResult<TableWriterBuilder> {
785    plan.validate()?;
786    let credential_source = plan.auth_source.as_ref().unwrap_or(&runtime);
787    let shared_volumes = plan
788        .shared_volumes
789        .iter()
790        .map(|volume| volume.with_credentials_from(credential_source))
791        .collect::<Vec<_>>();
792    let store_config = Config {
793        volumes: shared_volumes.clone(),
794        ..Config::default()
795    };
796    let mut config = scoped_table_config(&shared_volumes, &plan.storage_id, plan.table_id, runtime);
797    config.total_buckets = plan.total_buckets;
798    Ok(TableWriterBuilder::from_write_plan(
799        config,
800        physical_table_name(plan.table_id),
801        plan.clone(),
802        store_config,
803    ))
804}
805
806impl FileCatalog {
807    /// Load the core schema id used for one table schema on a shard.
808    pub fn load_shard_schema_mapping(
809        &self,
810        identifier: &TableIdentifier,
811        db_id: &str,
812        catalog_schema_id: CatalogSchemaId,
813    ) -> CatalogResult<ShardSchemaMapping> {
814        let table = self.load_table(identifier)?;
815        if catalog_schema_id > table.catalog_schema_id {
816            return Err(CatalogError::SchemaNotFound {
817                table: identifier.clone(),
818                catalog_schema_id,
819            });
820        }
821        let mapping: ShardSchemaMappingFile = read_json(
822            &self.store,
823            &schema_mapping_path(table.table_id, db_id, catalog_schema_id),
824        )?;
825        validate_header(&mapping.format, mapping.version)?;
826        validate_schema_mapping_key(&mapping, table.table_id, db_id, catalog_schema_id)?;
827        Ok(ShardSchemaMapping {
828            table_id: mapping.table_id,
829            db_id: mapping.db_id,
830            catalog_schema_id: mapping.catalog_schema_id,
831            core_schema_id: mapping.core_schema_id,
832        })
833    }
834}
835
836impl Catalog for FileCatalog {
837    fn create_namespace(&self, namespace: Vec<String>) -> CatalogResult<()> {
838        validate_namespace(&namespace)?;
839        self.with_current(|current| {
840            if Self::namespace_entry(current, &namespace).is_some() {
841                return Err(CatalogError::NamespaceAlreadyExists(namespace));
842            }
843            let entry = NamespaceEntry {
844                namespace: namespace.clone(),
845                namespace_id: Uuid::new_v4(),
846            };
847            self.commit_namespace(
848                &entry,
849                &NamespaceManifest {
850                    format: CATALOG_FORMAT.to_string(),
851                    version: CATALOG_VERSION,
852                    generation: 1,
853                    namespace,
854                    tables: Vec::new(),
855                },
856            )?;
857            let mut next = current.clone();
858            next.generation += 1;
859            next.namespaces.push(entry);
860            next.namespaces
861                .sort_by(|left, right| left.namespace.cmp(&right.namespace));
862            self.commit_catalog(current, next)
863        })
864    }
865
866    fn list_namespaces(&self) -> CatalogResult<Vec<Vec<String>>> {
867        self.with_current(|current| {
868            Ok(current
869                .namespaces
870                .iter()
871                .map(|entry| entry.namespace.clone())
872                .collect())
873        })
874    }
875
876    fn drop_namespace(&self, namespace: &[String]) -> CatalogResult<()> {
877        validate_namespace(namespace)?;
878        self.with_current(|current| {
879            let entry = Self::required_namespace(current, namespace)?.clone();
880            if !self.load_namespace(&entry)?.tables.is_empty() {
881                return Err(CatalogError::NamespaceNotEmpty(namespace.to_vec()));
882            }
883            let mut next = current.clone();
884            next.generation += 1;
885            next.namespaces
886                .retain(|candidate| candidate.namespace != namespace);
887            self.commit_catalog(current, next)
888        })
889    }
890
891    fn create_table(
892        &self,
893        identifier: TableIdentifier,
894        schema: TableSchema,
895    ) -> CatalogResult<CatalogTable> {
896        validate_identifier(&identifier)?;
897        schema.validate()?;
898        self.with_current(|current| {
899            let namespace_entry =
900                Self::required_namespace(current, identifier.namespace())?.clone();
901            let mut namespace = self.load_namespace(&namespace_entry)?;
902            if namespace
903                .tables
904                .iter()
905                .any(|entry| entry.name == identifier.name())
906            {
907                return Err(CatalogError::TableAlreadyExists(identifier));
908            }
909            let table_id = TableId::new(current.next_table_id);
910            let mut next = current.clone();
911            next.generation += 1;
912            next.next_table_id = next.next_table_id.checked_add(1).ok_or_else(|| {
913                CatalogError::InvalidMetadata("table id space exhausted".to_string())
914            })?;
915            self.commit_catalog(current, next)?;
916            let identity = TableIdentity {
917                format: CATALOG_FORMAT.to_string(),
918                version: CATALOG_VERSION,
919                table_id,
920                physical_name: physical_table_name(table_id),
921            };
922            write_json(&self.store, &table_identity_path(table_id), &identity)?;
923            let schema = TableSchemaRecord {
924                format: CATALOG_FORMAT.to_string(),
925                version: CATALOG_VERSION,
926                table_id,
927                catalog_schema_id: CatalogSchemaId::INITIAL,
928                used_field_ids: sorted_field_ids(schema_field_ids(&schema)),
929                field_transforms: Vec::new(),
930                schema,
931            };
932            write_json(
933                &self.store,
934                &table_schema_path(table_id, schema.catalog_schema_id),
935                &schema,
936            )?;
937            namespace.generation += 1;
938            namespace.tables.push(TableEntry {
939                name: identifier.name().to_string(),
940                table_id,
941                catalog_schema_id: schema.catalog_schema_id,
942            });
943            namespace
944                .tables
945                .sort_by(|left, right| left.name.cmp(&right.name));
946            self.commit_namespace(&namespace_entry, &namespace)?;
947            Ok(self.catalog_table(identifier, identity, schema))
948        })
949    }
950
951    fn load_table(&self, identifier: &TableIdentifier) -> CatalogResult<CatalogTable> {
952        validate_identifier(identifier)?;
953        self.with_current(|current| {
954            let namespace_entry = Self::required_namespace(current, identifier.namespace())?;
955            let namespace = self.load_namespace(namespace_entry)?;
956            let entry = namespace
957                .tables
958                .iter()
959                .find(|entry| entry.name == identifier.name())
960                .ok_or_else(|| CatalogError::TableNotFound(identifier.clone()))?;
961            let identity = self.load_identity(entry.table_id)?;
962            let schema = self.load_schema(entry.table_id, entry.catalog_schema_id)?;
963            Ok(self.catalog_table(identifier.clone(), identity, schema))
964        })
965    }
966
967    fn load_table_schema(
968        &self,
969        identifier: &TableIdentifier,
970        catalog_schema_id: CatalogSchemaId,
971    ) -> CatalogResult<TableSchema> {
972        validate_identifier(identifier)?;
973        self.with_current(|current| {
974            let namespace_entry = Self::required_namespace(current, identifier.namespace())?;
975            let namespace = self.load_namespace(namespace_entry)?;
976            let entry = namespace
977                .tables
978                .iter()
979                .find(|entry| entry.name == identifier.name())
980                .ok_or_else(|| CatalogError::TableNotFound(identifier.clone()))?;
981            if catalog_schema_id > entry.catalog_schema_id {
982                return Err(CatalogError::SchemaNotFound {
983                    table: identifier.clone(),
984                    catalog_schema_id,
985                });
986            }
987            Ok(self.load_schema(entry.table_id, catalog_schema_id)?.schema)
988        })
989    }
990
991    fn evolve_schema(
992        &self,
993        identifier: &TableIdentifier,
994        changes: Vec<SchemaChange>,
995    ) -> CatalogResult<CatalogTable> {
996        validate_identifier(identifier)?;
997        self.with_current(|current| {
998            let namespace_entry =
999                Self::required_namespace(current, identifier.namespace())?.clone();
1000            let mut namespace = self.load_namespace(&namespace_entry)?;
1001            let entry_index = namespace
1002                .tables
1003                .iter()
1004                .position(|entry| entry.name == identifier.name())
1005                .ok_or_else(|| CatalogError::TableNotFound(identifier.clone()))?;
1006            let table_id = namespace.tables[entry_index].table_id;
1007            let current_catalog_schema_id = namespace.tables[entry_index].catalog_schema_id;
1008            let current_schema = self.load_schema(table_id, current_catalog_schema_id)?;
1009            let used_field_ids = current_schema
1010                .used_field_ids
1011                .iter()
1012                .copied()
1013                .collect::<HashSet<_>>();
1014            let (next_schema, used_field_ids, field_transforms) =
1015                apply_schema_changes(current_schema.schema, changes, used_field_ids)
1016                    .map_err(|error| CatalogError::InvalidSchemaEvolution(error.to_string()))?;
1017            let next_catalog_schema_id = current_catalog_schema_id.next().ok_or_else(|| {
1018                CatalogError::InvalidSchemaEvolution("schema id space exhausted".to_string())
1019            })?;
1020            let record = TableSchemaRecord {
1021                format: CATALOG_FORMAT.to_string(),
1022                version: CATALOG_VERSION,
1023                table_id,
1024                catalog_schema_id: next_catalog_schema_id,
1025                schema: next_schema,
1026                used_field_ids: sorted_field_ids(used_field_ids),
1027                field_transforms,
1028            };
1029            write_json(
1030                &self.store,
1031                &table_schema_path(table_id, next_catalog_schema_id),
1032                &record,
1033            )?;
1034            namespace.tables[entry_index].catalog_schema_id = next_catalog_schema_id;
1035            namespace.generation += 1;
1036            self.commit_namespace(&namespace_entry, &namespace)?;
1037            Ok(self.catalog_table(identifier.clone(), self.load_identity(table_id)?, record))
1038        })
1039    }
1040
1041    fn list_tables(&self, namespace: &[String]) -> CatalogResult<Vec<TableIdentifier>> {
1042        validate_namespace(namespace)?;
1043        self.with_current(|current| {
1044            let namespace_entry = Self::required_namespace(current, namespace)?;
1045            Ok(self
1046                .load_namespace(namespace_entry)?
1047                .tables
1048                .into_iter()
1049                .map(|entry| TableIdentifier::new(namespace.to_vec(), entry.name))
1050                .collect())
1051        })
1052    }
1053
1054    fn table_exists(&self, identifier: &TableIdentifier) -> CatalogResult<bool> {
1055        validate_identifier(identifier)?;
1056        self.with_current(|current| {
1057            let Some(namespace_entry) = Self::namespace_entry(current, identifier.namespace())
1058            else {
1059                return Ok(false);
1060            };
1061            Ok(self
1062                .load_namespace(namespace_entry)?
1063                .tables
1064                .iter()
1065                .any(|entry| entry.name == identifier.name()))
1066        })
1067    }
1068
1069    fn rename_table(
1070        &self,
1071        identifier: &TableIdentifier,
1072        new_name: String,
1073    ) -> CatalogResult<CatalogTable> {
1074        validate_identifier(identifier)?;
1075        let new_identifier = identifier.renamed(new_name);
1076        validate_identifier(&new_identifier)?;
1077        self.with_current(|current| {
1078            let namespace_entry =
1079                Self::required_namespace(current, identifier.namespace())?.clone();
1080            let mut namespace = self.load_namespace(&namespace_entry)?;
1081            let source_index = namespace
1082                .tables
1083                .iter()
1084                .position(|entry| entry.name == identifier.name())
1085                .ok_or_else(|| CatalogError::TableNotFound(identifier.clone()))?;
1086            if namespace
1087                .tables
1088                .iter()
1089                .any(|entry| entry.name == new_identifier.name())
1090            {
1091                return Err(CatalogError::TableAlreadyExists(new_identifier));
1092            }
1093            let entry = &mut namespace.tables[source_index];
1094            let table_id = entry.table_id;
1095            let catalog_schema_id = entry.catalog_schema_id;
1096            entry.name = new_identifier.name().to_string();
1097            namespace.generation += 1;
1098            namespace
1099                .tables
1100                .sort_by(|left, right| left.name.cmp(&right.name));
1101            self.commit_namespace(&namespace_entry, &namespace)?;
1102            let identity = self.load_identity(table_id)?;
1103            let schema = self.load_schema(table_id, catalog_schema_id)?;
1104            Ok(self.catalog_table(new_identifier, identity, schema))
1105        })
1106    }
1107
1108    fn drop_table(&self, identifier: &TableIdentifier) -> CatalogResult<()> {
1109        validate_identifier(identifier)?;
1110        self.with_current(|current| {
1111            let namespace_entry =
1112                Self::required_namespace(current, identifier.namespace())?.clone();
1113            let mut namespace = self.load_namespace(&namespace_entry)?;
1114            let original_len = namespace.tables.len();
1115            namespace
1116                .tables
1117                .retain(|entry| entry.name != identifier.name());
1118            if namespace.tables.len() == original_len {
1119                return Err(CatalogError::TableNotFound(identifier.clone()));
1120            }
1121            namespace.generation += 1;
1122            self.commit_namespace(&namespace_entry, &namespace)
1123        })
1124    }
1125}
1126
1127fn load_catalog_manifest(store: &CatalogStore) -> CatalogResult<CatalogManifest> {
1128    if !store.exists("CURRENT")? {
1129        return Ok(CatalogManifest::empty());
1130    }
1131    let current: CurrentPointer = read_json(store, "CURRENT")?;
1132    validate_header(&current.format, current.version)?;
1133    let manifest: CatalogManifest = read_json(store, &format!("CATALOG-{}", current.generation))?;
1134    validate_header(&manifest.format, manifest.version)?;
1135    if manifest.generation != current.generation {
1136        return Err(CatalogError::InvalidMetadata(
1137            "catalog generation does not match CURRENT".to_string(),
1138        ));
1139    }
1140    Ok(manifest)
1141}
1142
1143fn process_operation_lock(storage_id: &str) -> CatalogResult<Arc<Mutex<()>>> {
1144    static LOCKS: OnceLock<Mutex<HashMap<String, Weak<Mutex<()>>>>> = OnceLock::new();
1145    let mut locks = lock(LOCKS.get_or_init(|| Mutex::new(HashMap::new())))?;
1146    if let Some(existing) = locks.get(storage_id).and_then(Weak::upgrade) {
1147        return Ok(existing);
1148    }
1149    let operation_lock = Arc::new(Mutex::new(()));
1150    locks.insert(storage_id.to_string(), Arc::downgrade(&operation_lock));
1151    Ok(operation_lock)
1152}
1153
1154fn lock<T>(mutex: &Mutex<T>) -> CatalogResult<std::sync::MutexGuard<'_, T>> {
1155    mutex
1156        .lock()
1157        .map_err(|_| CatalogError::InvalidMetadata("catalog lock poisoned".to_string()))
1158}
1159
1160fn validate_header(format: &str, version: u32) -> CatalogResult<()> {
1161    if format != CATALOG_FORMAT || version != CATALOG_VERSION {
1162        return Err(CatalogError::InvalidMetadata(format!(
1163            "unsupported format/version: {format}/{version}"
1164        )));
1165    }
1166    Ok(())
1167}
1168
1169fn validate_namespace(namespace: &[String]) -> CatalogResult<()> {
1170    if namespace.is_empty() {
1171        return Err(CatalogError::InvalidIdentifier(
1172            "namespace must contain at least one component".to_string(),
1173        ));
1174    }
1175    for component in namespace {
1176        validate_name("namespace component", component)?;
1177    }
1178    Ok(())
1179}
1180
1181fn validate_identifier(identifier: &TableIdentifier) -> CatalogResult<()> {
1182    validate_namespace(identifier.namespace())?;
1183    validate_name("table name", identifier.name())
1184}
1185
1186fn validate_name(label: &str, value: &str) -> CatalogResult<()> {
1187    if value.is_empty() || value != value.trim() || value.chars().any(char::is_control) {
1188        return Err(CatalogError::InvalidIdentifier(format!(
1189            "invalid {label}: {value:?}"
1190        )));
1191    }
1192    Ok(())
1193}
1194
1195fn namespace_prefix(namespace_id: Uuid) -> String {
1196    format!("namespaces/{namespace_id}")
1197}
1198
1199fn table_identity_path(table_id: TableId) -> String {
1200    format!("tables/TABLE-{table_id}/IDENTITY")
1201}
1202
1203pub(crate) fn physical_table_name(table_id: TableId) -> String {
1204    format!("t{table_id}")
1205}
1206
1207fn table_schema_path(table_id: TableId, schema_id: CatalogSchemaId) -> String {
1208    format!(
1209        "tables/TABLE-{table_id}/schemas/SCHEMA-{}",
1210        schema_id.as_u32()
1211    )
1212}
1213
1214fn schema_mapping_path(
1215    table_id: TableId,
1216    db_id: &str,
1217    catalog_schema_id: CatalogSchemaId,
1218) -> String {
1219    let digest = Sha256::digest(db_id.as_bytes());
1220    let mut shard = String::with_capacity(digest.len() * 2);
1221    for byte in digest {
1222        write!(&mut shard, "{byte:02x}").expect("writing to a string cannot fail");
1223    }
1224    format!(
1225        "tables/TABLE-{table_id}/shards/{shard}/SCHEMA-{}",
1226        catalog_schema_id.as_u32()
1227    )
1228}
1229
1230fn validate_schema_mapping_key(
1231    mapping: &ShardSchemaMappingFile,
1232    table_id: TableId,
1233    db_id: &str,
1234    catalog_schema_id: CatalogSchemaId,
1235) -> CatalogResult<()> {
1236    if mapping.table_id != table_id
1237        || mapping.db_id != db_id
1238        || mapping.catalog_schema_id != catalog_schema_id
1239    {
1240        return Err(CatalogError::InvalidMetadata(
1241            "shard schema mapping does not match its lookup key".to_string(),
1242        ));
1243    }
1244    Ok(())
1245}
1246
1247fn sorted_field_ids(field_ids: HashSet<FieldId>) -> Vec<FieldId> {
1248    let mut field_ids = field_ids.into_iter().collect::<Vec<_>>();
1249    field_ids.sort_unstable();
1250    field_ids
1251}
1252
1253fn read_json<T: DeserializeOwned>(store: &CatalogStore, path: &str) -> CatalogResult<T> {
1254    let bytes = store.read(path)?;
1255    serde_json::from_slice(&bytes).map_err(|error| CatalogError::InvalidMetadata(error.to_string()))
1256}
1257
1258fn write_json<T: Serialize>(store: &CatalogStore, path: &str, value: &T) -> CatalogResult<()> {
1259    let bytes = serde_json::to_vec(value)
1260        .map_err(|error| CatalogError::InvalidMetadata(error.to_string()))?;
1261    store.write(path, &bytes)?;
1262    Ok(())
1263}