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 session_ts: u128,
103 pending_files: Vec<DataFileEntry>,
104 parent_snapshot_id: Option<SnapshotId>,
105 captured_schema: Option<SchemaRef>,
108 extra_vec_policies: Vec<VectorStoragePolicy>,
110 cached_ivf_codebook: Option<Arc<IvfPqCodebook>>,
113 deferred_ivf_codebook: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
116 bm25_text_column: Option<String>,
120 pending_blooms: Vec<(String, Vec<u8>)>,
123 fts_config: Option<ailake_fts::FtsConfig>,
125}
126
127impl TableWriter {
128 pub fn new(
129 catalog: Arc<dyn CatalogProvider>,
130 store: Arc<dyn Store>,
131 policy: VectorStoragePolicy,
132 table: TableIdent,
133 ) -> Self {
134 Self {
135 catalog,
136 store,
137 policy,
138 table,
139 part_counter: Arc::new(AtomicU32::new(0)),
140 session_ts: std::time::SystemTime::now()
141 .duration_since(std::time::UNIX_EPOCH)
142 .unwrap_or_else(|e| e.duration())
143 .as_millis(),
144 pending_files: Vec::new(),
145 parent_snapshot_id: None,
146 captured_schema: None,
147 extra_vec_policies: Vec::new(),
148 cached_ivf_codebook: None,
149 deferred_ivf_codebook: Arc::new(tokio::sync::OnceCell::new()),
150 bm25_text_column: None,
151 pending_blooms: Vec::new(),
152 fts_config: None,
153 }
154 }
155
156 pub fn with_bm25(mut self, text_column: impl Into<String>) -> Self {
164 self.bm25_text_column = Some(text_column.into());
165 self
166 }
167
168 pub fn with_fts_config(mut self, cfg: ailake_fts::FtsConfig) -> Self {
173 self.fts_config = Some(cfg);
174 self
175 }
176
177 pub fn with_parent_snapshot(mut self, id: SnapshotId) -> Self {
178 self.parent_snapshot_id = Some(id);
179 self
180 }
181
182 pub async fn write_batch_deferred(
192 &mut self,
193 batch: &RecordBatch,
194 embeddings: &[Vec<f32>],
195 ) -> AilakeResult<()> {
196 self.ensure_deferred_supported()?;
197 self.validate_embedding_dim(embeddings)?;
198 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
199 let file_path = self.next_part_path();
200
201 let file_writer = AilakeFileWriter::new(self.policy.clone());
203 let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
204 let file_size = parquet_bytes.len() as u64;
205 self.store.put(&file_path, parquet_bytes).await?;
206
207 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
209 let mut entry = make_data_file_entry_indexing(
210 &file_path,
211 embeddings.len() as u64,
212 file_size,
213 ¢roid,
214 &self.policy.column_name,
215 self.policy.dim,
216 );
217 entry.embedding_model = self
218 .policy
219 .embedding_model
220 .as_ref()
221 .map(|m| m.to_property_value());
222 entry.partition_value =
223 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
224 self.pending_files.push(entry);
225
226 let store = self.store.clone();
228 let catalog = self.catalog.clone();
229 let policy = self.policy.clone();
230 let table = self.table.clone();
231 let fp = file_path.clone();
232 tokio::spawn(async move {
233 if let Err(e) = build_and_patch_index(
234 store.clone(),
235 catalog.clone(),
236 policy,
237 table.clone(),
238 fp.clone(),
239 )
240 .await
241 {
242 error!(
243 "ailake: deferred HNSW build failed for {fp}: {e}; \
244 marking IndexStatus::Failed — compaction will rebuild"
245 );
246 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
247 }
248 });
249
250 if self.bm25_text_column.is_some() {
252 self.update_bm25_stats_from_batch(batch).await?;
253 self.build_bloom_for_file(batch, &file_path);
254 }
255
256 Ok(())
257 }
258
259 pub async fn write_batch_ivf_pq_deferred(
265 &mut self,
266 batch: &RecordBatch,
267 embeddings: &[Vec<f32>],
268 ivf_config: IvfPqConfig,
269 ) -> AilakeResult<()> {
270 self.ensure_deferred_supported()?;
271 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
272 let file_path = self.next_part_path();
273
274 let file_writer = AilakeFileWriter::new(self.policy.clone());
275 let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
276 let file_size = parquet_bytes.len() as u64;
277 self.store.put(&file_path, parquet_bytes).await?;
278
279 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
280 let mut entry = make_data_file_entry_indexing(
281 &file_path,
282 embeddings.len() as u64,
283 file_size,
284 ¢roid,
285 &self.policy.column_name,
286 self.policy.dim,
287 );
288 entry.embedding_model = self
289 .policy
290 .embedding_model
291 .as_ref()
292 .map(|m| m.to_property_value());
293 entry.partition_value =
294 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
295 self.pending_files.push(entry);
296
297 let store = self.store.clone();
298 let catalog = self.catalog.clone();
299 let policy = self.policy.clone();
300 let table = self.table.clone();
301 let fp = file_path.clone();
302 let codebook_cell = self.deferred_ivf_codebook.clone();
303 tokio::spawn(async move {
304 if let Err(e) = build_ivf_pq_and_patch_index(
305 store.clone(),
306 catalog.clone(),
307 policy,
308 table.clone(),
309 fp.clone(),
310 ivf_config,
311 codebook_cell,
312 )
313 .await
314 {
315 error!(
316 "ailake: deferred IVF-PQ build failed for {fp}: {e}; \
317 marking IndexStatus::Failed — compaction will rebuild"
318 );
319 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
320 }
321 });
322
323 Ok(())
324 }
325
326 pub async fn write_batch_idempotent(
341 &mut self,
342 batch: &RecordBatch,
343 embeddings: &[Vec<f32>],
344 batch_id: &str,
345 ) -> AilakeResult<()> {
346 let existing = self.catalog.list_files(&self.table, None).await?;
347 if existing
348 .iter()
349 .any(|f| f.batch_id.as_deref() == Some(batch_id))
350 {
351 return Ok(());
352 }
353 self.write_batch_with_id(batch, embeddings, Some(batch_id.to_string()))
354 .await
355 }
356
357 fn validate_embedding_dim(&self, embeddings: &[Vec<f32>]) -> AilakeResult<()> {
362 Self::validate_embedding_dim_for_policy(embeddings, &self.policy)
363 }
364
365 fn next_part_path(&self) -> String {
371 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
372 format!("data/part-{}-{:05}.parquet", self.session_ts, part_num)
373 }
374
375 fn ensure_deferred_supported(&self) -> AilakeResult<()> {
382 if self.catalog.supports_in_place_rewrite() {
383 Ok(())
384 } else {
385 Err(AilakeError::Catalog(
386 "deferred writes are not supported with this catalog backend: the \
387 background index build patches the data file in place at its committed \
388 path, which this catalog cannot re-register — use a blocking write"
389 .into(),
390 ))
391 }
392 }
393
394 fn validate_embedding_dim_for_policy(
395 embeddings: &[Vec<f32>],
396 policy: &VectorStoragePolicy,
397 ) -> AilakeResult<()> {
398 for emb in embeddings {
399 let actual = emb.len() as u32;
400 if actual != policy.dim {
401 let table_model = policy
402 .embedding_model
403 .as_ref()
404 .map(|m| m.to_property_value())
405 .unwrap_or_else(|| format!("dim={}", policy.dim));
406 return Err(AilakeError::ModelMismatch {
407 table_model,
408 table_dim: policy.dim,
409 batch_model: format!("dim={}", actual),
410 batch_dim: actual,
411 });
412 }
413 }
414 Ok(())
415 }
416
417 pub async fn write_batch(
418 &mut self,
419 batch: &RecordBatch,
420 embeddings: &[Vec<f32>],
421 ) -> AilakeResult<()> {
422 self.write_batch_with_id(batch, embeddings, None).await
423 }
424
425 async fn write_batch_with_id(
426 &mut self,
427 batch: &RecordBatch,
428 embeddings: &[Vec<f32>],
429 batch_id: Option<String>,
430 ) -> AilakeResult<()> {
431 self.validate_embedding_dim(embeddings)?;
432 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
433 let file_path = self.next_part_path();
434
435 let mut file_writer = AilakeFileWriter::new(self.policy.clone());
437 if let Some(ref fts_cfg) = self.fts_config {
438 file_writer = file_writer.with_fts(fts_cfg.clone());
439 }
440 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
441 let file_size = file_bytes.len() as u64;
442
443 self.store.put(&file_path, file_bytes.clone()).await?;
445
446 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
448
449 let reader = ailake_file::AilakeFileReader::new(
451 file_bytes,
452 &self.policy.column_name,
453 self.policy.dim,
454 );
455 let header = reader.read_header()?;
456 let ailk_start = reader.ailk_offset()?;
457 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
458 let hnsw_len = header.hnsw_len;
459
460 let mut entry = make_data_file_entry(
461 &file_path,
462 embeddings.len() as u64,
463 file_size,
464 ¢roid,
465 VectorIndexInfo {
466 column: &self.policy.column_name,
467 dim: self.policy.dim,
468 hnsw_offset: hnsw_abs_offset,
469 hnsw_len,
470 },
471 );
472 entry.batch_id = batch_id;
473 entry.embedding_model = self
474 .policy
475 .embedding_model
476 .as_ref()
477 .map(|m| m.to_property_value());
478 entry.partition_value =
479 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
480 self.pending_files.push(entry);
481
482 if self.bm25_text_column.is_some() {
484 self.update_bm25_stats_from_batch(batch).await?;
485 self.build_bloom_for_file(batch, &file_path);
486 }
487 Ok(())
488 }
489
490 pub async fn write_batch_auto(
496 &mut self,
497 batch: &RecordBatch,
498 embeddings: &[Vec<f32>],
499 ) -> AilakeResult<()> {
500 let profile = ailake_index::HardwareProfile::detect();
501 if profile.recommend_ivf_pq(embeddings.len()) {
502 let mut ivf_config =
503 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
504 if self.policy.ivf_residual {
505 ivf_config = ivf_config.with_residual();
506 }
507 self.write_batch_ivf_pq(batch, embeddings, ivf_config).await
508 } else {
509 self.write_batch(batch, embeddings).await
510 }
511 }
512
513 pub async fn write_batch_auto_deferred(
524 &mut self,
525 batch: &RecordBatch,
526 embeddings: &[Vec<f32>],
527 ) -> AilakeResult<()> {
528 let profile = ailake_index::HardwareProfile::detect();
529 if profile.recommend_ivf_pq(embeddings.len()) {
530 let mut ivf_config =
531 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
532 if self.policy.ivf_residual {
533 ivf_config = ivf_config.with_residual();
534 }
535 self.write_batch_ivf_pq_deferred(batch, embeddings, ivf_config)
536 .await
537 } else {
538 self.write_batch_deferred(batch, embeddings).await
539 }
540 }
541
542 pub async fn write_batch_ivf_pq(
546 &mut self,
547 batch: &RecordBatch,
548 embeddings: &[Vec<f32>],
549 ivf_config: IvfPqConfig,
550 ) -> AilakeResult<()> {
551 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
552 let file_path = self.next_part_path();
553
554 if self.cached_ivf_codebook.is_none() {
558 let codebook = tokio::task::spawn_blocking({
559 let embeddings = embeddings.to_vec();
560 let metric = self.policy.metric;
561 let config = ivf_config.clone();
562 move || ailake_index::IvfPqIndex::train_codebook(&embeddings, metric, &config)
563 })
564 .await
565 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
566 self.cached_ivf_codebook = Some(Arc::new(codebook));
567 }
568 let codebook = self
570 .cached_ivf_codebook
571 .as_ref()
572 .expect("IVF-PQ codebook must be Some after training block")
573 .clone();
574
575 let file_writer = AilakeFileWriter::new(self.policy.clone())
576 .with_index_type(IndexType::IvfPq(ivf_config))
577 .with_shared_ivf_codebook(codebook);
578 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
579 let file_size = file_bytes.len() as u64;
580
581 self.store.put(&file_path, file_bytes.clone()).await?;
582
583 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
584
585 let reader = ailake_file::AilakeFileReader::new(
586 file_bytes,
587 &self.policy.column_name,
588 self.policy.dim,
589 );
590 let header = reader.read_header()?;
591 let ailk_start = reader.ailk_offset()?;
592 let index_abs_offset = ailk_start + header.hnsw_offset;
593 let index_len = header.hnsw_len;
594
595 let mut entry = make_data_file_entry(
596 &file_path,
597 embeddings.len() as u64,
598 file_size,
599 ¢roid,
600 VectorIndexInfo {
601 column: &self.policy.column_name,
602 dim: self.policy.dim,
603 hnsw_offset: index_abs_offset,
604 hnsw_len: index_len,
605 },
606 );
607 entry.embedding_model = self
608 .policy
609 .embedding_model
610 .as_ref()
611 .map(|m| m.to_property_value());
612 entry.partition_value =
613 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
614 self.pending_files.push(entry);
615 Ok(())
616 }
617
618 pub async fn write_batch_multi(
623 &mut self,
624 batch: &RecordBatch,
625 columns: &[MultiVectorBatch<'_>],
626 ) -> AilakeResult<()> {
627 use ailake_core::AilakeError;
628 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
629 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
630 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
631 }
632
633 if columns.is_empty() {
634 return Err(AilakeError::InvalidArgument(
635 "write_batch_multi requires at least one column".into(),
636 ));
637 }
638
639 for col in columns {
640 Self::validate_embedding_dim_for_policy(col.embeddings, &col.policy)?;
641 }
642
643 let file_path = self.next_part_path();
644
645 let col_batches: Vec<VectorColumnBatch<'_>> = columns
646 .iter()
647 .map(|c| VectorColumnBatch {
648 policy: &c.policy,
649 embeddings: c.embeddings,
650 })
651 .collect();
652
653 let primary_policy = &columns[0].policy;
654 let mut file_writer = AilakeFileWriter::new(primary_policy.clone());
655 if let Some(ref fts_cfg) = self.fts_config {
656 file_writer = file_writer.with_fts(fts_cfg.clone());
657 }
658 let file_bytes: Bytes = file_writer.write_multi(batch, &col_batches)?;
659 let file_size = file_bytes.len() as u64;
660
661 self.store.put(&file_path, file_bytes.clone()).await?;
662
663 let primary_centroid =
665 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
666
667 let reader = ailake_file::AilakeFileReader::new(
669 file_bytes.clone(),
670 &primary_policy.column_name,
671 primary_policy.dim,
672 );
673 let primary_ailk_start = reader.ailk_offset()?;
674 let primary_header = {
675 use ailake_file::HEADER_SIZE;
676 let start = primary_ailk_start as usize;
677 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
678 .try_into()
679 .map_err(|_| AilakeError::NotAnAilakeFile)?;
680 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
681 };
682 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
683
684 let mut extra: Vec<ExtraVectorIndex> = Vec::new();
686 for col in columns.iter().skip(1) {
687 let col_ailk_start = reader.ailk_offset_for_column(&col.policy.column_name)?;
688 let col_header = {
689 use ailake_file::HEADER_SIZE;
690 let start = col_ailk_start as usize;
691 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
692 .try_into()
693 .map_err(|_| AilakeError::NotAnAilakeFile)?;
694 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
695 };
696 let col_centroid = compute_centroid_and_radius(col.embeddings, col.policy.metric);
697 extra.push(ExtraVectorIndex {
698 column: col.policy.column_name.clone(),
699 dim: col.policy.dim,
700 hnsw_offset: col_ailk_start + col_header.hnsw_offset,
701 hnsw_len: col_header.hnsw_len,
702 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
703 radius: Some(col_centroid.radius),
704 });
705 }
706
707 let mut entry = make_multi_column_data_file_entry(
708 &file_path,
709 columns[0].embeddings.len() as u64,
710 file_size,
711 &primary_centroid,
712 VectorIndexInfo {
713 column: &primary_policy.column_name,
714 dim: primary_policy.dim,
715 hnsw_offset: primary_hnsw_abs,
716 hnsw_len: primary_header.hnsw_len,
717 },
718 &extra,
719 );
720 entry.embedding_model = self
721 .policy
722 .embedding_model
723 .as_ref()
724 .map(|m| m.to_property_value());
725 entry.partition_value =
726 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
727 self.pending_files.push(entry);
728 Ok(())
729 }
730
731 pub async fn write_batch_multi_deferred(
744 &mut self,
745 batch: &RecordBatch,
746 columns: &[MultiVectorBatch<'_>],
747 ) -> AilakeResult<()> {
748 use ailake_core::AilakeError;
749 self.ensure_deferred_supported()?;
750 if columns.is_empty() {
751 return Err(AilakeError::InvalidArgument(
752 "write_batch_multi_deferred requires at least one column".into(),
753 ));
754 }
755 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
756 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
757 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
758 }
759
760 let file_path = self.next_part_path();
761
762 let primary_policy = &columns[0].policy;
764 let file_writer = AilakeFileWriter::new(primary_policy.clone());
765 let parquet_bytes = file_writer.write_parquet_only(batch, columns[0].embeddings)?;
766 let file_size = parquet_bytes.len() as u64;
767 self.store.put(&file_path, parquet_bytes).await?;
768
769 let primary_centroid =
771 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
772 let mut entry = make_data_file_entry_indexing(
773 &file_path,
774 columns[0].embeddings.len() as u64,
775 file_size,
776 &primary_centroid,
777 &primary_policy.column_name,
778 primary_policy.dim,
779 );
780 entry.extra_vector_indexes = columns[1..]
783 .iter()
784 .map(|c| {
785 let col_centroid = compute_centroid_and_radius(c.embeddings, c.policy.metric);
786 ExtraVectorIndex {
787 column: c.policy.column_name.clone(),
788 dim: c.policy.dim,
789 hnsw_offset: 0,
790 hnsw_len: 0,
791 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
792 radius: Some(col_centroid.radius),
793 }
794 })
795 .collect();
796 entry.embedding_model = self
797 .policy
798 .embedding_model
799 .as_ref()
800 .map(|m| m.to_property_value());
801 entry.partition_value =
802 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
803 self.pending_files.push(entry);
804
805 let all_policies: Vec<VectorStoragePolicy> =
807 columns.iter().map(|c| c.policy.clone()).collect();
808 let all_embeddings: Vec<Vec<Vec<f32>>> =
809 columns.iter().map(|c| c.embeddings.to_vec()).collect();
810 let store = self.store.clone();
811 let catalog = self.catalog.clone();
812 let table = self.table.clone();
813 let fp = file_path.clone();
814 tokio::spawn(async move {
815 if let Err(e) = build_and_patch_multi_index(
816 store,
817 catalog.clone(),
818 all_policies,
819 table.clone(),
820 fp.clone(),
821 all_embeddings,
822 )
823 .await
824 {
825 error!(
826 "ailake: deferred multi-column HNSW build failed for {fp}: {e}; \
827 marking IndexStatus::Failed — compaction will rebuild"
828 );
829 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
830 }
831 });
832
833 Ok(())
834 }
835
836 fn build_bloom_for_file(&mut self, batch: &RecordBatch, file_path: &str) {
845 use arrow_array::cast::AsArray;
846 let col_name = match &self.bm25_text_column {
847 Some(c) => c.clone(),
848 None => return,
849 };
850 let col = match batch.column_by_name(&col_name) {
851 Some(c) => c,
852 None => return,
853 };
854 let str_arr = match col.as_string_opt::<i32>() {
855 Some(a) => a,
856 None => return,
857 };
858 let cap = (batch.num_rows() * 10).max(128);
860 let mut bloom = crate::bloom::BloomFilter::with_capacity(cap, 0.01);
861 for i in 0..str_arr.len() {
862 if str_arr.is_valid(i) {
863 for term in crate::bm25::tokenize(str_arr.value(i)) {
864 bloom.insert(&term);
865 }
866 }
867 }
868 self.pending_blooms
869 .push((file_path.to_string(), bloom.to_bytes()));
870 }
871
872 async fn update_bm25_stats_from_batch(&self, batch: &RecordBatch) -> AilakeResult<()> {
878 use arrow_array::cast::AsArray;
879
880 let col_name = match &self.bm25_text_column {
881 Some(c) => c.as_str(),
882 None => return Ok(()),
883 };
884 let col = match batch.column_by_name(col_name) {
885 Some(c) => c,
886 None => {
887 tracing::warn!(
888 "ailake: BM25 text column '{}' not found in batch — skipping IDF update",
889 col_name
890 );
891 return Ok(());
892 }
893 };
894 let str_arr = match col.as_string_opt::<i32>() {
895 Some(a) => a,
896 None => {
897 tracing::warn!(
898 "ailake: BM25 text column '{}' is not a Utf8 column — skipping",
899 col_name
900 );
901 return Ok(());
902 }
903 };
904
905 let texts: Vec<&str> = (0..str_arr.len())
906 .filter(|&i| str_arr.is_valid(i))
907 .map(|i| str_arr.value(i))
908 .collect();
909
910 let stats_path = crate::bm25::BM25_STATS_FILE;
912 let mut stats: crate::bm25::IdfStats = match self.store.get(stats_path).await {
913 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
914 Err(_) => crate::bm25::IdfStats::default(),
915 };
916
917 stats.merge_batch(&texts);
918
919 let bytes = stats.to_bytes()?;
920 self.store
921 .put(stats_path, bytes::Bytes::from(bytes))
922 .await?;
923 Ok(())
924 }
925
926 pub async fn commit(mut self) -> AilakeResult<SnapshotId> {
927 if self.pending_files.is_empty() {
928 let current = self
929 .catalog
930 .load_table(&self.table)
931 .await
932 .ok()
933 .and_then(|m| m.current_snapshot_id)
934 .unwrap_or(0);
935 return Ok(current);
936 }
937 let iceberg_schema = self
938 .captured_schema
939 .as_deref()
940 .map(|s| arrow_schema_to_iceberg_update(s, &self.policy, &self.extra_vec_policies));
941 let mut extra_properties = std::collections::HashMap::new();
944 if let Some(ref fts_cfg) = self.fts_config {
945 extra_properties.insert("ailake.fts.enabled".to_string(), "true".to_string());
946 extra_properties.insert(
947 "ailake.fts.text-columns".to_string(),
948 fts_cfg.text_columns.join(","),
949 );
950 extra_properties.insert(
951 "ailake.fts.tokenizer".to_string(),
952 fts_cfg.tokenizer.clone(),
953 );
954 }
955 for ep in &self.extra_vec_policies {
956 extra_properties.insert(format!("ailake.dim-{}", ep.column_name), ep.dim.to_string());
957 extra_properties.insert(
958 format!("ailake.metric-{}", ep.column_name),
959 ailake_parquet::schema::metric_str(ep.metric).to_string(),
960 );
961 if let Some(modality) = ep.modality {
962 extra_properties.insert(
963 format!("ailake.modality-{}", ep.column_name),
964 modality.as_str().to_string(),
965 );
966 }
967 }
968 let snapshot = NewSnapshot {
969 snapshot_id: new_snapshot_id(),
970 parent_snapshot_id: self.parent_snapshot_id,
971 files: std::mem::take(&mut self.pending_files),
972 operation: SnapshotOperation::Append,
973 iceberg_schema,
974 extra_properties,
975 bloom_filters: std::mem::take(&mut self.pending_blooms),
976 equality_delete_files: vec![],
977 };
978 self.catalog.commit_snapshot(&self.table, snapshot).await
979 }
980
981 pub async fn create_or_open(
983 catalog: Arc<dyn CatalogProvider>,
984 store: Arc<dyn Store>,
985 policy: VectorStoragePolicy,
986 table: TableIdent,
987 format_version: u8,
988 ) -> AilakeResult<Self> {
989 match catalog.load_table(&table).await {
994 Ok(existing_meta) => {
995 if let Some(stored_dim_str) = existing_meta.properties.get("ailake.vector-dim") {
999 if let Ok(stored_dim) = stored_dim_str.parse::<u32>() {
1000 if stored_dim != policy.dim {
1001 let table_model = policy
1002 .embedding_model
1003 .as_ref()
1004 .map(|m| m.to_property_value())
1005 .unwrap_or_else(|| format!("dim={}", stored_dim));
1006 return Err(AilakeError::ModelMismatch {
1007 table_model,
1008 table_dim: stored_dim,
1009 batch_model: format!("dim={}", policy.dim),
1010 batch_dim: policy.dim,
1011 });
1012 }
1013 }
1014 }
1015 if let Some(incoming) = &policy.embedding_model {
1019 if let Some(stored_val) = existing_meta
1020 .properties
1021 .get(EmbeddingModelInfo::property_key())
1022 {
1023 let stored = EmbeddingModelInfo::from_property_value(stored_val);
1024 if stored.name != incoming.name {
1025 warn!(
1026 "ailake: embedding model name changed: table has '{}', writing with '{}' \
1027 (dim={}). Vectors may be incompatible for similarity search.",
1028 stored.name, incoming.name, policy.dim
1029 );
1030 }
1031 }
1032 }
1033 }
1034 Err(_) => {
1035 catalog
1036 .create_table(
1037 &table,
1038 &TableProperties {
1039 partition_column_type: policy.partition_column_type.clone(),
1040 policy: policy.clone(),
1041 extra: std::collections::HashMap::new(),
1042 format_version,
1043 },
1044 )
1045 .await?;
1046 }
1047 }
1048 let parent_snapshot_id = catalog
1049 .load_table(&table)
1050 .await
1051 .ok()
1052 .and_then(|m| m.current_snapshot_id);
1053 let mut writer = Self::new(catalog, store, policy, table);
1054 writer.parent_snapshot_id = parent_snapshot_id;
1055 Ok(writer)
1056 }
1057}
1058
1059fn arrow_schema_to_iceberg_update(
1066 schema: &arrow_schema::Schema,
1067 policy: &VectorStoragePolicy,
1068 extra_vec_policies: &[VectorStoragePolicy],
1069) -> IcebergSchemaUpdate {
1070 let bytes_per_dim = policy.precision.bytes_per_element() as u32;
1071 let vec_fixed_len = policy.dim * bytes_per_dim;
1072
1073 let has_primary_in_batch = schema
1075 .fields()
1076 .iter()
1077 .any(|f| f.name() == &policy.column_name);
1078 let vec_cols: Vec<(String, u32)> = {
1079 let mut v = Vec::new();
1080 if !has_primary_in_batch {
1081 v.push((policy.column_name.clone(), vec_fixed_len));
1082 }
1083 for ep in extra_vec_policies {
1084 let ep_fixed_len = ep.dim * ep.precision.bytes_per_element() as u32;
1085 if !schema.fields().iter().any(|f| f.name() == &ep.column_name) {
1086 v.push((ep.column_name.clone(), ep_fixed_len));
1087 }
1088 }
1089 v
1090 };
1091
1092 let top_level_count = schema.fields().len() + vec_cols.len();
1094 let mut nested_id = top_level_count as i32;
1096
1097 let mut fields: Vec<serde_json::Value> = Vec::new();
1098 let mut name_mapping: Vec<serde_json::Value> = Vec::new();
1099
1100 for (idx, field) in schema.fields().iter().enumerate() {
1101 let field_id = (idx + 1) as i32;
1102 let iceberg_type = arrow_type_to_iceberg(field.data_type(), &mut nested_id);
1103 fields.push(serde_json::json!({
1104 "id": field_id,
1105 "name": field.name(),
1106 "required": false,
1107 "type": iceberg_type,
1108 }));
1109 name_mapping.push(serde_json::json!({
1110 "field-id": field_id,
1111 "names": [field.name()],
1112 }));
1113 }
1114
1115 for (i, (col_name, fixed_len)) in vec_cols.iter().enumerate() {
1117 let field_id = (schema.fields().len() + 1 + i) as i32;
1118 fields.push(serde_json::json!({
1119 "id": field_id,
1120 "name": col_name,
1121 "required": false,
1122 "type": format!("fixed[{fixed_len}]"),
1123 }));
1124 name_mapping.push(serde_json::json!({
1125 "field-id": field_id,
1126 "names": [col_name],
1127 }));
1128 }
1129
1130 let last_column_id = nested_id;
1131 let name_mapping_json = serde_json::to_string(&name_mapping).unwrap_or_else(|_| "[]".into());
1132
1133 IcebergSchemaUpdate {
1134 fields,
1135 last_column_id,
1136 name_mapping_json,
1137 }
1138}
1139
1140fn arrow_type_to_iceberg(dt: &arrow_schema::DataType, nested_id: &mut i32) -> serde_json::Value {
1145 use arrow_schema::DataType;
1146 match dt {
1147 DataType::Boolean => serde_json::json!("boolean"),
1148 DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
1149 serde_json::json!("int")
1150 }
1151 DataType::Int64 | DataType::UInt32 | DataType::UInt64 => serde_json::json!("long"),
1152 DataType::Float16 | DataType::Float32 => serde_json::json!("float"),
1153 DataType::Float64 => serde_json::json!("double"),
1154 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => serde_json::json!("string"),
1155 DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
1156 serde_json::json!("binary")
1157 }
1158 DataType::Date32 | DataType::Date64 => serde_json::json!("date"),
1159 DataType::Timestamp(_, Some(_)) => serde_json::json!("timestamptz"),
1161 DataType::Timestamp(_, None) => serde_json::json!("timestamp"),
1162 DataType::Time32(_) | DataType::Time64(_) => serde_json::json!("time"),
1163 DataType::FixedSizeBinary(n) => serde_json::json!(format!("fixed[{n}]")),
1164 DataType::Decimal128(p, s) | DataType::Decimal256(p, s) => {
1165 serde_json::json!(format!("decimal({p}, {s})"))
1166 }
1167 DataType::List(inner)
1168 | DataType::LargeList(inner)
1169 | DataType::ListView(inner)
1170 | DataType::FixedSizeList(inner, _) => {
1171 *nested_id += 1;
1172 let element_id = *nested_id;
1173 let element_type = arrow_type_to_iceberg(inner.data_type(), nested_id);
1174 serde_json::json!({
1175 "type": "list",
1176 "element-id": element_id,
1177 "element": element_type,
1178 "element-required": !inner.is_nullable(),
1179 })
1180 }
1181 DataType::Struct(arrow_fields) => {
1182 let struct_fields: Vec<serde_json::Value> = arrow_fields
1183 .iter()
1184 .map(|f| {
1185 *nested_id += 1;
1186 let fid = *nested_id;
1187 let ftype = arrow_type_to_iceberg(f.data_type(), nested_id);
1188 serde_json::json!({
1189 "id": fid,
1190 "name": f.name(),
1191 "required": !f.is_nullable(),
1192 "type": ftype,
1193 })
1194 })
1195 .collect();
1196 serde_json::json!({ "type": "struct", "fields": struct_fields })
1197 }
1198 DataType::Map(entries, _) => {
1199 *nested_id += 1;
1201 let key_id = *nested_id;
1202 *nested_id += 1;
1203 let val_id = *nested_id;
1204 if let DataType::Struct(kv_fields) = entries.data_type() {
1205 let key_f = kv_fields
1206 .iter()
1207 .find(|f| f.name() == "key" || f.name() == "keys");
1208 let val_f = kv_fields
1209 .iter()
1210 .find(|f| f.name() == "value" || f.name() == "values");
1211 let key_type = key_f
1212 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1213 .unwrap_or(serde_json::json!("binary"));
1214 let val_type = val_f
1215 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1216 .unwrap_or(serde_json::json!("binary"));
1217 let val_required = val_f.map(|f| !f.is_nullable()).unwrap_or(false);
1218 serde_json::json!({
1219 "type": "map",
1220 "key-id": key_id,
1221 "key": key_type,
1222 "value-id": val_id,
1223 "value": val_type,
1224 "value-required": val_required,
1225 })
1226 } else {
1227 serde_json::json!("binary")
1228 }
1229 }
1230 _ => serde_json::json!("binary"),
1231 }
1232}
1233
1234async fn patch_index_failed(
1239 catalog: Arc<dyn CatalogProvider>,
1240 table: &TableIdent,
1241 file_path: &str,
1242 reason: &str,
1243) {
1244 let Ok(table_meta) = catalog.load_table(table).await else {
1245 return;
1246 };
1247 let parent_snapshot_id = table_meta.current_snapshot_id;
1248 let Ok(mut files) = catalog.list_files(table, None).await else {
1249 return;
1250 };
1251 for f in &mut files {
1252 if f.path == file_path {
1253 f.index_status = IndexStatus::Failed;
1254 f.index_error = Some(reason.to_string());
1255 break;
1256 }
1257 }
1258 let _ = catalog
1259 .commit_snapshot(
1260 table,
1261 NewSnapshot {
1262 snapshot_id: new_snapshot_id(),
1263 parent_snapshot_id,
1264 files,
1265 operation: SnapshotOperation::Replace,
1266 iceberg_schema: None,
1267 extra_properties: std::collections::HashMap::new(),
1268 bloom_filters: vec![],
1269 equality_delete_files: vec![],
1270 },
1271 )
1272 .await
1273 .map_err(|e| {
1274 error!(
1275 "ailake: failed to write IndexStatus::Failed for {file_path}: {e}; \
1276 file will remain Indexing until compaction"
1277 )
1278 });
1279}
1280
1281pub(crate) async fn build_and_patch_index(
1282 store: Arc<dyn Store>,
1283 catalog: Arc<dyn CatalogProvider>,
1284 policy: VectorStoragePolicy,
1285 table: TableIdent,
1286 file_path: String,
1287) -> AilakeResult<()> {
1288 let parquet_bytes = store.get(&file_path).await?;
1290 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1291 let (batch, embeddings) = reader.read_parquet()?;
1292
1293 let full_bytes = tokio::task::spawn_blocking({
1296 let policy = policy.clone();
1297 move || {
1298 let file_writer = AilakeFileWriter::new(policy);
1299 file_writer.write(&batch, &embeddings)
1300 }
1301 })
1302 .await
1303 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1304
1305 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1307 let header = full_reader.read_header()?;
1308 let ailk_start = full_reader.ailk_offset()?;
1309 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1310 let hnsw_len = header.hnsw_len;
1311
1312 full_reader.verify_integrity()?;
1317
1318 store.put(&file_path, full_bytes).await?;
1320
1321 let mut committed = false;
1324 for _ in 0..120u32 {
1325 match catalog.load_table(&table).await {
1326 Ok(meta) if meta.current_snapshot_id.is_some() => {
1327 committed = true;
1328 break;
1329 }
1330 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1331 }
1332 }
1333 if !committed {
1334 return Err(ailake_core::AilakeError::Store(format!(
1335 "deferred HNSW build: no snapshot committed for {file_path} after 60 s — \
1336 did you call TableWriter::commit()?"
1337 )));
1338 }
1339
1340 for attempt in 0..50u32 {
1345 let table_meta = catalog.load_table(&table).await?;
1346 let parent_snapshot_id = table_meta.current_snapshot_id;
1347 let mut files = catalog.list_files(&table, None).await?;
1348
1349 if files
1351 .iter()
1352 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1353 {
1354 break;
1355 }
1356
1357 for f in &mut files {
1358 if f.path == file_path {
1359 f.hnsw_offset = Some(hnsw_abs_offset);
1360 f.hnsw_len = Some(hnsw_len);
1361 f.index_status = IndexStatus::Ready;
1362 break;
1363 }
1364 }
1365 catalog
1366 .commit_snapshot(
1367 &table,
1368 NewSnapshot {
1369 snapshot_id: new_snapshot_id(),
1370 parent_snapshot_id,
1371 files,
1372 operation: SnapshotOperation::Replace,
1373 iceberg_schema: None,
1374 extra_properties: std::collections::HashMap::new(),
1375 bloom_filters: vec![],
1376 equality_delete_files: vec![],
1377 },
1378 )
1379 .await?;
1380
1381 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1383
1384 let verify = catalog.list_files(&table, None).await?;
1385 if verify
1386 .iter()
1387 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1388 {
1389 break;
1390 }
1391 }
1393
1394 info!(
1395 "ailake: deferred HNSW index built for {} (offset={}, len={})",
1396 file_path, hnsw_abs_offset, hnsw_len
1397 );
1398 Ok(())
1399}
1400
1401async fn build_ivf_pq_and_patch_index(
1406 store: Arc<dyn Store>,
1407 catalog: Arc<dyn CatalogProvider>,
1408 policy: VectorStoragePolicy,
1409 table: TableIdent,
1410 file_path: String,
1411 ivf_config: IvfPqConfig,
1412 codebook_cell: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
1413) -> AilakeResult<()> {
1414 let parquet_bytes = store.get(&file_path).await?;
1415 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1416 let (batch, embeddings) = reader.read_parquet()?;
1417
1418 let codebook = codebook_cell
1420 .get_or_try_init(|| async {
1421 let vecs = embeddings.clone();
1422 let metric = policy.metric;
1423 let cfg = ivf_config.clone();
1424 tokio::task::spawn_blocking(move || {
1425 ailake_index::IvfPqIndex::train_codebook(&vecs, metric, &cfg)
1426 })
1427 .await
1428 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))?
1429 })
1430 .await?;
1431
1432 let full_bytes = tokio::task::spawn_blocking({
1433 let policy = policy.clone();
1434 let codebook = codebook.clone();
1435 move || {
1436 let file_writer = AilakeFileWriter::new(policy)
1437 .with_index_type(IndexType::IvfPq(ivf_config))
1438 .with_shared_ivf_codebook(Arc::new(codebook));
1439 file_writer.write(&batch, &embeddings)
1440 }
1441 })
1442 .await
1443 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1444
1445 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1446 let header = full_reader.read_header()?;
1447 let ailk_start = full_reader.ailk_offset()?;
1448 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1449 let hnsw_len = header.hnsw_len;
1450
1451 store.put(&file_path, full_bytes).await?;
1452
1453 let mut committed = false;
1455 for _ in 0..120u32 {
1456 match catalog.load_table(&table).await {
1457 Ok(meta) if meta.current_snapshot_id.is_some() => {
1458 committed = true;
1459 break;
1460 }
1461 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1462 }
1463 }
1464 if !committed {
1465 return Err(ailake_core::AilakeError::Store(format!(
1466 "deferred IVF-PQ build: no snapshot committed for {file_path} after 60 s — \
1467 did you call TableWriter::commit()?"
1468 )));
1469 }
1470
1471 for attempt in 0..50u32 {
1472 let table_meta = catalog.load_table(&table).await?;
1473 let parent_snapshot_id = table_meta.current_snapshot_id;
1474 let mut files = catalog.list_files(&table, None).await?;
1475
1476 if files
1477 .iter()
1478 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1479 {
1480 break;
1481 }
1482
1483 for f in &mut files {
1484 if f.path == file_path {
1485 f.hnsw_offset = Some(hnsw_abs_offset);
1486 f.hnsw_len = Some(hnsw_len);
1487 f.index_status = IndexStatus::Ready;
1488 break;
1489 }
1490 }
1491 catalog
1492 .commit_snapshot(
1493 &table,
1494 NewSnapshot {
1495 snapshot_id: new_snapshot_id(),
1496 parent_snapshot_id,
1497 files,
1498 operation: SnapshotOperation::Replace,
1499 iceberg_schema: None,
1500 extra_properties: std::collections::HashMap::new(),
1501 bloom_filters: vec![],
1502 equality_delete_files: vec![],
1503 },
1504 )
1505 .await?;
1506
1507 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1508
1509 let verify = catalog.list_files(&table, None).await?;
1510 if verify
1511 .iter()
1512 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1513 {
1514 break;
1515 }
1516 }
1517
1518 info!(
1519 "ailake: deferred IVF-PQ index built for {} (offset={}, len={})",
1520 file_path, hnsw_abs_offset, hnsw_len
1521 );
1522 Ok(())
1523}
1524
1525async fn build_and_patch_multi_index(
1531 store: Arc<dyn Store>,
1532 catalog: Arc<dyn CatalogProvider>,
1533 policies: Vec<VectorStoragePolicy>,
1534 table: TableIdent,
1535 file_path: String,
1536 all_embeddings: Vec<Vec<Vec<f32>>>,
1537) -> AilakeResult<()> {
1538 let parquet_bytes = store.get(&file_path).await?;
1540 let primary_reader =
1541 AilakeFileReader::new(parquet_bytes, &policies[0].column_name, policies[0].dim);
1542 let (batch, _) = primary_reader.read_parquet()?;
1543
1544 let full_bytes = tokio::task::spawn_blocking({
1546 let policies = policies.clone();
1547 let all_embeddings = all_embeddings.clone();
1548 move || {
1549 let col_batches: Vec<VectorColumnBatch<'_>> = policies
1550 .iter()
1551 .zip(all_embeddings.iter())
1552 .map(|(p, embs)| VectorColumnBatch {
1553 policy: p,
1554 embeddings: embs.as_slice(),
1555 })
1556 .collect();
1557 let file_writer = AilakeFileWriter::new(policies[0].clone());
1558 file_writer.write_multi(&batch, &col_batches)
1559 }
1560 })
1561 .await
1562 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1563
1564 let primary_reader = AilakeFileReader::new(
1566 full_bytes.clone(),
1567 &policies[0].column_name,
1568 policies[0].dim,
1569 );
1570 let primary_header = primary_reader.read_header()?;
1571 let primary_ailk_start = primary_reader.ailk_offset()?;
1572 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
1573 let primary_hnsw_len = primary_header.hnsw_len;
1574
1575 let mut extra_offsets: Vec<(u64, u64)> = Vec::with_capacity(policies.len().saturating_sub(1));
1580 for col_policy in policies.iter().skip(1) {
1581 let col_reader =
1582 AilakeFileReader::new(full_bytes.clone(), &col_policy.column_name, col_policy.dim);
1583 let col_ailk_start = col_reader.ailk_offset_for_column(&col_policy.column_name)?;
1584 let col_header = col_reader.read_header_for_column(&col_policy.column_name)?;
1585 extra_offsets.push((col_ailk_start + col_header.hnsw_offset, col_header.hnsw_len));
1586 }
1587
1588 store.put(&file_path, full_bytes).await?;
1590
1591 let mut committed = false;
1593 for _ in 0..120u32 {
1594 match catalog.load_table(&table).await {
1595 Ok(meta) if meta.current_snapshot_id.is_some() => {
1596 committed = true;
1597 break;
1598 }
1599 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1600 }
1601 }
1602 if !committed {
1603 return Err(ailake_core::AilakeError::Store(format!(
1604 "deferred index build: no snapshot committed for {file_path} after 60 s — \
1605 did you call TableWriter::commit()?"
1606 )));
1607 }
1608
1609 for attempt in 0..50u32 {
1611 let table_meta = catalog.load_table(&table).await?;
1612 let parent_snapshot_id = table_meta.current_snapshot_id;
1613 let mut files = catalog.list_files(&table, None).await?;
1614
1615 if files
1616 .iter()
1617 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1618 {
1619 break;
1620 }
1621
1622 for f in &mut files {
1623 if f.path == file_path {
1624 f.hnsw_offset = Some(primary_hnsw_abs);
1625 f.hnsw_len = Some(primary_hnsw_len);
1626 f.index_status = IndexStatus::Ready;
1627 for (i, &(off, len)) in extra_offsets.iter().enumerate() {
1628 if let Some(xi) = f.extra_vector_indexes.get_mut(i) {
1629 xi.hnsw_offset = off;
1630 xi.hnsw_len = len;
1631 }
1632 }
1633 break;
1634 }
1635 }
1636 catalog
1637 .commit_snapshot(
1638 &table,
1639 NewSnapshot {
1640 snapshot_id: new_snapshot_id(),
1641 parent_snapshot_id,
1642 files,
1643 operation: SnapshotOperation::Replace,
1644 iceberg_schema: None,
1645 extra_properties: std::collections::HashMap::new(),
1646 bloom_filters: vec![],
1647 equality_delete_files: vec![],
1648 },
1649 )
1650 .await?;
1651
1652 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1653
1654 let verify = catalog.list_files(&table, None).await?;
1655 if verify
1656 .iter()
1657 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1658 {
1659 break;
1660 }
1661 }
1662
1663 info!(
1664 "ailake: deferred multi-column HNSW built for {} ({} cols, primary offset={})",
1665 file_path,
1666 policies.len(),
1667 primary_hnsw_abs
1668 );
1669 Ok(())
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674 use super::*;
1675 use ailake_core::{VectorMetric, VectorPrecision};
1676 use arrow_schema::{DataType, Field, Schema, TimeUnit};
1677
1678 fn policy(col: &str, dim: u32) -> VectorStoragePolicy {
1679 VectorStoragePolicy {
1680 column_name: col.to_string(),
1681 dim,
1682 metric: VectorMetric::Cosine,
1683 precision: VectorPrecision::F16,
1684 pq: None,
1685 keep_raw_for_reranking: true,
1686 pre_normalize: false,
1687 hnsw_m: None,
1688 hnsw_ef_construction: None,
1689 ivf_residual: false,
1690 embedding_model: None,
1691 modality: None,
1692 partition_by: None,
1693 partition_value: None,
1694 partition_column_type: None,
1695 partition_fields: vec![],
1696 }
1697 }
1698
1699 fn update_for(schema: &Schema, pol: &VectorStoragePolicy) -> IcebergSchemaUpdate {
1700 arrow_schema_to_iceberg_update(schema, pol, &[])
1701 }
1702
1703 #[test]
1704 fn simple_schema_produces_correct_fields() {
1705 let schema = Schema::new(vec![
1706 Field::new("id", DataType::Int32, false),
1707 Field::new("text", DataType::Utf8, false),
1708 ]);
1709 let pol = policy("embedding", 8);
1710 let upd = update_for(&schema, &pol);
1711
1712 assert_eq!(upd.fields.len(), 3);
1713 assert_eq!(upd.fields[0]["id"], 1);
1714 assert_eq!(upd.fields[0]["type"], "int");
1715 assert_eq!(upd.fields[1]["id"], 2);
1716 assert_eq!(upd.fields[1]["type"], "string");
1717 assert_eq!(upd.fields[2]["id"], 3);
1718 assert_eq!(upd.fields[2]["type"], "fixed[16]"); let nm: Vec<serde_json::Value> = serde_json::from_str(&upd.name_mapping_json).unwrap();
1721 assert_eq!(nm.len(), 3);
1722 assert_eq!(nm[2]["field-id"], 3);
1723 assert_eq!(nm[2]["names"][0], "embedding");
1724 assert_eq!(upd.last_column_id, 3);
1725 }
1726
1727 #[test]
1728 fn timestamp_without_tz_maps_to_timestamp_not_timestamptz() {
1729 let schema = Schema::new(vec![
1730 Field::new(
1731 "created_at",
1732 DataType::Timestamp(TimeUnit::Microsecond, None),
1733 true,
1734 ),
1735 Field::new(
1736 "updated_at",
1737 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
1738 true,
1739 ),
1740 ]);
1741 let pol = policy("vec", 4);
1742 let upd = update_for(&schema, &pol);
1743
1744 assert_eq!(upd.fields[0]["type"], "timestamp");
1745 assert_eq!(upd.fields[1]["type"], "timestamptz");
1746 }
1747
1748 #[test]
1749 fn list_type_produces_iceberg_list_object() {
1750 let schema = Schema::new(vec![Field::new(
1751 "tags",
1752 DataType::List(std::sync::Arc::new(Field::new(
1753 "item",
1754 DataType::Utf8,
1755 true,
1756 ))),
1757 true,
1758 )]);
1759 let pol = policy("vec", 4);
1760 let upd = update_for(&schema, &pol);
1761
1762 let t = &upd.fields[0]["type"];
1763 assert_eq!(t["type"], "list");
1764 assert_eq!(t["element"], "string");
1765 assert!(t["element-id"].as_i64().unwrap() > 2);
1767 }
1768
1769 #[test]
1770 fn struct_type_produces_nested_fields() {
1771 let schema = Schema::new(vec![Field::new(
1772 "meta",
1773 DataType::Struct(
1774 vec![
1775 Field::new("key", DataType::Utf8, false),
1776 Field::new("val", DataType::Int64, false),
1777 ]
1778 .into(),
1779 ),
1780 true,
1781 )]);
1782 let pol = policy("vec", 4);
1783 let upd = update_for(&schema, &pol);
1784
1785 let t = &upd.fields[0]["type"];
1786 assert_eq!(t["type"], "struct");
1787 let nested = t["fields"].as_array().unwrap();
1788 assert_eq!(nested.len(), 2);
1789 assert_eq!(nested[0]["name"], "key");
1790 assert_eq!(nested[0]["type"], "string");
1791 assert_eq!(nested[1]["name"], "val");
1792 assert_eq!(nested[1]["type"], "long");
1793 assert!(nested[0]["id"].as_i64().unwrap() > 2);
1795 }
1796
1797 #[test]
1798 fn no_duplicate_vec_column_when_already_in_batch() {
1799 let schema = Schema::new(vec![
1801 Field::new("id", DataType::Int32, false),
1802 Field::new("embedding", DataType::FixedSizeBinary(16), false),
1803 ]);
1804 let pol = policy("embedding", 8);
1805 let upd = update_for(&schema, &pol);
1806
1807 assert_eq!(upd.fields.len(), 2, "should not add embedding twice");
1808 let names: Vec<&str> = upd
1809 .fields
1810 .iter()
1811 .map(|f| f["name"].as_str().unwrap())
1812 .collect();
1813 assert_eq!(names.iter().filter(|&&n| n == "embedding").count(), 1);
1814 }
1815
1816 #[test]
1817 fn multi_vec_policies_all_appended() {
1818 let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
1819 let primary = policy("embedding", 4);
1820 let extra = vec![policy("context_embedding", 4)];
1821 let upd = arrow_schema_to_iceberg_update(&schema, &primary, &extra);
1822
1823 assert_eq!(upd.fields.len(), 3); let names: Vec<&str> = upd
1825 .fields
1826 .iter()
1827 .map(|f| f["name"].as_str().unwrap())
1828 .collect();
1829 assert!(names.contains(&"embedding"));
1830 assert!(names.contains(&"context_embedding"));
1831 }
1832
1833 #[test]
1834 fn top_level_field_ids_match_parquet_stamp_sequence() {
1835 let schema = Schema::new(vec![
1837 Field::new("id", DataType::Int64, false),
1838 Field::new(
1839 "tags",
1840 DataType::List(std::sync::Arc::new(Field::new(
1841 "item",
1842 DataType::Utf8,
1843 true,
1844 ))),
1845 true,
1846 ),
1847 ]);
1848 let pol = policy("vec", 4);
1849 let upd = update_for(&schema, &pol);
1850
1851 assert_eq!(upd.fields[0]["id"], 1);
1853 assert_eq!(upd.fields[1]["id"], 2);
1854 assert_eq!(upd.fields[2]["id"], 3);
1855
1856 assert!(upd.fields[1]["type"]["element-id"].as_i64().unwrap() > 3);
1858 }
1859
1860 #[test]
1865 fn merge_schema_accumulates_columns_across_batches() {
1866 let first = Arc::new(Schema::new(vec![
1867 Field::new("id", DataType::Int32, false),
1868 Field::new("text", DataType::Utf8, false),
1869 ]));
1870 let second = Arc::new(Schema::new(vec![
1871 Field::new("id", DataType::Int32, false),
1872 Field::new("text", DataType::Utf8, false),
1873 Field::new("author", DataType::Utf8, true),
1874 ]));
1875
1876 let merged = merge_schema(None, &first);
1877 assert_eq!(merged.fields().len(), 2);
1878 let merged = merge_schema(Some(merged), &second);
1879
1880 assert_eq!(
1881 merged.fields().len(),
1882 3,
1883 "author from the second batch must survive"
1884 );
1885 assert!(merged.field_with_name("id").is_ok());
1886 assert!(merged.field_with_name("text").is_ok());
1887 assert!(merged.field_with_name("author").is_ok());
1888
1889 let third = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1891 let merged = merge_schema(Some(merged), &third);
1892 assert_eq!(
1893 merged.fields().len(),
1894 3,
1895 "shrinking batch must not drop prior columns"
1896 );
1897 }
1898
1899 #[tokio::test]
1902 async fn write_batch_auto_deferred_stages_file() {
1903 use ailake_catalog::{HadoopCatalog, 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 pol = policy("embedding", 4);
1912 let ident = TableIdent::new("default", "t");
1913
1914 let mut writer = TableWriter::create_or_open(catalog, store, 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",
1924 ]))],
1925 )
1926 .unwrap();
1927 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
1928
1929 writer
1930 .write_batch_auto_deferred(&batch, &embeddings)
1931 .await
1932 .unwrap();
1933
1934 assert_eq!(writer.pending_files.len(), 1);
1936 }
1937
1938 #[tokio::test]
1941 async fn write_batch_multi_deferred_stages_file_with_extra_indexes() {
1942 use ailake_catalog::{HadoopCatalog, IndexStatus, TableIdent};
1943 use ailake_store::LocalStore;
1944 use arrow_schema::{DataType, Field, Schema};
1945
1946 let dir = tempfile::tempdir().unwrap();
1947 let store: std::sync::Arc<dyn ailake_store::Store> =
1948 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1949 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1950 let primary_pol = policy("embedding", 4);
1951 let ident = TableIdent::new("default", "t");
1952
1953 let mut writer = TableWriter::create_or_open(catalog, store, primary_pol, ident, 2)
1954 .await
1955 .unwrap();
1956
1957 let schema =
1958 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1959 let batch = arrow_array::RecordBatch::try_new(
1960 schema,
1961 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1962 "hello", "world",
1963 ]))],
1964 )
1965 .unwrap();
1966
1967 let text_embs = vec![vec![1.0f32, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
1968 let img_embs = vec![vec![1.0f32, 0.0], vec![0.0, 1.0]];
1969
1970 let columns = vec![
1971 MultiVectorBatch {
1972 policy: policy("embedding", 4),
1973 embeddings: &text_embs,
1974 },
1975 MultiVectorBatch {
1976 policy: policy("img_embedding", 2),
1977 embeddings: &img_embs,
1978 },
1979 ];
1980
1981 writer
1982 .write_batch_multi_deferred(&batch, &columns)
1983 .await
1984 .unwrap();
1985
1986 assert_eq!(writer.pending_files.len(), 1);
1987 let entry = &writer.pending_files[0];
1988 assert_eq!(entry.index_status, IndexStatus::Indexing);
1990 assert!(entry.centroid_b64.is_some());
1992 assert_eq!(entry.extra_vector_indexes.len(), 1);
1994 let xi = &entry.extra_vector_indexes[0];
1995 assert_eq!(xi.column, "img_embedding");
1996 assert_eq!(xi.dim, 2);
1997 assert_eq!(xi.hnsw_offset, 0); assert_eq!(xi.hnsw_len, 0); assert!(xi.centroid_b64.is_some());
2000 }
2001
2002 #[tokio::test]
2009 async fn part_paths_unique_across_sessions_after_compaction() {
2010 use ailake_catalog::{
2011 new_snapshot_id, HadoopCatalog, NewSnapshot, SnapshotOperation, TableIdent,
2012 };
2013 use ailake_store::LocalStore;
2014 use arrow_schema::{DataType, Field, Schema};
2015
2016 let dir = tempfile::tempdir().unwrap();
2017 let store: std::sync::Arc<dyn ailake_store::Store> =
2018 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
2019 let catalog: std::sync::Arc<dyn CatalogProvider> =
2020 std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
2021 let ident = TableIdent::new("default", "t");
2022
2023 let schema =
2024 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
2025 let batch = arrow_array::RecordBatch::try_new(
2026 schema,
2027 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
2028 "hello",
2029 ]))],
2030 )
2031 .unwrap();
2032 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
2033
2034 let mut w1 = TableWriter::create_or_open(
2036 catalog.clone(),
2037 store.clone(),
2038 policy("embedding", 4),
2039 ident.clone(),
2040 2,
2041 )
2042 .await
2043 .unwrap();
2044 w1.write_batch(&batch, &embeddings).await.unwrap();
2045 w1.write_batch(&batch, &embeddings).await.unwrap();
2046 w1.commit().await.unwrap();
2047 let session1_paths: Vec<String> = catalog
2048 .list_files(&ident, None)
2049 .await
2050 .unwrap()
2051 .iter()
2052 .map(|f| f.path.clone())
2053 .collect();
2054 assert_eq!(session1_paths.len(), 2);
2055
2056 let mut merged = catalog.list_files(&ident, None).await.unwrap()[0].clone();
2059 merged.path = "data/compacted-test.parquet".to_string();
2060 store
2061 .put(&merged.path, store.get(&session1_paths[0]).await.unwrap())
2062 .await
2063 .unwrap();
2064 let parent = catalog
2065 .load_table(&ident)
2066 .await
2067 .unwrap()
2068 .current_snapshot_id;
2069 catalog
2070 .commit_snapshot(
2071 &ident,
2072 NewSnapshot {
2073 snapshot_id: new_snapshot_id(),
2074 parent_snapshot_id: parent,
2075 files: vec![merged],
2076 operation: SnapshotOperation::Replace,
2077 iceberg_schema: None,
2078 extra_properties: std::collections::HashMap::new(),
2079 bloom_filters: vec![],
2080 equality_delete_files: vec![],
2081 },
2082 )
2083 .await
2084 .unwrap();
2085
2086 let mut w2 = TableWriter::create_or_open(
2088 catalog.clone(),
2089 store.clone(),
2090 policy("embedding", 4),
2091 ident.clone(),
2092 2,
2093 )
2094 .await
2095 .unwrap();
2096 w2.write_batch(&batch, &embeddings).await.unwrap();
2097 w2.commit().await.unwrap();
2098
2099 let final_paths: Vec<String> = catalog
2100 .list_files(&ident, None)
2101 .await
2102 .unwrap()
2103 .iter()
2104 .map(|f| f.path.clone())
2105 .collect();
2106 let new_part = final_paths
2107 .iter()
2108 .find(|p| p.starts_with("data/part-"))
2109 .expect("session 2 must have committed a part file");
2110 assert!(
2111 !session1_paths.contains(new_part),
2112 "session 2 reused a retired part name: {new_part} (session 1 wrote {session1_paths:?})"
2113 );
2114 }
2115}