1use std::sync::atomic::{AtomicU32, Ordering};
3use std::sync::Arc;
4
5use ailake_catalog::{
6 encode_centroid_b64, make_data_file_entry, make_data_file_entry_indexing,
7 make_multi_column_data_file_entry, new_snapshot_id, CatalogProvider, DataFileEntry,
8 ExtraVectorIndex, IcebergSchemaUpdate, IndexStatus, NewSnapshot, SnapshotId, SnapshotOperation,
9 TableIdent, TableProperties, VectorIndexInfo,
10};
11use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, VectorStoragePolicy};
12use ailake_file::{AilakeFileReader, AilakeFileWriter, IndexType, VectorColumnBatch};
13use ailake_index::{IvfPqCodebook, IvfPqConfig};
14use ailake_store::Store;
15use ailake_vec::compute_centroid_and_radius;
16use arrow_array::Array;
17use arrow_array::RecordBatch;
18use arrow_schema::SchemaRef;
19use bytes::Bytes;
20use serde_json;
21use tracing::{error, info, warn};
22
23fn merge_schema(existing: Option<SchemaRef>, new: &SchemaRef) -> SchemaRef {
34 match existing {
35 None => new.clone(),
36 Some(existing)
37 if existing.fields().len() >= new.fields().len()
38 && new
39 .fields()
40 .iter()
41 .all(|f| existing.field_with_name(f.name()).is_ok()) =>
42 {
43 existing
45 }
46 Some(existing) => {
47 let mut fields: Vec<arrow_schema::FieldRef> =
48 existing.fields().iter().cloned().collect();
49 for f in new.fields() {
50 if existing.field_with_name(f.name()).is_err() {
51 fields.push(f.clone());
52 }
53 }
54 Arc::new(arrow_schema::Schema::new(fields))
55 }
56 }
57}
58
59fn apply_partition_transforms(policy: &VectorStoragePolicy, raw: Option<&str>) -> Option<String> {
64 let raw = raw?;
65 if policy.partition_fields.is_empty() {
66 return Some(raw.to_string());
67 }
68 let parts: Vec<&str> = raw.split('\x1f').collect();
69 let transformed: Vec<String> = policy
70 .partition_fields
71 .iter()
72 .enumerate()
73 .map(|(i, pf)| {
74 let v = parts.get(i).copied().unwrap_or("");
75 pf.apply(v)
76 })
77 .collect();
78 Some(transformed.join("\x1f"))
79}
80
81pub struct MultiVectorBatch<'a> {
83 pub policy: VectorStoragePolicy,
84 pub embeddings: &'a [Vec<f32>],
85}
86
87pub struct TableWriter {
88 catalog: Arc<dyn CatalogProvider>,
89 store: Arc<dyn Store>,
90 policy: VectorStoragePolicy,
91 table: TableIdent,
92 part_counter: Arc<AtomicU32>,
93 pending_files: Vec<DataFileEntry>,
94 parent_snapshot_id: Option<SnapshotId>,
95 captured_schema: Option<SchemaRef>,
98 extra_vec_policies: Vec<VectorStoragePolicy>,
100 cached_ivf_codebook: Option<Arc<IvfPqCodebook>>,
103 deferred_ivf_codebook: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
106 bm25_text_column: Option<String>,
110 pending_blooms: Vec<(String, Vec<u8>)>,
113 fts_config: Option<ailake_fts::FtsConfig>,
115}
116
117impl TableWriter {
118 pub fn new(
119 catalog: Arc<dyn CatalogProvider>,
120 store: Arc<dyn Store>,
121 policy: VectorStoragePolicy,
122 table: TableIdent,
123 ) -> Self {
124 Self {
125 catalog,
126 store,
127 policy,
128 table,
129 part_counter: Arc::new(AtomicU32::new(0)),
130 pending_files: Vec::new(),
131 parent_snapshot_id: None,
132 captured_schema: None,
133 extra_vec_policies: Vec::new(),
134 cached_ivf_codebook: None,
135 deferred_ivf_codebook: Arc::new(tokio::sync::OnceCell::new()),
136 bm25_text_column: None,
137 pending_blooms: Vec::new(),
138 fts_config: None,
139 }
140 }
141
142 pub fn with_bm25(mut self, text_column: impl Into<String>) -> Self {
150 self.bm25_text_column = Some(text_column.into());
151 self
152 }
153
154 pub fn with_fts_config(mut self, cfg: ailake_fts::FtsConfig) -> Self {
159 self.fts_config = Some(cfg);
160 self
161 }
162
163 pub fn with_parent_snapshot(mut self, id: SnapshotId) -> Self {
164 self.parent_snapshot_id = Some(id);
165 self
166 }
167
168 pub async fn write_batch_deferred(
178 &mut self,
179 batch: &RecordBatch,
180 embeddings: &[Vec<f32>],
181 ) -> AilakeResult<()> {
182 self.validate_embedding_dim(embeddings)?;
183 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
184 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
185 let file_path = format!("data/part-{:05}.parquet", part_num);
186
187 let file_writer = AilakeFileWriter::new(self.policy.clone());
189 let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
190 let file_size = parquet_bytes.len() as u64;
191 self.store.put(&file_path, parquet_bytes).await?;
192
193 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
195 let mut entry = make_data_file_entry_indexing(
196 &file_path,
197 embeddings.len() as u64,
198 file_size,
199 ¢roid,
200 &self.policy.column_name,
201 self.policy.dim,
202 );
203 entry.embedding_model = self
204 .policy
205 .embedding_model
206 .as_ref()
207 .map(|m| m.to_property_value());
208 entry.partition_value =
209 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
210 self.pending_files.push(entry);
211
212 let store = self.store.clone();
214 let catalog = self.catalog.clone();
215 let policy = self.policy.clone();
216 let table = self.table.clone();
217 let fp = file_path.clone();
218 tokio::spawn(async move {
219 if let Err(e) = build_and_patch_index(
220 store.clone(),
221 catalog.clone(),
222 policy,
223 table.clone(),
224 fp.clone(),
225 )
226 .await
227 {
228 error!(
229 "ailake: deferred HNSW build failed for {fp}: {e}; \
230 marking IndexStatus::Failed — compaction will rebuild"
231 );
232 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
233 }
234 });
235
236 if self.bm25_text_column.is_some() {
238 self.update_bm25_stats_from_batch(batch).await?;
239 self.build_bloom_for_file(batch, &file_path);
240 }
241
242 Ok(())
243 }
244
245 pub async fn write_batch_ivf_pq_deferred(
251 &mut self,
252 batch: &RecordBatch,
253 embeddings: &[Vec<f32>],
254 ivf_config: IvfPqConfig,
255 ) -> AilakeResult<()> {
256 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
257 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
258 let file_path = format!("data/part-{:05}.parquet", part_num);
259
260 let file_writer = AilakeFileWriter::new(self.policy.clone());
261 let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
262 let file_size = parquet_bytes.len() as u64;
263 self.store.put(&file_path, parquet_bytes).await?;
264
265 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
266 let mut entry = make_data_file_entry_indexing(
267 &file_path,
268 embeddings.len() as u64,
269 file_size,
270 ¢roid,
271 &self.policy.column_name,
272 self.policy.dim,
273 );
274 entry.embedding_model = self
275 .policy
276 .embedding_model
277 .as_ref()
278 .map(|m| m.to_property_value());
279 entry.partition_value =
280 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
281 self.pending_files.push(entry);
282
283 let store = self.store.clone();
284 let catalog = self.catalog.clone();
285 let policy = self.policy.clone();
286 let table = self.table.clone();
287 let fp = file_path.clone();
288 let codebook_cell = self.deferred_ivf_codebook.clone();
289 tokio::spawn(async move {
290 if let Err(e) = build_ivf_pq_and_patch_index(
291 store.clone(),
292 catalog.clone(),
293 policy,
294 table.clone(),
295 fp.clone(),
296 ivf_config,
297 codebook_cell,
298 )
299 .await
300 {
301 error!(
302 "ailake: deferred IVF-PQ build failed for {fp}: {e}; \
303 marking IndexStatus::Failed — compaction will rebuild"
304 );
305 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
306 }
307 });
308
309 Ok(())
310 }
311
312 pub async fn write_batch_idempotent(
321 &mut self,
322 batch: &RecordBatch,
323 embeddings: &[Vec<f32>],
324 batch_id: &str,
325 ) -> AilakeResult<()> {
326 let existing = self.catalog.list_files(&self.table, None).await?;
327 if existing
328 .iter()
329 .any(|f| f.batch_id.as_deref() == Some(batch_id))
330 {
331 return Ok(());
332 }
333 self.write_batch_with_id(batch, embeddings, Some(batch_id.to_string()))
334 .await
335 }
336
337 fn validate_embedding_dim(&self, embeddings: &[Vec<f32>]) -> AilakeResult<()> {
342 Self::validate_embedding_dim_for_policy(embeddings, &self.policy)
343 }
344
345 fn validate_embedding_dim_for_policy(
346 embeddings: &[Vec<f32>],
347 policy: &VectorStoragePolicy,
348 ) -> AilakeResult<()> {
349 for emb in embeddings {
350 let actual = emb.len() as u32;
351 if actual != policy.dim {
352 let table_model = policy
353 .embedding_model
354 .as_ref()
355 .map(|m| m.to_property_value())
356 .unwrap_or_else(|| format!("dim={}", policy.dim));
357 return Err(AilakeError::ModelMismatch {
358 table_model,
359 table_dim: policy.dim,
360 batch_model: format!("dim={}", actual),
361 batch_dim: actual,
362 });
363 }
364 }
365 Ok(())
366 }
367
368 pub async fn write_batch(
369 &mut self,
370 batch: &RecordBatch,
371 embeddings: &[Vec<f32>],
372 ) -> AilakeResult<()> {
373 self.write_batch_with_id(batch, embeddings, None).await
374 }
375
376 async fn write_batch_with_id(
377 &mut self,
378 batch: &RecordBatch,
379 embeddings: &[Vec<f32>],
380 batch_id: Option<String>,
381 ) -> AilakeResult<()> {
382 self.validate_embedding_dim(embeddings)?;
383 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
384 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
385 let file_path = format!("data/part-{:05}.parquet", part_num);
386
387 let mut file_writer = AilakeFileWriter::new(self.policy.clone());
389 if let Some(ref fts_cfg) = self.fts_config {
390 file_writer = file_writer.with_fts(fts_cfg.clone());
391 }
392 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
393 let file_size = file_bytes.len() as u64;
394
395 self.store.put(&file_path, file_bytes.clone()).await?;
397
398 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
400
401 let reader = ailake_file::AilakeFileReader::new(
403 file_bytes,
404 &self.policy.column_name,
405 self.policy.dim,
406 );
407 let header = reader.read_header()?;
408 let ailk_start = reader.ailk_offset()?;
409 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
410 let hnsw_len = header.hnsw_len;
411
412 let mut entry = make_data_file_entry(
413 &file_path,
414 embeddings.len() as u64,
415 file_size,
416 ¢roid,
417 VectorIndexInfo {
418 column: &self.policy.column_name,
419 dim: self.policy.dim,
420 hnsw_offset: hnsw_abs_offset,
421 hnsw_len,
422 },
423 );
424 entry.batch_id = batch_id;
425 entry.embedding_model = self
426 .policy
427 .embedding_model
428 .as_ref()
429 .map(|m| m.to_property_value());
430 entry.partition_value =
431 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
432 self.pending_files.push(entry);
433
434 if self.bm25_text_column.is_some() {
436 self.update_bm25_stats_from_batch(batch).await?;
437 self.build_bloom_for_file(batch, &file_path);
438 }
439 Ok(())
440 }
441
442 pub async fn write_batch_auto(
448 &mut self,
449 batch: &RecordBatch,
450 embeddings: &[Vec<f32>],
451 ) -> AilakeResult<()> {
452 let profile = ailake_index::HardwareProfile::detect();
453 if profile.recommend_ivf_pq(embeddings.len()) {
454 let mut ivf_config =
455 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
456 if self.policy.ivf_residual {
457 ivf_config = ivf_config.with_residual();
458 }
459 self.write_batch_ivf_pq(batch, embeddings, ivf_config).await
460 } else {
461 self.write_batch(batch, embeddings).await
462 }
463 }
464
465 pub async fn write_batch_auto_deferred(
476 &mut self,
477 batch: &RecordBatch,
478 embeddings: &[Vec<f32>],
479 ) -> AilakeResult<()> {
480 let profile = ailake_index::HardwareProfile::detect();
481 if profile.recommend_ivf_pq(embeddings.len()) {
482 let mut ivf_config =
483 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
484 if self.policy.ivf_residual {
485 ivf_config = ivf_config.with_residual();
486 }
487 self.write_batch_ivf_pq_deferred(batch, embeddings, ivf_config)
488 .await
489 } else {
490 self.write_batch_deferred(batch, embeddings).await
491 }
492 }
493
494 pub async fn write_batch_ivf_pq(
498 &mut self,
499 batch: &RecordBatch,
500 embeddings: &[Vec<f32>],
501 ivf_config: IvfPqConfig,
502 ) -> AilakeResult<()> {
503 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
504 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
505 let file_path = format!("data/part-{:05}.parquet", part_num);
506
507 if self.cached_ivf_codebook.is_none() {
511 let codebook = tokio::task::spawn_blocking({
512 let embeddings = embeddings.to_vec();
513 let metric = self.policy.metric;
514 let config = ivf_config.clone();
515 move || ailake_index::IvfPqIndex::train_codebook(&embeddings, metric, &config)
516 })
517 .await
518 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
519 self.cached_ivf_codebook = Some(Arc::new(codebook));
520 }
521 let codebook = self
523 .cached_ivf_codebook
524 .as_ref()
525 .expect("IVF-PQ codebook must be Some after training block")
526 .clone();
527
528 let file_writer = AilakeFileWriter::new(self.policy.clone())
529 .with_index_type(IndexType::IvfPq(ivf_config))
530 .with_shared_ivf_codebook(codebook);
531 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
532 let file_size = file_bytes.len() as u64;
533
534 self.store.put(&file_path, file_bytes.clone()).await?;
535
536 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
537
538 let reader = ailake_file::AilakeFileReader::new(
539 file_bytes,
540 &self.policy.column_name,
541 self.policy.dim,
542 );
543 let header = reader.read_header()?;
544 let ailk_start = reader.ailk_offset()?;
545 let index_abs_offset = ailk_start + header.hnsw_offset;
546 let index_len = header.hnsw_len;
547
548 let mut entry = make_data_file_entry(
549 &file_path,
550 embeddings.len() as u64,
551 file_size,
552 ¢roid,
553 VectorIndexInfo {
554 column: &self.policy.column_name,
555 dim: self.policy.dim,
556 hnsw_offset: index_abs_offset,
557 hnsw_len: index_len,
558 },
559 );
560 entry.embedding_model = self
561 .policy
562 .embedding_model
563 .as_ref()
564 .map(|m| m.to_property_value());
565 entry.partition_value =
566 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
567 self.pending_files.push(entry);
568 Ok(())
569 }
570
571 pub async fn write_batch_multi(
576 &mut self,
577 batch: &RecordBatch,
578 columns: &[MultiVectorBatch<'_>],
579 ) -> AilakeResult<()> {
580 use ailake_core::AilakeError;
581 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
582 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
583 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
584 }
585
586 if columns.is_empty() {
587 return Err(AilakeError::InvalidArgument(
588 "write_batch_multi requires at least one column".into(),
589 ));
590 }
591
592 for col in columns {
593 Self::validate_embedding_dim_for_policy(col.embeddings, &col.policy)?;
594 }
595
596 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
597 let file_path = format!("data/part-{:05}.parquet", part_num);
598
599 let col_batches: Vec<VectorColumnBatch<'_>> = columns
600 .iter()
601 .map(|c| VectorColumnBatch {
602 policy: &c.policy,
603 embeddings: c.embeddings,
604 })
605 .collect();
606
607 let primary_policy = &columns[0].policy;
608 let mut file_writer = AilakeFileWriter::new(primary_policy.clone());
609 if let Some(ref fts_cfg) = self.fts_config {
610 file_writer = file_writer.with_fts(fts_cfg.clone());
611 }
612 let file_bytes: Bytes = file_writer.write_multi(batch, &col_batches)?;
613 let file_size = file_bytes.len() as u64;
614
615 self.store.put(&file_path, file_bytes.clone()).await?;
616
617 let primary_centroid =
619 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
620
621 let reader = ailake_file::AilakeFileReader::new(
623 file_bytes.clone(),
624 &primary_policy.column_name,
625 primary_policy.dim,
626 );
627 let primary_ailk_start = reader.ailk_offset()?;
628 let primary_header = {
629 use ailake_file::HEADER_SIZE;
630 let start = primary_ailk_start as usize;
631 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
632 .try_into()
633 .map_err(|_| AilakeError::NotAnAilakeFile)?;
634 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
635 };
636 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
637
638 let mut extra: Vec<ExtraVectorIndex> = Vec::new();
640 for col in columns.iter().skip(1) {
641 let col_ailk_start = reader.ailk_offset_for_column(&col.policy.column_name)?;
642 let col_header = {
643 use ailake_file::HEADER_SIZE;
644 let start = col_ailk_start as usize;
645 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
646 .try_into()
647 .map_err(|_| AilakeError::NotAnAilakeFile)?;
648 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
649 };
650 let col_centroid = compute_centroid_and_radius(col.embeddings, col.policy.metric);
651 extra.push(ExtraVectorIndex {
652 column: col.policy.column_name.clone(),
653 dim: col.policy.dim,
654 hnsw_offset: col_ailk_start + col_header.hnsw_offset,
655 hnsw_len: col_header.hnsw_len,
656 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
657 radius: Some(col_centroid.radius),
658 });
659 }
660
661 let mut entry = make_multi_column_data_file_entry(
662 &file_path,
663 columns[0].embeddings.len() as u64,
664 file_size,
665 &primary_centroid,
666 VectorIndexInfo {
667 column: &primary_policy.column_name,
668 dim: primary_policy.dim,
669 hnsw_offset: primary_hnsw_abs,
670 hnsw_len: primary_header.hnsw_len,
671 },
672 &extra,
673 );
674 entry.embedding_model = self
675 .policy
676 .embedding_model
677 .as_ref()
678 .map(|m| m.to_property_value());
679 entry.partition_value =
680 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
681 self.pending_files.push(entry);
682 Ok(())
683 }
684
685 pub async fn write_batch_multi_deferred(
698 &mut self,
699 batch: &RecordBatch,
700 columns: &[MultiVectorBatch<'_>],
701 ) -> AilakeResult<()> {
702 use ailake_core::AilakeError;
703 if columns.is_empty() {
704 return Err(AilakeError::InvalidArgument(
705 "write_batch_multi_deferred requires at least one column".into(),
706 ));
707 }
708 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
709 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
710 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
711 }
712
713 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
714 let file_path = format!("data/part-{:05}.parquet", part_num);
715
716 let primary_policy = &columns[0].policy;
718 let file_writer = AilakeFileWriter::new(primary_policy.clone());
719 let parquet_bytes = file_writer.write_parquet_only(batch, columns[0].embeddings)?;
720 let file_size = parquet_bytes.len() as u64;
721 self.store.put(&file_path, parquet_bytes).await?;
722
723 let primary_centroid =
725 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
726 let mut entry = make_data_file_entry_indexing(
727 &file_path,
728 columns[0].embeddings.len() as u64,
729 file_size,
730 &primary_centroid,
731 &primary_policy.column_name,
732 primary_policy.dim,
733 );
734 entry.extra_vector_indexes = columns[1..]
737 .iter()
738 .map(|c| {
739 let col_centroid = compute_centroid_and_radius(c.embeddings, c.policy.metric);
740 ExtraVectorIndex {
741 column: c.policy.column_name.clone(),
742 dim: c.policy.dim,
743 hnsw_offset: 0,
744 hnsw_len: 0,
745 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
746 radius: Some(col_centroid.radius),
747 }
748 })
749 .collect();
750 entry.embedding_model = self
751 .policy
752 .embedding_model
753 .as_ref()
754 .map(|m| m.to_property_value());
755 entry.partition_value =
756 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
757 self.pending_files.push(entry);
758
759 let all_policies: Vec<VectorStoragePolicy> =
761 columns.iter().map(|c| c.policy.clone()).collect();
762 let all_embeddings: Vec<Vec<Vec<f32>>> =
763 columns.iter().map(|c| c.embeddings.to_vec()).collect();
764 let store = self.store.clone();
765 let catalog = self.catalog.clone();
766 let table = self.table.clone();
767 let fp = file_path.clone();
768 tokio::spawn(async move {
769 if let Err(e) = build_and_patch_multi_index(
770 store,
771 catalog.clone(),
772 all_policies,
773 table.clone(),
774 fp.clone(),
775 all_embeddings,
776 )
777 .await
778 {
779 error!(
780 "ailake: deferred multi-column HNSW build failed for {fp}: {e}; \
781 marking IndexStatus::Failed — compaction will rebuild"
782 );
783 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
784 }
785 });
786
787 Ok(())
788 }
789
790 fn build_bloom_for_file(&mut self, batch: &RecordBatch, file_path: &str) {
799 use arrow_array::cast::AsArray;
800 let col_name = match &self.bm25_text_column {
801 Some(c) => c.clone(),
802 None => return,
803 };
804 let col = match batch.column_by_name(&col_name) {
805 Some(c) => c,
806 None => return,
807 };
808 let str_arr = match col.as_string_opt::<i32>() {
809 Some(a) => a,
810 None => return,
811 };
812 let cap = (batch.num_rows() * 10).max(128);
814 let mut bloom = crate::bloom::BloomFilter::with_capacity(cap, 0.01);
815 for i in 0..str_arr.len() {
816 if str_arr.is_valid(i) {
817 for term in crate::bm25::tokenize(str_arr.value(i)) {
818 bloom.insert(&term);
819 }
820 }
821 }
822 self.pending_blooms
823 .push((file_path.to_string(), bloom.to_bytes()));
824 }
825
826 async fn update_bm25_stats_from_batch(&self, batch: &RecordBatch) -> AilakeResult<()> {
832 use arrow_array::cast::AsArray;
833
834 let col_name = match &self.bm25_text_column {
835 Some(c) => c.as_str(),
836 None => return Ok(()),
837 };
838 let col = match batch.column_by_name(col_name) {
839 Some(c) => c,
840 None => {
841 tracing::warn!(
842 "ailake: BM25 text column '{}' not found in batch — skipping IDF update",
843 col_name
844 );
845 return Ok(());
846 }
847 };
848 let str_arr = match col.as_string_opt::<i32>() {
849 Some(a) => a,
850 None => {
851 tracing::warn!(
852 "ailake: BM25 text column '{}' is not a Utf8 column — skipping",
853 col_name
854 );
855 return Ok(());
856 }
857 };
858
859 let texts: Vec<&str> = (0..str_arr.len())
860 .filter(|&i| str_arr.is_valid(i))
861 .map(|i| str_arr.value(i))
862 .collect();
863
864 let stats_path = crate::bm25::BM25_STATS_FILE;
866 let mut stats: crate::bm25::IdfStats = match self.store.get(stats_path).await {
867 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
868 Err(_) => crate::bm25::IdfStats::default(),
869 };
870
871 stats.merge_batch(&texts);
872
873 let bytes = stats.to_bytes()?;
874 self.store
875 .put(stats_path, bytes::Bytes::from(bytes))
876 .await?;
877 Ok(())
878 }
879
880 pub async fn commit(mut self) -> AilakeResult<SnapshotId> {
881 if self.pending_files.is_empty() {
882 let current = self
883 .catalog
884 .load_table(&self.table)
885 .await
886 .ok()
887 .and_then(|m| m.current_snapshot_id)
888 .unwrap_or(0);
889 return Ok(current);
890 }
891 let iceberg_schema = self
892 .captured_schema
893 .as_deref()
894 .map(|s| arrow_schema_to_iceberg_update(s, &self.policy, &self.extra_vec_policies));
895 let mut extra_properties = std::collections::HashMap::new();
898 if let Some(ref fts_cfg) = self.fts_config {
899 extra_properties.insert("ailake.fts.enabled".to_string(), "true".to_string());
900 extra_properties.insert(
901 "ailake.fts.text-columns".to_string(),
902 fts_cfg.text_columns.join(","),
903 );
904 extra_properties.insert(
905 "ailake.fts.tokenizer".to_string(),
906 fts_cfg.tokenizer.clone(),
907 );
908 }
909 for ep in &self.extra_vec_policies {
910 extra_properties.insert(format!("ailake.dim-{}", ep.column_name), ep.dim.to_string());
911 extra_properties.insert(
912 format!("ailake.metric-{}", ep.column_name),
913 ailake_parquet::schema::metric_str(ep.metric).to_string(),
914 );
915 if let Some(modality) = ep.modality {
916 extra_properties.insert(
917 format!("ailake.modality-{}", ep.column_name),
918 modality.as_str().to_string(),
919 );
920 }
921 }
922 let snapshot = NewSnapshot {
923 snapshot_id: new_snapshot_id(),
924 parent_snapshot_id: self.parent_snapshot_id,
925 files: std::mem::take(&mut self.pending_files),
926 operation: SnapshotOperation::Append,
927 iceberg_schema,
928 extra_properties,
929 bloom_filters: std::mem::take(&mut self.pending_blooms),
930 equality_delete_files: vec![],
931 };
932 self.catalog.commit_snapshot(&self.table, snapshot).await
933 }
934
935 pub async fn create_or_open(
937 catalog: Arc<dyn CatalogProvider>,
938 store: Arc<dyn Store>,
939 policy: VectorStoragePolicy,
940 table: TableIdent,
941 format_version: u8,
942 ) -> AilakeResult<Self> {
943 let existing_file_count: u32;
946
947 match catalog.load_table(&table).await {
948 Ok(existing_meta) => {
949 if let Some(stored_dim_str) = existing_meta.properties.get("ailake.vector-dim") {
953 if let Ok(stored_dim) = stored_dim_str.parse::<u32>() {
954 if stored_dim != policy.dim {
955 let table_model = policy
956 .embedding_model
957 .as_ref()
958 .map(|m| m.to_property_value())
959 .unwrap_or_else(|| format!("dim={}", stored_dim));
960 return Err(AilakeError::ModelMismatch {
961 table_model,
962 table_dim: stored_dim,
963 batch_model: format!("dim={}", policy.dim),
964 batch_dim: policy.dim,
965 });
966 }
967 }
968 }
969 if let Some(incoming) = &policy.embedding_model {
973 if let Some(stored_val) = existing_meta
974 .properties
975 .get(EmbeddingModelInfo::property_key())
976 {
977 let stored = EmbeddingModelInfo::from_property_value(stored_val);
978 if stored.name != incoming.name {
979 warn!(
980 "ailake: embedding model name changed: table has '{}', writing with '{}' \
981 (dim={}). Vectors may be incompatible for similarity search.",
982 stored.name, incoming.name, policy.dim
983 );
984 }
985 }
986 }
987 existing_file_count = catalog
988 .list_files(&table, None)
989 .await
990 .unwrap_or_default()
991 .len() as u32;
992 }
993 Err(_) => {
994 catalog
995 .create_table(
996 &table,
997 &TableProperties {
998 partition_column_type: policy.partition_column_type.clone(),
999 policy: policy.clone(),
1000 extra: std::collections::HashMap::new(),
1001 format_version,
1002 },
1003 )
1004 .await?;
1005 existing_file_count = 0;
1006 }
1007 }
1008 let parent_snapshot_id = catalog
1009 .load_table(&table)
1010 .await
1011 .ok()
1012 .and_then(|m| m.current_snapshot_id);
1013 let mut writer = Self::new(catalog, store, policy, table);
1014 writer.part_counter = Arc::new(AtomicU32::new(existing_file_count));
1015 writer.parent_snapshot_id = parent_snapshot_id;
1016 Ok(writer)
1017 }
1018}
1019
1020fn arrow_schema_to_iceberg_update(
1027 schema: &arrow_schema::Schema,
1028 policy: &VectorStoragePolicy,
1029 extra_vec_policies: &[VectorStoragePolicy],
1030) -> IcebergSchemaUpdate {
1031 let bytes_per_dim = policy.precision.bytes_per_element() as u32;
1032 let vec_fixed_len = policy.dim * bytes_per_dim;
1033
1034 let has_primary_in_batch = schema
1036 .fields()
1037 .iter()
1038 .any(|f| f.name() == &policy.column_name);
1039 let vec_cols: Vec<(String, u32)> = {
1040 let mut v = Vec::new();
1041 if !has_primary_in_batch {
1042 v.push((policy.column_name.clone(), vec_fixed_len));
1043 }
1044 for ep in extra_vec_policies {
1045 let ep_fixed_len = ep.dim * ep.precision.bytes_per_element() as u32;
1046 if !schema.fields().iter().any(|f| f.name() == &ep.column_name) {
1047 v.push((ep.column_name.clone(), ep_fixed_len));
1048 }
1049 }
1050 v
1051 };
1052
1053 let top_level_count = schema.fields().len() + vec_cols.len();
1055 let mut nested_id = top_level_count as i32;
1057
1058 let mut fields: Vec<serde_json::Value> = Vec::new();
1059 let mut name_mapping: Vec<serde_json::Value> = Vec::new();
1060
1061 for (idx, field) in schema.fields().iter().enumerate() {
1062 let field_id = (idx + 1) as i32;
1063 let iceberg_type = arrow_type_to_iceberg(field.data_type(), &mut nested_id);
1064 fields.push(serde_json::json!({
1065 "id": field_id,
1066 "name": field.name(),
1067 "required": false,
1068 "type": iceberg_type,
1069 }));
1070 name_mapping.push(serde_json::json!({
1071 "field-id": field_id,
1072 "names": [field.name()],
1073 }));
1074 }
1075
1076 for (i, (col_name, fixed_len)) in vec_cols.iter().enumerate() {
1078 let field_id = (schema.fields().len() + 1 + i) as i32;
1079 fields.push(serde_json::json!({
1080 "id": field_id,
1081 "name": col_name,
1082 "required": false,
1083 "type": format!("fixed[{fixed_len}]"),
1084 }));
1085 name_mapping.push(serde_json::json!({
1086 "field-id": field_id,
1087 "names": [col_name],
1088 }));
1089 }
1090
1091 let last_column_id = nested_id;
1092 let name_mapping_json = serde_json::to_string(&name_mapping).unwrap_or_else(|_| "[]".into());
1093
1094 IcebergSchemaUpdate {
1095 fields,
1096 last_column_id,
1097 name_mapping_json,
1098 }
1099}
1100
1101fn arrow_type_to_iceberg(dt: &arrow_schema::DataType, nested_id: &mut i32) -> serde_json::Value {
1106 use arrow_schema::DataType;
1107 match dt {
1108 DataType::Boolean => serde_json::json!("boolean"),
1109 DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
1110 serde_json::json!("int")
1111 }
1112 DataType::Int64 | DataType::UInt32 | DataType::UInt64 => serde_json::json!("long"),
1113 DataType::Float16 | DataType::Float32 => serde_json::json!("float"),
1114 DataType::Float64 => serde_json::json!("double"),
1115 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => serde_json::json!("string"),
1116 DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
1117 serde_json::json!("binary")
1118 }
1119 DataType::Date32 | DataType::Date64 => serde_json::json!("date"),
1120 DataType::Timestamp(_, Some(_)) => serde_json::json!("timestamptz"),
1122 DataType::Timestamp(_, None) => serde_json::json!("timestamp"),
1123 DataType::Time32(_) | DataType::Time64(_) => serde_json::json!("time"),
1124 DataType::FixedSizeBinary(n) => serde_json::json!(format!("fixed[{n}]")),
1125 DataType::Decimal128(p, s) | DataType::Decimal256(p, s) => {
1126 serde_json::json!(format!("decimal({p}, {s})"))
1127 }
1128 DataType::List(inner)
1129 | DataType::LargeList(inner)
1130 | DataType::ListView(inner)
1131 | DataType::FixedSizeList(inner, _) => {
1132 *nested_id += 1;
1133 let element_id = *nested_id;
1134 let element_type = arrow_type_to_iceberg(inner.data_type(), nested_id);
1135 serde_json::json!({
1136 "type": "list",
1137 "element-id": element_id,
1138 "element": element_type,
1139 "element-required": !inner.is_nullable(),
1140 })
1141 }
1142 DataType::Struct(arrow_fields) => {
1143 let struct_fields: Vec<serde_json::Value> = arrow_fields
1144 .iter()
1145 .map(|f| {
1146 *nested_id += 1;
1147 let fid = *nested_id;
1148 let ftype = arrow_type_to_iceberg(f.data_type(), nested_id);
1149 serde_json::json!({
1150 "id": fid,
1151 "name": f.name(),
1152 "required": !f.is_nullable(),
1153 "type": ftype,
1154 })
1155 })
1156 .collect();
1157 serde_json::json!({ "type": "struct", "fields": struct_fields })
1158 }
1159 DataType::Map(entries, _) => {
1160 *nested_id += 1;
1162 let key_id = *nested_id;
1163 *nested_id += 1;
1164 let val_id = *nested_id;
1165 if let DataType::Struct(kv_fields) = entries.data_type() {
1166 let key_f = kv_fields
1167 .iter()
1168 .find(|f| f.name() == "key" || f.name() == "keys");
1169 let val_f = kv_fields
1170 .iter()
1171 .find(|f| f.name() == "value" || f.name() == "values");
1172 let key_type = key_f
1173 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1174 .unwrap_or(serde_json::json!("binary"));
1175 let val_type = val_f
1176 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1177 .unwrap_or(serde_json::json!("binary"));
1178 let val_required = val_f.map(|f| !f.is_nullable()).unwrap_or(false);
1179 serde_json::json!({
1180 "type": "map",
1181 "key-id": key_id,
1182 "key": key_type,
1183 "value-id": val_id,
1184 "value": val_type,
1185 "value-required": val_required,
1186 })
1187 } else {
1188 serde_json::json!("binary")
1189 }
1190 }
1191 _ => serde_json::json!("binary"),
1192 }
1193}
1194
1195async fn patch_index_failed(
1200 catalog: Arc<dyn CatalogProvider>,
1201 table: &TableIdent,
1202 file_path: &str,
1203 reason: &str,
1204) {
1205 let Ok(table_meta) = catalog.load_table(table).await else {
1206 return;
1207 };
1208 let parent_snapshot_id = table_meta.current_snapshot_id;
1209 let Ok(mut files) = catalog.list_files(table, None).await else {
1210 return;
1211 };
1212 for f in &mut files {
1213 if f.path == file_path {
1214 f.index_status = IndexStatus::Failed;
1215 f.index_error = Some(reason.to_string());
1216 break;
1217 }
1218 }
1219 let _ = catalog
1220 .commit_snapshot(
1221 table,
1222 NewSnapshot {
1223 snapshot_id: new_snapshot_id(),
1224 parent_snapshot_id,
1225 files,
1226 operation: SnapshotOperation::Replace,
1227 iceberg_schema: None,
1228 extra_properties: std::collections::HashMap::new(),
1229 bloom_filters: vec![],
1230 equality_delete_files: vec![],
1231 },
1232 )
1233 .await
1234 .map_err(|e| {
1235 error!(
1236 "ailake: failed to write IndexStatus::Failed for {file_path}: {e}; \
1237 file will remain Indexing until compaction"
1238 )
1239 });
1240}
1241
1242pub(crate) async fn build_and_patch_index(
1243 store: Arc<dyn Store>,
1244 catalog: Arc<dyn CatalogProvider>,
1245 policy: VectorStoragePolicy,
1246 table: TableIdent,
1247 file_path: String,
1248) -> AilakeResult<()> {
1249 let parquet_bytes = store.get(&file_path).await?;
1251 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1252 let (batch, embeddings) = reader.read_parquet()?;
1253
1254 let full_bytes = tokio::task::spawn_blocking({
1257 let policy = policy.clone();
1258 move || {
1259 let file_writer = AilakeFileWriter::new(policy);
1260 file_writer.write(&batch, &embeddings)
1261 }
1262 })
1263 .await
1264 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1265
1266 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1268 let header = full_reader.read_header()?;
1269 let ailk_start = full_reader.ailk_offset()?;
1270 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1271 let hnsw_len = header.hnsw_len;
1272
1273 full_reader.verify_integrity()?;
1278
1279 store.put(&file_path, full_bytes).await?;
1281
1282 let mut committed = false;
1285 for _ in 0..120u32 {
1286 match catalog.load_table(&table).await {
1287 Ok(meta) if meta.current_snapshot_id.is_some() => {
1288 committed = true;
1289 break;
1290 }
1291 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1292 }
1293 }
1294 if !committed {
1295 return Err(ailake_core::AilakeError::Store(format!(
1296 "deferred HNSW build: no snapshot committed for {file_path} after 60 s — \
1297 did you call TableWriter::commit()?"
1298 )));
1299 }
1300
1301 for attempt in 0..50u32 {
1306 let table_meta = catalog.load_table(&table).await?;
1307 let parent_snapshot_id = table_meta.current_snapshot_id;
1308 let mut files = catalog.list_files(&table, None).await?;
1309
1310 if files
1312 .iter()
1313 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1314 {
1315 break;
1316 }
1317
1318 for f in &mut files {
1319 if f.path == file_path {
1320 f.hnsw_offset = Some(hnsw_abs_offset);
1321 f.hnsw_len = Some(hnsw_len);
1322 f.index_status = IndexStatus::Ready;
1323 break;
1324 }
1325 }
1326 catalog
1327 .commit_snapshot(
1328 &table,
1329 NewSnapshot {
1330 snapshot_id: new_snapshot_id(),
1331 parent_snapshot_id,
1332 files,
1333 operation: SnapshotOperation::Replace,
1334 iceberg_schema: None,
1335 extra_properties: std::collections::HashMap::new(),
1336 bloom_filters: vec![],
1337 equality_delete_files: vec![],
1338 },
1339 )
1340 .await?;
1341
1342 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1344
1345 let verify = catalog.list_files(&table, None).await?;
1346 if verify
1347 .iter()
1348 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1349 {
1350 break;
1351 }
1352 }
1354
1355 info!(
1356 "ailake: deferred HNSW index built for {} (offset={}, len={})",
1357 file_path, hnsw_abs_offset, hnsw_len
1358 );
1359 Ok(())
1360}
1361
1362async fn build_ivf_pq_and_patch_index(
1367 store: Arc<dyn Store>,
1368 catalog: Arc<dyn CatalogProvider>,
1369 policy: VectorStoragePolicy,
1370 table: TableIdent,
1371 file_path: String,
1372 ivf_config: IvfPqConfig,
1373 codebook_cell: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
1374) -> AilakeResult<()> {
1375 let parquet_bytes = store.get(&file_path).await?;
1376 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1377 let (batch, embeddings) = reader.read_parquet()?;
1378
1379 let codebook = codebook_cell
1381 .get_or_try_init(|| async {
1382 let vecs = embeddings.clone();
1383 let metric = policy.metric;
1384 let cfg = ivf_config.clone();
1385 tokio::task::spawn_blocking(move || {
1386 ailake_index::IvfPqIndex::train_codebook(&vecs, metric, &cfg)
1387 })
1388 .await
1389 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))?
1390 })
1391 .await?;
1392
1393 let full_bytes = tokio::task::spawn_blocking({
1394 let policy = policy.clone();
1395 let codebook = codebook.clone();
1396 move || {
1397 let file_writer = AilakeFileWriter::new(policy)
1398 .with_index_type(IndexType::IvfPq(ivf_config))
1399 .with_shared_ivf_codebook(Arc::new(codebook));
1400 file_writer.write(&batch, &embeddings)
1401 }
1402 })
1403 .await
1404 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1405
1406 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1407 let header = full_reader.read_header()?;
1408 let ailk_start = full_reader.ailk_offset()?;
1409 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1410 let hnsw_len = header.hnsw_len;
1411
1412 store.put(&file_path, full_bytes).await?;
1413
1414 let mut committed = false;
1416 for _ in 0..120u32 {
1417 match catalog.load_table(&table).await {
1418 Ok(meta) if meta.current_snapshot_id.is_some() => {
1419 committed = true;
1420 break;
1421 }
1422 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1423 }
1424 }
1425 if !committed {
1426 return Err(ailake_core::AilakeError::Store(format!(
1427 "deferred IVF-PQ build: no snapshot committed for {file_path} after 60 s — \
1428 did you call TableWriter::commit()?"
1429 )));
1430 }
1431
1432 for attempt in 0..50u32 {
1433 let table_meta = catalog.load_table(&table).await?;
1434 let parent_snapshot_id = table_meta.current_snapshot_id;
1435 let mut files = catalog.list_files(&table, None).await?;
1436
1437 if files
1438 .iter()
1439 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1440 {
1441 break;
1442 }
1443
1444 for f in &mut files {
1445 if f.path == file_path {
1446 f.hnsw_offset = Some(hnsw_abs_offset);
1447 f.hnsw_len = Some(hnsw_len);
1448 f.index_status = IndexStatus::Ready;
1449 break;
1450 }
1451 }
1452 catalog
1453 .commit_snapshot(
1454 &table,
1455 NewSnapshot {
1456 snapshot_id: new_snapshot_id(),
1457 parent_snapshot_id,
1458 files,
1459 operation: SnapshotOperation::Replace,
1460 iceberg_schema: None,
1461 extra_properties: std::collections::HashMap::new(),
1462 bloom_filters: vec![],
1463 equality_delete_files: vec![],
1464 },
1465 )
1466 .await?;
1467
1468 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1469
1470 let verify = catalog.list_files(&table, None).await?;
1471 if verify
1472 .iter()
1473 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1474 {
1475 break;
1476 }
1477 }
1478
1479 info!(
1480 "ailake: deferred IVF-PQ index built for {} (offset={}, len={})",
1481 file_path, hnsw_abs_offset, hnsw_len
1482 );
1483 Ok(())
1484}
1485
1486async fn build_and_patch_multi_index(
1492 store: Arc<dyn Store>,
1493 catalog: Arc<dyn CatalogProvider>,
1494 policies: Vec<VectorStoragePolicy>,
1495 table: TableIdent,
1496 file_path: String,
1497 all_embeddings: Vec<Vec<Vec<f32>>>,
1498) -> AilakeResult<()> {
1499 let parquet_bytes = store.get(&file_path).await?;
1501 let primary_reader =
1502 AilakeFileReader::new(parquet_bytes, &policies[0].column_name, policies[0].dim);
1503 let (batch, _) = primary_reader.read_parquet()?;
1504
1505 let full_bytes = tokio::task::spawn_blocking({
1507 let policies = policies.clone();
1508 let all_embeddings = all_embeddings.clone();
1509 move || {
1510 let col_batches: Vec<VectorColumnBatch<'_>> = policies
1511 .iter()
1512 .zip(all_embeddings.iter())
1513 .map(|(p, embs)| VectorColumnBatch {
1514 policy: p,
1515 embeddings: embs.as_slice(),
1516 })
1517 .collect();
1518 let file_writer = AilakeFileWriter::new(policies[0].clone());
1519 file_writer.write_multi(&batch, &col_batches)
1520 }
1521 })
1522 .await
1523 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1524
1525 let primary_reader = AilakeFileReader::new(
1527 full_bytes.clone(),
1528 &policies[0].column_name,
1529 policies[0].dim,
1530 );
1531 let primary_header = primary_reader.read_header()?;
1532 let primary_ailk_start = primary_reader.ailk_offset()?;
1533 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
1534 let primary_hnsw_len = primary_header.hnsw_len;
1535
1536 let mut extra_offsets: Vec<(u64, u64)> = Vec::with_capacity(policies.len().saturating_sub(1));
1541 for col_policy in policies.iter().skip(1) {
1542 let col_reader =
1543 AilakeFileReader::new(full_bytes.clone(), &col_policy.column_name, col_policy.dim);
1544 let col_ailk_start = col_reader.ailk_offset_for_column(&col_policy.column_name)?;
1545 let col_header = col_reader.read_header_for_column(&col_policy.column_name)?;
1546 extra_offsets.push((col_ailk_start + col_header.hnsw_offset, col_header.hnsw_len));
1547 }
1548
1549 store.put(&file_path, full_bytes).await?;
1551
1552 let mut committed = false;
1554 for _ in 0..120u32 {
1555 match catalog.load_table(&table).await {
1556 Ok(meta) if meta.current_snapshot_id.is_some() => {
1557 committed = true;
1558 break;
1559 }
1560 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1561 }
1562 }
1563 if !committed {
1564 return Err(ailake_core::AilakeError::Store(format!(
1565 "deferred index build: no snapshot committed for {file_path} after 60 s — \
1566 did you call TableWriter::commit()?"
1567 )));
1568 }
1569
1570 for attempt in 0..50u32 {
1572 let table_meta = catalog.load_table(&table).await?;
1573 let parent_snapshot_id = table_meta.current_snapshot_id;
1574 let mut files = catalog.list_files(&table, None).await?;
1575
1576 if files
1577 .iter()
1578 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1579 {
1580 break;
1581 }
1582
1583 for f in &mut files {
1584 if f.path == file_path {
1585 f.hnsw_offset = Some(primary_hnsw_abs);
1586 f.hnsw_len = Some(primary_hnsw_len);
1587 f.index_status = IndexStatus::Ready;
1588 for (i, &(off, len)) in extra_offsets.iter().enumerate() {
1589 if let Some(xi) = f.extra_vector_indexes.get_mut(i) {
1590 xi.hnsw_offset = off;
1591 xi.hnsw_len = len;
1592 }
1593 }
1594 break;
1595 }
1596 }
1597 catalog
1598 .commit_snapshot(
1599 &table,
1600 NewSnapshot {
1601 snapshot_id: new_snapshot_id(),
1602 parent_snapshot_id,
1603 files,
1604 operation: SnapshotOperation::Replace,
1605 iceberg_schema: None,
1606 extra_properties: std::collections::HashMap::new(),
1607 bloom_filters: vec![],
1608 equality_delete_files: vec![],
1609 },
1610 )
1611 .await?;
1612
1613 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1614
1615 let verify = catalog.list_files(&table, None).await?;
1616 if verify
1617 .iter()
1618 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1619 {
1620 break;
1621 }
1622 }
1623
1624 info!(
1625 "ailake: deferred multi-column HNSW built for {} ({} cols, primary offset={})",
1626 file_path,
1627 policies.len(),
1628 primary_hnsw_abs
1629 );
1630 Ok(())
1631}
1632
1633#[cfg(test)]
1634mod tests {
1635 use super::*;
1636 use ailake_core::{VectorMetric, VectorPrecision};
1637 use arrow_schema::{DataType, Field, Schema, TimeUnit};
1638
1639 fn policy(col: &str, dim: u32) -> VectorStoragePolicy {
1640 VectorStoragePolicy {
1641 column_name: col.to_string(),
1642 dim,
1643 metric: VectorMetric::Cosine,
1644 precision: VectorPrecision::F16,
1645 pq: None,
1646 keep_raw_for_reranking: true,
1647 pre_normalize: false,
1648 hnsw_m: None,
1649 hnsw_ef_construction: None,
1650 ivf_residual: false,
1651 embedding_model: None,
1652 modality: None,
1653 partition_by: None,
1654 partition_value: None,
1655 partition_column_type: None,
1656 partition_fields: vec![],
1657 }
1658 }
1659
1660 fn update_for(schema: &Schema, pol: &VectorStoragePolicy) -> IcebergSchemaUpdate {
1661 arrow_schema_to_iceberg_update(schema, pol, &[])
1662 }
1663
1664 #[test]
1665 fn simple_schema_produces_correct_fields() {
1666 let schema = Schema::new(vec![
1667 Field::new("id", DataType::Int32, false),
1668 Field::new("text", DataType::Utf8, false),
1669 ]);
1670 let pol = policy("embedding", 8);
1671 let upd = update_for(&schema, &pol);
1672
1673 assert_eq!(upd.fields.len(), 3);
1674 assert_eq!(upd.fields[0]["id"], 1);
1675 assert_eq!(upd.fields[0]["type"], "int");
1676 assert_eq!(upd.fields[1]["id"], 2);
1677 assert_eq!(upd.fields[1]["type"], "string");
1678 assert_eq!(upd.fields[2]["id"], 3);
1679 assert_eq!(upd.fields[2]["type"], "fixed[16]"); let nm: Vec<serde_json::Value> = serde_json::from_str(&upd.name_mapping_json).unwrap();
1682 assert_eq!(nm.len(), 3);
1683 assert_eq!(nm[2]["field-id"], 3);
1684 assert_eq!(nm[2]["names"][0], "embedding");
1685 assert_eq!(upd.last_column_id, 3);
1686 }
1687
1688 #[test]
1689 fn timestamp_without_tz_maps_to_timestamp_not_timestamptz() {
1690 let schema = Schema::new(vec![
1691 Field::new(
1692 "created_at",
1693 DataType::Timestamp(TimeUnit::Microsecond, None),
1694 true,
1695 ),
1696 Field::new(
1697 "updated_at",
1698 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
1699 true,
1700 ),
1701 ]);
1702 let pol = policy("vec", 4);
1703 let upd = update_for(&schema, &pol);
1704
1705 assert_eq!(upd.fields[0]["type"], "timestamp");
1706 assert_eq!(upd.fields[1]["type"], "timestamptz");
1707 }
1708
1709 #[test]
1710 fn list_type_produces_iceberg_list_object() {
1711 let schema = Schema::new(vec![Field::new(
1712 "tags",
1713 DataType::List(std::sync::Arc::new(Field::new(
1714 "item",
1715 DataType::Utf8,
1716 true,
1717 ))),
1718 true,
1719 )]);
1720 let pol = policy("vec", 4);
1721 let upd = update_for(&schema, &pol);
1722
1723 let t = &upd.fields[0]["type"];
1724 assert_eq!(t["type"], "list");
1725 assert_eq!(t["element"], "string");
1726 assert!(t["element-id"].as_i64().unwrap() > 2);
1728 }
1729
1730 #[test]
1731 fn struct_type_produces_nested_fields() {
1732 let schema = Schema::new(vec![Field::new(
1733 "meta",
1734 DataType::Struct(
1735 vec![
1736 Field::new("key", DataType::Utf8, false),
1737 Field::new("val", DataType::Int64, false),
1738 ]
1739 .into(),
1740 ),
1741 true,
1742 )]);
1743 let pol = policy("vec", 4);
1744 let upd = update_for(&schema, &pol);
1745
1746 let t = &upd.fields[0]["type"];
1747 assert_eq!(t["type"], "struct");
1748 let nested = t["fields"].as_array().unwrap();
1749 assert_eq!(nested.len(), 2);
1750 assert_eq!(nested[0]["name"], "key");
1751 assert_eq!(nested[0]["type"], "string");
1752 assert_eq!(nested[1]["name"], "val");
1753 assert_eq!(nested[1]["type"], "long");
1754 assert!(nested[0]["id"].as_i64().unwrap() > 2);
1756 }
1757
1758 #[test]
1759 fn no_duplicate_vec_column_when_already_in_batch() {
1760 let schema = Schema::new(vec![
1762 Field::new("id", DataType::Int32, false),
1763 Field::new("embedding", DataType::FixedSizeBinary(16), false),
1764 ]);
1765 let pol = policy("embedding", 8);
1766 let upd = update_for(&schema, &pol);
1767
1768 assert_eq!(upd.fields.len(), 2, "should not add embedding twice");
1769 let names: Vec<&str> = upd
1770 .fields
1771 .iter()
1772 .map(|f| f["name"].as_str().unwrap())
1773 .collect();
1774 assert_eq!(names.iter().filter(|&&n| n == "embedding").count(), 1);
1775 }
1776
1777 #[test]
1778 fn multi_vec_policies_all_appended() {
1779 let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
1780 let primary = policy("embedding", 4);
1781 let extra = vec![policy("context_embedding", 4)];
1782 let upd = arrow_schema_to_iceberg_update(&schema, &primary, &extra);
1783
1784 assert_eq!(upd.fields.len(), 3); let names: Vec<&str> = upd
1786 .fields
1787 .iter()
1788 .map(|f| f["name"].as_str().unwrap())
1789 .collect();
1790 assert!(names.contains(&"embedding"));
1791 assert!(names.contains(&"context_embedding"));
1792 }
1793
1794 #[test]
1795 fn top_level_field_ids_match_parquet_stamp_sequence() {
1796 let schema = Schema::new(vec![
1798 Field::new("id", DataType::Int64, false),
1799 Field::new(
1800 "tags",
1801 DataType::List(std::sync::Arc::new(Field::new(
1802 "item",
1803 DataType::Utf8,
1804 true,
1805 ))),
1806 true,
1807 ),
1808 ]);
1809 let pol = policy("vec", 4);
1810 let upd = update_for(&schema, &pol);
1811
1812 assert_eq!(upd.fields[0]["id"], 1);
1814 assert_eq!(upd.fields[1]["id"], 2);
1815 assert_eq!(upd.fields[2]["id"], 3);
1816
1817 assert!(upd.fields[1]["type"]["element-id"].as_i64().unwrap() > 3);
1819 }
1820
1821 #[test]
1826 fn merge_schema_accumulates_columns_across_batches() {
1827 let first = Arc::new(Schema::new(vec![
1828 Field::new("id", DataType::Int32, false),
1829 Field::new("text", DataType::Utf8, false),
1830 ]));
1831 let second = Arc::new(Schema::new(vec![
1832 Field::new("id", DataType::Int32, false),
1833 Field::new("text", DataType::Utf8, false),
1834 Field::new("author", DataType::Utf8, true),
1835 ]));
1836
1837 let merged = merge_schema(None, &first);
1838 assert_eq!(merged.fields().len(), 2);
1839 let merged = merge_schema(Some(merged), &second);
1840
1841 assert_eq!(
1842 merged.fields().len(),
1843 3,
1844 "author from the second batch must survive"
1845 );
1846 assert!(merged.field_with_name("id").is_ok());
1847 assert!(merged.field_with_name("text").is_ok());
1848 assert!(merged.field_with_name("author").is_ok());
1849
1850 let third = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1852 let merged = merge_schema(Some(merged), &third);
1853 assert_eq!(
1854 merged.fields().len(),
1855 3,
1856 "shrinking batch must not drop prior columns"
1857 );
1858 }
1859
1860 #[tokio::test]
1863 async fn write_batch_auto_deferred_stages_file() {
1864 use ailake_catalog::{HadoopCatalog, TableIdent};
1865 use ailake_store::LocalStore;
1866 use arrow_schema::{DataType, Field, Schema};
1867
1868 let dir = tempfile::tempdir().unwrap();
1869 let store: std::sync::Arc<dyn ailake_store::Store> =
1870 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1871 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1872 let pol = policy("embedding", 4);
1873 let ident = TableIdent::new("default", "t");
1874
1875 let mut writer = TableWriter::create_or_open(catalog, store, pol, ident, 2)
1876 .await
1877 .unwrap();
1878
1879 let schema =
1880 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1881 let batch = arrow_array::RecordBatch::try_new(
1882 schema,
1883 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1884 "hello",
1885 ]))],
1886 )
1887 .unwrap();
1888 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
1889
1890 writer
1891 .write_batch_auto_deferred(&batch, &embeddings)
1892 .await
1893 .unwrap();
1894
1895 assert_eq!(writer.pending_files.len(), 1);
1897 }
1898
1899 #[tokio::test]
1902 async fn write_batch_multi_deferred_stages_file_with_extra_indexes() {
1903 use ailake_catalog::{HadoopCatalog, IndexStatus, TableIdent};
1904 use ailake_store::LocalStore;
1905 use arrow_schema::{DataType, Field, Schema};
1906
1907 let dir = tempfile::tempdir().unwrap();
1908 let store: std::sync::Arc<dyn ailake_store::Store> =
1909 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1910 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1911 let primary_pol = policy("embedding", 4);
1912 let ident = TableIdent::new("default", "t");
1913
1914 let mut writer = TableWriter::create_or_open(catalog, store, primary_pol, ident, 2)
1915 .await
1916 .unwrap();
1917
1918 let schema =
1919 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1920 let batch = arrow_array::RecordBatch::try_new(
1921 schema,
1922 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1923 "hello", "world",
1924 ]))],
1925 )
1926 .unwrap();
1927
1928 let text_embs = vec![vec![1.0f32, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
1929 let img_embs = vec![vec![1.0f32, 0.0], vec![0.0, 1.0]];
1930
1931 let columns = vec![
1932 MultiVectorBatch {
1933 policy: policy("embedding", 4),
1934 embeddings: &text_embs,
1935 },
1936 MultiVectorBatch {
1937 policy: policy("img_embedding", 2),
1938 embeddings: &img_embs,
1939 },
1940 ];
1941
1942 writer
1943 .write_batch_multi_deferred(&batch, &columns)
1944 .await
1945 .unwrap();
1946
1947 assert_eq!(writer.pending_files.len(), 1);
1948 let entry = &writer.pending_files[0];
1949 assert_eq!(entry.index_status, IndexStatus::Indexing);
1951 assert!(entry.centroid_b64.is_some());
1953 assert_eq!(entry.extra_vector_indexes.len(), 1);
1955 let xi = &entry.extra_vector_indexes[0];
1956 assert_eq!(xi.column, "img_embedding");
1957 assert_eq!(xi.dim, 2);
1958 assert_eq!(xi.hnsw_offset, 0); assert_eq!(xi.hnsw_len, 0); assert!(xi.centroid_b64.is_some());
1961 }
1962}