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                        // Every property-driven consumer (CLI search/decay-memories/info,
148                        // ailake.search() when the caller doesn't override vector_column,
149                        // etc.) resolves "the primary vector column" from this property —
150                        // previously never updated here, so it kept pointing at
151                        // `old_column`, which the newly-written file (physically named
152                        // `new_column`) doesn't have. Confirmed live:
153                        // `ailake decay-memories` failed with "vector dimension mismatch:
154                        // expected N, got 0" on the very first file migrated.
155                        extra_properties: std::collections::HashMap::from([(
156                            "ailake.vector-column".to_string(),
157                            new_policy.column_name.clone(),
158                        )]),
159                        bloom_filters: vec![],
160                        equality_delete_files: vec![],
161                    },
162                )
163                .await?;
164            parent_snap = Some(snap_id);
165
166            if let Some(cb) = &self.on_progress {
167                cb(MigrationProgress {
168                    files_done: idx + 1,
169                    files_total: total,
170                    rows_migrated,
171                });
172            }
173
174            info!(
175                "ailake migration: AtomicReplace {}/{} files done, {} rows migrated",
176                idx + 1,
177                total,
178                rows_migrated
179            );
180        }
181
182        Ok(())
183    }
184
185    /// DualWriteThenCutover: write all new files first, then commit one Replace snapshot.
186    async fn run_dual_write(
187        &self,
188        catalog: Arc<dyn CatalogProvider>,
189        store: Arc<dyn Store>,
190    ) -> AilakeResult<()> {
191        let table_meta = catalog.load_table(&self.table).await?;
192        let old_files = catalog
193            .list_files(&self.table, table_meta.current_snapshot_id)
194            .await?;
195        let total = old_files.len();
196        let mut rows_migrated: u64 = 0;
197
198        let new_policy = self.new_policy_from_metadata(&table_meta.properties)?;
199        let mut new_entries: Vec<DataFileEntry> = Vec::with_capacity(total);
200
201        for (idx, old_entry) in old_files.iter().enumerate() {
202            let (batch, texts) = self.read_file_texts(old_entry, &store, &new_policy).await?;
203            let new_embeddings = self.embed_in_batches(&texts)?;
204
205            let entry = self
206                .write_new_file(&batch, &new_embeddings, &new_policy, &store, idx)
207                .await?;
208
209            rows_migrated += entry.record_count;
210            new_entries.push(entry);
211
212            if let Some(cb) = &self.on_progress {
213                cb(MigrationProgress {
214                    files_done: idx + 1,
215                    files_total: total,
216                    rows_migrated,
217                });
218            }
219
220            info!(
221                "ailake migration: DualWrite phase {}/{} files ready",
222                idx + 1,
223                total
224            );
225        }
226
227        // Single atomic cutover: replace all old files with all new files.
228        let snap_id = new_snapshot_id();
229        catalog
230            .commit_snapshot(
231                &self.table,
232                NewSnapshot {
233                    snapshot_id: snap_id,
234                    parent_snapshot_id: table_meta.current_snapshot_id,
235                    files: new_entries,
236                    operation: SnapshotOperation::Replace,
237                    iceberg_schema: None,
238                    // See the identical comment in run_atomic_replace — this cutover commit
239                    // is the one place DualWriteThenCutover ever touches table properties;
240                    // without it every property-driven vector-column consumer (CLI
241                    // search/decay-memories/info, etc.) keeps resolving to `old_column`
242                    // after migration, which no longer exists in any file.
243                    extra_properties: std::collections::HashMap::from([(
244                        "ailake.vector-column".to_string(),
245                        new_policy.column_name.clone(),
246                    )]),
247                    bloom_filters: vec![],
248                    equality_delete_files: vec![],
249                },
250            )
251            .await?;
252
253        info!(
254            "ailake migration: DualWriteThenCutover complete — {} files, {} rows",
255            total, rows_migrated
256        );
257        Ok(())
258    }
259
260    /// Read Parquet bytes from store, decode the text column, and drop DV-masked rows
261    /// (the migrated file is brand-new, so a deleted row must not be re-embedded and
262    /// resurrected — see `dv::filter_deleted_rows`).
263    async fn read_file_texts(
264        &self,
265        entry: &DataFileEntry,
266        store: &Arc<dyn Store>,
267        policy: &VectorStoragePolicy,
268    ) -> AilakeResult<(RecordBatch, Vec<String>)> {
269        let bytes = store.get(&entry.path).await?;
270        let reader = AilakeFileReader::new(bytes, &self.old_column, policy.dim);
271        let (batch, _) = reader.read_parquet()?;
272
273        let texts = extract_string_column(&batch, &self.text_column)?;
274        if let Some(dv) = &entry.deletion_vector {
275            let bitmap = crate::dv::load_deletion_vector(store, dv).await?;
276            crate::dv::filter_deleted_rows(batch, texts, &bitmap)
277        } else {
278            Ok((batch, texts))
279        }
280    }
281
282    /// Call embed_fn in chunks of batch_size.
283    fn embed_in_batches(&self, texts: &[String]) -> AilakeResult<Vec<Vec<f32>>> {
284        let mut all: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
285        for chunk in texts.chunks(self.batch_size) {
286            let mut chunk_vecs = (self.embed_fn)(chunk)?;
287            all.append(&mut chunk_vecs);
288        }
289        Ok(all)
290    }
291
292    /// Write a new AI-Lake file with the re-embedded vectors, return its catalog entry.
293    async fn write_new_file(
294        &self,
295        batch: &RecordBatch,
296        embeddings: &[Vec<f32>],
297        policy: &VectorStoragePolicy,
298        store: &Arc<dyn Store>,
299        idx: usize,
300    ) -> AilakeResult<DataFileEntry> {
301        // Timestamped so a second migration (e.g. v2 -> v3 after v1 -> v2) never
302        // reuses a path from an earlier run — the old plain-index name made run 2
303        // overwrite the committed migrated-00000 it was itself reading, an
304        // in-place rewrite of a live file (breaks readers holding the old entry;
305        // hard error under catalogs with supports_in_place_rewrite() == false).
306        let file_path = format!(
307            "data/migrated-{}-{:05}.parquet",
308            std::time::SystemTime::now()
309                .duration_since(std::time::UNIX_EPOCH)
310                .unwrap_or_else(|e| e.duration())
311                .as_millis(),
312            idx
313        );
314
315        let writer = AilakeFileWriter::new(policy.clone());
316        let file_bytes = writer.write(batch, embeddings)?;
317        let file_size = file_bytes.len() as u64;
318
319        store.put(&file_path, file_bytes.clone()).await?;
320
321        let centroid = compute_centroid_and_radius(embeddings, policy.metric);
322        let reader = AilakeFileReader::new(file_bytes, &policy.column_name, policy.dim);
323        let header = reader.read_header()?;
324        let ailk_start = reader.ailk_offset()?;
325        let hnsw_abs = ailk_start + header.hnsw_offset;
326
327        Ok(make_data_file_entry(
328            &file_path,
329            embeddings.len() as u64,
330            file_size,
331            &centroid,
332            VectorIndexInfo {
333                column: &policy.column_name,
334                dim: policy.dim,
335                hnsw_offset: hnsw_abs,
336                hnsw_len: header.hnsw_len,
337            },
338        ))
339    }
340
341    /// Build the new `VectorStoragePolicy` from existing table properties,
342    /// overriding the column name and embedding model.
343    fn new_policy_from_metadata(
344        &self,
345        props: &std::collections::HashMap<String, String>,
346    ) -> AilakeResult<VectorStoragePolicy> {
347        use ailake_core::{VectorMetric, VectorPrecision};
348
349        let dim: u32 = props
350            .get("ailake.vector-dim")
351            .and_then(|s| s.parse().ok())
352            .ok_or_else(|| {
353                AilakeError::InvalidArgument("table missing ailake.vector-dim property".into())
354            })?;
355
356        let metric = match props
357            .get("ailake.vector-metric")
358            .map(|s| s.as_str())
359            .unwrap_or("cosine")
360        {
361            "euclidean" => VectorMetric::Euclidean,
362            "dotproduct" | "dot_product" => VectorMetric::DotProduct,
363            "normalizedcosine" | "normalized_cosine" => VectorMetric::NormalizedCosine,
364            _ => VectorMetric::Cosine,
365        };
366
367        let precision = match props
368            .get("ailake.vector-precision")
369            .map(|s| s.as_str())
370            .unwrap_or("f16")
371        {
372            "f32" => VectorPrecision::F32,
373            "i8" => VectorPrecision::I8,
374            _ => VectorPrecision::F16,
375        };
376
377        Ok(VectorStoragePolicy {
378            column_name: self.new_column.clone(),
379            dim,
380            metric,
381            precision,
382            pq: None,
383            keep_raw_for_reranking: true,
384            pre_normalize: props
385                .get("ailake.pre-normalize")
386                .map(|s| s == "true")
387                .unwrap_or(false),
388            hnsw_m: props.get("ailake.hnsw-m").and_then(|s| s.parse().ok()),
389            hnsw_ef_construction: props
390                .get("ailake.hnsw-ef-construction")
391                .and_then(|s| s.parse().ok()),
392            ivf_residual: false,
393            embedding_model: self.new_model.clone(),
394            modality: None,
395            partition_by: None,
396            partition_value: None,
397            partition_column_type: None,
398            partition_fields: vec![],
399        })
400    }
401}
402
403fn extract_string_column(batch: &RecordBatch, column_name: &str) -> AilakeResult<Vec<String>> {
404    let col = batch.column_by_name(column_name).ok_or_else(|| {
405        AilakeError::InvalidArgument(format!(
406            "text column '{}' not found in Parquet file; available: {}",
407            column_name,
408            batch
409                .schema()
410                .fields()
411                .iter()
412                .map(|f| f.name().as_str())
413                .collect::<Vec<_>>()
414                .join(", ")
415        ))
416    })?;
417
418    let arr = col.as_any().downcast_ref::<StringArray>().ok_or_else(|| {
419        AilakeError::InvalidArgument(format!(
420            "column '{}' is not a Utf8/String column",
421            column_name
422        ))
423    })?;
424
425    Ok((0..arr.len())
426        .map(|i| {
427            if arr.is_null(i) {
428                String::new()
429            } else {
430                arr.value(i).to_string()
431            }
432        })
433        .collect())
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use ailake_catalog::{HadoopCatalog, TableProperties};
440    use ailake_core::{VectorMetric, VectorPrecision};
441    use ailake_store::LocalStore;
442    use arrow_array::{Int32Array, StringArray};
443    use arrow_schema::{DataType, Field, Schema};
444    use tempfile::TempDir;
445
446    fn make_policy(dim: u32) -> VectorStoragePolicy {
447        VectorStoragePolicy {
448            column_name: "embedding".into(),
449            dim,
450            metric: VectorMetric::Cosine,
451            precision: VectorPrecision::F16,
452            pq: None,
453            keep_raw_for_reranking: true,
454            pre_normalize: false,
455            hnsw_m: None,
456            hnsw_ef_construction: None,
457            ivf_residual: false,
458            embedding_model: None,
459            modality: None,
460            partition_by: None,
461            partition_value: None,
462            partition_column_type: None,
463            partition_fields: vec![],
464        }
465    }
466
467    /// Regression test: `run_atomic_replace` used to commit `SnapshotOperation::Replace`
468    /// with `files: vec![new_entry]` per loop iteration. Since `Replace` doesn't inherit
469    /// the previous manifest, every iteration after the first wiped out every file
470    /// migrated (or not yet migrated) by every other iteration — a 3-file table ended up
471    /// with just its last-migrated file after `run()` completed. This test uses 3 files
472    /// specifically because the bug is invisible with 1 file (replacing "the only file"
473    /// with a partial list is coincidentally correct).
474    #[tokio::test]
475    async fn run_atomic_replace_preserves_all_files_not_just_the_last() {
476        let dir = TempDir::new().unwrap();
477        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
478        let catalog_dir = TempDir::new().unwrap();
479        let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
480        let catalog: Arc<dyn CatalogProvider> = Arc::new(HadoopCatalog::new(catalog_store, ""));
481        let table = TableIdent::new("ns", "tbl");
482
483        let dim = 4u32;
484        let policy = make_policy(dim);
485        catalog
486            .create_table(
487                &table,
488                &TableProperties {
489                    policy: policy.clone(),
490                    extra: std::collections::HashMap::new(),
491                    format_version: 2,
492                    partition_column_type: None,
493                },
494            )
495            .await
496            .unwrap();
497
498        let schema = Arc::new(Schema::new(vec![
499            Field::new("id", DataType::Int32, false),
500            Field::new("chunk_text", DataType::Utf8, false),
501        ]));
502
503        // Three old files — each its own commit, so list_files() returns 3 entries.
504        let mut parent_snap = None;
505        for (i, (ids, texts)) in [
506            (vec![0i32, 1], vec!["a0", "a1"]),
507            (vec![2, 3], vec!["b0", "b1"]),
508            (vec![4, 5], vec!["c0", "c1"]),
509        ]
510        .into_iter()
511        .enumerate()
512        {
513            let embs: Vec<Vec<f32>> = ids.iter().map(|&v| vec![v as f32; dim as usize]).collect();
514            let batch = RecordBatch::try_new(
515                schema.clone(),
516                vec![
517                    Arc::new(Int32Array::from(ids.clone())),
518                    Arc::new(StringArray::from(texts)),
519                ],
520            )
521            .unwrap();
522            let bytes = AilakeFileWriter::new(policy.clone())
523                .write(&batch, &embs)
524                .unwrap();
525            let path = format!("data/old_{i}.parquet");
526            store.put(&path, bytes.clone()).await.unwrap();
527
528            let centroid = compute_centroid_and_radius(&embs, VectorMetric::Cosine);
529            let reader = AilakeFileReader::new(bytes.clone(), "embedding", dim);
530            let header = reader.read_header().unwrap();
531            let ailk_start = reader.ailk_offset().unwrap();
532            let entry = make_data_file_entry(
533                &path,
534                ids.len() as u64,
535                bytes.len() as u64,
536                &centroid,
537                VectorIndexInfo {
538                    column: "embedding",
539                    dim,
540                    hnsw_offset: ailk_start + header.hnsw_offset,
541                    hnsw_len: header.hnsw_len,
542                },
543            );
544            let snap_id = new_snapshot_id();
545            catalog
546                .commit_snapshot(
547                    &table,
548                    NewSnapshot {
549                        snapshot_id: snap_id,
550                        parent_snapshot_id: parent_snap,
551                        files: vec![entry],
552                        operation: SnapshotOperation::Append,
553                        iceberg_schema: None,
554                        extra_properties: std::collections::HashMap::new(),
555                        bloom_filters: vec![],
556                        equality_delete_files: vec![],
557                    },
558                )
559                .await
560                .unwrap();
561            parent_snap = Some(snap_id);
562        }
563
564        let files_before = catalog.list_files(&table, None).await.unwrap();
565        assert_eq!(
566            files_before.len(),
567            3,
568            "sanity: 3 files committed via Append"
569        );
570
571        let job = MigrationJob {
572            table: table.clone(),
573            old_column: "embedding".into(),
574            new_column: "embedding".into(),
575            text_column: "chunk_text".into(),
576            embed_fn: Arc::new(|texts: &[String]| {
577                Ok(texts.iter().map(|_| vec![9.0f32; 4]).collect())
578            }),
579            strategy: MigrationStrategy::AtomicReplace,
580            batch_size: 10,
581            new_model: None,
582            on_progress: None,
583        };
584        job.run(catalog.clone(), store.clone()).await.unwrap();
585
586        let files_after = catalog.list_files(&table, None).await.unwrap();
587        assert_eq!(
588            files_after.len(),
589            3,
590            "BUG: expected all 3 migrated files to remain visible, got {:?}",
591            files_after.iter().map(|f| &f.path).collect::<Vec<_>>()
592        );
593        let total_rows: u64 = files_after.iter().map(|f| f.record_count).sum();
594        assert_eq!(total_rows, 6, "all 6 original rows must survive migration");
595
596        // Every file must be independently readable with the re-embedded vectors.
597        for entry in &files_after {
598            let bytes = store.get(&entry.path).await.unwrap();
599            let reader = AilakeFileReader::new(bytes, "embedding", dim);
600            let (batch, embs) = reader.read_parquet().unwrap();
601            assert_eq!(batch.num_rows(), 2);
602            assert!(embs.iter().all(|v| v == &vec![9.0f32; 4]));
603        }
604    }
605
606    /// Sets up a table with one file (`chunk_text`/`embedding`) and returns
607    /// `(catalog, store, table, _dir, _catalog_dir)`, shared by both cutover-property
608    /// regression tests below. Callers must keep the two `TempDir` guards alive for the
609    /// duration of the test — dropping them deletes the backing directories even though
610    /// `store`/`catalog` still hold paths into them.
611    async fn setup_single_file_table(
612        dim: u32,
613        policy: &VectorStoragePolicy,
614    ) -> (
615        Arc<dyn CatalogProvider>,
616        Arc<dyn Store>,
617        TableIdent,
618        TempDir,
619        TempDir,
620    ) {
621        let dir = TempDir::new().unwrap();
622        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
623        let catalog_dir = TempDir::new().unwrap();
624        let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
625        let catalog: Arc<dyn CatalogProvider> = Arc::new(HadoopCatalog::new(catalog_store, ""));
626        let table = TableIdent::new("ns", "tbl");
627
628        catalog
629            .create_table(
630                &table,
631                &TableProperties {
632                    policy: policy.clone(),
633                    extra: std::collections::HashMap::new(),
634                    format_version: 2,
635                    partition_column_type: None,
636                },
637            )
638            .await
639            .unwrap();
640
641        let schema = Arc::new(Schema::new(vec![
642            Field::new("id", DataType::Int32, false),
643            Field::new("chunk_text", DataType::Utf8, false),
644        ]));
645        let ids = vec![0i32, 1];
646        let embs: Vec<Vec<f32>> = ids.iter().map(|&v| vec![v as f32; dim as usize]).collect();
647        let batch = RecordBatch::try_new(
648            schema,
649            vec![
650                Arc::new(Int32Array::from(ids.clone())),
651                Arc::new(StringArray::from(vec!["a0", "a1"])),
652            ],
653        )
654        .unwrap();
655        let bytes = AilakeFileWriter::new(policy.clone())
656            .write(&batch, &embs)
657            .unwrap();
658        let path = "data/old_0.parquet".to_string();
659        store.put(&path, bytes.clone()).await.unwrap();
660
661        let centroid = compute_centroid_and_radius(&embs, policy.metric);
662        let reader = AilakeFileReader::new(bytes.clone(), "embedding", dim);
663        let header = reader.read_header().unwrap();
664        let ailk_start = reader.ailk_offset().unwrap();
665        let entry = make_data_file_entry(
666            &path,
667            ids.len() as u64,
668            bytes.len() as u64,
669            &centroid,
670            VectorIndexInfo {
671                column: "embedding",
672                dim,
673                hnsw_offset: ailk_start + header.hnsw_offset,
674                hnsw_len: header.hnsw_len,
675            },
676        );
677        catalog
678            .commit_snapshot(
679                &table,
680                NewSnapshot {
681                    snapshot_id: new_snapshot_id(),
682                    parent_snapshot_id: None,
683                    files: vec![entry],
684                    operation: SnapshotOperation::Append,
685                    iceberg_schema: None,
686                    extra_properties: std::collections::HashMap::new(),
687                    bloom_filters: vec![],
688                    equality_delete_files: vec![],
689                },
690            )
691            .await
692            .unwrap();
693
694        (catalog, store, table, dir, catalog_dir)
695    }
696
697    /// Regression: neither migration strategy ever updated the `ailake.vector-column`
698    /// table property to the new column name on cutover — every property-driven
699    /// consumer (CLI `search`/`decay-memories`/`info`, `MemoryDecayJob`, etc.) kept
700    /// resolving "the primary vector column" to `old_column`, which the freshly
701    /// written file (physically named `new_column`) doesn't have. Confirmed live:
702    /// `ailake decay-memories` failed with "vector dimension mismatch: expected N,
703    /// got 0" on the very first file migrated; `ailake search` with no `--vec-col`
704    /// override silently searched a column that no longer existed.
705    #[tokio::test]
706    async fn dual_write_cutover_updates_vector_column_property() {
707        let dim = 4u32;
708        let policy = make_policy(dim);
709        let (catalog, store, table, _dir, _catalog_dir) =
710            setup_single_file_table(dim, &policy).await;
711
712        let job = MigrationJob {
713            table: table.clone(),
714            old_column: "embedding".into(),
715            new_column: "embedding_v2".into(),
716            text_column: "chunk_text".into(),
717            embed_fn: Arc::new(|texts: &[String]| {
718                Ok(texts.iter().map(|_| vec![9.0f32; 4]).collect())
719            }),
720            strategy: MigrationStrategy::DualWriteThenCutover,
721            batch_size: 10,
722            new_model: None,
723            on_progress: None,
724        };
725        job.run(catalog.clone(), store.clone()).await.unwrap();
726
727        let meta = catalog.load_table(&table).await.unwrap();
728        assert_eq!(
729            meta.properties
730                .get("ailake.vector-column")
731                .map(|s| s.as_str()),
732            Some("embedding_v2"),
733            "ailake.vector-column must point at the new column after cutover"
734        );
735    }
736
737    #[tokio::test]
738    async fn atomic_replace_updates_vector_column_property() {
739        let dim = 4u32;
740        let policy = make_policy(dim);
741        let (catalog, store, table, _dir, _catalog_dir) =
742            setup_single_file_table(dim, &policy).await;
743
744        let job = MigrationJob {
745            table: table.clone(),
746            old_column: "embedding".into(),
747            new_column: "embedding_v2".into(),
748            text_column: "chunk_text".into(),
749            embed_fn: Arc::new(|texts: &[String]| {
750                Ok(texts.iter().map(|_| vec![9.0f32; 4]).collect())
751            }),
752            strategy: MigrationStrategy::AtomicReplace,
753            batch_size: 10,
754            new_model: None,
755            on_progress: None,
756        };
757        job.run(catalog.clone(), store.clone()).await.unwrap();
758
759        let meta = catalog.load_table(&table).await.unwrap();
760        assert_eq!(
761            meta.properties
762                .get("ailake.vector-column")
763                .map(|s| s.as_str()),
764            Some("embedding_v2"),
765            "ailake.vector-column must point at the new column after cutover"
766        );
767    }
768}