Skip to main content

ailake_query/
migration.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Embedding model migration for AI-Lake tables.
3//!
4//! Reads all chunks from a table, re-embeds them with a new model, and writes
5//! new files with the updated embedding column. Two strategies are supported:
6//!
7//! - `AtomicReplace`: replaces each file one at a time. Lower peak storage, but
8//!   during the migration window different shards may have different columns.
9//! - `DualWriteThenCutover`: writes new files containing both old and new columns,
10//!   then atomically replaces all old files. Higher peak storage, zero downtime.
11
12use std::sync::Arc;
13
14use ailake_catalog::{
15    make_data_file_entry, new_snapshot_id, CatalogProvider, DataFileEntry, NewSnapshot,
16    SnapshotOperation, TableIdent, VectorIndexInfo,
17};
18use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, VectorStoragePolicy};
19use ailake_file::{AilakeFileReader, AilakeFileWriter};
20use ailake_store::Store;
21use ailake_vec::compute_centroid_and_radius;
22use arrow_array::{Array, RecordBatch, StringArray};
23use tracing::info;
24
25pub type EmbedFn = Arc<dyn Fn(&[String]) -> AilakeResult<Vec<Vec<f32>>> + Send + Sync>;
26pub type ProgressFn = Arc<dyn Fn(MigrationProgress) + Send + Sync>;
27
28/// How files are replaced during migration.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum MigrationStrategy {
31    /// Write new files file-by-file, replacing each old file as it completes.
32    /// Lower peak storage. During migration, some shards have old column, others new.
33    AtomicReplace,
34    /// Write all new files first (old files untouched), then commit a single Replace
35    /// snapshot swapping all old files for new ones atomically.
36    /// Higher peak storage (2× during migration), but reads always see a consistent view.
37    DualWriteThenCutover,
38}
39
40/// Progress reported via `on_progress` callback.
41#[derive(Debug, Clone)]
42pub struct MigrationProgress {
43    pub files_done: usize,
44    pub files_total: usize,
45    pub rows_migrated: u64,
46}
47
48/// Migrates embedding columns in an AI-Lake table to a new model.
49///
50/// Usage:
51/// ```ignore
52/// let job = MigrationJob {
53///     table: TableIdent::new("default", "docs"),
54///     old_column: "embedding".to_string(),
55///     new_column: "embedding_v2".to_string(),
56///     text_column: "chunk_text".to_string(),
57///     embed_fn: Arc::new(|texts| Ok(my_model.encode(texts))),
58///     strategy: MigrationStrategy::DualWriteThenCutover,
59///     batch_size: 10_000,
60///     new_model: Some(EmbeddingModelInfo::new("my-model-v2")),
61///     on_progress: None,
62/// };
63/// job.run(catalog, store).await?;
64/// ```
65pub struct MigrationJob {
66    pub table: TableIdent,
67    /// Name of the embedding column to replace (e.g., "embedding").
68    pub old_column: String,
69    /// Name to give the new embedding column (e.g., "embedding_v2").
70    /// Can be the same as `old_column` to do an in-place model upgrade.
71    pub new_column: String,
72    /// Column in the Parquet files that holds the text to re-embed.
73    /// Defaults to `chunk_text` (the `LlmContextSchema` canonical name).
74    pub text_column: String,
75    /// Callable that converts a slice of texts to embeddings.
76    /// Must return exactly `texts.len()` vectors, all of the same dimension.
77    pub embed_fn: EmbedFn,
78    pub strategy: MigrationStrategy,
79    /// How many rows to embed per `embed_fn` call. Tune based on model batch size.
80    pub batch_size: usize,
81    /// Metadata for the new embedding model — stored in Iceberg properties after migration.
82    pub new_model: Option<EmbeddingModelInfo>,
83    /// Optional callback called after each file completes.
84    pub on_progress: Option<ProgressFn>,
85}
86
87impl MigrationJob {
88    pub async fn run(
89        self,
90        catalog: Arc<dyn CatalogProvider>,
91        store: Arc<dyn Store>,
92    ) -> AilakeResult<()> {
93        match self.strategy {
94            MigrationStrategy::AtomicReplace => self.run_atomic_replace(catalog, store).await,
95            MigrationStrategy::DualWriteThenCutover => self.run_dual_write(catalog, store).await,
96        }
97    }
98
99    /// AtomicReplace: process and commit each file one at a time.
100    async fn run_atomic_replace(
101        &self,
102        catalog: Arc<dyn CatalogProvider>,
103        store: Arc<dyn Store>,
104    ) -> AilakeResult<()> {
105        let table_meta = catalog.load_table(&self.table).await?;
106        let old_files = catalog
107            .list_files(&self.table, table_meta.current_snapshot_id)
108            .await?;
109        let total = old_files.len();
110        let mut rows_migrated: u64 = 0;
111
112        // Derive new policy from table properties + new model info
113        let new_policy = self.new_policy_from_metadata(&table_meta.properties)?;
114
115        let mut parent_snap = table_meta.current_snapshot_id;
116        // Running view of every file currently in the table. `Replace` does not inherit
117        // the previous manifest (see `HadoopCatalog::commit_snapshot`), so each commit
118        // below must carry the complete current state — not just the one file that
119        // changed this iteration — or every file processed in a prior iteration (and
120        // every file not yet reached) would vanish from the table on this commit.
121        let mut current_files = old_files.clone();
122
123        for (idx, old_entry) in old_files.iter().enumerate() {
124            let (batch, texts) = self.read_file_texts(old_entry, &store, &new_policy).await?;
125            let new_embeddings = self.embed_in_batches(&texts)?;
126
127            let new_entry = self
128                .write_new_file(&batch, &new_embeddings, &new_policy, &store, idx)
129                .await?;
130
131            rows_migrated += new_entry.record_count;
132
133            // Swap this file's entry in place; every other file (already migrated in a
134            // prior iteration, or not yet reached) is carried forward unchanged.
135            current_files[idx] = new_entry;
136
137            let snap_id = new_snapshot_id();
138            catalog
139                .commit_snapshot(
140                    &self.table,
141                    NewSnapshot {
142                        snapshot_id: snap_id,
143                        parent_snapshot_id: parent_snap,
144                        files: current_files.clone(),
145                        operation: SnapshotOperation::Replace,
146                        iceberg_schema: None,
147                        extra_properties: std::collections::HashMap::new(),
148                        bloom_filters: vec![],
149                        equality_delete_files: vec![],
150                    },
151                )
152                .await?;
153            parent_snap = Some(snap_id);
154
155            if let Some(cb) = &self.on_progress {
156                cb(MigrationProgress {
157                    files_done: idx + 1,
158                    files_total: total,
159                    rows_migrated,
160                });
161            }
162
163            info!(
164                "ailake migration: AtomicReplace {}/{} files done, {} rows migrated",
165                idx + 1,
166                total,
167                rows_migrated
168            );
169        }
170
171        Ok(())
172    }
173
174    /// DualWriteThenCutover: write all new files first, then commit one Replace snapshot.
175    async fn run_dual_write(
176        &self,
177        catalog: Arc<dyn CatalogProvider>,
178        store: Arc<dyn Store>,
179    ) -> AilakeResult<()> {
180        let table_meta = catalog.load_table(&self.table).await?;
181        let old_files = catalog
182            .list_files(&self.table, table_meta.current_snapshot_id)
183            .await?;
184        let total = old_files.len();
185        let mut rows_migrated: u64 = 0;
186
187        let new_policy = self.new_policy_from_metadata(&table_meta.properties)?;
188        let mut new_entries: Vec<DataFileEntry> = Vec::with_capacity(total);
189
190        for (idx, old_entry) in old_files.iter().enumerate() {
191            let (batch, texts) = self.read_file_texts(old_entry, &store, &new_policy).await?;
192            let new_embeddings = self.embed_in_batches(&texts)?;
193
194            let entry = self
195                .write_new_file(&batch, &new_embeddings, &new_policy, &store, idx)
196                .await?;
197
198            rows_migrated += entry.record_count;
199            new_entries.push(entry);
200
201            if let Some(cb) = &self.on_progress {
202                cb(MigrationProgress {
203                    files_done: idx + 1,
204                    files_total: total,
205                    rows_migrated,
206                });
207            }
208
209            info!(
210                "ailake migration: DualWrite phase {}/{} files ready",
211                idx + 1,
212                total
213            );
214        }
215
216        // Single atomic cutover: replace all old files with all new files.
217        let snap_id = new_snapshot_id();
218        catalog
219            .commit_snapshot(
220                &self.table,
221                NewSnapshot {
222                    snapshot_id: snap_id,
223                    parent_snapshot_id: table_meta.current_snapshot_id,
224                    files: new_entries,
225                    operation: SnapshotOperation::Replace,
226                    iceberg_schema: None,
227                    extra_properties: std::collections::HashMap::new(),
228                    bloom_filters: vec![],
229                    equality_delete_files: vec![],
230                },
231            )
232            .await?;
233
234        info!(
235            "ailake migration: DualWriteThenCutover complete — {} files, {} rows",
236            total, rows_migrated
237        );
238        Ok(())
239    }
240
241    /// Read Parquet bytes from store, decode the text column, and drop DV-masked rows
242    /// (the migrated file is brand-new, so a deleted row must not be re-embedded and
243    /// resurrected — see `dv::filter_deleted_rows`).
244    async fn read_file_texts(
245        &self,
246        entry: &DataFileEntry,
247        store: &Arc<dyn Store>,
248        policy: &VectorStoragePolicy,
249    ) -> AilakeResult<(RecordBatch, Vec<String>)> {
250        let bytes = store.get(&entry.path).await?;
251        let reader = AilakeFileReader::new(bytes, &self.old_column, policy.dim);
252        let (batch, _) = reader.read_parquet()?;
253
254        let texts = extract_string_column(&batch, &self.text_column)?;
255        if let Some(dv) = &entry.deletion_vector {
256            let bitmap = crate::dv::load_deletion_vector(store, dv).await?;
257            crate::dv::filter_deleted_rows(batch, texts, &bitmap)
258        } else {
259            Ok((batch, texts))
260        }
261    }
262
263    /// Call embed_fn in chunks of batch_size.
264    fn embed_in_batches(&self, texts: &[String]) -> AilakeResult<Vec<Vec<f32>>> {
265        let mut all: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
266        for chunk in texts.chunks(self.batch_size) {
267            let mut chunk_vecs = (self.embed_fn)(chunk)?;
268            all.append(&mut chunk_vecs);
269        }
270        Ok(all)
271    }
272
273    /// Write a new AI-Lake file with the re-embedded vectors, return its catalog entry.
274    async fn write_new_file(
275        &self,
276        batch: &RecordBatch,
277        embeddings: &[Vec<f32>],
278        policy: &VectorStoragePolicy,
279        store: &Arc<dyn Store>,
280        idx: usize,
281    ) -> AilakeResult<DataFileEntry> {
282        let file_path = format!("data/migrated-{:05}.parquet", idx);
283
284        let writer = AilakeFileWriter::new(policy.clone());
285        let file_bytes = writer.write(batch, embeddings)?;
286        let file_size = file_bytes.len() as u64;
287
288        store.put(&file_path, file_bytes.clone()).await?;
289
290        let centroid = compute_centroid_and_radius(embeddings, policy.metric);
291        let reader = AilakeFileReader::new(file_bytes, &policy.column_name, policy.dim);
292        let header = reader.read_header()?;
293        let ailk_start = reader.ailk_offset()?;
294        let hnsw_abs = ailk_start + header.hnsw_offset;
295
296        Ok(make_data_file_entry(
297            &file_path,
298            embeddings.len() as u64,
299            file_size,
300            &centroid,
301            VectorIndexInfo {
302                column: &policy.column_name,
303                dim: policy.dim,
304                hnsw_offset: hnsw_abs,
305                hnsw_len: header.hnsw_len,
306            },
307        ))
308    }
309
310    /// Build the new `VectorStoragePolicy` from existing table properties,
311    /// overriding the column name and embedding model.
312    fn new_policy_from_metadata(
313        &self,
314        props: &std::collections::HashMap<String, String>,
315    ) -> AilakeResult<VectorStoragePolicy> {
316        use ailake_core::{VectorMetric, VectorPrecision};
317
318        let dim: u32 = props
319            .get("ailake.vector-dim")
320            .and_then(|s| s.parse().ok())
321            .ok_or_else(|| {
322                AilakeError::InvalidArgument("table missing ailake.vector-dim property".into())
323            })?;
324
325        let metric = match props
326            .get("ailake.vector-metric")
327            .map(|s| s.as_str())
328            .unwrap_or("cosine")
329        {
330            "euclidean" => VectorMetric::Euclidean,
331            "dotproduct" | "dot_product" => VectorMetric::DotProduct,
332            "normalizedcosine" | "normalized_cosine" => VectorMetric::NormalizedCosine,
333            _ => VectorMetric::Cosine,
334        };
335
336        let precision = match props
337            .get("ailake.vector-precision")
338            .map(|s| s.as_str())
339            .unwrap_or("f16")
340        {
341            "f32" => VectorPrecision::F32,
342            "i8" => VectorPrecision::I8,
343            _ => VectorPrecision::F16,
344        };
345
346        Ok(VectorStoragePolicy {
347            column_name: self.new_column.clone(),
348            dim,
349            metric,
350            precision,
351            pq: None,
352            keep_raw_for_reranking: true,
353            pre_normalize: props
354                .get("ailake.pre-normalize")
355                .map(|s| s == "true")
356                .unwrap_or(false),
357            hnsw_m: props.get("ailake.hnsw-m").and_then(|s| s.parse().ok()),
358            hnsw_ef_construction: props
359                .get("ailake.hnsw-ef-construction")
360                .and_then(|s| s.parse().ok()),
361            ivf_residual: false,
362            embedding_model: self.new_model.clone(),
363            modality: None,
364            partition_by: None,
365            partition_value: None,
366            partition_column_type: None,
367            partition_fields: vec![],
368        })
369    }
370}
371
372fn extract_string_column(batch: &RecordBatch, column_name: &str) -> AilakeResult<Vec<String>> {
373    let col = batch.column_by_name(column_name).ok_or_else(|| {
374        AilakeError::InvalidArgument(format!(
375            "text column '{}' not found in Parquet file; available: {}",
376            column_name,
377            batch
378                .schema()
379                .fields()
380                .iter()
381                .map(|f| f.name().as_str())
382                .collect::<Vec<_>>()
383                .join(", ")
384        ))
385    })?;
386
387    let arr = col.as_any().downcast_ref::<StringArray>().ok_or_else(|| {
388        AilakeError::InvalidArgument(format!(
389            "column '{}' is not a Utf8/String column",
390            column_name
391        ))
392    })?;
393
394    Ok((0..arr.len())
395        .map(|i| {
396            if arr.is_null(i) {
397                String::new()
398            } else {
399                arr.value(i).to_string()
400            }
401        })
402        .collect())
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use ailake_catalog::{HadoopCatalog, TableProperties};
409    use ailake_core::{VectorMetric, VectorPrecision};
410    use ailake_store::LocalStore;
411    use arrow_array::{Int32Array, StringArray};
412    use arrow_schema::{DataType, Field, Schema};
413    use tempfile::TempDir;
414
415    fn make_policy(dim: u32) -> VectorStoragePolicy {
416        VectorStoragePolicy {
417            column_name: "embedding".into(),
418            dim,
419            metric: VectorMetric::Cosine,
420            precision: VectorPrecision::F16,
421            pq: None,
422            keep_raw_for_reranking: true,
423            pre_normalize: false,
424            hnsw_m: None,
425            hnsw_ef_construction: None,
426            ivf_residual: false,
427            embedding_model: None,
428            modality: None,
429            partition_by: None,
430            partition_value: None,
431            partition_column_type: None,
432            partition_fields: vec![],
433        }
434    }
435
436    /// Regression test: `run_atomic_replace` used to commit `SnapshotOperation::Replace`
437    /// with `files: vec![new_entry]` per loop iteration. Since `Replace` doesn't inherit
438    /// the previous manifest, every iteration after the first wiped out every file
439    /// migrated (or not yet migrated) by every other iteration — a 3-file table ended up
440    /// with just its last-migrated file after `run()` completed. This test uses 3 files
441    /// specifically because the bug is invisible with 1 file (replacing "the only file"
442    /// with a partial list is coincidentally correct).
443    #[tokio::test]
444    async fn run_atomic_replace_preserves_all_files_not_just_the_last() {
445        let dir = TempDir::new().unwrap();
446        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
447        let catalog_dir = TempDir::new().unwrap();
448        let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
449        let catalog: Arc<dyn CatalogProvider> = Arc::new(HadoopCatalog::new(catalog_store, ""));
450        let table = TableIdent::new("ns", "tbl");
451
452        let dim = 4u32;
453        let policy = make_policy(dim);
454        catalog
455            .create_table(
456                &table,
457                &TableProperties {
458                    policy: policy.clone(),
459                    extra: std::collections::HashMap::new(),
460                    format_version: 2,
461                    partition_column_type: None,
462                },
463            )
464            .await
465            .unwrap();
466
467        let schema = Arc::new(Schema::new(vec![
468            Field::new("id", DataType::Int32, false),
469            Field::new("chunk_text", DataType::Utf8, false),
470        ]));
471
472        // Three old files — each its own commit, so list_files() returns 3 entries.
473        let mut parent_snap = None;
474        for (i, (ids, texts)) in [
475            (vec![0i32, 1], vec!["a0", "a1"]),
476            (vec![2, 3], vec!["b0", "b1"]),
477            (vec![4, 5], vec!["c0", "c1"]),
478        ]
479        .into_iter()
480        .enumerate()
481        {
482            let embs: Vec<Vec<f32>> = ids.iter().map(|&v| vec![v as f32; dim as usize]).collect();
483            let batch = RecordBatch::try_new(
484                schema.clone(),
485                vec![
486                    Arc::new(Int32Array::from(ids.clone())),
487                    Arc::new(StringArray::from(texts)),
488                ],
489            )
490            .unwrap();
491            let bytes = AilakeFileWriter::new(policy.clone())
492                .write(&batch, &embs)
493                .unwrap();
494            let path = format!("data/old_{i}.parquet");
495            store.put(&path, bytes.clone()).await.unwrap();
496
497            let centroid = compute_centroid_and_radius(&embs, VectorMetric::Cosine);
498            let reader = AilakeFileReader::new(bytes.clone(), "embedding", dim);
499            let header = reader.read_header().unwrap();
500            let ailk_start = reader.ailk_offset().unwrap();
501            let entry = make_data_file_entry(
502                &path,
503                ids.len() as u64,
504                bytes.len() as u64,
505                &centroid,
506                VectorIndexInfo {
507                    column: "embedding",
508                    dim,
509                    hnsw_offset: ailk_start + header.hnsw_offset,
510                    hnsw_len: header.hnsw_len,
511                },
512            );
513            let snap_id = new_snapshot_id();
514            catalog
515                .commit_snapshot(
516                    &table,
517                    NewSnapshot {
518                        snapshot_id: snap_id,
519                        parent_snapshot_id: parent_snap,
520                        files: vec![entry],
521                        operation: SnapshotOperation::Append,
522                        iceberg_schema: None,
523                        extra_properties: std::collections::HashMap::new(),
524                        bloom_filters: vec![],
525                        equality_delete_files: vec![],
526                    },
527                )
528                .await
529                .unwrap();
530            parent_snap = Some(snap_id);
531        }
532
533        let files_before = catalog.list_files(&table, None).await.unwrap();
534        assert_eq!(
535            files_before.len(),
536            3,
537            "sanity: 3 files committed via Append"
538        );
539
540        let job = MigrationJob {
541            table: table.clone(),
542            old_column: "embedding".into(),
543            new_column: "embedding".into(),
544            text_column: "chunk_text".into(),
545            embed_fn: Arc::new(|texts: &[String]| {
546                Ok(texts.iter().map(|_| vec![9.0f32; 4]).collect())
547            }),
548            strategy: MigrationStrategy::AtomicReplace,
549            batch_size: 10,
550            new_model: None,
551            on_progress: None,
552        };
553        job.run(catalog.clone(), store.clone()).await.unwrap();
554
555        let files_after = catalog.list_files(&table, None).await.unwrap();
556        assert_eq!(
557            files_after.len(),
558            3,
559            "BUG: expected all 3 migrated files to remain visible, got {:?}",
560            files_after.iter().map(|f| &f.path).collect::<Vec<_>>()
561        );
562        let total_rows: u64 = files_after.iter().map(|f| f.record_count).sum();
563        assert_eq!(total_rows, 6, "all 6 original rows must survive migration");
564
565        // Every file must be independently readable with the re-embedded vectors.
566        for entry in &files_after {
567            let bytes = store.get(&entry.path).await.unwrap();
568            let reader = AilakeFileReader::new(bytes, "embedding", dim);
569            let (batch, embs) = reader.read_parquet().unwrap();
570            assert_eq!(batch.num_rows(), 2);
571            assert!(embs.iter().all(|v| v == &vec![9.0f32; 4]));
572        }
573    }
574}