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::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 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 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 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 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 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 ¢roid,
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 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 #[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 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 ¢roid,
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 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}