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    // Timestamped so a second backfill run (another column, or a retry) never
285    // reuses a path from an earlier run — the old plain-index name made run 2
286    // overwrite the committed backfill-00000 it was itself reading, an in-place
287    // rewrite of a live file (breaks readers holding the old entry; hard error
288    // under catalogs with supports_in_place_rewrite() == false).
289    let file_path = format!(
290        "data/backfill-{}-{:05}.parquet",
291        std::time::SystemTime::now()
292            .duration_since(std::time::UNIX_EPOCH)
293            .unwrap_or_else(|e| e.duration())
294            .as_millis(),
295        idx
296    );
297
298    let writer = AilakeFileWriter::new(primary_policy.clone());
299    let file_bytes = writer.write_multi(
300        batch,
301        &[
302            VectorColumnBatch {
303                policy: primary_policy,
304                embeddings: primary_embeddings,
305            },
306            VectorColumnBatch {
307                policy: new_policy,
308                embeddings: new_embeddings,
309            },
310        ],
311    )?;
312    let file_size = file_bytes.len() as u64;
313    store.put(&file_path, file_bytes.clone()).await?;
314
315    // Read back AILK offsets for both columns using read_header_for_column.
316    let reader = AilakeFileReader::new(file_bytes, &primary_policy.column_name, primary_policy.dim);
317    let primary_ailk_offset = reader.ailk_offset()?;
318    let primary_header = reader.read_header()?;
319    let primary_hnsw_abs = primary_ailk_offset + primary_header.hnsw_offset;
320
321    let new_ailk_offset = reader.ailk_offset_for_column(&new_policy.column_name)?;
322    let new_header = reader.read_header_for_column(&new_policy.column_name)?;
323    let new_hnsw_abs = new_ailk_offset + new_header.hnsw_offset;
324
325    let primary_centroid = compute_centroid_and_radius(primary_embeddings, primary_policy.metric);
326    let new_centroid = compute_centroid_and_radius(new_embeddings, new_policy.metric);
327
328    let extra = vec![ExtraVectorIndex {
329        column: new_policy.column_name.clone(),
330        dim: new_policy.dim,
331        hnsw_offset: new_hnsw_abs,
332        hnsw_len: new_header.hnsw_len,
333        centroid_b64: Some(encode_centroid_b64(&new_centroid)),
334        radius: Some(new_centroid.radius),
335    }];
336
337    Ok(make_multi_column_data_file_entry(
338        &file_path,
339        new_embeddings.len() as u64,
340        file_size,
341        &primary_centroid,
342        VectorIndexInfo {
343            column: &primary_policy.column_name,
344            dim: primary_policy.dim,
345            hnsw_offset: primary_hnsw_abs,
346            hnsw_len: primary_header.hnsw_len,
347        },
348        &extra,
349    ))
350}
351
352fn primary_policy_from_props(
353    props: &std::collections::HashMap<String, String>,
354) -> AilakeResult<VectorStoragePolicy> {
355    use ailake_core::{VectorMetric, VectorPrecision};
356
357    let column_name = props
358        .get("ailake.vector-column")
359        .cloned()
360        .unwrap_or_else(|| "embedding".to_string());
361
362    let dim: u32 = props
363        .get("ailake.vector-dim")
364        .and_then(|s| s.parse().ok())
365        .ok_or_else(|| {
366            AilakeError::InvalidArgument("table missing ailake.vector-dim property".into())
367        })?;
368
369    let metric = match props
370        .get("ailake.vector-metric")
371        .map(|s| s.as_str())
372        .unwrap_or("cosine")
373    {
374        "euclidean" => VectorMetric::Euclidean,
375        "dotproduct" | "dot_product" => VectorMetric::DotProduct,
376        "normalizedcosine" | "normalized_cosine" => VectorMetric::NormalizedCosine,
377        _ => VectorMetric::Cosine,
378    };
379
380    let precision = match props
381        .get("ailake.vector-precision")
382        .map(|s| s.as_str())
383        .unwrap_or("f16")
384    {
385        "f32" => VectorPrecision::F32,
386        "i8" => VectorPrecision::I8,
387        _ => VectorPrecision::F16,
388    };
389
390    Ok(VectorStoragePolicy {
391        column_name,
392        dim,
393        metric,
394        precision,
395        pre_normalize: props
396            .get("ailake.pre-normalize")
397            .map(|s| s == "true")
398            .unwrap_or(false),
399        hnsw_m: props.get("ailake.hnsw-m").and_then(|s| s.parse().ok()),
400        hnsw_ef_construction: props
401            .get("ailake.hnsw-ef-construction")
402            .and_then(|s| s.parse().ok()),
403        pq: None,
404        keep_raw_for_reranking: true,
405        ivf_residual: false,
406        embedding_model: None,
407        modality: None,
408        partition_by: None,
409        partition_value: None,
410        partition_column_type: None,
411        partition_fields: vec![],
412    })
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use ailake_catalog::{HadoopCatalog, TableProperties};
419    use ailake_core::{VectorMetric, VectorPrecision};
420    use ailake_store::LocalStore;
421    use arrow_array::Int32Array;
422    use arrow_schema::{DataType, Field, Schema};
423    use tempfile::TempDir;
424
425    fn make_policy(dim: u32) -> VectorStoragePolicy {
426        VectorStoragePolicy {
427            column_name: "embedding".into(),
428            dim,
429            metric: VectorMetric::Cosine,
430            precision: VectorPrecision::F16,
431            pq: None,
432            keep_raw_for_reranking: true,
433            pre_normalize: false,
434            hnsw_m: None,
435            hnsw_ef_construction: None,
436            ivf_residual: false,
437            embedding_model: None,
438            modality: None,
439            partition_by: None,
440            partition_value: None,
441            partition_column_type: None,
442            partition_fields: vec![],
443        }
444    }
445
446    /// Regression test: `BackfillJob::run` used to commit `SnapshotOperation::Overwrite`
447    /// with `files: vec![new_entry]` per loop iteration. `Overwrite` doesn't inherit the
448    /// previous manifest (same contract as `Replace` — see
449    /// `hadoop.rs::replace_does_not_inherit_previous_manifest`), so every iteration after
450    /// the first silently discarded every file backfilled by prior iterations. Uses 3
451    /// files specifically because the bug is invisible with only 1 (replacing "the only
452    /// file" with a partial list is coincidentally correct).
453    #[tokio::test]
454    async fn run_preserves_all_files_not_just_the_last() {
455        let dir = TempDir::new().unwrap();
456        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
457        let catalog_dir = TempDir::new().unwrap();
458        let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
459        let catalog: Arc<dyn CatalogProvider> = Arc::new(HadoopCatalog::new(catalog_store, ""));
460        let table = TableIdent::new("ns", "tbl");
461
462        let dim = 4u32;
463        let policy = make_policy(dim);
464        catalog
465            .create_table(
466                &table,
467                &TableProperties {
468                    policy: policy.clone(),
469                    extra: std::collections::HashMap::new(),
470                    format_version: 2,
471                    partition_column_type: None,
472                },
473            )
474            .await
475            .unwrap();
476
477        let schema = Arc::new(Schema::new(vec![
478            Field::new("id", DataType::Int32, false),
479            Field::new("chunk_text", DataType::Utf8, false),
480        ]));
481
482        // Three files — each its own commit, so list_files() returns 3 entries.
483        let mut parent_snap = None;
484        for (i, (ids, texts)) in [
485            (vec![0i32, 1], vec!["a0", "a1"]),
486            (vec![2, 3], vec!["b0", "b1"]),
487            (vec![4, 5], vec!["c0", "c1"]),
488        ]
489        .into_iter()
490        .enumerate()
491        {
492            let embs: Vec<Vec<f32>> = ids.iter().map(|&v| vec![v as f32; dim as usize]).collect();
493            let batch = RecordBatch::try_new(
494                schema.clone(),
495                vec![
496                    Arc::new(Int32Array::from(ids.clone())),
497                    Arc::new(StringArray::from(texts)),
498                ],
499            )
500            .unwrap();
501            let bytes = AilakeFileWriter::new(policy.clone())
502                .write(&batch, &embs)
503                .unwrap();
504            let path = format!("data/old_{i}.parquet");
505            store.put(&path, bytes.clone()).await.unwrap();
506
507            let centroid = compute_centroid_and_radius(&embs, VectorMetric::Cosine);
508            let reader = AilakeFileReader::new(bytes.clone(), "embedding", dim);
509            let header = reader.read_header().unwrap();
510            let ailk_start = reader.ailk_offset().unwrap();
511            let entry = ailake_catalog::make_data_file_entry(
512                &path,
513                ids.len() as u64,
514                bytes.len() as u64,
515                &centroid,
516                VectorIndexInfo {
517                    column: "embedding",
518                    dim,
519                    hnsw_offset: ailk_start + header.hnsw_offset,
520                    hnsw_len: header.hnsw_len,
521                },
522            );
523            let snap_id = new_snapshot_id();
524            catalog
525                .commit_snapshot(
526                    &table,
527                    NewSnapshot {
528                        snapshot_id: snap_id,
529                        parent_snapshot_id: parent_snap,
530                        files: vec![entry],
531                        operation: SnapshotOperation::Append,
532                        iceberg_schema: None,
533                        extra_properties: std::collections::HashMap::new(),
534                        bloom_filters: vec![],
535                        equality_delete_files: vec![],
536                    },
537                )
538                .await
539                .unwrap();
540            parent_snap = Some(snap_id);
541        }
542
543        let files_before = catalog.list_files(&table, None).await.unwrap();
544        assert_eq!(
545            files_before.len(),
546            3,
547            "sanity: 3 files committed via Append"
548        );
549
550        let job = BackfillJob {
551            table: table.clone(),
552            text_column: "chunk_text".into(),
553            new_col: ailake_core::VectorColSpec {
554                column_name: "embedding_v2".into(),
555                dim,
556                metric: VectorMetric::Cosine,
557                precision: VectorPrecision::F16,
558                pre_normalize: false,
559                hnsw_m: None,
560                hnsw_ef_construction: None,
561            },
562            embed_fn: Arc::new(|texts: &[String]| {
563                Ok(texts.iter().map(|_| vec![9.0f32; 4]).collect())
564            }),
565            batch_size: 10,
566            on_progress: None,
567        };
568        job.run(catalog.clone(), store.clone()).await.unwrap();
569
570        let files_after = catalog.list_files(&table, None).await.unwrap();
571        assert_eq!(
572            files_after.len(),
573            3,
574            "BUG: expected all 3 backfilled files to remain visible, got {:?}",
575            files_after.iter().map(|f| &f.path).collect::<Vec<_>>()
576        );
577        let total_rows: u64 = files_after.iter().map(|f| f.record_count).sum();
578        assert_eq!(total_rows, 6, "all 6 original rows must survive backfill");
579
580        // Every file must actually have been rewritten (not skipped as a false-positive
581        // "already has the column" idempotency match) and be independently readable
582        // with both columns present.
583        for entry in &files_after {
584            assert!(
585                entry.path.starts_with("data/backfill-"),
586                "BUG: {} was never backfilled (idempotency check false-skipped it)",
587                entry.path
588            );
589            let bytes = store.get(&entry.path).await.unwrap();
590            let reader = AilakeFileReader::new(bytes, "embedding", dim);
591            assert!(
592                reader.has_column_footer("embedding_v2"),
593                "file {} missing backfilled column",
594                entry.path
595            );
596            let (batch, _) = reader.read_parquet().unwrap();
597            assert_eq!(batch.num_rows(), 2);
598        }
599    }
600}