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