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!("data/backfill-{:05}.parquet", idx);
285
286 let writer = AilakeFileWriter::new(primary_policy.clone());
287 let file_bytes = writer.write_multi(
288 batch,
289 &[
290 VectorColumnBatch {
291 policy: primary_policy,
292 embeddings: primary_embeddings,
293 },
294 VectorColumnBatch {
295 policy: new_policy,
296 embeddings: new_embeddings,
297 },
298 ],
299 )?;
300 let file_size = file_bytes.len() as u64;
301 store.put(&file_path, file_bytes.clone()).await?;
302
303 let reader = AilakeFileReader::new(file_bytes, &primary_policy.column_name, primary_policy.dim);
305 let primary_ailk_offset = reader.ailk_offset()?;
306 let primary_header = reader.read_header()?;
307 let primary_hnsw_abs = primary_ailk_offset + primary_header.hnsw_offset;
308
309 let new_ailk_offset = reader.ailk_offset_for_column(&new_policy.column_name)?;
310 let new_header = reader.read_header_for_column(&new_policy.column_name)?;
311 let new_hnsw_abs = new_ailk_offset + new_header.hnsw_offset;
312
313 let primary_centroid = compute_centroid_and_radius(primary_embeddings, primary_policy.metric);
314 let new_centroid = compute_centroid_and_radius(new_embeddings, new_policy.metric);
315
316 let extra = vec![ExtraVectorIndex {
317 column: new_policy.column_name.clone(),
318 dim: new_policy.dim,
319 hnsw_offset: new_hnsw_abs,
320 hnsw_len: new_header.hnsw_len,
321 centroid_b64: Some(encode_centroid_b64(&new_centroid)),
322 radius: Some(new_centroid.radius),
323 }];
324
325 Ok(make_multi_column_data_file_entry(
326 &file_path,
327 new_embeddings.len() as u64,
328 file_size,
329 &primary_centroid,
330 VectorIndexInfo {
331 column: &primary_policy.column_name,
332 dim: primary_policy.dim,
333 hnsw_offset: primary_hnsw_abs,
334 hnsw_len: primary_header.hnsw_len,
335 },
336 &extra,
337 ))
338}
339
340fn primary_policy_from_props(
341 props: &std::collections::HashMap<String, String>,
342) -> AilakeResult<VectorStoragePolicy> {
343 use ailake_core::{VectorMetric, VectorPrecision};
344
345 let column_name = props
346 .get("ailake.vector-column")
347 .cloned()
348 .unwrap_or_else(|| "embedding".to_string());
349
350 let dim: u32 = props
351 .get("ailake.vector-dim")
352 .and_then(|s| s.parse().ok())
353 .ok_or_else(|| {
354 AilakeError::InvalidArgument("table missing ailake.vector-dim property".into())
355 })?;
356
357 let metric = match props
358 .get("ailake.vector-metric")
359 .map(|s| s.as_str())
360 .unwrap_or("cosine")
361 {
362 "euclidean" => VectorMetric::Euclidean,
363 "dotproduct" | "dot_product" => VectorMetric::DotProduct,
364 "normalizedcosine" | "normalized_cosine" => VectorMetric::NormalizedCosine,
365 _ => VectorMetric::Cosine,
366 };
367
368 let precision = match props
369 .get("ailake.vector-precision")
370 .map(|s| s.as_str())
371 .unwrap_or("f16")
372 {
373 "f32" => VectorPrecision::F32,
374 "i8" => VectorPrecision::I8,
375 _ => VectorPrecision::F16,
376 };
377
378 Ok(VectorStoragePolicy {
379 column_name,
380 dim,
381 metric,
382 precision,
383 pre_normalize: props
384 .get("ailake.pre-normalize")
385 .map(|s| s == "true")
386 .unwrap_or(false),
387 hnsw_m: props.get("ailake.hnsw-m").and_then(|s| s.parse().ok()),
388 hnsw_ef_construction: props
389 .get("ailake.hnsw-ef-construction")
390 .and_then(|s| s.parse().ok()),
391 pq: None,
392 keep_raw_for_reranking: true,
393 ivf_residual: false,
394 embedding_model: None,
395 modality: None,
396 partition_by: None,
397 partition_value: None,
398 partition_column_type: None,
399 partition_fields: vec![],
400 })
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use ailake_catalog::{HadoopCatalog, TableProperties};
407 use ailake_core::{VectorMetric, VectorPrecision};
408 use ailake_store::LocalStore;
409 use arrow_array::Int32Array;
410 use arrow_schema::{DataType, Field, Schema};
411 use tempfile::TempDir;
412
413 fn make_policy(dim: u32) -> VectorStoragePolicy {
414 VectorStoragePolicy {
415 column_name: "embedding".into(),
416 dim,
417 metric: VectorMetric::Cosine,
418 precision: VectorPrecision::F16,
419 pq: None,
420 keep_raw_for_reranking: true,
421 pre_normalize: false,
422 hnsw_m: None,
423 hnsw_ef_construction: None,
424 ivf_residual: false,
425 embedding_model: None,
426 modality: None,
427 partition_by: None,
428 partition_value: None,
429 partition_column_type: None,
430 partition_fields: vec![],
431 }
432 }
433
434 #[tokio::test]
442 async fn run_preserves_all_files_not_just_the_last() {
443 let dir = TempDir::new().unwrap();
444 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
445 let catalog_dir = TempDir::new().unwrap();
446 let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
447 let catalog: Arc<dyn CatalogProvider> = Arc::new(HadoopCatalog::new(catalog_store, ""));
448 let table = TableIdent::new("ns", "tbl");
449
450 let dim = 4u32;
451 let policy = make_policy(dim);
452 catalog
453 .create_table(
454 &table,
455 &TableProperties {
456 policy: policy.clone(),
457 extra: std::collections::HashMap::new(),
458 format_version: 2,
459 partition_column_type: None,
460 },
461 )
462 .await
463 .unwrap();
464
465 let schema = Arc::new(Schema::new(vec![
466 Field::new("id", DataType::Int32, false),
467 Field::new("chunk_text", DataType::Utf8, false),
468 ]));
469
470 let mut parent_snap = None;
472 for (i, (ids, texts)) in [
473 (vec![0i32, 1], vec!["a0", "a1"]),
474 (vec![2, 3], vec!["b0", "b1"]),
475 (vec![4, 5], vec!["c0", "c1"]),
476 ]
477 .into_iter()
478 .enumerate()
479 {
480 let embs: Vec<Vec<f32>> = ids.iter().map(|&v| vec![v as f32; dim as usize]).collect();
481 let batch = RecordBatch::try_new(
482 schema.clone(),
483 vec![
484 Arc::new(Int32Array::from(ids.clone())),
485 Arc::new(StringArray::from(texts)),
486 ],
487 )
488 .unwrap();
489 let bytes = AilakeFileWriter::new(policy.clone())
490 .write(&batch, &embs)
491 .unwrap();
492 let path = format!("data/old_{i}.parquet");
493 store.put(&path, bytes.clone()).await.unwrap();
494
495 let centroid = compute_centroid_and_radius(&embs, VectorMetric::Cosine);
496 let reader = AilakeFileReader::new(bytes.clone(), "embedding", dim);
497 let header = reader.read_header().unwrap();
498 let ailk_start = reader.ailk_offset().unwrap();
499 let entry = ailake_catalog::make_data_file_entry(
500 &path,
501 ids.len() as u64,
502 bytes.len() as u64,
503 ¢roid,
504 VectorIndexInfo {
505 column: "embedding",
506 dim,
507 hnsw_offset: ailk_start + header.hnsw_offset,
508 hnsw_len: header.hnsw_len,
509 },
510 );
511 let snap_id = new_snapshot_id();
512 catalog
513 .commit_snapshot(
514 &table,
515 NewSnapshot {
516 snapshot_id: snap_id,
517 parent_snapshot_id: parent_snap,
518 files: vec![entry],
519 operation: SnapshotOperation::Append,
520 iceberg_schema: None,
521 extra_properties: std::collections::HashMap::new(),
522 bloom_filters: vec![],
523 equality_delete_files: vec![],
524 },
525 )
526 .await
527 .unwrap();
528 parent_snap = Some(snap_id);
529 }
530
531 let files_before = catalog.list_files(&table, None).await.unwrap();
532 assert_eq!(
533 files_before.len(),
534 3,
535 "sanity: 3 files committed via Append"
536 );
537
538 let job = BackfillJob {
539 table: table.clone(),
540 text_column: "chunk_text".into(),
541 new_col: ailake_core::VectorColSpec {
542 column_name: "embedding_v2".into(),
543 dim,
544 metric: VectorMetric::Cosine,
545 precision: VectorPrecision::F16,
546 pre_normalize: false,
547 hnsw_m: None,
548 hnsw_ef_construction: None,
549 },
550 embed_fn: Arc::new(|texts: &[String]| {
551 Ok(texts.iter().map(|_| vec![9.0f32; 4]).collect())
552 }),
553 batch_size: 10,
554 on_progress: None,
555 };
556 job.run(catalog.clone(), store.clone()).await.unwrap();
557
558 let files_after = catalog.list_files(&table, None).await.unwrap();
559 assert_eq!(
560 files_after.len(),
561 3,
562 "BUG: expected all 3 backfilled files to remain visible, got {:?}",
563 files_after.iter().map(|f| &f.path).collect::<Vec<_>>()
564 );
565 let total_rows: u64 = files_after.iter().map(|f| f.record_count).sum();
566 assert_eq!(total_rows, 6, "all 6 original rows must survive backfill");
567
568 for entry in &files_after {
572 assert!(
573 entry.path.starts_with("data/backfill-"),
574 "BUG: {} was never backfilled (idempotency check false-skipped it)",
575 entry.path
576 );
577 let bytes = store.get(&entry.path).await.unwrap();
578 let reader = AilakeFileReader::new(bytes, "embedding", dim);
579 assert!(
580 reader.has_column_footer("embedding_v2"),
581 "file {} missing backfilled column",
582 entry.path
583 );
584 let (batch, _) = reader.read_parquet().unwrap();
585 assert_eq!(batch.num_rows(), 2);
586 }
587 }
588}