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