Skip to main content

ailake_catalog/
provider.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use ailake_core::{AilakeResult, Centroid, VectorStoragePolicy};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Whether a shard's HNSW index has been built.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
9#[serde(rename_all = "lowercase")]
10pub enum IndexStatus {
11    /// HNSW index embedded in the file — normal HNSW search applies.
12    #[default]
13    Ready,
14    /// Parquet written; HNSW build running in background — flat scan applies.
15    Indexing,
16    /// Background index build failed permanently; `DataFileEntry::index_error`
17    /// holds the cause. Compaction will rebuild the index on next run.
18    Failed,
19}
20
21/// Fully-qualified table identifier: namespace.table_name.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct TableIdent {
24    pub namespace: String,
25    pub name: String,
26}
27
28impl TableIdent {
29    pub fn new(namespace: &str, name: &str) -> Self {
30        Self {
31            namespace: namespace.to_string(),
32            name: name.to_string(),
33        }
34    }
35}
36
37pub type SnapshotId = i64;
38
39/// Iceberg V3 Deletion Vector reference stored in a manifest entry.
40///
41/// Points to a Roaring Bitmap blob inside a Puffin `.dvd` file.
42/// `offset` + `length` address the blob bytes directly — no full Puffin
43/// footer parse required for Phase B read support.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct DeletionVector {
46    /// Absolute path to the Puffin `.dvd` file in the object store.
47    pub path: String,
48    /// Byte offset of the Roaring Bitmap blob within the Puffin file.
49    pub offset: u64,
50    /// Byte length of the Roaring Bitmap blob.
51    pub length: u64,
52    /// Number of deleted rows (bitmap popcount; -1 when unknown).
53    pub cardinality: i64,
54}
55
56/// HNSW index info for one additional (non-primary) vector column.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ExtraVectorIndex {
59    pub column: String,
60    pub dim: u32,
61    pub hnsw_offset: u64,
62    pub hnsw_len: u64,
63    pub centroid_b64: Option<String>,
64    pub radius: Option<f32>,
65}
66
67/// Metadata about a single data file in a table snapshot.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DataFileEntry {
70    /// Relative path within the warehouse (e.g., "data/part-00001.parquet")
71    pub path: String,
72    pub record_count: u64,
73    pub file_size_bytes: u64,
74    /// base64-encoded centroid F32 values (primary vector column)
75    pub centroid_b64: Option<String>,
76    pub radius: Option<f32>,
77    pub hnsw_offset: Option<u64>,
78    pub hnsw_len: Option<u64>,
79    pub vector_column: Option<String>,
80    pub vector_dim: Option<u32>,
81    /// Additional vector columns beyond the primary (empty for single-column tables).
82    #[serde(default)]
83    pub extra_vector_indexes: Vec<ExtraVectorIndex>,
84    /// Index build status. Defaults to Ready for backward compatibility with old manifests.
85    #[serde(default)]
86    pub index_status: IndexStatus,
87    /// Human-readable error from a failed background index build. Set only when
88    /// `index_status == IndexStatus::Failed`; None otherwise.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub index_error: Option<String>,
91    /// Caller-supplied idempotency key. When set, `write_batch_idempotent` skips the
92    /// write if a file with the same batch_id is already committed in the snapshot.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub batch_id: Option<String>,
95    /// Embedding model identifier stored per-file so mixed-model tables (during migration)
96    /// can be identified without reading the main metadata.json.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub embedding_model: Option<String>,
99    /// Partition value for this file (e.g. the agent_id UUID).
100    /// Written per-file when `VectorStoragePolicy::partition_by` is set.
101    /// Enables manifest-level pruning: search skips files whose partition_value
102    /// doesn't match the requested partition filter, avoiding all HNSW I/O.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub partition_value: Option<String>,
105    /// Iceberg V3 Deletion Vector: Roaring Bitmap of deleted row positions.
106    /// None for V2 tables or V3 tables with no deletes for this file.
107    /// When present, scanner masks these row IDs from HNSW results.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub deletion_vector: Option<DeletionVector>,
110    /// Iceberg V3 Row Lineage: globally unique first row ID assigned to this file.
111    /// Computed at commit time from the table's cumulative `next-row-id` counter.
112    /// None for V2 tables (row lineage requires format-version=3).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub first_row_id: Option<i64>,
115}
116
117impl DataFileEntry {
118    /// True iff this file was never written by the AI-Lake SDK — most likely rewritten
119    /// by a generic Iceberg engine (Spark/Trino `OPTIMIZE`/`rewrite_data_files`, DuckDB)
120    /// with no knowledge of AI-Lake.
121    ///
122    /// Every AI-Lake write path (`make_data_file_entry`, `make_data_file_entry_indexing`)
123    /// computes and stores a centroid unconditionally, even for `IndexStatus::Indexing`
124    /// files — before the HNSW build itself completes. A missing centroid is therefore a
125    /// reliable "not ours" signal, distinct from `IndexStatus::Failed` (an internal
126    /// indexing failure on a file the SDK *did* write — see `writer.rs::patch_index_failed`,
127    /// which never touches `centroid_b64`). Shared by `CompactionPlanner::plan()`,
128    /// `scanner.rs::search`, and `ailake info` so the three can't drift on the definition.
129    pub fn is_foreign(&self) -> bool {
130        self.centroid_b64.is_none()
131    }
132}
133
134/// One field from the current Iceberg table schema (Phase G).
135///
136/// Parsed from `schemas[current-schema-id].fields` in `metadata.json`.
137/// Used by `SchemaFiller` to inject missing columns when reading old files.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SchemaField {
140    pub id: i32,
141    pub name: String,
142    pub required: bool,
143    /// Iceberg type string, e.g. `"int"`, `"string"`, `"timestamptz"`.
144    pub iceberg_type: String,
145    /// Value injected when reading old files that predate this field.
146    /// `None` → null is used.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub initial_default: Option<serde_json::Value>,
149    /// Default value written to new files. Same as `initial_default` in most cases.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub write_default: Option<serde_json::Value>,
152}
153
154/// Iceberg-compatible table metadata read from the catalog.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TableMetadata {
157    pub table_uuid: String,
158    pub format_version: i32,
159    pub location: String,
160    /// Ailake-specific properties: ailake.vector-column, ailake.dim, etc.
161    pub properties: HashMap<String, String>,
162    pub current_snapshot_id: Option<SnapshotId>,
163    /// Absolute path to the Puffin stats file for the current snapshot (Phase F).
164    /// `None` for V2 tables or V3 tables without any committed statistics yet.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub current_statistics_path: Option<String>,
167    /// Current schema fields parsed from `metadata.json` (Phase G).
168    /// Empty for tables created before Phase G or tables with no schema committed.
169    /// Used by `SchemaFiller` in the scanner to inject missing columns with defaults.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub schema_fields: Vec<SchemaField>,
172    /// Equality delete files active in the current snapshot (Phase H).
173    /// Loaded from delete manifests (content=2 in the manifest list).
174    /// Populated by `CatalogProvider::list_equality_deletes` — empty until first call.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub equality_delete_files: Vec<EqualityDeleteFile>,
177    /// Active partition spec for this table (Phase I).
178    /// `None` for unpartitioned tables or tables created before Phase I.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub partition_spec: Option<PartitionSpec>,
181}
182
183/// Iceberg schema update carried inside a snapshot commit.
184///
185/// When present, `HadoopCatalog::commit_snapshot` patches `schemas[0].fields`,
186/// `last-column-id`, and `schema.name-mapping.default` in the persisted metadata.
187/// REST/Glue/JDBC backends that delegate schema management to the server ignore this.
188#[derive(Debug, Clone)]
189pub struct IcebergSchemaUpdate {
190    /// Iceberg-typed field descriptors, e.g. `[{"id":1,"name":"id","required":false,"type":"int"}]`.
191    pub fields: Vec<serde_json::Value>,
192    pub last_column_id: i32,
193    /// Compact JSON string: `[{"field-id":1,"names":["id"]},...]`.
194    pub name_mapping_json: String,
195}
196
197/// Reference to an Iceberg equality delete file (Phase H).
198///
199/// The delete file is an Avro file with `content=2` whose rows contain equality
200/// predicates — any data row matching one of those rows is logically deleted.
201/// `equality_ids` lists the field IDs used for the equality check.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct EqualityDeleteFile {
204    /// Absolute or warehouse-relative path of the Avro delete file.
205    pub path: String,
206    /// Field IDs whose values must all match to delete a data row.
207    pub equality_ids: Vec<i32>,
208    /// Number of predicates (rows) in the delete file.
209    pub record_count: u64,
210    pub file_size_bytes: u64,
211}
212
213/// Snapshot commit request.
214#[derive(Debug, Clone)]
215pub struct NewSnapshot {
216    pub snapshot_id: SnapshotId,
217    pub parent_snapshot_id: Option<SnapshotId>,
218    pub files: Vec<DataFileEntry>,
219    pub operation: SnapshotOperation,
220    /// When set, the catalog backend should update the table schema on commit.
221    pub iceberg_schema: Option<IcebergSchemaUpdate>,
222    /// Additional table-level properties to merge on commit (e.g. secondary column dims).
223    /// Keys use `ailake.dim-<col>` / `ailake.metric-<col>` convention.
224    pub extra_properties: HashMap<String, String>,
225    /// Per-file BM25 Bloom filter bytes for term-level file pruning (Phase F).
226    /// Key = data file path (relative, matches `DataFileEntry::path`).
227    /// Written to the Puffin stats file on V3 commits; ignored for V2 tables.
228    pub bloom_filters: Vec<(String, Vec<u8>)>,
229    /// Equality delete files to add to this snapshot (Phase H).
230    /// Written as a separate delete manifest with content=2 in the manifest list.
231    pub equality_delete_files: Vec<EqualityDeleteFile>,
232}
233
234#[derive(Debug, Clone)]
235pub enum SnapshotOperation {
236    Append,
237    Overwrite,
238    Delete,
239    Replace,
240}
241
242/// One field in an Iceberg partition spec (Phase I).
243///
244/// For an `identity` partition on column "agent_id":
245/// `source_id=1` (must match the field id in the table schema),
246/// `field_id=1000` (Iceberg convention: partition fields start at 1000).
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct PartitionField {
249    /// Field ID of the source column in the table schema.
250    pub source_id: i32,
251    /// Partition field ID (≥ 1000 by convention).
252    pub field_id: i32,
253    /// Partition column name (usually same as the source column).
254    pub name: String,
255    /// Transform function: "identity", "bucket[N]", "truncate[W]", "year", etc.
256    pub transform: String,
257    /// Iceberg type of the source column ("string", "int", "long", "uuid").
258    /// Derived from the table schema at read time; stored here for encoding.
259    pub source_type: String,
260}
261
262/// Iceberg partition spec (Phase I).
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct PartitionSpec {
265    pub spec_id: i32,
266    pub fields: Vec<PartitionField>,
267}
268
269impl PartitionSpec {
270    /// True when this spec has no partition fields (unpartitioned table).
271    pub fn is_unpartitioned(&self) -> bool {
272        self.fields.is_empty()
273    }
274}
275
276/// Schema properties passed at table creation time.
277#[derive(Debug, Clone)]
278pub struct TableProperties {
279    pub policy: VectorStoragePolicy,
280    pub extra: HashMap<String, String>,
281    /// Iceberg format version to write. 2 = default (V2). 3 = opt-in V3.
282    pub format_version: u8,
283    /// Iceberg type of the partition column when `policy.partition_by` is set.
284    /// Defaults to `"string"` when `None`. Supported: "string", "uuid", "int", "long".
285    pub partition_column_type: Option<String>,
286}
287
288/// Unified catalog interface. All backends implement this trait.
289#[async_trait]
290pub trait CatalogProvider: Send + Sync {
291    async fn create_table(&self, name: &TableIdent, props: &TableProperties) -> AilakeResult<()>;
292
293    async fn load_table(&self, name: &TableIdent) -> AilakeResult<TableMetadata>;
294
295    async fn commit_snapshot(
296        &self,
297        table: &TableIdent,
298        snapshot: NewSnapshot,
299    ) -> AilakeResult<SnapshotId>;
300
301    async fn list_files(
302        &self,
303        table: &TableIdent,
304        snapshot_id: Option<SnapshotId>,
305    ) -> AilakeResult<Vec<DataFileEntry>>;
306
307    async fn drop_table(&self, name: &TableIdent) -> AilakeResult<()>;
308
309    /// Whether a retired data file (one dropped by a `Replace`/`Overwrite` commit,
310    /// e.g. compaction's merged-away inputs) should be physically deleted from the
311    /// object store immediately after that commit succeeds.
312    ///
313    /// True for manifest-based backends (`HadoopCatalog` and friends): once a file
314    /// drops out of the Iceberg manifest, the catalog has no other record of it, so
315    /// deleting the bytes right away is safe.
316    ///
317    /// `DuckLakeCatalog` overrides this to `false` — see "The retirement problem"
318    /// in `docs/guides/DUCKLAKE_CATALOG.md`: its `commit_snapshot` only issues a
319    /// row-level `DELETE`, which attaches a deletion vector but leaves the file
320    /// registered in `ducklake_list_files()` until an operator runs DuckLake's own
321    /// `ducklake_expire_snapshots`/`ducklake_cleanup_files`. Deleting the bytes
322    /// immediately (the default here) would leave that still-registered path
323    /// dangling — breaking any subsequent DuckLake-native read that touches it.
324    fn retires_files_physically(&self) -> bool {
325        true
326    }
327
328    /// Whether data files, once committed at a path, may have their bytes
329    /// rewritten in place at that same path.
330    ///
331    /// True for manifest-based backends: the manifest entry the caller commits
332    /// alongside the rewrite is the only statistics record, so a same-path
333    /// rewrite is self-consistent.
334    ///
335    /// `DuckLakeCatalog` overrides this to `false`: DuckLake snapshots record
336    /// per-file zone-map stats and the exact footer size at
337    /// `ducklake_add_data_files` time and trust both afterwards — verified
338    /// against a live extension: a same-length rewrite silently returns wrong
339    /// filtered rows (stale zone-map prunes the file), and a changed-length
340    /// rewrite breaks every subsequent read of the file outright ("Parquet
341    /// footer length stored in file is not equal to footer length provided"),
342    /// including the row-`DELETE` needed to retire it — an unrecoverable state
343    /// through sanctioned SQL. Writers that rewrite files (memory decay,
344    /// deferred index patching) must either write to a fresh path and retire
345    /// the old one, or refuse to run against catalogs returning `false` here.
346    fn supports_in_place_rewrite(&self) -> bool {
347        true
348    }
349
350    /// Apply schema evolution (add columns / rename columns) without rewriting data files.
351    ///
352    /// Returns the new `schema-id` assigned in `metadata.json`.
353    /// Old files missing new columns will have their values filled at read time using
354    /// `AddColumnRequest::initial_default` (Phase G `SchemaFiller`).
355    ///
356    /// Default implementation returns an error — override in file-based backends.
357    async fn evolve_schema(
358        &self,
359        _table: &TableIdent,
360        _evolution: crate::schema_evolution::SchemaEvolution,
361    ) -> AilakeResult<i32> {
362        Err(ailake_core::AilakeError::Catalog(
363            "evolve_schema not supported by this catalog backend".into(),
364        ))
365    }
366
367    /// Return all equality delete files active in the current (or specified) snapshot.
368    ///
369    /// Reads delete manifests (manifest list entries with `content=1`) and parses their
370    /// `content=2` entries. Default returns empty vec for catalog backends that do not
371    /// support Iceberg equality deletes.
372    async fn list_equality_deletes(
373        &self,
374        _table: &TableIdent,
375        _snapshot_id: Option<SnapshotId>,
376    ) -> AilakeResult<Vec<EqualityDeleteFile>> {
377        Ok(vec![])
378    }
379
380    /// Add a new vector column to the table schema without rewriting data files.
381    ///
382    /// - Adds the column as Iceberg type `binary` (maps to `FIXED_LEN_BYTE_ARRAY` in Parquet).
383    /// - Stores `ailake.dim-<col>`, `ailake.metric-<col>`, `ailake.precision-<col>` in properties.
384    /// - Old files missing the column return `null` at read time (`initial-default = null`).
385    /// - Does NOT commit a snapshot or backfill embeddings — use `BackfillJob` for that.
386    ///
387    /// Returns the new `schema-id`.
388    async fn add_vector_column(
389        &self,
390        table: &TableIdent,
391        spec: &ailake_core::VectorColSpec,
392    ) -> AilakeResult<i32> {
393        use crate::schema_evolution::{AddColumnRequest, SchemaEvolution};
394        use std::collections::HashMap;
395
396        let mut props: HashMap<String, String> = HashMap::new();
397        props.insert(
398            format!("ailake.dim-{}", spec.column_name),
399            spec.dim.to_string(),
400        );
401        props.insert(
402            format!("ailake.metric-{}", spec.column_name),
403            format!("{:?}", spec.metric).to_lowercase(),
404        );
405        props.insert(
406            format!("ailake.precision-{}", spec.column_name),
407            format!("{:?}", spec.precision).to_lowercase(),
408        );
409        if spec.pre_normalize {
410            props.insert(
411                format!("ailake.pre-normalize-{}", spec.column_name),
412                "true".to_string(),
413            );
414        }
415        if let Some(m) = spec.hnsw_m {
416            props.insert(format!("ailake.hnsw-m-{}", spec.column_name), m.to_string());
417        }
418        if let Some(ef) = spec.hnsw_ef_construction {
419            props.insert(
420                format!("ailake.hnsw-ef-construction-{}", spec.column_name),
421                ef.to_string(),
422            );
423        }
424
425        let evolution = SchemaEvolution::new()
426            .add_column(AddColumnRequest {
427                name: spec.column_name.clone(),
428                iceberg_type: "binary".to_string(),
429                required: false,
430                initial_default: None,
431                write_default: None,
432                doc: Some(format!(
433                    "Vector column {} dim={} metric={:?}",
434                    spec.column_name, spec.dim, spec.metric
435                )),
436            })
437            .with_properties(props);
438
439        self.evolve_schema(table, evolution).await
440    }
441}
442
443/// Vector index metadata for a single data file.
444pub struct VectorIndexInfo<'a> {
445    pub column: &'a str,
446    pub dim: u32,
447    pub hnsw_offset: u64,
448    pub hnsw_len: u64,
449}
450
451/// Build DataFileEntry from a centroid, encoding it as base64.
452pub fn make_data_file_entry(
453    path: &str,
454    record_count: u64,
455    file_size_bytes: u64,
456    centroid: &Centroid,
457    index: VectorIndexInfo<'_>,
458) -> DataFileEntry {
459    make_multi_column_data_file_entry(path, record_count, file_size_bytes, centroid, index, &[])
460}
461
462/// Build DataFileEntry for a file with multiple vector columns.
463///
464/// `primary_centroid` and `primary_index` describe the primary (first) vector column.
465/// `extra` contains info for additional columns.
466pub fn make_multi_column_data_file_entry(
467    path: &str,
468    record_count: u64,
469    file_size_bytes: u64,
470    primary_centroid: &Centroid,
471    primary_index: VectorIndexInfo<'_>,
472    extra: &[ExtraVectorIndex],
473) -> DataFileEntry {
474    use base64::Engine;
475    let centroid_bytes: Vec<u8> = primary_centroid
476        .values
477        .iter()
478        .flat_map(|v| v.to_le_bytes())
479        .collect();
480    let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(&centroid_bytes);
481    DataFileEntry {
482        path: path.to_string(),
483        record_count,
484        file_size_bytes,
485        centroid_b64: Some(centroid_b64),
486        radius: Some(primary_centroid.radius),
487        hnsw_offset: Some(primary_index.hnsw_offset),
488        hnsw_len: Some(primary_index.hnsw_len),
489        vector_column: Some(primary_index.column.to_string()),
490        vector_dim: Some(primary_index.dim),
491        extra_vector_indexes: extra.to_vec(),
492        index_status: IndexStatus::Ready,
493        index_error: None,
494        batch_id: None,
495        embedding_model: None,
496        partition_value: None,
497        deletion_vector: None,
498        first_row_id: None,
499    }
500}
501
502/// Build a DataFileEntry for a file whose HNSW is still being built asynchronously.
503///
504/// `hnsw_offset` and `hnsw_len` are `None`; `index_status` is `Indexing`.
505/// The centroid is included so geometric pruning still works during the build window.
506pub fn make_data_file_entry_indexing(
507    path: &str,
508    record_count: u64,
509    file_size_bytes: u64,
510    centroid: &Centroid,
511    column: &str,
512    dim: u32,
513) -> DataFileEntry {
514    use base64::Engine;
515    let centroid_bytes: Vec<u8> = centroid
516        .values
517        .iter()
518        .flat_map(|v| v.to_le_bytes())
519        .collect();
520    let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(&centroid_bytes);
521    DataFileEntry {
522        path: path.to_string(),
523        record_count,
524        file_size_bytes,
525        centroid_b64: Some(centroid_b64),
526        radius: Some(centroid.radius),
527        hnsw_offset: None,
528        hnsw_len: None,
529        vector_column: Some(column.to_string()),
530        vector_dim: Some(dim),
531        extra_vector_indexes: vec![],
532        index_status: IndexStatus::Indexing,
533        index_error: None,
534        batch_id: None,
535        embedding_model: None,
536        partition_value: None,
537        deletion_vector: None,
538        first_row_id: None,
539    }
540}
541
542/// Encode a centroid to base64 for use in ExtraVectorIndex.
543pub fn encode_centroid_b64(centroid: &Centroid) -> String {
544    use base64::Engine;
545    let bytes: Vec<u8> = centroid
546        .values
547        .iter()
548        .flat_map(|v| v.to_le_bytes())
549        .collect();
550    base64::engine::general_purpose::STANDARD.encode(&bytes)
551}
552
553/// Decode centroid bytes from base64 in a DataFileEntry.
554pub fn decode_centroid(
555    entry: &DataFileEntry,
556    metric: ailake_core::VectorMetric,
557) -> Option<Centroid> {
558    use base64::Engine;
559    let b64 = entry.centroid_b64.as_ref()?;
560    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
561    let values: Vec<f32> = bytes
562        .chunks_exact(4)
563        .map(|b| {
564            f32::from_le_bytes(
565                b.try_into()
566                    .expect("chunks_exact(4) guarantees 4-byte slices"),
567            )
568        })
569        .collect();
570    Some(Centroid {
571        values,
572        radius: entry.radius.unwrap_or(0.0),
573        metric,
574    })
575}
576
577pub fn new_snapshot_id() -> SnapshotId {
578    // Microsecond precision avoids collisions when multiple snapshots are committed
579    // within the same millisecond (common in fast local-filesystem tests).
580    std::time::SystemTime::now()
581        .duration_since(std::time::UNIX_EPOCH)
582        .unwrap()
583        .as_micros() as i64
584}