1use 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#[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
39pub struct BackfillJob {
45 pub table: TableIdent,
46 pub text_column: String,
48 pub new_col: VectorColSpec,
50 pub embed_fn: EmbedFn,
52 pub batch_size: usize,
54 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 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 let primary_policy = primary_policy_from_props(&table_meta.properties)?;
94
95 let mut parent_snap = table_meta.current_snapshot_id;
96 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 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 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 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 let new_embeddings = embed_in_batches(&self.embed_fn, &texts, self.batch_size)?;
153
154 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 current_files[idx] = new_entry;
172
173 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 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 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 #[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 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 ¢roid,
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 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}