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
7pub use crate::metadata::{IcebergMetadata, IcebergSnapshot};
8
9/// Whether a shard's HNSW index has been built.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
11#[serde(rename_all = "lowercase")]
12pub enum IndexStatus {
13    /// HNSW index embedded in the file — normal HNSW search applies.
14    #[default]
15    Ready,
16    /// Parquet written; HNSW build running in background — flat scan applies.
17    Indexing,
18    /// Background index build failed permanently; `DataFileEntry::index_error`
19    /// holds the cause. Compaction will rebuild the index on next run.
20    Failed,
21}
22
23/// Fully-qualified table identifier: namespace.table_name.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct TableIdent {
26    pub namespace: String,
27    pub name: String,
28}
29
30impl TableIdent {
31    pub fn new(namespace: &str, name: &str) -> Self {
32        Self {
33            namespace: namespace.to_string(),
34            name: name.to_string(),
35        }
36    }
37}
38
39pub type SnapshotId = i64;
40
41/// Iceberg V3 Deletion Vector reference stored in a manifest entry.
42///
43/// Points to a Roaring Bitmap blob inside a Puffin `.dvd` file.
44/// `offset` + `length` address the blob bytes directly — no full Puffin
45/// footer parse required for Phase B read support.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DeletionVector {
48    /// Absolute path to the Puffin `.dvd` file in the object store.
49    pub path: String,
50    /// Byte offset of the Roaring Bitmap blob within the Puffin file.
51    pub offset: u64,
52    /// Byte length of the Roaring Bitmap blob.
53    pub length: u64,
54    /// Number of deleted rows (bitmap popcount; -1 when unknown).
55    pub cardinality: i64,
56}
57
58/// HNSW index info for one additional (non-primary) vector column.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ExtraVectorIndex {
61    pub column: String,
62    pub dim: u32,
63    pub hnsw_offset: u64,
64    pub hnsw_len: u64,
65    pub centroid_b64: Option<String>,
66    pub radius: Option<f32>,
67}
68
69/// Metadata about a single data file in a table snapshot.
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct DataFileEntry {
72    /// Relative path within the warehouse (e.g., "data/part-00001.parquet")
73    pub path: String,
74    pub record_count: u64,
75    pub file_size_bytes: u64,
76    /// base64-encoded centroid F32 values (primary vector column)
77    pub centroid_b64: Option<String>,
78    pub radius: Option<f32>,
79    pub hnsw_offset: Option<u64>,
80    pub hnsw_len: Option<u64>,
81    pub vector_column: Option<String>,
82    pub vector_dim: Option<u32>,
83    /// Additional vector columns beyond the primary (empty for single-column tables).
84    #[serde(default)]
85    pub extra_vector_indexes: Vec<ExtraVectorIndex>,
86    /// Index build status. Defaults to Ready for backward compatibility with old manifests.
87    #[serde(default)]
88    pub index_status: IndexStatus,
89    /// Human-readable error from a failed background index build. Set only when
90    /// `index_status == IndexStatus::Failed`; None otherwise.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub index_error: Option<String>,
93    /// Caller-supplied idempotency key. When set, `write_batch_idempotent` skips the
94    /// write if a file with the same batch_id is already committed in the snapshot.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub batch_id: Option<String>,
97    /// Embedding model identifier stored per-file so mixed-model tables (during migration)
98    /// can be identified without reading the main metadata.json.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub embedding_model: Option<String>,
101    /// Partition value for this file (e.g. the agent_id UUID).
102    /// Written per-file when `VectorStoragePolicy::partition_by` is set.
103    /// Enables manifest-level pruning: search skips files whose partition_value
104    /// doesn't match the requested partition filter, avoiding all HNSW I/O.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub partition_value: Option<String>,
107    /// Iceberg V3 Deletion Vector: Roaring Bitmap of deleted row positions.
108    /// None for V2 tables or V3 tables with no deletes for this file.
109    /// When present, scanner masks these row IDs from HNSW results.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub deletion_vector: Option<DeletionVector>,
112    /// Iceberg V3 Row Lineage: globally unique first row ID assigned to this file.
113    /// Computed at commit time from the table's cumulative `next-row-id` counter.
114    /// None for V2 tables (row lineage requires format-version=3).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub first_row_id: Option<i64>,
117    /// JSON-encoded `BTreeMap<i32, FieldStats>` (`column_stats::extract_column_stats`),
118    /// computed once from this file's own Parquet footer at write/compaction time.
119    /// `write_manifest_file` decodes this to emit real `value_counts`/
120    /// `null_value_counts`/`column_sizes`/`lower_bounds`/`upper_bounds` Avro fields
121    /// instead of `null`, enabling row-group pruning for Spark/Trino/DuckDB reading
122    /// the table as plain Iceberg. `None` for files predating this feature, or where
123    /// extraction failed — always spec-safe since these fields are optional at every
124    /// format version.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub column_stats: Option<String>,
127    /// Iceberg data sequence number of the snapshot that added this file (spec
128    /// `manifest_entry.sequence_number`). `0` for files predating this field
129    /// (legacy manifests, or any in-memory entry not yet round-tripped through
130    /// `read_manifest_file`) — safe because it only ever *lowers* an equality
131    /// delete's apparent reach (a delete with sequence_number > 0 still masks
132    /// a `0`-tagged file, same as before this field existed). Populated only
133    /// on read (`avro_manifest::read_manifest_file`); writers never need to
134    /// set it correctly — `write_manifest_file` always stamps every entry
135    /// with the *commit's* sequence number, ignoring whatever this field
136    /// held going in. See `EqualityDeleteFile::sequence_number` doc for why
137    /// both exist: Iceberg masks a data file only when
138    /// `data_file.sequence_number < delete_file.sequence_number`, which is
139    /// what makes an insert+delete of the same key inside one commit leave
140    /// the insert visible (equal sequence numbers never mask).
141    #[serde(default)]
142    pub sequence_number: i64,
143}
144
145impl DataFileEntry {
146    /// True iff this file was never written by the AI-Lake SDK — most likely rewritten
147    /// by a generic Iceberg engine (Spark/Trino `OPTIMIZE`/`rewrite_data_files`, DuckDB)
148    /// with no knowledge of AI-Lake.
149    ///
150    /// Every AI-Lake write path (`make_data_file_entry`, `make_data_file_entry_indexing`)
151    /// computes and stores a centroid unconditionally, even for `IndexStatus::Indexing`
152    /// files — before the HNSW build itself completes. A missing centroid is therefore a
153    /// reliable "not ours" signal, distinct from `IndexStatus::Failed` (an internal
154    /// indexing failure on a file the SDK *did* write — see `writer.rs::patch_index_failed`,
155    /// which never touches `centroid_b64`). Shared by `CompactionPlanner::plan()`,
156    /// `scanner.rs::search`, and `ailake info` so the three can't drift on the definition.
157    pub fn is_foreign(&self) -> bool {
158        self.centroid_b64.is_none()
159    }
160
161    /// Decode `batch_id` into the set of original idempotency keys it covers.
162    ///
163    /// A freshly-written file's `batch_id` is a plain string (the caller-supplied
164    /// key). A file produced by compaction (`merge_batch_ids`) instead carries a
165    /// JSON-encoded array of every source file's key(s), since one merged file can
166    /// supersede several idempotent writes at once. This tries the JSON-array form
167    /// first and falls back to treating the raw string as a single key — the two
168    /// encodings share the same `Option<String>` field so this is the only place
169    /// that needs to know the difference. Returns `[]` when `batch_id` is `None`.
170    pub fn batch_ids(&self) -> Vec<String> {
171        match &self.batch_id {
172            None => vec![],
173            Some(raw) => {
174                serde_json::from_str::<Vec<String>>(raw).unwrap_or_else(|_| vec![raw.clone()])
175            }
176        }
177    }
178
179    /// Build the `batch_id` value for a file merging `sources` (compaction).
180    ///
181    /// Flattens every source's `batch_ids()` (not just `source.batch_id` verbatim —
182    /// a source may itself already be a previous merge, so this must decode before
183    /// re-encoding to avoid nesting JSON arrays deeper on every subsequent compaction
184    /// pass), dedups while preserving first-seen order, and re-encodes as a JSON
185    /// array. Returns `None` when no source carried a `batch_id` — compaction of
186    /// files that were never written idempotently produces a plain, key-less file,
187    /// same as before this method existed.
188    pub fn merge_batch_ids(sources: &[DataFileEntry]) -> Option<String> {
189        let mut seen = std::collections::HashSet::new();
190        let ids: Vec<String> = sources
191            .iter()
192            .flat_map(DataFileEntry::batch_ids)
193            .filter(|id| seen.insert(id.clone()))
194            .collect();
195        if ids.is_empty() {
196            None
197        } else {
198            Some(serde_json::to_string(&ids).expect("Vec<String> always serializes"))
199        }
200    }
201}
202
203/// One field from the current Iceberg table schema (Phase G).
204///
205/// Parsed from `schemas[current-schema-id].fields` in `metadata.json`.
206/// Used by `SchemaFiller` to inject missing columns when reading old files.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct SchemaField {
209    pub id: i32,
210    pub name: String,
211    pub required: bool,
212    /// Iceberg type string, e.g. `"int"`, `"string"`, `"timestamptz"`.
213    pub iceberg_type: String,
214    /// Value injected when reading old files that predate this field.
215    /// `None` → null is used.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub initial_default: Option<serde_json::Value>,
218    /// Default value written to new files. Same as `initial_default` in most cases.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub write_default: Option<serde_json::Value>,
221}
222
223/// Iceberg-compatible table metadata read from the catalog.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct TableMetadata {
226    pub table_uuid: String,
227    pub format_version: i32,
228    pub location: String,
229    /// Ailake-specific properties: ailake.vector-column, ailake.dim, etc.
230    pub properties: HashMap<String, String>,
231    pub current_snapshot_id: Option<SnapshotId>,
232    /// Absolute path to the Puffin stats file for the current snapshot (Phase F).
233    /// `None` for V2 tables or V3 tables without any committed statistics yet.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub current_statistics_path: Option<String>,
236    /// Current schema fields parsed from `metadata.json` (Phase G).
237    /// Empty for tables created before Phase G or tables with no schema committed.
238    /// Used by `SchemaFiller` in the scanner to inject missing columns with defaults.
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub schema_fields: Vec<SchemaField>,
241    /// Equality delete files active in the current snapshot (Phase H).
242    /// Loaded from delete manifests (content=2 in the manifest list).
243    /// Populated by `CatalogProvider::list_equality_deletes` — empty until first call.
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub equality_delete_files: Vec<EqualityDeleteFile>,
246    /// Active partition spec for this table (Phase I).
247    /// `None` for unpartitioned tables or tables created before Phase I.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub partition_spec: Option<PartitionSpec>,
250}
251
252/// Iceberg schema update carried inside a snapshot commit.
253///
254/// When present, `HadoopCatalog::commit_snapshot` patches `schemas[0].fields`,
255/// `last-column-id`, and `schema.name-mapping.default` in the persisted metadata.
256/// REST/Glue/JDBC backends that delegate schema management to the server ignore this.
257#[derive(Debug, Clone)]
258pub struct IcebergSchemaUpdate {
259    /// Iceberg-typed field descriptors, e.g. `[{"id":1,"name":"id","required":false,"type":"int"}]`.
260    pub fields: Vec<serde_json::Value>,
261    pub last_column_id: i32,
262    /// Compact JSON string: `[{"field-id":1,"names":["id"]},...]`.
263    pub name_mapping_json: String,
264}
265
266/// Reference to an Iceberg equality delete file (Phase H).
267///
268/// The delete file is an Avro file with `content=2` whose rows contain equality
269/// predicates — any data row matching one of those rows is logically deleted.
270/// `equality_ids` lists the field IDs used for the equality check.
271#[derive(Debug, Clone, Serialize, Deserialize, Default)]
272pub struct EqualityDeleteFile {
273    /// Absolute or warehouse-relative path of the Avro delete file.
274    pub path: String,
275    /// Field IDs whose values must all match to delete a data row.
276    pub equality_ids: Vec<i32>,
277    /// Number of predicates (rows) in the delete file.
278    pub record_count: u64,
279    pub file_size_bytes: u64,
280    /// Write-path-only hint: `(column_name, values)` as passed to `delete_where`,
281    /// before Avro encoding. Never persisted to the Iceberg delete manifest —
282    /// `manifest_commit.rs` rebuilds `EqualityDeleteFile` field-by-field for the
283    /// Avro writer, so this drops out naturally on every backend except
284    /// `DuckLakeCatalog`, which uses it to additionally issue a native
285    /// `DELETE FROM lake.tbl WHERE col IN (...)` when the column is declared.
286    #[serde(default, skip_serializing)]
287    pub inline_values: Option<(String, Vec<String>)>,
288    /// Iceberg data sequence number this delete file was committed with (spec
289    /// `manifest_entry.sequence_number`). Per spec, an equality delete only
290    /// masks a data file whose own `DataFileEntry::sequence_number` is
291    /// strictly less than this — see that field's doc for why. `0` for
292    /// legacy/in-memory entries; populated on read by
293    /// `avro_manifest::read_equality_delete_manifest`. Writers don't need to
294    /// set it correctly for the same reason as `DataFileEntry::sequence_number`:
295    /// `write_equality_delete_manifest` always stamps every entry with the
296    /// commit's own sequence number, ignoring this field on the way in.
297    #[serde(default)]
298    pub sequence_number: i64,
299}
300
301/// Snapshot commit request.
302#[derive(Debug, Clone)]
303pub struct NewSnapshot {
304    pub snapshot_id: SnapshotId,
305    pub parent_snapshot_id: Option<SnapshotId>,
306    pub files: Vec<DataFileEntry>,
307    pub operation: SnapshotOperation,
308    /// When set, the catalog backend should update the table schema on commit.
309    pub iceberg_schema: Option<IcebergSchemaUpdate>,
310    /// Additional table-level properties to merge on commit (e.g. secondary column dims).
311    /// Keys use `ailake.dim-<col>` / `ailake.metric-<col>` convention.
312    pub extra_properties: HashMap<String, String>,
313    /// Per-file BM25 Bloom filter bytes for term-level file pruning (Phase F).
314    /// Key = data file path (relative, matches `DataFileEntry::path`).
315    /// Written to the Puffin stats file on V3 commits; ignored for V2 tables.
316    pub bloom_filters: Vec<(String, Vec<u8>)>,
317    /// Equality delete files to add to this snapshot (Phase H).
318    /// Written as a separate delete manifest with content=2 in the manifest list.
319    pub equality_delete_files: Vec<EqualityDeleteFile>,
320}
321
322#[derive(Debug, Clone)]
323pub enum SnapshotOperation {
324    Append,
325    Overwrite,
326    Delete,
327    Replace,
328}
329
330/// One field in an Iceberg partition spec (Phase I).
331///
332/// For an `identity` partition on column "agent_id":
333/// `source_id=1` (must match the field id in the table schema),
334/// `field_id=1000` (Iceberg convention: partition fields start at 1000).
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct PartitionField {
337    /// Field ID of the source column in the table schema.
338    pub source_id: i32,
339    /// Partition field ID (≥ 1000 by convention).
340    pub field_id: i32,
341    /// Partition column name (usually same as the source column).
342    pub name: String,
343    /// Transform function: "identity", "bucket[N]", "truncate[W]", "year", etc.
344    pub transform: String,
345    /// Iceberg type of the source column ("string", "int", "long", "uuid").
346    /// Derived from the table schema at read time; stored here for encoding.
347    pub source_type: String,
348}
349
350/// Iceberg partition spec (Phase I).
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct PartitionSpec {
353    pub spec_id: i32,
354    pub fields: Vec<PartitionField>,
355}
356
357impl PartitionSpec {
358    /// True when this spec has no partition fields (unpartitioned table).
359    pub fn is_unpartitioned(&self) -> bool {
360        self.fields.is_empty()
361    }
362}
363
364/// Schema properties passed at table creation time.
365#[derive(Debug, Clone)]
366pub struct TableProperties {
367    pub policy: VectorStoragePolicy,
368    pub extra: HashMap<String, String>,
369    /// Iceberg format version to write. 2 = default (V2). 3 = opt-in V3.
370    pub format_version: u8,
371    /// Iceberg type of the partition column when `policy.partition_by` is set.
372    /// Defaults to `"string"` when `None`. Supported: "string", "uuid", "int", "long".
373    pub partition_column_type: Option<String>,
374}
375
376/// Unified catalog interface. All backends implement this trait.
377#[async_trait]
378pub trait CatalogProvider: Send + Sync {
379    async fn create_table(&self, name: &TableIdent, props: &TableProperties) -> AilakeResult<()>;
380
381    async fn load_table(&self, name: &TableIdent) -> AilakeResult<TableMetadata>;
382
383    async fn commit_snapshot(
384        &self,
385        table: &TableIdent,
386        snapshot: NewSnapshot,
387    ) -> AilakeResult<SnapshotId>;
388
389    async fn list_files(
390        &self,
391        table: &TableIdent,
392        snapshot_id: Option<SnapshotId>,
393    ) -> AilakeResult<Vec<DataFileEntry>>;
394
395    async fn drop_table(&self, name: &TableIdent) -> AilakeResult<()>;
396
397    /// Whether a retired data file (one dropped by a `Replace`/`Overwrite` commit,
398    /// e.g. compaction's merged-away inputs) should be physically deleted from the
399    /// object store immediately after that commit succeeds.
400    ///
401    /// True for manifest-based backends (`HadoopCatalog` and friends): once a file
402    /// drops out of the Iceberg manifest, the catalog has no other record of it, so
403    /// deleting the bytes right away is safe.
404    ///
405    /// `DuckLakeCatalog` overrides this to `false` — see "The retirement problem"
406    /// in `docs/guides/DUCKLAKE_CATALOG.md`: its `commit_snapshot` only issues a
407    /// row-level `DELETE`, which attaches a deletion vector but leaves the file
408    /// registered in `ducklake_list_files()` until an operator runs DuckLake's own
409    /// `ducklake_expire_snapshots`/`ducklake_cleanup_files`. Deleting the bytes
410    /// immediately (the default here) would leave that still-registered path
411    /// dangling — breaking any subsequent DuckLake-native read that touches it.
412    fn retires_files_physically(&self) -> bool {
413        true
414    }
415
416    /// Whether data files, once committed at a path, may have their bytes
417    /// rewritten in place at that same path.
418    ///
419    /// True for manifest-based backends: the manifest entry the caller commits
420    /// alongside the rewrite is the only statistics record, so a same-path
421    /// rewrite is self-consistent.
422    ///
423    /// `DuckLakeCatalog` overrides this to `false`: DuckLake snapshots record
424    /// per-file zone-map stats and the exact footer size at
425    /// `ducklake_add_data_files` time and trust both afterwards — verified
426    /// against a live extension: a same-length rewrite silently returns wrong
427    /// filtered rows (stale zone-map prunes the file), and a changed-length
428    /// rewrite breaks every subsequent read of the file outright ("Parquet
429    /// footer length stored in file is not equal to footer length provided"),
430    /// including the row-`DELETE` needed to retire it — an unrecoverable state
431    /// through sanctioned SQL. Writers that rewrite files (memory decay,
432    /// deferred index patching) must either write to a fresh path and retire
433    /// the old one, or refuse to run against catalogs returning `false` here.
434    fn supports_in_place_rewrite(&self) -> bool {
435        true
436    }
437
438    /// Apply schema evolution (add columns / rename columns) without rewriting data files.
439    ///
440    /// Returns the new `schema-id` assigned in `metadata.json`.
441    /// Old files missing new columns will have their values filled at read time using
442    /// `AddColumnRequest::initial_default` (Phase G `SchemaFiller`).
443    ///
444    /// Default implementation returns an error — override in file-based backends.
445    async fn evolve_schema(
446        &self,
447        _table: &TableIdent,
448        _evolution: crate::schema_evolution::SchemaEvolution,
449    ) -> AilakeResult<i32> {
450        Err(ailake_core::AilakeError::Catalog(
451            "evolve_schema not supported by this catalog backend".into(),
452        ))
453    }
454
455    /// Return all equality delete files active in the current (or specified) snapshot.
456    ///
457    /// Reads delete manifests (manifest list entries with `content=1`) and parses their
458    /// `content=2` entries. Default returns empty vec for catalog backends that do not
459    /// support Iceberg equality deletes.
460    async fn list_equality_deletes(
461        &self,
462        _table: &TableIdent,
463        _snapshot_id: Option<SnapshotId>,
464    ) -> AilakeResult<Vec<EqualityDeleteFile>> {
465        Ok(vec![])
466    }
467
468    /// Load the raw Iceberg metadata object for this table.
469    ///
470    /// Required for CDC and other operations that need to inspect snapshots beyond
471    /// the current one. Defaults to an error; file-based backends (Hadoop, Glue, JDBC)
472    /// override this. Server-backed backends (REST, Nessie) may not expose raw metadata.
473    async fn load_raw_metadata(&self, _table: &TableIdent) -> AilakeResult<IcebergMetadata> {
474        Err(ailake_core::AilakeError::Catalog(
475            "load_raw_metadata not supported by this catalog backend".into(),
476        ))
477    }
478
479    /// List all snapshots known to this table.
480    async fn list_snapshots(&self, table: &TableIdent) -> AilakeResult<Vec<IcebergSnapshot>> {
481        let meta = self.load_raw_metadata(table).await?;
482        Ok(meta.snapshots.clone())
483    }
484
485    /// Add a new vector column to the table schema without rewriting data files.
486    ///
487    /// - Adds the column as Iceberg type `binary` (maps to `FIXED_LEN_BYTE_ARRAY` in Parquet).
488    /// - Stores `ailake.dim-<col>`, `ailake.metric-<col>`, `ailake.precision-<col>` in properties.
489    /// - Old files missing the column return `null` at read time (`initial-default = null`).
490    /// - Does NOT commit a snapshot or backfill embeddings — use `BackfillJob` for that.
491    ///
492    /// Returns the new `schema-id`.
493    async fn add_vector_column(
494        &self,
495        table: &TableIdent,
496        spec: &ailake_core::VectorColSpec,
497    ) -> AilakeResult<i32> {
498        use crate::schema_evolution::{AddColumnRequest, SchemaEvolution};
499        use std::collections::HashMap;
500
501        let mut props: HashMap<String, String> = HashMap::new();
502        props.insert(
503            format!("ailake.dim-{}", spec.column_name),
504            spec.dim.to_string(),
505        );
506        props.insert(
507            format!("ailake.metric-{}", spec.column_name),
508            format!("{:?}", spec.metric).to_lowercase(),
509        );
510        props.insert(
511            format!("ailake.precision-{}", spec.column_name),
512            format!("{:?}", spec.precision).to_lowercase(),
513        );
514        if spec.pre_normalize {
515            props.insert(
516                format!("ailake.pre-normalize-{}", spec.column_name),
517                "true".to_string(),
518            );
519        }
520        if let Some(m) = spec.hnsw_m {
521            props.insert(format!("ailake.hnsw-m-{}", spec.column_name), m.to_string());
522        }
523        if let Some(ef) = spec.hnsw_ef_construction {
524            props.insert(
525                format!("ailake.hnsw-ef-construction-{}", spec.column_name),
526                ef.to_string(),
527            );
528        }
529
530        let evolution = SchemaEvolution::new()
531            .add_column(AddColumnRequest {
532                name: spec.column_name.clone(),
533                iceberg_type: "binary".to_string(),
534                required: false,
535                initial_default: None,
536                write_default: None,
537                doc: Some(format!(
538                    "Vector column {} dim={} metric={:?}",
539                    spec.column_name, spec.dim, spec.metric
540                )),
541            })
542            .with_properties(props);
543
544        self.evolve_schema(table, evolution).await
545    }
546}
547
548/// Vector index metadata for a single data file.
549pub struct VectorIndexInfo<'a> {
550    pub column: &'a str,
551    pub dim: u32,
552    pub hnsw_offset: u64,
553    pub hnsw_len: u64,
554}
555
556/// Build DataFileEntry from a centroid, encoding it as base64.
557pub fn make_data_file_entry(
558    path: &str,
559    record_count: u64,
560    file_size_bytes: u64,
561    centroid: &Centroid,
562    index: VectorIndexInfo<'_>,
563) -> DataFileEntry {
564    make_multi_column_data_file_entry(path, record_count, file_size_bytes, centroid, index, &[])
565}
566
567/// Build DataFileEntry for a file with multiple vector columns.
568///
569/// `primary_centroid` and `primary_index` describe the primary (first) vector column.
570/// `extra` contains info for additional columns.
571pub fn make_multi_column_data_file_entry(
572    path: &str,
573    record_count: u64,
574    file_size_bytes: u64,
575    primary_centroid: &Centroid,
576    primary_index: VectorIndexInfo<'_>,
577    extra: &[ExtraVectorIndex],
578) -> DataFileEntry {
579    use base64::Engine;
580    let centroid_bytes: Vec<u8> = primary_centroid
581        .values
582        .iter()
583        .flat_map(|v| v.to_le_bytes())
584        .collect();
585    let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(&centroid_bytes);
586    DataFileEntry {
587        path: path.to_string(),
588        record_count,
589        file_size_bytes,
590        centroid_b64: Some(centroid_b64),
591        radius: Some(primary_centroid.radius),
592        hnsw_offset: Some(primary_index.hnsw_offset),
593        hnsw_len: Some(primary_index.hnsw_len),
594        vector_column: Some(primary_index.column.to_string()),
595        vector_dim: Some(primary_index.dim),
596        extra_vector_indexes: extra.to_vec(),
597        index_status: IndexStatus::Ready,
598        index_error: None,
599        batch_id: None,
600        embedding_model: None,
601        partition_value: None,
602        deletion_vector: None,
603        first_row_id: None,
604        column_stats: None,
605        sequence_number: 0,
606    }
607}
608
609/// Build a DataFileEntry for a file whose HNSW is still being built asynchronously.
610///
611/// `hnsw_offset` and `hnsw_len` are `None`; `index_status` is `Indexing`.
612/// The centroid is included so geometric pruning still works during the build window.
613pub fn make_data_file_entry_indexing(
614    path: &str,
615    record_count: u64,
616    file_size_bytes: u64,
617    centroid: &Centroid,
618    column: &str,
619    dim: u32,
620) -> DataFileEntry {
621    use base64::Engine;
622    let centroid_bytes: Vec<u8> = centroid
623        .values
624        .iter()
625        .flat_map(|v| v.to_le_bytes())
626        .collect();
627    let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(&centroid_bytes);
628    DataFileEntry {
629        path: path.to_string(),
630        record_count,
631        file_size_bytes,
632        centroid_b64: Some(centroid_b64),
633        radius: Some(centroid.radius),
634        hnsw_offset: None,
635        hnsw_len: None,
636        vector_column: Some(column.to_string()),
637        vector_dim: Some(dim),
638        extra_vector_indexes: vec![],
639        index_status: IndexStatus::Indexing,
640        index_error: None,
641        batch_id: None,
642        embedding_model: None,
643        partition_value: None,
644        deletion_vector: None,
645        first_row_id: None,
646        column_stats: None,
647        sequence_number: 0,
648    }
649}
650
651/// Encode a centroid to base64 for use in ExtraVectorIndex.
652pub fn encode_centroid_b64(centroid: &Centroid) -> String {
653    use base64::Engine;
654    let bytes: Vec<u8> = centroid
655        .values
656        .iter()
657        .flat_map(|v| v.to_le_bytes())
658        .collect();
659    base64::engine::general_purpose::STANDARD.encode(&bytes)
660}
661
662/// Decode centroid bytes from base64 in a DataFileEntry.
663pub fn decode_centroid(
664    entry: &DataFileEntry,
665    metric: ailake_core::VectorMetric,
666) -> Option<Centroid> {
667    use base64::Engine;
668    let b64 = entry.centroid_b64.as_ref()?;
669    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
670    let values: Vec<f32> = bytes
671        .chunks_exact(4)
672        .map(|b| {
673            f32::from_le_bytes(
674                b.try_into()
675                    .expect("chunks_exact(4) guarantees 4-byte slices"),
676            )
677        })
678        .collect();
679    Some(Centroid {
680        values,
681        radius: entry.radius.unwrap_or(0.0),
682        metric,
683    })
684}
685
686pub fn new_snapshot_id() -> SnapshotId {
687    // Microsecond precision avoids collisions when multiple snapshots are committed
688    // within the same millisecond (common in fast local-filesystem tests).
689    std::time::SystemTime::now()
690        .duration_since(std::time::UNIX_EPOCH)
691        .unwrap()
692        .as_micros() as i64
693}
694
695#[cfg(test)]
696mod batch_id_tests {
697    use super::*;
698
699    fn entry_with_batch_id(batch_id: Option<&str>) -> DataFileEntry {
700        DataFileEntry {
701            path: "data/x.parquet".into(),
702            record_count: 1,
703            file_size_bytes: 1,
704            centroid_b64: None,
705            radius: None,
706            hnsw_offset: None,
707            hnsw_len: None,
708            vector_column: None,
709            vector_dim: None,
710            extra_vector_indexes: vec![],
711            index_status: IndexStatus::Ready,
712            index_error: None,
713            batch_id: batch_id.map(String::from),
714            embedding_model: None,
715            partition_value: None,
716            deletion_vector: None,
717            first_row_id: None,
718            column_stats: None,
719            sequence_number: 0,
720        }
721    }
722
723    #[test]
724    fn batch_ids_decodes_plain_string_as_single_key() {
725        let e = entry_with_batch_id(Some("dag_run_2026-05-28_taskA"));
726        assert_eq!(e.batch_ids(), vec!["dag_run_2026-05-28_taskA".to_string()]);
727    }
728
729    #[test]
730    fn batch_ids_empty_when_none() {
731        assert!(entry_with_batch_id(None).batch_ids().is_empty());
732    }
733
734    #[test]
735    fn merge_batch_ids_aggregates_plain_source_keys() {
736        let sources = vec![
737            entry_with_batch_id(Some("a")),
738            entry_with_batch_id(Some("b")),
739        ];
740        let merged = DataFileEntry::merge_batch_ids(&sources);
741        let mut merged_entry = entry_with_batch_id(None);
742        merged_entry.batch_id = merged;
743        assert_eq!(
744            merged_entry.batch_ids(),
745            vec!["a".to_string(), "b".to_string()]
746        );
747    }
748
749    #[test]
750    fn merge_batch_ids_none_when_no_source_has_one() {
751        let sources = vec![entry_with_batch_id(None), entry_with_batch_id(None)];
752        assert_eq!(DataFileEntry::merge_batch_ids(&sources), None);
753    }
754
755    #[test]
756    fn merge_batch_ids_dedups_repeated_keys() {
757        let sources = vec![
758            entry_with_batch_id(Some("a")),
759            entry_with_batch_id(Some("a")),
760        ];
761        let mut merged_entry = entry_with_batch_id(None);
762        merged_entry.batch_id = DataFileEntry::merge_batch_ids(&sources);
763        assert_eq!(merged_entry.batch_ids(), vec!["a".to_string()]);
764    }
765
766    /// Compacting an already-compacted file (merge of a merge) must flatten to a
767    /// single-level list of the *original* keys, not nest a nother JSON array
768    /// inside the string — otherwise every subsequent compaction pass grows the
769    /// encoding unboundedly and `batch_ids()` would need recursive decoding.
770    #[test]
771    fn merge_batch_ids_flattens_a_previous_merge_without_nesting() {
772        let first_pass = vec![
773            entry_with_batch_id(Some("a")),
774            entry_with_batch_id(Some("b")),
775        ];
776        let mut already_merged = entry_with_batch_id(None);
777        already_merged.batch_id = DataFileEntry::merge_batch_ids(&first_pass);
778
779        let second_pass = vec![already_merged, entry_with_batch_id(Some("c"))];
780        let mut twice_merged = entry_with_batch_id(None);
781        twice_merged.batch_id = DataFileEntry::merge_batch_ids(&second_pass);
782
783        assert_eq!(
784            twice_merged.batch_ids(),
785            vec!["a".to_string(), "b".to_string(), "c".to_string()]
786        );
787        // Sanity: the encoding itself must be a flat array, not `["[\"a\",\"b\"]","c"]`.
788        assert_eq!(twice_merged.batch_id.as_deref(), Some(r#"["a","b","c"]"#));
789    }
790}