1use std::sync::atomic::{AtomicU32, Ordering};
3use std::sync::Arc;
4
5use ailake_catalog::{
6 encode_centroid_b64, extract_column_stats, 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
81fn column_stats_json(file_bytes: &Bytes, skip_columns: &[&str]) -> Option<String> {
87 extract_column_stats(file_bytes, skip_columns).and_then(|m| serde_json::to_string(&m).ok())
88}
89
90pub struct MultiVectorBatch<'a> {
92 pub policy: VectorStoragePolicy,
93 pub embeddings: &'a [Vec<f32>],
94}
95
96pub struct TableWriter {
97 catalog: Arc<dyn CatalogProvider>,
98 store: Arc<dyn Store>,
99 policy: VectorStoragePolicy,
100 table: TableIdent,
101 part_counter: Arc<AtomicU32>,
102 session_ts: u128,
112 pending_files: Vec<DataFileEntry>,
113 parent_snapshot_id: Option<SnapshotId>,
114 captured_schema: Option<SchemaRef>,
117 extra_vec_policies: Vec<VectorStoragePolicy>,
119 cached_ivf_codebook: Option<Arc<IvfPqCodebook>>,
122 deferred_ivf_codebook: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
125 bm25_text_column: Option<String>,
129 pending_blooms: Vec<(String, Vec<u8>)>,
132 fts_config: Option<ailake_fts::FtsConfig>,
134}
135
136impl TableWriter {
137 pub fn new(
138 catalog: Arc<dyn CatalogProvider>,
139 store: Arc<dyn Store>,
140 policy: VectorStoragePolicy,
141 table: TableIdent,
142 ) -> Self {
143 Self {
144 catalog,
145 store,
146 policy,
147 table,
148 part_counter: Arc::new(AtomicU32::new(0)),
149 session_ts: std::time::SystemTime::now()
150 .duration_since(std::time::UNIX_EPOCH)
151 .unwrap_or_else(|e| e.duration())
152 .as_millis(),
153 pending_files: Vec::new(),
154 parent_snapshot_id: None,
155 captured_schema: None,
156 extra_vec_policies: Vec::new(),
157 cached_ivf_codebook: None,
158 deferred_ivf_codebook: Arc::new(tokio::sync::OnceCell::new()),
159 bm25_text_column: None,
160 pending_blooms: Vec::new(),
161 fts_config: None,
162 }
163 }
164
165 pub fn with_bm25(mut self, text_column: impl Into<String>) -> Self {
173 self.bm25_text_column = Some(text_column.into());
174 self
175 }
176
177 pub fn with_fts_config(mut self, cfg: ailake_fts::FtsConfig) -> Self {
182 self.fts_config = Some(cfg);
183 self
184 }
185
186 pub fn with_parent_snapshot(mut self, id: SnapshotId) -> Self {
187 self.parent_snapshot_id = Some(id);
188 self
189 }
190
191 pub async fn write_batch_deferred(
201 &mut self,
202 batch: &RecordBatch,
203 embeddings: &[Vec<f32>],
204 ) -> AilakeResult<()> {
205 self.ensure_deferred_supported()?;
206 self.validate_embedding_dim(embeddings)?;
207 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
208 let file_path = self.next_part_path();
209
210 let file_writer = AilakeFileWriter::new(self.policy.clone());
212 let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
213 let file_size = parquet_bytes.len() as u64;
214 let column_stats = column_stats_json(&parquet_bytes, &[self.policy.column_name.as_str()]);
215 self.store.put(&file_path, parquet_bytes).await?;
216
217 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
219 let mut entry = make_data_file_entry_indexing(
220 &file_path,
221 embeddings.len() as u64,
222 file_size,
223 ¢roid,
224 &self.policy.column_name,
225 self.policy.dim,
226 );
227 entry.embedding_model = self
228 .policy
229 .embedding_model
230 .as_ref()
231 .map(|m| m.to_property_value());
232 entry.partition_value =
233 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
234 entry.column_stats = column_stats;
235 self.pending_files.push(entry);
236
237 let store = self.store.clone();
239 let catalog = self.catalog.clone();
240 let policy = self.policy.clone();
241 let table = self.table.clone();
242 let fp = file_path.clone();
243 tokio::spawn(async move {
244 if let Err(e) = build_and_patch_index(
245 store.clone(),
246 catalog.clone(),
247 policy,
248 table.clone(),
249 fp.clone(),
250 )
251 .await
252 {
253 error!(
254 "ailake: deferred HNSW build failed for {fp}: {e}; \
255 marking IndexStatus::Failed — compaction will rebuild"
256 );
257 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
258 }
259 });
260
261 if self.bm25_text_column.is_some() {
263 self.update_bm25_stats_from_batch(batch).await?;
264 self.build_bloom_for_file(batch, &file_path);
265 }
266
267 Ok(())
268 }
269
270 pub async fn write_batch_ivf_pq_deferred(
276 &mut self,
277 batch: &RecordBatch,
278 embeddings: &[Vec<f32>],
279 ivf_config: IvfPqConfig,
280 ) -> AilakeResult<()> {
281 self.ensure_deferred_supported()?;
282 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
283 let file_path = self.next_part_path();
284
285 let file_writer = AilakeFileWriter::new(self.policy.clone());
286 let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
287 let file_size = parquet_bytes.len() as u64;
288 let column_stats = column_stats_json(&parquet_bytes, &[self.policy.column_name.as_str()]);
289 self.store.put(&file_path, parquet_bytes).await?;
290
291 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
292 let mut entry = make_data_file_entry_indexing(
293 &file_path,
294 embeddings.len() as u64,
295 file_size,
296 ¢roid,
297 &self.policy.column_name,
298 self.policy.dim,
299 );
300 entry.embedding_model = self
301 .policy
302 .embedding_model
303 .as_ref()
304 .map(|m| m.to_property_value());
305 entry.partition_value =
306 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
307 entry.column_stats = column_stats;
308 self.pending_files.push(entry);
309
310 let store = self.store.clone();
311 let catalog = self.catalog.clone();
312 let policy = self.policy.clone();
313 let table = self.table.clone();
314 let fp = file_path.clone();
315 let codebook_cell = self.deferred_ivf_codebook.clone();
316 tokio::spawn(async move {
317 if let Err(e) = build_ivf_pq_and_patch_index(
318 store.clone(),
319 catalog.clone(),
320 policy,
321 table.clone(),
322 fp.clone(),
323 ivf_config,
324 codebook_cell,
325 )
326 .await
327 {
328 error!(
329 "ailake: deferred IVF-PQ build failed for {fp}: {e}; \
330 marking IndexStatus::Failed — compaction will rebuild"
331 );
332 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
333 }
334 });
335
336 Ok(())
337 }
338
339 pub async fn write_batch_idempotent(
354 &mut self,
355 batch: &RecordBatch,
356 embeddings: &[Vec<f32>],
357 batch_id: &str,
358 ) -> AilakeResult<()> {
359 let existing = self.catalog.list_files(&self.table, None).await?;
363 if existing
364 .iter()
365 .any(|f| f.batch_ids().iter().any(|id| id == batch_id))
366 {
367 return Ok(());
368 }
369 self.write_batch_with_id(batch, embeddings, Some(batch_id.to_string()))
370 .await
371 }
372
373 fn validate_embedding_dim(&self, embeddings: &[Vec<f32>]) -> AilakeResult<()> {
378 Self::validate_embedding_dim_for_policy(embeddings, &self.policy)
379 }
380
381 fn next_part_path(&self) -> String {
387 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
388 format!("data/part-{}-{:05}.parquet", self.session_ts, part_num)
389 }
390
391 fn ensure_deferred_supported(&self) -> AilakeResult<()> {
398 if self.catalog.supports_in_place_rewrite() {
399 Ok(())
400 } else {
401 Err(AilakeError::Catalog(
402 "deferred writes are not supported with this catalog backend: the \
403 background index build patches the data file in place at its committed \
404 path, which this catalog cannot re-register — use a blocking write"
405 .into(),
406 ))
407 }
408 }
409
410 fn validate_embedding_dim_for_policy(
411 embeddings: &[Vec<f32>],
412 policy: &VectorStoragePolicy,
413 ) -> AilakeResult<()> {
414 for emb in embeddings {
415 let actual = emb.len() as u32;
416 if actual != policy.dim {
417 let table_model = policy
418 .embedding_model
419 .as_ref()
420 .map(|m| m.to_property_value())
421 .unwrap_or_else(|| format!("dim={}", policy.dim));
422 return Err(AilakeError::ModelMismatch {
423 table_model,
424 table_dim: policy.dim,
425 batch_model: format!("dim={}", actual),
426 batch_dim: actual,
427 });
428 }
429 }
430 Ok(())
431 }
432
433 pub async fn write_batch(
434 &mut self,
435 batch: &RecordBatch,
436 embeddings: &[Vec<f32>],
437 ) -> AilakeResult<()> {
438 self.write_batch_with_id(batch, embeddings, None).await
439 }
440
441 async fn write_batch_with_id(
442 &mut self,
443 batch: &RecordBatch,
444 embeddings: &[Vec<f32>],
445 batch_id: Option<String>,
446 ) -> AilakeResult<()> {
447 self.validate_embedding_dim(embeddings)?;
448 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
449 let file_path = self.next_part_path();
450
451 let mut file_writer = AilakeFileWriter::new(self.policy.clone());
453 if let Some(ref fts_cfg) = self.fts_config {
454 file_writer = file_writer.with_fts(fts_cfg.clone());
455 }
456 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
457 let file_size = file_bytes.len() as u64;
458 let column_stats = column_stats_json(&file_bytes, &[self.policy.column_name.as_str()]);
459
460 self.store.put(&file_path, file_bytes.clone()).await?;
462
463 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
465
466 let reader = ailake_file::AilakeFileReader::new(
468 file_bytes,
469 &self.policy.column_name,
470 self.policy.dim,
471 );
472 let header = reader.read_header()?;
473 let ailk_start = reader.ailk_offset()?;
474 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
475 let hnsw_len = header.hnsw_len;
476
477 let mut entry = make_data_file_entry(
478 &file_path,
479 embeddings.len() as u64,
480 file_size,
481 ¢roid,
482 VectorIndexInfo {
483 column: &self.policy.column_name,
484 dim: self.policy.dim,
485 hnsw_offset: hnsw_abs_offset,
486 hnsw_len,
487 },
488 );
489 entry.batch_id = batch_id;
490 entry.embedding_model = self
491 .policy
492 .embedding_model
493 .as_ref()
494 .map(|m| m.to_property_value());
495 entry.partition_value =
496 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
497 entry.column_stats = column_stats;
498 self.pending_files.push(entry);
499
500 if self.bm25_text_column.is_some() {
502 self.update_bm25_stats_from_batch(batch).await?;
503 self.build_bloom_for_file(batch, &file_path);
504 }
505 Ok(())
506 }
507
508 pub async fn write_batch_auto(
514 &mut self,
515 batch: &RecordBatch,
516 embeddings: &[Vec<f32>],
517 ) -> AilakeResult<()> {
518 let profile = ailake_index::HardwareProfile::detect();
519 if profile.recommend_ivf_pq(embeddings.len()) {
520 let mut ivf_config =
521 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
522 if self.policy.ivf_residual {
523 ivf_config = ivf_config.with_residual();
524 }
525 self.write_batch_ivf_pq(batch, embeddings, ivf_config).await
526 } else {
527 self.write_batch(batch, embeddings).await
528 }
529 }
530
531 pub async fn write_batch_auto_deferred(
542 &mut self,
543 batch: &RecordBatch,
544 embeddings: &[Vec<f32>],
545 ) -> AilakeResult<()> {
546 let profile = ailake_index::HardwareProfile::detect();
547 if profile.recommend_ivf_pq(embeddings.len()) {
548 let mut ivf_config =
549 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
550 if self.policy.ivf_residual {
551 ivf_config = ivf_config.with_residual();
552 }
553 self.write_batch_ivf_pq_deferred(batch, embeddings, ivf_config)
554 .await
555 } else {
556 self.write_batch_deferred(batch, embeddings).await
557 }
558 }
559
560 pub async fn write_batch_ivf_pq(
564 &mut self,
565 batch: &RecordBatch,
566 embeddings: &[Vec<f32>],
567 ivf_config: IvfPqConfig,
568 ) -> AilakeResult<()> {
569 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
570 let file_path = self.next_part_path();
571
572 if self.cached_ivf_codebook.is_none() {
576 let codebook = tokio::task::spawn_blocking({
577 let embeddings = embeddings.to_vec();
578 let metric = self.policy.metric;
579 let config = ivf_config.clone();
580 move || ailake_index::IvfPqIndex::train_codebook(&embeddings, metric, &config)
581 })
582 .await
583 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
584 self.cached_ivf_codebook = Some(Arc::new(codebook));
585 }
586 let codebook = self
588 .cached_ivf_codebook
589 .as_ref()
590 .expect("IVF-PQ codebook must be Some after training block")
591 .clone();
592
593 let file_writer = AilakeFileWriter::new(self.policy.clone())
594 .with_index_type(IndexType::IvfPq(ivf_config))
595 .with_shared_ivf_codebook(codebook);
596 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
597 let file_size = file_bytes.len() as u64;
598 let column_stats = column_stats_json(&file_bytes, &[self.policy.column_name.as_str()]);
599
600 self.store.put(&file_path, file_bytes.clone()).await?;
601
602 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
603
604 let reader = ailake_file::AilakeFileReader::new(
605 file_bytes,
606 &self.policy.column_name,
607 self.policy.dim,
608 );
609 let header = reader.read_header()?;
610 let ailk_start = reader.ailk_offset()?;
611 let index_abs_offset = ailk_start + header.hnsw_offset;
612 let index_len = header.hnsw_len;
613
614 let mut entry = make_data_file_entry(
615 &file_path,
616 embeddings.len() as u64,
617 file_size,
618 ¢roid,
619 VectorIndexInfo {
620 column: &self.policy.column_name,
621 dim: self.policy.dim,
622 hnsw_offset: index_abs_offset,
623 hnsw_len: index_len,
624 },
625 );
626 entry.embedding_model = self
627 .policy
628 .embedding_model
629 .as_ref()
630 .map(|m| m.to_property_value());
631 entry.partition_value =
632 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
633 entry.column_stats = column_stats;
634 self.pending_files.push(entry);
635 Ok(())
636 }
637
638 pub async fn write_batch_multi(
643 &mut self,
644 batch: &RecordBatch,
645 columns: &[MultiVectorBatch<'_>],
646 ) -> AilakeResult<()> {
647 use ailake_core::AilakeError;
648 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
649 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
650 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
651 }
652
653 if columns.is_empty() {
654 return Err(AilakeError::InvalidArgument(
655 "write_batch_multi requires at least one column".into(),
656 ));
657 }
658
659 for col in columns {
660 Self::validate_embedding_dim_for_policy(col.embeddings, &col.policy)?;
661 }
662
663 let file_path = self.next_part_path();
664
665 let col_batches: Vec<VectorColumnBatch<'_>> = columns
666 .iter()
667 .map(|c| VectorColumnBatch {
668 policy: &c.policy,
669 embeddings: c.embeddings,
670 })
671 .collect();
672
673 let primary_policy = &columns[0].policy;
674 let mut file_writer = AilakeFileWriter::new(primary_policy.clone());
675 if let Some(ref fts_cfg) = self.fts_config {
676 file_writer = file_writer.with_fts(fts_cfg.clone());
677 }
678 let file_bytes: Bytes = file_writer.write_multi(batch, &col_batches)?;
679 let file_size = file_bytes.len() as u64;
680 let vector_col_names: Vec<&str> = columns
681 .iter()
682 .map(|c| c.policy.column_name.as_str())
683 .collect();
684 let column_stats = column_stats_json(&file_bytes, &vector_col_names);
685
686 self.store.put(&file_path, file_bytes.clone()).await?;
687
688 let primary_centroid =
690 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
691
692 let reader = ailake_file::AilakeFileReader::new(
694 file_bytes.clone(),
695 &primary_policy.column_name,
696 primary_policy.dim,
697 );
698 let primary_ailk_start = reader.ailk_offset()?;
699 let primary_header = {
700 use ailake_file::HEADER_SIZE;
701 let start = primary_ailk_start as usize;
702 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
703 .try_into()
704 .map_err(|_| AilakeError::NotAnAilakeFile)?;
705 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
706 };
707 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
708
709 let mut extra: Vec<ExtraVectorIndex> = Vec::new();
711 for col in columns.iter().skip(1) {
712 let col_ailk_start = reader.ailk_offset_for_column(&col.policy.column_name)?;
713 let col_header = {
714 use ailake_file::HEADER_SIZE;
715 let start = col_ailk_start as usize;
716 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
717 .try_into()
718 .map_err(|_| AilakeError::NotAnAilakeFile)?;
719 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
720 };
721 let col_centroid = compute_centroid_and_radius(col.embeddings, col.policy.metric);
722 extra.push(ExtraVectorIndex {
723 column: col.policy.column_name.clone(),
724 dim: col.policy.dim,
725 hnsw_offset: col_ailk_start + col_header.hnsw_offset,
726 hnsw_len: col_header.hnsw_len,
727 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
728 radius: Some(col_centroid.radius),
729 });
730 }
731
732 let mut entry = make_multi_column_data_file_entry(
733 &file_path,
734 columns[0].embeddings.len() as u64,
735 file_size,
736 &primary_centroid,
737 VectorIndexInfo {
738 column: &primary_policy.column_name,
739 dim: primary_policy.dim,
740 hnsw_offset: primary_hnsw_abs,
741 hnsw_len: primary_header.hnsw_len,
742 },
743 &extra,
744 );
745 entry.embedding_model = self
746 .policy
747 .embedding_model
748 .as_ref()
749 .map(|m| m.to_property_value());
750 entry.partition_value =
751 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
752 entry.column_stats = column_stats;
753 self.pending_files.push(entry);
754 Ok(())
755 }
756
757 pub async fn write_batch_multi_deferred(
770 &mut self,
771 batch: &RecordBatch,
772 columns: &[MultiVectorBatch<'_>],
773 ) -> AilakeResult<()> {
774 use ailake_core::AilakeError;
775 self.ensure_deferred_supported()?;
776 if columns.is_empty() {
777 return Err(AilakeError::InvalidArgument(
778 "write_batch_multi_deferred requires at least one column".into(),
779 ));
780 }
781 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
782 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
783 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
784 }
785
786 let file_path = self.next_part_path();
787
788 let primary_policy = &columns[0].policy;
790 let file_writer = AilakeFileWriter::new(primary_policy.clone());
791 let parquet_bytes = file_writer.write_parquet_only(batch, columns[0].embeddings)?;
792 let file_size = parquet_bytes.len() as u64;
793 let column_stats =
799 column_stats_json(&parquet_bytes, &[primary_policy.column_name.as_str()]);
800 self.store.put(&file_path, parquet_bytes).await?;
801
802 let primary_centroid =
804 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
805 let mut entry = make_data_file_entry_indexing(
806 &file_path,
807 columns[0].embeddings.len() as u64,
808 file_size,
809 &primary_centroid,
810 &primary_policy.column_name,
811 primary_policy.dim,
812 );
813 entry.extra_vector_indexes = columns[1..]
816 .iter()
817 .map(|c| {
818 let col_centroid = compute_centroid_and_radius(c.embeddings, c.policy.metric);
819 ExtraVectorIndex {
820 column: c.policy.column_name.clone(),
821 dim: c.policy.dim,
822 hnsw_offset: 0,
823 hnsw_len: 0,
824 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
825 radius: Some(col_centroid.radius),
826 }
827 })
828 .collect();
829 entry.embedding_model = self
830 .policy
831 .embedding_model
832 .as_ref()
833 .map(|m| m.to_property_value());
834 entry.partition_value =
835 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
836 entry.column_stats = column_stats;
837 self.pending_files.push(entry);
838
839 let all_policies: Vec<VectorStoragePolicy> =
841 columns.iter().map(|c| c.policy.clone()).collect();
842 let all_embeddings: Vec<Vec<Vec<f32>>> =
843 columns.iter().map(|c| c.embeddings.to_vec()).collect();
844 let store = self.store.clone();
845 let catalog = self.catalog.clone();
846 let table = self.table.clone();
847 let fp = file_path.clone();
848 tokio::spawn(async move {
849 if let Err(e) = build_and_patch_multi_index(
850 store,
851 catalog.clone(),
852 all_policies,
853 table.clone(),
854 fp.clone(),
855 all_embeddings,
856 )
857 .await
858 {
859 error!(
860 "ailake: deferred multi-column HNSW build failed for {fp}: {e}; \
861 marking IndexStatus::Failed — compaction will rebuild"
862 );
863 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
864 }
865 });
866
867 Ok(())
868 }
869
870 fn build_bloom_for_file(&mut self, batch: &RecordBatch, file_path: &str) {
879 use arrow_array::cast::AsArray;
880 let col_name = match &self.bm25_text_column {
881 Some(c) => c.clone(),
882 None => return,
883 };
884 let col = match batch.column_by_name(&col_name) {
885 Some(c) => c,
886 None => return,
887 };
888 let str_arr = match col.as_string_opt::<i32>() {
889 Some(a) => a,
890 None => return,
891 };
892 let cap = (batch.num_rows() * 10).max(128);
894 let mut bloom = crate::bloom::BloomFilter::with_capacity(cap, 0.01);
895 for i in 0..str_arr.len() {
896 if str_arr.is_valid(i) {
897 for term in crate::bm25::tokenize(str_arr.value(i)) {
898 bloom.insert(&term);
899 }
900 }
901 }
902 self.pending_blooms
903 .push((file_path.to_string(), bloom.to_bytes()));
904 }
905
906 async fn update_bm25_stats_from_batch(&self, batch: &RecordBatch) -> AilakeResult<()> {
912 use arrow_array::cast::AsArray;
913
914 let col_name = match &self.bm25_text_column {
915 Some(c) => c.as_str(),
916 None => return Ok(()),
917 };
918 let col = match batch.column_by_name(col_name) {
919 Some(c) => c,
920 None => {
921 tracing::warn!(
922 "ailake: BM25 text column '{}' not found in batch — skipping IDF update",
923 col_name
924 );
925 return Ok(());
926 }
927 };
928 let str_arr = match col.as_string_opt::<i32>() {
929 Some(a) => a,
930 None => {
931 tracing::warn!(
932 "ailake: BM25 text column '{}' is not a Utf8 column — skipping",
933 col_name
934 );
935 return Ok(());
936 }
937 };
938
939 let texts: Vec<&str> = (0..str_arr.len())
940 .filter(|&i| str_arr.is_valid(i))
941 .map(|i| str_arr.value(i))
942 .collect();
943
944 let stats_path = crate::bm25::BM25_STATS_FILE;
946 let mut stats: crate::bm25::IdfStats = match self.store.get(stats_path).await {
947 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
948 Err(_) => crate::bm25::IdfStats::default(),
949 };
950
951 stats.merge_batch(&texts);
952
953 let bytes = stats.to_bytes()?;
954 self.store
955 .put(stats_path, bytes::Bytes::from(bytes))
956 .await?;
957 Ok(())
958 }
959
960 pub async fn commit(mut self) -> AilakeResult<SnapshotId> {
961 if self.pending_files.is_empty() {
962 let current = self
963 .catalog
964 .load_table(&self.table)
965 .await
966 .ok()
967 .and_then(|m| m.current_snapshot_id)
968 .unwrap_or(0);
969 return Ok(current);
970 }
971 let iceberg_schema = self
972 .captured_schema
973 .as_deref()
974 .map(|s| arrow_schema_to_iceberg_update(s, &self.policy, &self.extra_vec_policies));
975 let mut extra_properties = std::collections::HashMap::new();
978 if let Some(ref fts_cfg) = self.fts_config {
979 extra_properties.insert("ailake.fts.enabled".to_string(), "true".to_string());
980 extra_properties.insert(
981 "ailake.fts.text-columns".to_string(),
982 fts_cfg.text_columns.join(","),
983 );
984 extra_properties.insert(
985 "ailake.fts.tokenizer".to_string(),
986 fts_cfg.tokenizer.clone(),
987 );
988 }
989 for ep in &self.extra_vec_policies {
990 extra_properties.insert(format!("ailake.dim-{}", ep.column_name), ep.dim.to_string());
991 extra_properties.insert(
992 format!("ailake.metric-{}", ep.column_name),
993 ailake_parquet::schema::metric_str(ep.metric).to_string(),
994 );
995 if let Some(modality) = ep.modality {
996 extra_properties.insert(
997 format!("ailake.modality-{}", ep.column_name),
998 modality.as_str().to_string(),
999 );
1000 }
1001 }
1002 let snapshot = NewSnapshot {
1003 snapshot_id: new_snapshot_id(),
1004 parent_snapshot_id: self.parent_snapshot_id,
1005 files: std::mem::take(&mut self.pending_files),
1006 operation: SnapshotOperation::Append,
1007 iceberg_schema,
1008 extra_properties,
1009 bloom_filters: std::mem::take(&mut self.pending_blooms),
1010 equality_delete_files: vec![],
1011 };
1012 self.catalog.commit_snapshot(&self.table, snapshot).await
1013 }
1014
1015 pub async fn create_or_open(
1017 catalog: Arc<dyn CatalogProvider>,
1018 store: Arc<dyn Store>,
1019 policy: VectorStoragePolicy,
1020 table: TableIdent,
1021 format_version: u8,
1022 ) -> AilakeResult<Self> {
1023 match catalog.load_table(&table).await {
1028 Ok(existing_meta) => {
1029 if let Some(stored_dim_str) = existing_meta.properties.get("ailake.vector-dim") {
1033 if let Ok(stored_dim) = stored_dim_str.parse::<u32>() {
1034 if stored_dim != policy.dim {
1035 let table_model = policy
1036 .embedding_model
1037 .as_ref()
1038 .map(|m| m.to_property_value())
1039 .unwrap_or_else(|| format!("dim={}", stored_dim));
1040 return Err(AilakeError::ModelMismatch {
1041 table_model,
1042 table_dim: stored_dim,
1043 batch_model: format!("dim={}", policy.dim),
1044 batch_dim: policy.dim,
1045 });
1046 }
1047 }
1048 }
1049 if let Some(incoming) = &policy.embedding_model {
1053 if let Some(stored_val) = existing_meta
1054 .properties
1055 .get(EmbeddingModelInfo::property_key())
1056 {
1057 let stored = EmbeddingModelInfo::from_property_value(stored_val);
1058 if stored.name != incoming.name {
1059 warn!(
1060 "ailake: embedding model name changed: table has '{}', writing with '{}' \
1061 (dim={}). Vectors may be incompatible for similarity search.",
1062 stored.name, incoming.name, policy.dim
1063 );
1064 }
1065 }
1066 }
1067 }
1068 Err(_) => {
1069 catalog
1070 .create_table(
1071 &table,
1072 &TableProperties {
1073 partition_column_type: policy.partition_column_type.clone(),
1074 policy: policy.clone(),
1075 extra: std::collections::HashMap::new(),
1076 format_version,
1077 },
1078 )
1079 .await?;
1080 }
1081 }
1082 let parent_snapshot_id = catalog
1083 .load_table(&table)
1084 .await
1085 .ok()
1086 .and_then(|m| m.current_snapshot_id);
1087 let mut writer = Self::new(catalog, store, policy, table);
1088 writer.parent_snapshot_id = parent_snapshot_id;
1089 Ok(writer)
1090 }
1091}
1092
1093fn arrow_schema_to_iceberg_update(
1100 schema: &arrow_schema::Schema,
1101 policy: &VectorStoragePolicy,
1102 extra_vec_policies: &[VectorStoragePolicy],
1103) -> IcebergSchemaUpdate {
1104 let bytes_per_dim = policy.precision.bytes_per_element() as u32;
1105 let vec_fixed_len = policy.dim * bytes_per_dim;
1106
1107 let has_primary_in_batch = schema
1109 .fields()
1110 .iter()
1111 .any(|f| f.name() == &policy.column_name);
1112 let vec_cols: Vec<(String, u32)> = {
1113 let mut v = Vec::new();
1114 if !has_primary_in_batch {
1115 v.push((policy.column_name.clone(), vec_fixed_len));
1116 }
1117 for ep in extra_vec_policies {
1118 let ep_fixed_len = ep.dim * ep.precision.bytes_per_element() as u32;
1119 if !schema.fields().iter().any(|f| f.name() == &ep.column_name) {
1120 v.push((ep.column_name.clone(), ep_fixed_len));
1121 }
1122 }
1123 v
1124 };
1125
1126 let top_level_count = schema.fields().len() + vec_cols.len();
1128 let mut nested_id = top_level_count as i32;
1130
1131 let mut fields: Vec<serde_json::Value> = Vec::new();
1132 let mut name_mapping: Vec<serde_json::Value> = Vec::new();
1133
1134 for (idx, field) in schema.fields().iter().enumerate() {
1135 let field_id = (idx + 1) as i32;
1136 let iceberg_type = arrow_type_to_iceberg(field.data_type(), &mut nested_id);
1137 fields.push(serde_json::json!({
1138 "id": field_id,
1139 "name": field.name(),
1140 "required": false,
1141 "type": iceberg_type,
1142 }));
1143 name_mapping.push(serde_json::json!({
1144 "field-id": field_id,
1145 "names": [field.name()],
1146 }));
1147 }
1148
1149 for (i, (col_name, fixed_len)) in vec_cols.iter().enumerate() {
1151 let field_id = (schema.fields().len() + 1 + i) as i32;
1152 fields.push(serde_json::json!({
1153 "id": field_id,
1154 "name": col_name,
1155 "required": false,
1156 "type": format!("fixed[{fixed_len}]"),
1157 }));
1158 name_mapping.push(serde_json::json!({
1159 "field-id": field_id,
1160 "names": [col_name],
1161 }));
1162 }
1163
1164 let last_column_id = nested_id;
1165 let name_mapping_json = serde_json::to_string(&name_mapping).unwrap_or_else(|_| "[]".into());
1166
1167 IcebergSchemaUpdate {
1168 fields,
1169 last_column_id,
1170 name_mapping_json,
1171 }
1172}
1173
1174fn arrow_type_to_iceberg(dt: &arrow_schema::DataType, nested_id: &mut i32) -> serde_json::Value {
1179 use arrow_schema::DataType;
1180 match dt {
1181 DataType::Boolean => serde_json::json!("boolean"),
1182 DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
1183 serde_json::json!("int")
1184 }
1185 DataType::Int64 | DataType::UInt32 | DataType::UInt64 => serde_json::json!("long"),
1186 DataType::Float16 | DataType::Float32 => serde_json::json!("float"),
1187 DataType::Float64 => serde_json::json!("double"),
1188 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => serde_json::json!("string"),
1189 DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
1190 serde_json::json!("binary")
1191 }
1192 DataType::Date32 | DataType::Date64 => serde_json::json!("date"),
1193 DataType::Timestamp(_, Some(_)) => serde_json::json!("timestamptz"),
1195 DataType::Timestamp(_, None) => serde_json::json!("timestamp"),
1196 DataType::Time32(_) | DataType::Time64(_) => serde_json::json!("time"),
1197 DataType::FixedSizeBinary(n) => serde_json::json!(format!("fixed[{n}]")),
1198 DataType::Decimal128(p, s) | DataType::Decimal256(p, s) => {
1199 serde_json::json!(format!("decimal({p}, {s})"))
1200 }
1201 DataType::List(inner)
1202 | DataType::LargeList(inner)
1203 | DataType::ListView(inner)
1204 | DataType::FixedSizeList(inner, _) => {
1205 *nested_id += 1;
1206 let element_id = *nested_id;
1207 let element_type = arrow_type_to_iceberg(inner.data_type(), nested_id);
1208 serde_json::json!({
1209 "type": "list",
1210 "element-id": element_id,
1211 "element": element_type,
1212 "element-required": !inner.is_nullable(),
1213 })
1214 }
1215 DataType::Struct(arrow_fields) => {
1216 let struct_fields: Vec<serde_json::Value> = arrow_fields
1217 .iter()
1218 .map(|f| {
1219 *nested_id += 1;
1220 let fid = *nested_id;
1221 let ftype = arrow_type_to_iceberg(f.data_type(), nested_id);
1222 serde_json::json!({
1223 "id": fid,
1224 "name": f.name(),
1225 "required": !f.is_nullable(),
1226 "type": ftype,
1227 })
1228 })
1229 .collect();
1230 serde_json::json!({ "type": "struct", "fields": struct_fields })
1231 }
1232 DataType::Map(entries, _) => {
1233 *nested_id += 1;
1235 let key_id = *nested_id;
1236 *nested_id += 1;
1237 let val_id = *nested_id;
1238 if let DataType::Struct(kv_fields) = entries.data_type() {
1239 let key_f = kv_fields
1240 .iter()
1241 .find(|f| f.name() == "key" || f.name() == "keys");
1242 let val_f = kv_fields
1243 .iter()
1244 .find(|f| f.name() == "value" || f.name() == "values");
1245 let key_type = key_f
1246 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1247 .unwrap_or(serde_json::json!("binary"));
1248 let val_type = val_f
1249 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1250 .unwrap_or(serde_json::json!("binary"));
1251 let val_required = val_f.map(|f| !f.is_nullable()).unwrap_or(false);
1252 serde_json::json!({
1253 "type": "map",
1254 "key-id": key_id,
1255 "key": key_type,
1256 "value-id": val_id,
1257 "value": val_type,
1258 "value-required": val_required,
1259 })
1260 } else {
1261 serde_json::json!("binary")
1262 }
1263 }
1264 _ => serde_json::json!("binary"),
1265 }
1266}
1267
1268async fn patch_index_failed(
1273 catalog: Arc<dyn CatalogProvider>,
1274 table: &TableIdent,
1275 file_path: &str,
1276 reason: &str,
1277) {
1278 let Ok(table_meta) = catalog.load_table(table).await else {
1279 return;
1280 };
1281 let parent_snapshot_id = table_meta.current_snapshot_id;
1282 let Ok(mut files) = catalog.list_files(table, None).await else {
1283 return;
1284 };
1285 for f in &mut files {
1286 if f.path == file_path {
1287 f.index_status = IndexStatus::Failed;
1288 f.index_error = Some(reason.to_string());
1289 break;
1290 }
1291 }
1292 let _ = catalog
1293 .commit_snapshot(
1294 table,
1295 NewSnapshot {
1296 snapshot_id: new_snapshot_id(),
1297 parent_snapshot_id,
1298 files,
1299 operation: SnapshotOperation::Replace,
1300 iceberg_schema: None,
1301 extra_properties: std::collections::HashMap::new(),
1302 bloom_filters: vec![],
1303 equality_delete_files: vec![],
1304 },
1305 )
1306 .await
1307 .map_err(|e| {
1308 error!(
1309 "ailake: failed to write IndexStatus::Failed for {file_path}: {e}; \
1310 file will remain Indexing until compaction"
1311 )
1312 });
1313}
1314
1315pub(crate) async fn build_and_patch_index(
1316 store: Arc<dyn Store>,
1317 catalog: Arc<dyn CatalogProvider>,
1318 policy: VectorStoragePolicy,
1319 table: TableIdent,
1320 file_path: String,
1321) -> AilakeResult<()> {
1322 let parquet_bytes = store.get(&file_path).await?;
1324 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1325 let (batch, embeddings) = reader.read_parquet()?;
1326
1327 let full_bytes = tokio::task::spawn_blocking({
1330 let policy = policy.clone();
1331 move || {
1332 let file_writer = AilakeFileWriter::new(policy);
1333 file_writer.write(&batch, &embeddings)
1334 }
1335 })
1336 .await
1337 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1338
1339 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1341 let header = full_reader.read_header()?;
1342 let ailk_start = full_reader.ailk_offset()?;
1343 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1344 let hnsw_len = header.hnsw_len;
1345
1346 full_reader.verify_integrity()?;
1351
1352 store.put(&file_path, full_bytes).await?;
1354
1355 let mut committed = false;
1358 for _ in 0..120u32 {
1359 match catalog.load_table(&table).await {
1360 Ok(meta) if meta.current_snapshot_id.is_some() => {
1361 committed = true;
1362 break;
1363 }
1364 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1365 }
1366 }
1367 if !committed {
1368 return Err(ailake_core::AilakeError::Store(format!(
1369 "deferred HNSW build: no snapshot committed for {file_path} after 60 s — \
1370 did you call TableWriter::commit()?"
1371 )));
1372 }
1373
1374 for attempt in 0..50u32 {
1379 let table_meta = catalog.load_table(&table).await?;
1380 let parent_snapshot_id = table_meta.current_snapshot_id;
1381 let mut files = catalog.list_files(&table, None).await?;
1382
1383 if files
1385 .iter()
1386 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1387 {
1388 break;
1389 }
1390
1391 for f in &mut files {
1392 if f.path == file_path {
1393 f.hnsw_offset = Some(hnsw_abs_offset);
1394 f.hnsw_len = Some(hnsw_len);
1395 f.index_status = IndexStatus::Ready;
1396 break;
1397 }
1398 }
1399 catalog
1400 .commit_snapshot(
1401 &table,
1402 NewSnapshot {
1403 snapshot_id: new_snapshot_id(),
1404 parent_snapshot_id,
1405 files,
1406 operation: SnapshotOperation::Replace,
1407 iceberg_schema: None,
1408 extra_properties: std::collections::HashMap::new(),
1409 bloom_filters: vec![],
1410 equality_delete_files: vec![],
1411 },
1412 )
1413 .await?;
1414
1415 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1417
1418 let verify = catalog.list_files(&table, None).await?;
1419 if verify
1420 .iter()
1421 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1422 {
1423 break;
1424 }
1425 }
1427
1428 info!(
1429 "ailake: deferred HNSW index built for {} (offset={}, len={})",
1430 file_path, hnsw_abs_offset, hnsw_len
1431 );
1432 Ok(())
1433}
1434
1435async fn build_ivf_pq_and_patch_index(
1440 store: Arc<dyn Store>,
1441 catalog: Arc<dyn CatalogProvider>,
1442 policy: VectorStoragePolicy,
1443 table: TableIdent,
1444 file_path: String,
1445 ivf_config: IvfPqConfig,
1446 codebook_cell: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
1447) -> AilakeResult<()> {
1448 let parquet_bytes = store.get(&file_path).await?;
1449 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1450 let (batch, embeddings) = reader.read_parquet()?;
1451
1452 let codebook = codebook_cell
1454 .get_or_try_init(|| async {
1455 let vecs = embeddings.clone();
1456 let metric = policy.metric;
1457 let cfg = ivf_config.clone();
1458 tokio::task::spawn_blocking(move || {
1459 ailake_index::IvfPqIndex::train_codebook(&vecs, metric, &cfg)
1460 })
1461 .await
1462 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))?
1463 })
1464 .await?;
1465
1466 let full_bytes = tokio::task::spawn_blocking({
1467 let policy = policy.clone();
1468 let codebook = codebook.clone();
1469 move || {
1470 let file_writer = AilakeFileWriter::new(policy)
1471 .with_index_type(IndexType::IvfPq(ivf_config))
1472 .with_shared_ivf_codebook(Arc::new(codebook));
1473 file_writer.write(&batch, &embeddings)
1474 }
1475 })
1476 .await
1477 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1478
1479 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1480 let header = full_reader.read_header()?;
1481 let ailk_start = full_reader.ailk_offset()?;
1482 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1483 let hnsw_len = header.hnsw_len;
1484
1485 store.put(&file_path, full_bytes).await?;
1486
1487 let mut committed = false;
1489 for _ in 0..120u32 {
1490 match catalog.load_table(&table).await {
1491 Ok(meta) if meta.current_snapshot_id.is_some() => {
1492 committed = true;
1493 break;
1494 }
1495 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1496 }
1497 }
1498 if !committed {
1499 return Err(ailake_core::AilakeError::Store(format!(
1500 "deferred IVF-PQ build: no snapshot committed for {file_path} after 60 s — \
1501 did you call TableWriter::commit()?"
1502 )));
1503 }
1504
1505 for attempt in 0..50u32 {
1506 let table_meta = catalog.load_table(&table).await?;
1507 let parent_snapshot_id = table_meta.current_snapshot_id;
1508 let mut files = catalog.list_files(&table, None).await?;
1509
1510 if files
1511 .iter()
1512 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1513 {
1514 break;
1515 }
1516
1517 for f in &mut files {
1518 if f.path == file_path {
1519 f.hnsw_offset = Some(hnsw_abs_offset);
1520 f.hnsw_len = Some(hnsw_len);
1521 f.index_status = IndexStatus::Ready;
1522 break;
1523 }
1524 }
1525 catalog
1526 .commit_snapshot(
1527 &table,
1528 NewSnapshot {
1529 snapshot_id: new_snapshot_id(),
1530 parent_snapshot_id,
1531 files,
1532 operation: SnapshotOperation::Replace,
1533 iceberg_schema: None,
1534 extra_properties: std::collections::HashMap::new(),
1535 bloom_filters: vec![],
1536 equality_delete_files: vec![],
1537 },
1538 )
1539 .await?;
1540
1541 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1542
1543 let verify = catalog.list_files(&table, None).await?;
1544 if verify
1545 .iter()
1546 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1547 {
1548 break;
1549 }
1550 }
1551
1552 info!(
1553 "ailake: deferred IVF-PQ index built for {} (offset={}, len={})",
1554 file_path, hnsw_abs_offset, hnsw_len
1555 );
1556 Ok(())
1557}
1558
1559async fn build_and_patch_multi_index(
1565 store: Arc<dyn Store>,
1566 catalog: Arc<dyn CatalogProvider>,
1567 policies: Vec<VectorStoragePolicy>,
1568 table: TableIdent,
1569 file_path: String,
1570 all_embeddings: Vec<Vec<Vec<f32>>>,
1571) -> AilakeResult<()> {
1572 let parquet_bytes = store.get(&file_path).await?;
1574 let primary_reader =
1575 AilakeFileReader::new(parquet_bytes, &policies[0].column_name, policies[0].dim);
1576 let (batch, _) = primary_reader.read_parquet()?;
1577
1578 let full_bytes = tokio::task::spawn_blocking({
1580 let policies = policies.clone();
1581 let all_embeddings = all_embeddings.clone();
1582 move || {
1583 let col_batches: Vec<VectorColumnBatch<'_>> = policies
1584 .iter()
1585 .zip(all_embeddings.iter())
1586 .map(|(p, embs)| VectorColumnBatch {
1587 policy: p,
1588 embeddings: embs.as_slice(),
1589 })
1590 .collect();
1591 let file_writer = AilakeFileWriter::new(policies[0].clone());
1592 file_writer.write_multi(&batch, &col_batches)
1593 }
1594 })
1595 .await
1596 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1597
1598 let primary_reader = AilakeFileReader::new(
1600 full_bytes.clone(),
1601 &policies[0].column_name,
1602 policies[0].dim,
1603 );
1604 let primary_header = primary_reader.read_header()?;
1605 let primary_ailk_start = primary_reader.ailk_offset()?;
1606 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
1607 let primary_hnsw_len = primary_header.hnsw_len;
1608
1609 let mut extra_offsets: Vec<(u64, u64)> = Vec::with_capacity(policies.len().saturating_sub(1));
1614 for col_policy in policies.iter().skip(1) {
1615 let col_reader =
1616 AilakeFileReader::new(full_bytes.clone(), &col_policy.column_name, col_policy.dim);
1617 let col_ailk_start = col_reader.ailk_offset_for_column(&col_policy.column_name)?;
1618 let col_header = col_reader.read_header_for_column(&col_policy.column_name)?;
1619 extra_offsets.push((col_ailk_start + col_header.hnsw_offset, col_header.hnsw_len));
1620 }
1621
1622 store.put(&file_path, full_bytes).await?;
1624
1625 let mut committed = false;
1627 for _ in 0..120u32 {
1628 match catalog.load_table(&table).await {
1629 Ok(meta) if meta.current_snapshot_id.is_some() => {
1630 committed = true;
1631 break;
1632 }
1633 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1634 }
1635 }
1636 if !committed {
1637 return Err(ailake_core::AilakeError::Store(format!(
1638 "deferred index build: no snapshot committed for {file_path} after 60 s — \
1639 did you call TableWriter::commit()?"
1640 )));
1641 }
1642
1643 for attempt in 0..50u32 {
1645 let table_meta = catalog.load_table(&table).await?;
1646 let parent_snapshot_id = table_meta.current_snapshot_id;
1647 let mut files = catalog.list_files(&table, None).await?;
1648
1649 if files
1650 .iter()
1651 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1652 {
1653 break;
1654 }
1655
1656 for f in &mut files {
1657 if f.path == file_path {
1658 f.hnsw_offset = Some(primary_hnsw_abs);
1659 f.hnsw_len = Some(primary_hnsw_len);
1660 f.index_status = IndexStatus::Ready;
1661 for (i, &(off, len)) in extra_offsets.iter().enumerate() {
1662 if let Some(xi) = f.extra_vector_indexes.get_mut(i) {
1663 xi.hnsw_offset = off;
1664 xi.hnsw_len = len;
1665 }
1666 }
1667 break;
1668 }
1669 }
1670 catalog
1671 .commit_snapshot(
1672 &table,
1673 NewSnapshot {
1674 snapshot_id: new_snapshot_id(),
1675 parent_snapshot_id,
1676 files,
1677 operation: SnapshotOperation::Replace,
1678 iceberg_schema: None,
1679 extra_properties: std::collections::HashMap::new(),
1680 bloom_filters: vec![],
1681 equality_delete_files: vec![],
1682 },
1683 )
1684 .await?;
1685
1686 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1687
1688 let verify = catalog.list_files(&table, None).await?;
1689 if verify
1690 .iter()
1691 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1692 {
1693 break;
1694 }
1695 }
1696
1697 info!(
1698 "ailake: deferred multi-column HNSW built for {} ({} cols, primary offset={})",
1699 file_path,
1700 policies.len(),
1701 primary_hnsw_abs
1702 );
1703 Ok(())
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708 use super::*;
1709 use ailake_core::{VectorMetric, VectorPrecision};
1710 use arrow_schema::{DataType, Field, Schema, TimeUnit};
1711
1712 fn policy(col: &str, dim: u32) -> VectorStoragePolicy {
1713 VectorStoragePolicy {
1714 column_name: col.to_string(),
1715 dim,
1716 metric: VectorMetric::Cosine,
1717 precision: VectorPrecision::F16,
1718 pq: None,
1719 keep_raw_for_reranking: true,
1720 pre_normalize: false,
1721 hnsw_m: None,
1722 hnsw_ef_construction: None,
1723 ivf_residual: false,
1724 embedding_model: None,
1725 modality: None,
1726 partition_by: None,
1727 partition_value: None,
1728 partition_column_type: None,
1729 partition_fields: vec![],
1730 }
1731 }
1732
1733 fn update_for(schema: &Schema, pol: &VectorStoragePolicy) -> IcebergSchemaUpdate {
1734 arrow_schema_to_iceberg_update(schema, pol, &[])
1735 }
1736
1737 #[test]
1738 fn simple_schema_produces_correct_fields() {
1739 let schema = Schema::new(vec![
1740 Field::new("id", DataType::Int32, false),
1741 Field::new("text", DataType::Utf8, false),
1742 ]);
1743 let pol = policy("embedding", 8);
1744 let upd = update_for(&schema, &pol);
1745
1746 assert_eq!(upd.fields.len(), 3);
1747 assert_eq!(upd.fields[0]["id"], 1);
1748 assert_eq!(upd.fields[0]["type"], "int");
1749 assert_eq!(upd.fields[1]["id"], 2);
1750 assert_eq!(upd.fields[1]["type"], "string");
1751 assert_eq!(upd.fields[2]["id"], 3);
1752 assert_eq!(upd.fields[2]["type"], "fixed[16]"); let nm: Vec<serde_json::Value> = serde_json::from_str(&upd.name_mapping_json).unwrap();
1755 assert_eq!(nm.len(), 3);
1756 assert_eq!(nm[2]["field-id"], 3);
1757 assert_eq!(nm[2]["names"][0], "embedding");
1758 assert_eq!(upd.last_column_id, 3);
1759 }
1760
1761 #[test]
1762 fn timestamp_without_tz_maps_to_timestamp_not_timestamptz() {
1763 let schema = Schema::new(vec![
1764 Field::new(
1765 "created_at",
1766 DataType::Timestamp(TimeUnit::Microsecond, None),
1767 true,
1768 ),
1769 Field::new(
1770 "updated_at",
1771 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
1772 true,
1773 ),
1774 ]);
1775 let pol = policy("vec", 4);
1776 let upd = update_for(&schema, &pol);
1777
1778 assert_eq!(upd.fields[0]["type"], "timestamp");
1779 assert_eq!(upd.fields[1]["type"], "timestamptz");
1780 }
1781
1782 #[test]
1783 fn list_type_produces_iceberg_list_object() {
1784 let schema = Schema::new(vec![Field::new(
1785 "tags",
1786 DataType::List(std::sync::Arc::new(Field::new(
1787 "item",
1788 DataType::Utf8,
1789 true,
1790 ))),
1791 true,
1792 )]);
1793 let pol = policy("vec", 4);
1794 let upd = update_for(&schema, &pol);
1795
1796 let t = &upd.fields[0]["type"];
1797 assert_eq!(t["type"], "list");
1798 assert_eq!(t["element"], "string");
1799 assert!(t["element-id"].as_i64().unwrap() > 2);
1801 }
1802
1803 #[test]
1804 fn struct_type_produces_nested_fields() {
1805 let schema = Schema::new(vec![Field::new(
1806 "meta",
1807 DataType::Struct(
1808 vec![
1809 Field::new("key", DataType::Utf8, false),
1810 Field::new("val", DataType::Int64, false),
1811 ]
1812 .into(),
1813 ),
1814 true,
1815 )]);
1816 let pol = policy("vec", 4);
1817 let upd = update_for(&schema, &pol);
1818
1819 let t = &upd.fields[0]["type"];
1820 assert_eq!(t["type"], "struct");
1821 let nested = t["fields"].as_array().unwrap();
1822 assert_eq!(nested.len(), 2);
1823 assert_eq!(nested[0]["name"], "key");
1824 assert_eq!(nested[0]["type"], "string");
1825 assert_eq!(nested[1]["name"], "val");
1826 assert_eq!(nested[1]["type"], "long");
1827 assert!(nested[0]["id"].as_i64().unwrap() > 2);
1829 }
1830
1831 #[test]
1832 fn no_duplicate_vec_column_when_already_in_batch() {
1833 let schema = Schema::new(vec![
1835 Field::new("id", DataType::Int32, false),
1836 Field::new("embedding", DataType::FixedSizeBinary(16), false),
1837 ]);
1838 let pol = policy("embedding", 8);
1839 let upd = update_for(&schema, &pol);
1840
1841 assert_eq!(upd.fields.len(), 2, "should not add embedding twice");
1842 let names: Vec<&str> = upd
1843 .fields
1844 .iter()
1845 .map(|f| f["name"].as_str().unwrap())
1846 .collect();
1847 assert_eq!(names.iter().filter(|&&n| n == "embedding").count(), 1);
1848 }
1849
1850 #[test]
1851 fn multi_vec_policies_all_appended() {
1852 let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
1853 let primary = policy("embedding", 4);
1854 let extra = vec![policy("context_embedding", 4)];
1855 let upd = arrow_schema_to_iceberg_update(&schema, &primary, &extra);
1856
1857 assert_eq!(upd.fields.len(), 3); let names: Vec<&str> = upd
1859 .fields
1860 .iter()
1861 .map(|f| f["name"].as_str().unwrap())
1862 .collect();
1863 assert!(names.contains(&"embedding"));
1864 assert!(names.contains(&"context_embedding"));
1865 }
1866
1867 #[test]
1868 fn top_level_field_ids_match_parquet_stamp_sequence() {
1869 let schema = Schema::new(vec![
1871 Field::new("id", DataType::Int64, false),
1872 Field::new(
1873 "tags",
1874 DataType::List(std::sync::Arc::new(Field::new(
1875 "item",
1876 DataType::Utf8,
1877 true,
1878 ))),
1879 true,
1880 ),
1881 ]);
1882 let pol = policy("vec", 4);
1883 let upd = update_for(&schema, &pol);
1884
1885 assert_eq!(upd.fields[0]["id"], 1);
1887 assert_eq!(upd.fields[1]["id"], 2);
1888 assert_eq!(upd.fields[2]["id"], 3);
1889
1890 assert!(upd.fields[1]["type"]["element-id"].as_i64().unwrap() > 3);
1892 }
1893
1894 #[test]
1899 fn merge_schema_accumulates_columns_across_batches() {
1900 let first = Arc::new(Schema::new(vec![
1901 Field::new("id", DataType::Int32, false),
1902 Field::new("text", DataType::Utf8, false),
1903 ]));
1904 let second = Arc::new(Schema::new(vec![
1905 Field::new("id", DataType::Int32, false),
1906 Field::new("text", DataType::Utf8, false),
1907 Field::new("author", DataType::Utf8, true),
1908 ]));
1909
1910 let merged = merge_schema(None, &first);
1911 assert_eq!(merged.fields().len(), 2);
1912 let merged = merge_schema(Some(merged), &second);
1913
1914 assert_eq!(
1915 merged.fields().len(),
1916 3,
1917 "author from the second batch must survive"
1918 );
1919 assert!(merged.field_with_name("id").is_ok());
1920 assert!(merged.field_with_name("text").is_ok());
1921 assert!(merged.field_with_name("author").is_ok());
1922
1923 let third = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1925 let merged = merge_schema(Some(merged), &third);
1926 assert_eq!(
1927 merged.fields().len(),
1928 3,
1929 "shrinking batch must not drop prior columns"
1930 );
1931 }
1932
1933 #[tokio::test]
1936 async fn write_batch_auto_deferred_stages_file() {
1937 use ailake_catalog::{HadoopCatalog, TableIdent};
1938 use ailake_store::LocalStore;
1939 use arrow_schema::{DataType, Field, Schema};
1940
1941 let dir = tempfile::tempdir().unwrap();
1942 let store: std::sync::Arc<dyn ailake_store::Store> =
1943 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1944 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1945 let pol = policy("embedding", 4);
1946 let ident = TableIdent::new("default", "t");
1947
1948 let mut writer = TableWriter::create_or_open(catalog, store, pol, ident, 2)
1949 .await
1950 .unwrap();
1951
1952 let schema =
1953 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1954 let batch = arrow_array::RecordBatch::try_new(
1955 schema,
1956 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1957 "hello",
1958 ]))],
1959 )
1960 .unwrap();
1961 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
1962
1963 writer
1964 .write_batch_auto_deferred(&batch, &embeddings)
1965 .await
1966 .unwrap();
1967
1968 assert_eq!(writer.pending_files.len(), 1);
1970 }
1971
1972 #[tokio::test]
1975 async fn write_batch_multi_deferred_stages_file_with_extra_indexes() {
1976 use ailake_catalog::{HadoopCatalog, IndexStatus, TableIdent};
1977 use ailake_store::LocalStore;
1978 use arrow_schema::{DataType, Field, Schema};
1979
1980 let dir = tempfile::tempdir().unwrap();
1981 let store: std::sync::Arc<dyn ailake_store::Store> =
1982 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1983 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1984 let primary_pol = policy("embedding", 4);
1985 let ident = TableIdent::new("default", "t");
1986
1987 let mut writer = TableWriter::create_or_open(catalog, store, primary_pol, ident, 2)
1988 .await
1989 .unwrap();
1990
1991 let schema =
1992 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1993 let batch = arrow_array::RecordBatch::try_new(
1994 schema,
1995 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1996 "hello", "world",
1997 ]))],
1998 )
1999 .unwrap();
2000
2001 let text_embs = vec![vec![1.0f32, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
2002 let img_embs = vec![vec![1.0f32, 0.0], vec![0.0, 1.0]];
2003
2004 let columns = vec![
2005 MultiVectorBatch {
2006 policy: policy("embedding", 4),
2007 embeddings: &text_embs,
2008 },
2009 MultiVectorBatch {
2010 policy: policy("img_embedding", 2),
2011 embeddings: &img_embs,
2012 },
2013 ];
2014
2015 writer
2016 .write_batch_multi_deferred(&batch, &columns)
2017 .await
2018 .unwrap();
2019
2020 assert_eq!(writer.pending_files.len(), 1);
2021 let entry = &writer.pending_files[0];
2022 assert_eq!(entry.index_status, IndexStatus::Indexing);
2024 assert!(entry.centroid_b64.is_some());
2026 assert_eq!(entry.extra_vector_indexes.len(), 1);
2028 let xi = &entry.extra_vector_indexes[0];
2029 assert_eq!(xi.column, "img_embedding");
2030 assert_eq!(xi.dim, 2);
2031 assert_eq!(xi.hnsw_offset, 0); assert_eq!(xi.hnsw_len, 0); assert!(xi.centroid_b64.is_some());
2034 }
2035
2036 #[tokio::test]
2043 async fn part_paths_unique_across_sessions_after_compaction() {
2044 use ailake_catalog::{
2045 new_snapshot_id, HadoopCatalog, NewSnapshot, SnapshotOperation, TableIdent,
2046 };
2047 use ailake_store::LocalStore;
2048 use arrow_schema::{DataType, Field, Schema};
2049
2050 let dir = tempfile::tempdir().unwrap();
2051 let store: std::sync::Arc<dyn ailake_store::Store> =
2052 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
2053 let catalog: std::sync::Arc<dyn CatalogProvider> =
2054 std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
2055 let ident = TableIdent::new("default", "t");
2056
2057 let schema =
2058 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
2059 let batch = arrow_array::RecordBatch::try_new(
2060 schema,
2061 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
2062 "hello",
2063 ]))],
2064 )
2065 .unwrap();
2066 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
2067
2068 let mut w1 = TableWriter::create_or_open(
2070 catalog.clone(),
2071 store.clone(),
2072 policy("embedding", 4),
2073 ident.clone(),
2074 2,
2075 )
2076 .await
2077 .unwrap();
2078 w1.write_batch(&batch, &embeddings).await.unwrap();
2079 w1.write_batch(&batch, &embeddings).await.unwrap();
2080 w1.commit().await.unwrap();
2081 let session1_paths: Vec<String> = catalog
2082 .list_files(&ident, None)
2083 .await
2084 .unwrap()
2085 .iter()
2086 .map(|f| f.path.clone())
2087 .collect();
2088 assert_eq!(session1_paths.len(), 2);
2089
2090 let mut merged = catalog.list_files(&ident, None).await.unwrap()[0].clone();
2093 merged.path = "data/compacted-test.parquet".to_string();
2094 store
2095 .put(&merged.path, store.get(&session1_paths[0]).await.unwrap())
2096 .await
2097 .unwrap();
2098 let parent = catalog
2099 .load_table(&ident)
2100 .await
2101 .unwrap()
2102 .current_snapshot_id;
2103 catalog
2104 .commit_snapshot(
2105 &ident,
2106 NewSnapshot {
2107 snapshot_id: new_snapshot_id(),
2108 parent_snapshot_id: parent,
2109 files: vec![merged],
2110 operation: SnapshotOperation::Replace,
2111 iceberg_schema: None,
2112 extra_properties: std::collections::HashMap::new(),
2113 bloom_filters: vec![],
2114 equality_delete_files: vec![],
2115 },
2116 )
2117 .await
2118 .unwrap();
2119
2120 let mut w2 = TableWriter::create_or_open(
2122 catalog.clone(),
2123 store.clone(),
2124 policy("embedding", 4),
2125 ident.clone(),
2126 2,
2127 )
2128 .await
2129 .unwrap();
2130 w2.write_batch(&batch, &embeddings).await.unwrap();
2131 w2.commit().await.unwrap();
2132
2133 let final_paths: Vec<String> = catalog
2134 .list_files(&ident, None)
2135 .await
2136 .unwrap()
2137 .iter()
2138 .map(|f| f.path.clone())
2139 .collect();
2140 let new_part = final_paths
2141 .iter()
2142 .find(|p| p.starts_with("data/part-"))
2143 .expect("session 2 must have committed a part file");
2144 assert!(
2145 !session1_paths.contains(new_part),
2146 "session 2 reused a retired part name: {new_part} (session 1 wrote {session1_paths:?})"
2147 );
2148 }
2149
2150 #[tokio::test]
2157 async fn write_batch_idempotent_no_ops_after_source_file_compacted() {
2158 use ailake_catalog::{
2159 new_snapshot_id, DataFileEntry, HadoopCatalog, NewSnapshot, SnapshotOperation,
2160 TableIdent,
2161 };
2162 use ailake_store::LocalStore;
2163 use arrow_schema::{DataType, Field, Schema};
2164
2165 let dir = tempfile::tempdir().unwrap();
2166 let store: std::sync::Arc<dyn ailake_store::Store> =
2167 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
2168 let catalog: std::sync::Arc<dyn CatalogProvider> =
2169 std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
2170 let ident = TableIdent::new("default", "t");
2171
2172 let schema =
2173 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
2174 let batch = arrow_array::RecordBatch::try_new(
2175 schema,
2176 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
2177 "hello",
2178 ]))],
2179 )
2180 .unwrap();
2181 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
2182
2183 let mut w1 = TableWriter::create_or_open(
2185 catalog.clone(),
2186 store.clone(),
2187 policy("embedding", 4),
2188 ident.clone(),
2189 2,
2190 )
2191 .await
2192 .unwrap();
2193 w1.write_batch_idempotent(&batch, &embeddings, "k-a")
2194 .await
2195 .unwrap();
2196 w1.commit().await.unwrap();
2197
2198 let mut w2 = TableWriter::create_or_open(
2199 catalog.clone(),
2200 store.clone(),
2201 policy("embedding", 4),
2202 ident.clone(),
2203 2,
2204 )
2205 .await
2206 .unwrap();
2207 w2.write_batch_idempotent(&batch, &embeddings, "k-b")
2208 .await
2209 .unwrap();
2210 w2.commit().await.unwrap();
2211
2212 let sources = catalog.list_files(&ident, None).await.unwrap();
2213 assert_eq!(sources.len(), 2);
2214
2215 let mut merged = sources[0].clone();
2218 merged.path = "data/compacted-test.parquet".to_string();
2219 merged.record_count = sources.iter().map(|f| f.record_count).sum();
2220 merged.batch_id = DataFileEntry::merge_batch_ids(&sources);
2221 store
2222 .put(&merged.path, store.get(&sources[0].path).await.unwrap())
2223 .await
2224 .unwrap();
2225 let parent = catalog
2226 .load_table(&ident)
2227 .await
2228 .unwrap()
2229 .current_snapshot_id;
2230 catalog
2231 .commit_snapshot(
2232 &ident,
2233 NewSnapshot {
2234 snapshot_id: new_snapshot_id(),
2235 parent_snapshot_id: parent,
2236 files: vec![merged],
2237 operation: SnapshotOperation::Replace,
2238 iceberg_schema: None,
2239 extra_properties: std::collections::HashMap::new(),
2240 bloom_filters: vec![],
2241 equality_delete_files: vec![],
2242 },
2243 )
2244 .await
2245 .unwrap();
2246 assert_eq!(catalog.list_files(&ident, None).await.unwrap().len(), 1);
2247
2248 let mut w3 = TableWriter::create_or_open(
2251 catalog.clone(),
2252 store.clone(),
2253 policy("embedding", 4),
2254 ident.clone(),
2255 2,
2256 )
2257 .await
2258 .unwrap();
2259 w3.write_batch_idempotent(&batch, &embeddings, "k-a")
2260 .await
2261 .unwrap();
2262 assert!(
2263 w3.pending_files.is_empty(),
2264 "retry of an already-compacted batch_id must not stage a new file"
2265 );
2266 w3.commit().await.unwrap();
2267
2268 let final_files = catalog.list_files(&ident, None).await.unwrap();
2269 assert_eq!(
2270 final_files.len(),
2271 1,
2272 "retry after compaction must not re-insert — table must still have exactly 1 file"
2273 );
2274 }
2275}