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
117/// One field from the current Iceberg table schema (Phase G).
118///
119/// Parsed from `schemas[current-schema-id].fields` in `metadata.json`.
120/// Used by `SchemaFiller` to inject missing columns when reading old files.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SchemaField {
123    pub id: i32,
124    pub name: String,
125    pub required: bool,
126    /// Iceberg type string, e.g. `"int"`, `"string"`, `"timestamptz"`.
127    pub iceberg_type: String,
128    /// Value injected when reading old files that predate this field.
129    /// `None` → null is used.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub initial_default: Option<serde_json::Value>,
132    /// Default value written to new files. Same as `initial_default` in most cases.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub write_default: Option<serde_json::Value>,
135}
136
137/// Iceberg-compatible table metadata read from the catalog.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct TableMetadata {
140    pub table_uuid: String,
141    pub format_version: i32,
142    pub location: String,
143    /// Ailake-specific properties: ailake.vector-column, ailake.dim, etc.
144    pub properties: HashMap<String, String>,
145    pub current_snapshot_id: Option<SnapshotId>,
146    /// Absolute path to the Puffin stats file for the current snapshot (Phase F).
147    /// `None` for V2 tables or V3 tables without any committed statistics yet.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub current_statistics_path: Option<String>,
150    /// Current schema fields parsed from `metadata.json` (Phase G).
151    /// Empty for tables created before Phase G or tables with no schema committed.
152    /// Used by `SchemaFiller` in the scanner to inject missing columns with defaults.
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub schema_fields: Vec<SchemaField>,
155    /// Equality delete files active in the current snapshot (Phase H).
156    /// Loaded from delete manifests (content=2 in the manifest list).
157    /// Populated by `CatalogProvider::list_equality_deletes` — empty until first call.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub equality_delete_files: Vec<EqualityDeleteFile>,
160    /// Active partition spec for this table (Phase I).
161    /// `None` for unpartitioned tables or tables created before Phase I.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub partition_spec: Option<PartitionSpec>,
164}
165
166/// Iceberg schema update carried inside a snapshot commit.
167///
168/// When present, `HadoopCatalog::commit_snapshot` patches `schemas[0].fields`,
169/// `last-column-id`, and `schema.name-mapping.default` in the persisted metadata.
170/// REST/Glue/JDBC backends that delegate schema management to the server ignore this.
171#[derive(Debug, Clone)]
172pub struct IcebergSchemaUpdate {
173    /// Iceberg-typed field descriptors, e.g. `[{"id":1,"name":"id","required":false,"type":"int"}]`.
174    pub fields: Vec<serde_json::Value>,
175    pub last_column_id: i32,
176    /// Compact JSON string: `[{"field-id":1,"names":["id"]},...]`.
177    pub name_mapping_json: String,
178}
179
180/// Reference to an Iceberg equality delete file (Phase H).
181///
182/// The delete file is an Avro file with `content=2` whose rows contain equality
183/// predicates — any data row matching one of those rows is logically deleted.
184/// `equality_ids` lists the field IDs used for the equality check.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct EqualityDeleteFile {
187    /// Absolute or warehouse-relative path of the Avro delete file.
188    pub path: String,
189    /// Field IDs whose values must all match to delete a data row.
190    pub equality_ids: Vec<i32>,
191    /// Number of predicates (rows) in the delete file.
192    pub record_count: u64,
193    pub file_size_bytes: u64,
194}
195
196/// Snapshot commit request.
197#[derive(Debug, Clone)]
198pub struct NewSnapshot {
199    pub snapshot_id: SnapshotId,
200    pub parent_snapshot_id: Option<SnapshotId>,
201    pub files: Vec<DataFileEntry>,
202    pub operation: SnapshotOperation,
203    /// When set, the catalog backend should update the table schema on commit.
204    pub iceberg_schema: Option<IcebergSchemaUpdate>,
205    /// Additional table-level properties to merge on commit (e.g. secondary column dims).
206    /// Keys use `ailake.dim-<col>` / `ailake.metric-<col>` convention.
207    pub extra_properties: HashMap<String, String>,
208    /// Per-file BM25 Bloom filter bytes for term-level file pruning (Phase F).
209    /// Key = data file path (relative, matches `DataFileEntry::path`).
210    /// Written to the Puffin stats file on V3 commits; ignored for V2 tables.
211    pub bloom_filters: Vec<(String, Vec<u8>)>,
212    /// Equality delete files to add to this snapshot (Phase H).
213    /// Written as a separate delete manifest with content=2 in the manifest list.
214    pub equality_delete_files: Vec<EqualityDeleteFile>,
215}
216
217#[derive(Debug, Clone)]
218pub enum SnapshotOperation {
219    Append,
220    Overwrite,
221    Delete,
222    Replace,
223}
224
225/// One field in an Iceberg partition spec (Phase I).
226///
227/// For an `identity` partition on column "agent_id":
228/// `source_id=1` (must match the field id in the table schema),
229/// `field_id=1000` (Iceberg convention: partition fields start at 1000).
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct PartitionField {
232    /// Field ID of the source column in the table schema.
233    pub source_id: i32,
234    /// Partition field ID (≥ 1000 by convention).
235    pub field_id: i32,
236    /// Partition column name (usually same as the source column).
237    pub name: String,
238    /// Transform function: "identity", "bucket[N]", "truncate[W]", "year", etc.
239    pub transform: String,
240    /// Iceberg type of the source column ("string", "int", "long", "uuid").
241    /// Derived from the table schema at read time; stored here for encoding.
242    pub source_type: String,
243}
244
245/// Iceberg partition spec (Phase I).
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct PartitionSpec {
248    pub spec_id: i32,
249    pub fields: Vec<PartitionField>,
250}
251
252impl PartitionSpec {
253    /// True when this spec has no partition fields (unpartitioned table).
254    pub fn is_unpartitioned(&self) -> bool {
255        self.fields.is_empty()
256    }
257}
258
259/// Schema properties passed at table creation time.
260#[derive(Debug, Clone)]
261pub struct TableProperties {
262    pub policy: VectorStoragePolicy,
263    pub extra: HashMap<String, String>,
264    /// Iceberg format version to write. 2 = default (V2). 3 = opt-in V3.
265    pub format_version: u8,
266    /// Iceberg type of the partition column when `policy.partition_by` is set.
267    /// Defaults to `"string"` when `None`. Supported: "string", "uuid", "int", "long".
268    pub partition_column_type: Option<String>,
269}
270
271/// Unified catalog interface. All backends implement this trait.
272#[async_trait]
273pub trait CatalogProvider: Send + Sync {
274    async fn create_table(&self, name: &TableIdent, props: &TableProperties) -> AilakeResult<()>;
275
276    async fn load_table(&self, name: &TableIdent) -> AilakeResult<TableMetadata>;
277
278    async fn commit_snapshot(
279        &self,
280        table: &TableIdent,
281        snapshot: NewSnapshot,
282    ) -> AilakeResult<SnapshotId>;
283
284    async fn list_files(
285        &self,
286        table: &TableIdent,
287        snapshot_id: Option<SnapshotId>,
288    ) -> AilakeResult<Vec<DataFileEntry>>;
289
290    async fn drop_table(&self, name: &TableIdent) -> AilakeResult<()>;
291
292    /// Apply schema evolution (add columns / rename columns) without rewriting data files.
293    ///
294    /// Returns the new `schema-id` assigned in `metadata.json`.
295    /// Old files missing new columns will have their values filled at read time using
296    /// `AddColumnRequest::initial_default` (Phase G `SchemaFiller`).
297    ///
298    /// Default implementation returns an error — override in file-based backends.
299    async fn evolve_schema(
300        &self,
301        _table: &TableIdent,
302        _evolution: crate::schema_evolution::SchemaEvolution,
303    ) -> AilakeResult<i32> {
304        Err(ailake_core::AilakeError::Catalog(
305            "evolve_schema not supported by this catalog backend".into(),
306        ))
307    }
308
309    /// Return all equality delete files active in the current (or specified) snapshot.
310    ///
311    /// Reads delete manifests (manifest list entries with `content=1`) and parses their
312    /// `content=2` entries. Default returns empty vec for catalog backends that do not
313    /// support Iceberg equality deletes.
314    async fn list_equality_deletes(
315        &self,
316        _table: &TableIdent,
317        _snapshot_id: Option<SnapshotId>,
318    ) -> AilakeResult<Vec<EqualityDeleteFile>> {
319        Ok(vec![])
320    }
321}
322
323/// Vector index metadata for a single data file.
324pub struct VectorIndexInfo<'a> {
325    pub column: &'a str,
326    pub dim: u32,
327    pub hnsw_offset: u64,
328    pub hnsw_len: u64,
329}
330
331/// Build DataFileEntry from a centroid, encoding it as base64.
332pub fn make_data_file_entry(
333    path: &str,
334    record_count: u64,
335    file_size_bytes: u64,
336    centroid: &Centroid,
337    index: VectorIndexInfo<'_>,
338) -> DataFileEntry {
339    make_multi_column_data_file_entry(path, record_count, file_size_bytes, centroid, index, &[])
340}
341
342/// Build DataFileEntry for a file with multiple vector columns.
343///
344/// `primary_centroid` and `primary_index` describe the primary (first) vector column.
345/// `extra` contains info for additional columns.
346pub fn make_multi_column_data_file_entry(
347    path: &str,
348    record_count: u64,
349    file_size_bytes: u64,
350    primary_centroid: &Centroid,
351    primary_index: VectorIndexInfo<'_>,
352    extra: &[ExtraVectorIndex],
353) -> DataFileEntry {
354    use base64::Engine;
355    let centroid_bytes: Vec<u8> = primary_centroid
356        .values
357        .iter()
358        .flat_map(|v| v.to_le_bytes())
359        .collect();
360    let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(&centroid_bytes);
361    DataFileEntry {
362        path: path.to_string(),
363        record_count,
364        file_size_bytes,
365        centroid_b64: Some(centroid_b64),
366        radius: Some(primary_centroid.radius),
367        hnsw_offset: Some(primary_index.hnsw_offset),
368        hnsw_len: Some(primary_index.hnsw_len),
369        vector_column: Some(primary_index.column.to_string()),
370        vector_dim: Some(primary_index.dim),
371        extra_vector_indexes: extra.to_vec(),
372        index_status: IndexStatus::Ready,
373        index_error: None,
374        batch_id: None,
375        embedding_model: None,
376        partition_value: None,
377        deletion_vector: None,
378        first_row_id: None,
379    }
380}
381
382/// Build a DataFileEntry for a file whose HNSW is still being built asynchronously.
383///
384/// `hnsw_offset` and `hnsw_len` are `None`; `index_status` is `Indexing`.
385/// The centroid is included so geometric pruning still works during the build window.
386pub fn make_data_file_entry_indexing(
387    path: &str,
388    record_count: u64,
389    file_size_bytes: u64,
390    centroid: &Centroid,
391    column: &str,
392    dim: u32,
393) -> DataFileEntry {
394    use base64::Engine;
395    let centroid_bytes: Vec<u8> = centroid
396        .values
397        .iter()
398        .flat_map(|v| v.to_le_bytes())
399        .collect();
400    let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(&centroid_bytes);
401    DataFileEntry {
402        path: path.to_string(),
403        record_count,
404        file_size_bytes,
405        centroid_b64: Some(centroid_b64),
406        radius: Some(centroid.radius),
407        hnsw_offset: None,
408        hnsw_len: None,
409        vector_column: Some(column.to_string()),
410        vector_dim: Some(dim),
411        extra_vector_indexes: vec![],
412        index_status: IndexStatus::Indexing,
413        index_error: None,
414        batch_id: None,
415        embedding_model: None,
416        partition_value: None,
417        deletion_vector: None,
418        first_row_id: None,
419    }
420}
421
422/// Encode a centroid to base64 for use in ExtraVectorIndex.
423pub fn encode_centroid_b64(centroid: &Centroid) -> String {
424    use base64::Engine;
425    let bytes: Vec<u8> = centroid
426        .values
427        .iter()
428        .flat_map(|v| v.to_le_bytes())
429        .collect();
430    base64::engine::general_purpose::STANDARD.encode(&bytes)
431}
432
433/// Decode centroid bytes from base64 in a DataFileEntry.
434pub fn decode_centroid(
435    entry: &DataFileEntry,
436    metric: ailake_core::VectorMetric,
437) -> Option<Centroid> {
438    use base64::Engine;
439    let b64 = entry.centroid_b64.as_ref()?;
440    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
441    let values: Vec<f32> = bytes
442        .chunks_exact(4)
443        .map(|b| {
444            f32::from_le_bytes(
445                b.try_into()
446                    .expect("chunks_exact(4) guarantees 4-byte slices"),
447            )
448        })
449        .collect();
450    Some(Centroid {
451        values,
452        radius: entry.radius.unwrap_or(0.0),
453        metric,
454    })
455}
456
457pub fn new_snapshot_id() -> SnapshotId {
458    // Microsecond precision avoids collisions when multiple snapshots are committed
459    // within the same millisecond (common in fast local-filesystem tests).
460    std::time::SystemTime::now()
461        .duration_since(std::time::UNIX_EPOCH)
462        .unwrap()
463        .as_micros() as i64
464}