1use 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum MigrationStrategy {
31 AtomicReplace,
34 DualWriteThenCutover,
38}
39
40#[derive(Debug, Clone)]
42pub struct MigrationProgress {
43 pub files_done: usize,
44 pub files_total: usize,
45 pub rows_migrated: u64,
46}
47
48pub struct MigrationJob {
66 pub table: TableIdent,
67 pub old_column: String,
69 pub new_column: String,
72 pub text_column: String,
75 pub embed_fn: EmbedFn,
78 pub strategy: MigrationStrategy,
79 pub batch_size: usize,
81 pub new_model: Option<EmbeddingModelInfo>,
83 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 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 let new_policy = self.new_policy_from_metadata(&table_meta.properties)?;
114
115 let mut parent_snap = table_meta.current_snapshot_id;
116 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 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::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 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 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 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 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 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 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 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 ¢roid,
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 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 #[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 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 ¢roid,
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 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 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 ¢roid,
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 #[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}