Skip to main content

ailake_query/
backfill.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Backfill job: adds a new vector column to existing files in an AI-Lake table.
3//!
4//! Reads each file, generates embeddings for the new column via `embed_fn`, and
5//! rewrites the file with both the original vector column and the new one (using
6//! `write_multi`). Commits an Overwrite snapshot per file (AtomicReplace semantics).
7//!
8//! Idempotent: files that already contain the new column (detected via
9//! `has_column_footer`) are skipped.
10
11use std::sync::Arc;
12
13use ailake_catalog::{
14    encode_centroid_b64, make_multi_column_data_file_entry, new_snapshot_id,
15    provider::{CatalogProvider, DataFileEntry, ExtraVectorIndex, NewSnapshot, SnapshotOperation},
16    TableIdent, VectorIndexInfo,
17};
18use ailake_core::{AilakeError, AilakeResult, VectorColSpec, VectorStoragePolicy};
19use ailake_file::{AilakeFileReader, AilakeFileWriter, VectorColumnBatch};
20use ailake_store::Store;
21use ailake_vec::compute_centroid_and_radius;
22use arrow_array::{Array, RecordBatch, StringArray};
23use bytes::Bytes;
24use tracing::info;
25
26pub use crate::migration::EmbedFn;
27
28pub type BackfillProgressFn = Arc<dyn Fn(BackfillProgress) + Send + Sync>;
29
30/// Progress reported after each file is backfilled.
31#[derive(Debug, Clone)]
32pub struct BackfillProgress {
33    pub files_done: usize,
34    pub files_total: usize,
35    pub files_skipped: usize,
36    pub rows_backfilled: u64,
37}
38
39/// Adds a new vector column to all existing files in a table.
40///
41/// Does not touch files that already have the column (idempotent).
42/// Concurrent new writes (after `add_vector_column` was called) already include
43/// the column and are also skipped.
44pub struct BackfillJob {
45    pub table: TableIdent,
46    /// Column in the Parquet files that holds the text to embed.
47    pub text_column: String,
48    /// Specification for the new vector column.
49    pub new_col: VectorColSpec,
50    /// Callable: given a slice of text strings, returns one F32 vector per text.
51    pub embed_fn: EmbedFn,
52    /// How many texts to embed per `embed_fn` call.
53    pub batch_size: usize,
54    /// Optional progress callback.
55    pub on_progress: Option<BackfillProgressFn>,
56}
57
58impl BackfillJob {
59    pub async fn run(
60        self,
61        catalog: Arc<dyn CatalogProvider>,
62        store: Arc<dyn Store>,
63    ) -> AilakeResult<()> {
64        let table_meta = catalog.load_table(&self.table).await?;
65        let files = catalog
66            .list_files(&self.table, table_meta.current_snapshot_id)
67            .await?;
68        let total = files.len();
69        let mut rows_backfilled: u64 = 0;
70        let mut files_skipped: usize = 0;
71
72        // Build policy for the new column from the VectorColSpec.
73        let new_policy = VectorStoragePolicy {
74            column_name: self.new_col.column_name.clone(),
75            dim: self.new_col.dim,
76            metric: self.new_col.metric,
77            precision: self.new_col.precision,
78            pre_normalize: self.new_col.pre_normalize,
79            hnsw_m: self.new_col.hnsw_m,
80            hnsw_ef_construction: self.new_col.hnsw_ef_construction,
81            pq: None,
82            keep_raw_for_reranking: true,
83            ivf_residual: false,
84            embedding_model: None,
85            modality: None,
86            partition_by: None,
87            partition_value: None,
88            partition_column_type: None,
89            partition_fields: vec![],
90        };
91
92        // Derive primary policy from table properties.
93        let primary_policy = primary_policy_from_props(&table_meta.properties)?;
94
95        let mut parent_snap = table_meta.current_snapshot_id;
96        // Running view of every file currently in the table. `Overwrite` does not
97        // inherit the previous manifest (same contract as `Replace` — see
98        // `HadoopCatalog::commit_snapshot`), so each commit below must carry the
99        // complete current state, not just the one file that changed this iteration,
100        // or every file backfilled (or not yet reached) in a prior iteration would
101        // vanish from the table on this commit.
102        let mut current_files = files.clone();
103
104        for (idx, entry) in files.iter().enumerate() {
105            let file_bytes = store.get(&entry.path).await?;
106
107            // Idempotency: skip if new column already has its own AILK section.
108            // `has_column_footer` checks the per-column KV key directly — unlike
109            // `ailk_offset_for_column`, it doesn't fall back to the primary column's
110            // footer, which would make every AI-Lake file look like it already has
111            // the new column and skip backfilling entirely.
112            let reader = AilakeFileReader::new(
113                file_bytes.clone(),
114                &primary_policy.column_name,
115                primary_policy.dim,
116            );
117            if reader.has_column_footer(&self.new_col.column_name) {
118                files_skipped += 1;
119                info!(
120                    "ailake backfill: skipping {} — column '{}' already present ({}/{})",
121                    entry.path,
122                    self.new_col.column_name,
123                    idx + 1,
124                    total
125                );
126                continue;
127            }
128
129            // Read Parquet data, text column, and existing primary embeddings.
130            let (batch, texts, primary_embeddings) = read_batch_texts_and_embeddings(
131                file_bytes,
132                &primary_policy.column_name,
133                primary_policy.dim,
134                &self.text_column,
135            )?;
136
137            // Drop DV-masked rows before re-embedding — the backfilled file is brand-new,
138            // so a deleted row must not get a fresh new-column embedding and resurrect.
139            let (batch, texts, primary_embeddings) = if let Some(dv) = &entry.deletion_vector {
140                let bitmap = crate::dv::load_deletion_vector(&store, dv).await?;
141                let combined: Vec<(String, Vec<f32>)> =
142                    texts.into_iter().zip(primary_embeddings).collect();
143                let (batch, combined) = crate::dv::filter_deleted_rows(batch, combined, &bitmap)?;
144                let (texts, primary_embeddings): (Vec<String>, Vec<Vec<f32>>) =
145                    combined.into_iter().unzip();
146                (batch, texts, primary_embeddings)
147            } else {
148                (batch, texts, primary_embeddings)
149            };
150
151            // Generate embeddings for new column in batches.
152            let new_embeddings = embed_in_batches(&self.embed_fn, &texts, self.batch_size)?;
153
154            // Write new file with both columns.
155            let new_entry = write_backfilled_file(
156                &batch,
157                &primary_embeddings,
158                &new_embeddings,
159                &primary_policy,
160                &new_policy,
161                &store,
162                idx,
163            )
164            .await?;
165
166            rows_backfilled += new_entry.record_count;
167
168            // Swap this file's entry in place; every other file (already backfilled in
169            // a prior iteration, skipped as idempotent, or not yet reached) is carried
170            // forward unchanged.
171            current_files[idx] = new_entry;
172
173            // Commit Overwrite snapshot: replaces old file with new multi-column file,
174            // carrying forward the full current file list (see comment above the loop).
175            let snap_id = new_snapshot_id();
176            catalog
177                .commit_snapshot(
178                    &self.table,
179                    NewSnapshot {
180                        snapshot_id: snap_id,
181                        parent_snapshot_id: parent_snap,
182                        files: current_files.clone(),
183                        operation: SnapshotOperation::Overwrite,
184                        iceberg_schema: None,
185                        extra_properties: std::collections::HashMap::new(),
186                        bloom_filters: vec![],
187                        equality_delete_files: vec![],
188                    },
189                )
190                .await?;
191            parent_snap = Some(snap_id);
192
193            if let Some(cb) = &self.on_progress {
194                cb(BackfillProgress {
195                    files_done: idx + 1 - files_skipped,
196                    files_total: total,
197                    files_skipped,
198                    rows_backfilled,
199                });
200            }
201
202            info!(
203                "ailake backfill: {}/{} files done ({} skipped), {} rows",
204                idx + 1,
205                total,
206                files_skipped,
207                rows_backfilled
208            );
209        }
210
211        info!(
212            "ailake backfill complete — column='{}', files={}, skipped={}, rows={}",
213            self.new_col.column_name,
214            total - files_skipped,
215            files_skipped,
216            rows_backfilled
217        );
218        Ok(())
219    }
220}
221
222fn read_batch_texts_and_embeddings(
223    bytes: Bytes,
224    vector_column: &str,
225    dim: u32,
226    text_column: &str,
227) -> AilakeResult<(RecordBatch, Vec<String>, Vec<Vec<f32>>)> {
228    let reader = AilakeFileReader::new(bytes, vector_column, dim);
229    let (batch, primary_embeddings) = reader.read_parquet()?;
230
231    let col = batch.column_by_name(text_column).ok_or_else(|| {
232        AilakeError::InvalidArgument(format!(
233            "text column '{}' not found; available: {}",
234            text_column,
235            batch
236                .schema()
237                .fields()
238                .iter()
239                .map(|f| f.name().as_str())
240                .collect::<Vec<_>>()
241                .join(", ")
242        ))
243    })?;
244
245    let arr = col.as_any().downcast_ref::<StringArray>().ok_or_else(|| {
246        AilakeError::InvalidArgument(format!("column '{text_column}' is not a String column"))
247    })?;
248
249    let texts: Vec<String> = (0..arr.len())
250        .map(|i| {
251            if arr.is_null(i) {
252                String::new()
253            } else {
254                arr.value(i).to_string()
255            }
256        })
257        .collect();
258
259    Ok((batch, texts, primary_embeddings))
260}
261
262fn embed_in_batches(
263    embed_fn: &EmbedFn,
264    texts: &[String],
265    batch_size: usize,
266) -> AilakeResult<Vec<Vec<f32>>> {
267    let mut all: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
268    for chunk in texts.chunks(batch_size) {
269        let mut vecs = embed_fn(chunk)?;
270        all.append(&mut vecs);
271    }
272    Ok(all)
273}
274
275async fn write_backfilled_file(
276    batch: &RecordBatch,
277    primary_embeddings: &[Vec<f32>],
278    new_embeddings: &[Vec<f32>],
279    primary_policy: &VectorStoragePolicy,
280    new_policy: &VectorStoragePolicy,
281    store: &Arc<dyn Store>,
282    idx: usize,
283) -> AilakeResult<DataFileEntry> {
284    let file_path = format!("data/backfill-{:05}.parquet", idx);
285
286    let writer = AilakeFileWriter::new(primary_policy.clone());
287    let file_bytes = writer.write_multi(
288        batch,
289        &[
290            VectorColumnBatch {
291                policy: primary_policy,
292                embeddings: primary_embeddings,
293            },
294            VectorColumnBatch {
295                policy: new_policy,
296                embeddings: new_embeddings,
297            },
298        ],
299    )?;
300    let file_size = file_bytes.len() as u64;
301    store.put(&file_path, file_bytes.clone()).await?;
302
303    // Read back AILK offsets for both columns using read_header_for_column.
304    let reader = AilakeFileReader::new(file_bytes, &primary_policy.column_name, primary_policy.dim);
305    let primary_ailk_offset = reader.ailk_offset()?;
306    let primary_header = reader.read_header()?;
307    let primary_hnsw_abs = primary_ailk_offset + primary_header.hnsw_offset;
308
309    let new_ailk_offset = reader.ailk_offset_for_column(&new_policy.column_name)?;
310    let new_header = reader.read_header_for_column(&new_policy.column_name)?;
311    let new_hnsw_abs = new_ailk_offset + new_header.hnsw_offset;
312
313    let primary_centroid = compute_centroid_and_radius(primary_embeddings, primary_policy.metric);
314    let new_centroid = compute_centroid_and_radius(new_embeddings, new_policy.metric);
315
316    let extra = vec![ExtraVectorIndex {
317        column: new_policy.column_name.clone(),
318        dim: new_policy.dim,
319        hnsw_offset: new_hnsw_abs,
320        hnsw_len: new_header.hnsw_len,
321        centroid_b64: Some(encode_centroid_b64(&new_centroid)),
322        radius: Some(new_centroid.radius),
323    }];
324
325    Ok(make_multi_column_data_file_entry(
326        &file_path,
327        new_embeddings.len() as u64,
328        file_size,
329        &primary_centroid,
330        VectorIndexInfo {
331            column: &primary_policy.column_name,
332            dim: primary_policy.dim,
333            hnsw_offset: primary_hnsw_abs,
334            hnsw_len: primary_header.hnsw_len,
335        },
336        &extra,
337    ))
338}
339
340fn primary_policy_from_props(
341    props: &std::collections::HashMap<String, String>,
342) -> AilakeResult<VectorStoragePolicy> {
343    use ailake_core::{VectorMetric, VectorPrecision};
344
345    let column_name = props
346        .get("ailake.vector-column")
347        .cloned()
348        .unwrap_or_else(|| "embedding".to_string());
349
350    let dim: u32 = props
351        .get("ailake.vector-dim")
352        .and_then(|s| s.parse().ok())
353        .ok_or_else(|| {
354            AilakeError::InvalidArgument("table missing ailake.vector-dim property".into())
355        })?;
356
357    let metric = match props
358        .get("ailake.vector-metric")
359        .map(|s| s.as_str())
360        .unwrap_or("cosine")
361    {
362        "euclidean" => VectorMetric::Euclidean,
363        "dotproduct" | "dot_product" => VectorMetric::DotProduct,
364        "normalizedcosine" | "normalized_cosine" => VectorMetric::NormalizedCosine,
365        _ => VectorMetric::Cosine,
366    };
367
368    let precision = match props
369        .get("ailake.vector-precision")
370        .map(|s| s.as_str())
371        .unwrap_or("f16")
372    {
373        "f32" => VectorPrecision::F32,
374        "i8" => VectorPrecision::I8,
375        _ => VectorPrecision::F16,
376    };
377
378    Ok(VectorStoragePolicy {
379        column_name,
380        dim,
381        metric,
382        precision,
383        pre_normalize: props
384            .get("ailake.pre-normalize")
385            .map(|s| s == "true")
386            .unwrap_or(false),
387        hnsw_m: props.get("ailake.hnsw-m").and_then(|s| s.parse().ok()),
388        hnsw_ef_construction: props
389            .get("ailake.hnsw-ef-construction")
390            .and_then(|s| s.parse().ok()),
391        pq: None,
392        keep_raw_for_reranking: true,
393        ivf_residual: false,
394        embedding_model: None,
395        modality: None,
396        partition_by: None,
397        partition_value: None,
398        partition_column_type: None,
399        partition_fields: vec![],
400    })
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use ailake_catalog::{HadoopCatalog, TableProperties};
407    use ailake_core::{VectorMetric, VectorPrecision};
408    use ailake_store::LocalStore;
409    use arrow_array::Int32Array;
410    use arrow_schema::{DataType, Field, Schema};
411    use tempfile::TempDir;
412
413    fn make_policy(dim: u32) -> VectorStoragePolicy {
414        VectorStoragePolicy {
415            column_name: "embedding".into(),
416            dim,
417            metric: VectorMetric::Cosine,
418            precision: VectorPrecision::F16,
419            pq: None,
420            keep_raw_for_reranking: true,
421            pre_normalize: false,
422            hnsw_m: None,
423            hnsw_ef_construction: None,
424            ivf_residual: false,
425            embedding_model: None,
426            modality: None,
427            partition_by: None,
428            partition_value: None,
429            partition_column_type: None,
430            partition_fields: vec![],
431        }
432    }
433
434    /// Regression test: `BackfillJob::run` used to commit `SnapshotOperation::Overwrite`
435    /// with `files: vec![new_entry]` per loop iteration. `Overwrite` doesn't inherit the
436    /// previous manifest (same contract as `Replace` — see
437    /// `hadoop.rs::replace_does_not_inherit_previous_manifest`), so every iteration after
438    /// the first silently discarded every file backfilled by prior iterations. Uses 3
439    /// files specifically because the bug is invisible with only 1 (replacing "the only
440    /// file" with a partial list is coincidentally correct).
441    #[tokio::test]
442    async fn run_preserves_all_files_not_just_the_last() {
443        let dir = TempDir::new().unwrap();
444        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
445        let catalog_dir = TempDir::new().unwrap();
446        let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
447        let catalog: Arc<dyn CatalogProvider> = Arc::new(HadoopCatalog::new(catalog_store, ""));
448        let table = TableIdent::new("ns", "tbl");
449
450        let dim = 4u32;
451        let policy = make_policy(dim);
452        catalog
453            .create_table(
454                &table,
455                &TableProperties {
456                    policy: policy.clone(),
457                    extra: std::collections::HashMap::new(),
458                    format_version: 2,
459                    partition_column_type: None,
460                },
461            )
462            .await
463            .unwrap();
464
465        let schema = Arc::new(Schema::new(vec![
466            Field::new("id", DataType::Int32, false),
467            Field::new("chunk_text", DataType::Utf8, false),
468        ]));
469
470        // Three files — each its own commit, so list_files() returns 3 entries.
471        let mut parent_snap = None;
472        for (i, (ids, texts)) in [
473            (vec![0i32, 1], vec!["a0", "a1"]),
474            (vec![2, 3], vec!["b0", "b1"]),
475            (vec![4, 5], vec!["c0", "c1"]),
476        ]
477        .into_iter()
478        .enumerate()
479        {
480            let embs: Vec<Vec<f32>> = ids.iter().map(|&v| vec![v as f32; dim as usize]).collect();
481            let batch = RecordBatch::try_new(
482                schema.clone(),
483                vec![
484                    Arc::new(Int32Array::from(ids.clone())),
485                    Arc::new(StringArray::from(texts)),
486                ],
487            )
488            .unwrap();
489            let bytes = AilakeFileWriter::new(policy.clone())
490                .write(&batch, &embs)
491                .unwrap();
492            let path = format!("data/old_{i}.parquet");
493            store.put(&path, bytes.clone()).await.unwrap();
494
495            let centroid = compute_centroid_and_radius(&embs, VectorMetric::Cosine);
496            let reader = AilakeFileReader::new(bytes.clone(), "embedding", dim);
497            let header = reader.read_header().unwrap();
498            let ailk_start = reader.ailk_offset().unwrap();
499            let entry = ailake_catalog::make_data_file_entry(
500                &path,
501                ids.len() as u64,
502                bytes.len() as u64,
503                &centroid,
504                VectorIndexInfo {
505                    column: "embedding",
506                    dim,
507                    hnsw_offset: ailk_start + header.hnsw_offset,
508                    hnsw_len: header.hnsw_len,
509                },
510            );
511            let snap_id = new_snapshot_id();
512            catalog
513                .commit_snapshot(
514                    &table,
515                    NewSnapshot {
516                        snapshot_id: snap_id,
517                        parent_snapshot_id: parent_snap,
518                        files: vec![entry],
519                        operation: SnapshotOperation::Append,
520                        iceberg_schema: None,
521                        extra_properties: std::collections::HashMap::new(),
522                        bloom_filters: vec![],
523                        equality_delete_files: vec![],
524                    },
525                )
526                .await
527                .unwrap();
528            parent_snap = Some(snap_id);
529        }
530
531        let files_before = catalog.list_files(&table, None).await.unwrap();
532        assert_eq!(
533            files_before.len(),
534            3,
535            "sanity: 3 files committed via Append"
536        );
537
538        let job = BackfillJob {
539            table: table.clone(),
540            text_column: "chunk_text".into(),
541            new_col: ailake_core::VectorColSpec {
542                column_name: "embedding_v2".into(),
543                dim,
544                metric: VectorMetric::Cosine,
545                precision: VectorPrecision::F16,
546                pre_normalize: false,
547                hnsw_m: None,
548                hnsw_ef_construction: None,
549            },
550            embed_fn: Arc::new(|texts: &[String]| {
551                Ok(texts.iter().map(|_| vec![9.0f32; 4]).collect())
552            }),
553            batch_size: 10,
554            on_progress: None,
555        };
556        job.run(catalog.clone(), store.clone()).await.unwrap();
557
558        let files_after = catalog.list_files(&table, None).await.unwrap();
559        assert_eq!(
560            files_after.len(),
561            3,
562            "BUG: expected all 3 backfilled files to remain visible, got {:?}",
563            files_after.iter().map(|f| &f.path).collect::<Vec<_>>()
564        );
565        let total_rows: u64 = files_after.iter().map(|f| f.record_count).sum();
566        assert_eq!(total_rows, 6, "all 6 original rows must survive backfill");
567
568        // Every file must actually have been rewritten (not skipped as a false-positive
569        // "already has the column" idempotency match) and be independently readable
570        // with both columns present.
571        for entry in &files_after {
572            assert!(
573                entry.path.starts_with("data/backfill-"),
574                "BUG: {} was never backfilled (idempotency check false-skipped it)",
575                entry.path
576            );
577            let bytes = store.get(&entry.path).await.unwrap();
578            let reader = AilakeFileReader::new(bytes, "embedding", dim);
579            assert!(
580                reader.has_column_footer("embedding_v2"),
581                "file {} missing backfilled column",
582                entry.path
583            );
584            let (batch, _) = reader.read_parquet().unwrap();
585            assert_eq!(batch.num_rows(), 2);
586        }
587    }
588}