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 if let Some(bad) = emb.iter().find(|x| !x.is_finite()) {
432 return Err(AilakeError::Catalog(format!(
433 "embedding contains non-finite value ({bad}); NaN/Infinity embeddings are rejected at write time"
434 )));
435 }
436 }
437 Ok(())
438 }
439
440 pub async fn write_batch(
441 &mut self,
442 batch: &RecordBatch,
443 embeddings: &[Vec<f32>],
444 ) -> AilakeResult<()> {
445 self.write_batch_with_id(batch, embeddings, None).await
446 }
447
448 async fn write_batch_with_id(
449 &mut self,
450 batch: &RecordBatch,
451 embeddings: &[Vec<f32>],
452 batch_id: Option<String>,
453 ) -> AilakeResult<()> {
454 self.validate_embedding_dim(embeddings)?;
455 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
456 let file_path = self.next_part_path();
457
458 let mut file_writer = AilakeFileWriter::new(self.policy.clone());
460 if let Some(ref fts_cfg) = self.fts_config {
461 file_writer = file_writer.with_fts(fts_cfg.clone());
462 }
463 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
464 let file_size = file_bytes.len() as u64;
465 let column_stats = column_stats_json(&file_bytes, &[self.policy.column_name.as_str()]);
466
467 self.store.put(&file_path, file_bytes.clone()).await?;
469
470 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
472
473 let reader = ailake_file::AilakeFileReader::new(
475 file_bytes,
476 &self.policy.column_name,
477 self.policy.dim,
478 );
479 let header = reader.read_header()?;
480 let ailk_start = reader.ailk_offset()?;
481 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
482 let hnsw_len = header.hnsw_len;
483
484 let mut entry = make_data_file_entry(
485 &file_path,
486 embeddings.len() as u64,
487 file_size,
488 ¢roid,
489 VectorIndexInfo {
490 column: &self.policy.column_name,
491 dim: self.policy.dim,
492 hnsw_offset: hnsw_abs_offset,
493 hnsw_len,
494 },
495 );
496 entry.batch_id = batch_id;
497 entry.embedding_model = self
498 .policy
499 .embedding_model
500 .as_ref()
501 .map(|m| m.to_property_value());
502 entry.partition_value =
503 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
504 entry.column_stats = column_stats;
505 self.pending_files.push(entry);
506
507 if self.bm25_text_column.is_some() {
509 self.update_bm25_stats_from_batch(batch).await?;
510 self.build_bloom_for_file(batch, &file_path);
511 }
512 Ok(())
513 }
514
515 pub async fn write_batch_auto(
521 &mut self,
522 batch: &RecordBatch,
523 embeddings: &[Vec<f32>],
524 ) -> AilakeResult<()> {
525 let profile = ailake_index::HardwareProfile::detect();
526 if profile.recommend_ivf_pq(embeddings.len()) {
527 let mut ivf_config =
528 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
529 if self.policy.ivf_residual {
530 ivf_config = ivf_config.with_residual();
531 }
532 self.write_batch_ivf_pq(batch, embeddings, ivf_config).await
533 } else {
534 self.write_batch(batch, embeddings).await
535 }
536 }
537
538 pub async fn write_batch_auto_deferred(
549 &mut self,
550 batch: &RecordBatch,
551 embeddings: &[Vec<f32>],
552 ) -> AilakeResult<()> {
553 let profile = ailake_index::HardwareProfile::detect();
554 if profile.recommend_ivf_pq(embeddings.len()) {
555 let mut ivf_config =
556 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
557 if self.policy.ivf_residual {
558 ivf_config = ivf_config.with_residual();
559 }
560 self.write_batch_ivf_pq_deferred(batch, embeddings, ivf_config)
561 .await
562 } else {
563 self.write_batch_deferred(batch, embeddings).await
564 }
565 }
566
567 pub async fn write_batch_ivf_pq(
571 &mut self,
572 batch: &RecordBatch,
573 embeddings: &[Vec<f32>],
574 ivf_config: IvfPqConfig,
575 ) -> AilakeResult<()> {
576 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
577 let file_path = self.next_part_path();
578
579 if self.cached_ivf_codebook.is_none() {
583 let codebook = tokio::task::spawn_blocking({
584 let embeddings = embeddings.to_vec();
585 let metric = self.policy.metric;
586 let config = ivf_config.clone();
587 move || ailake_index::IvfPqIndex::train_codebook(&embeddings, metric, &config)
588 })
589 .await
590 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
591 self.cached_ivf_codebook = Some(Arc::new(codebook));
592 }
593 let codebook = self
595 .cached_ivf_codebook
596 .as_ref()
597 .expect("IVF-PQ codebook must be Some after training block")
598 .clone();
599
600 let file_writer = AilakeFileWriter::new(self.policy.clone())
601 .with_index_type(IndexType::IvfPq(ivf_config))
602 .with_shared_ivf_codebook(codebook);
603 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
604 let file_size = file_bytes.len() as u64;
605 let column_stats = column_stats_json(&file_bytes, &[self.policy.column_name.as_str()]);
606
607 self.store.put(&file_path, file_bytes.clone()).await?;
608
609 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
610
611 let reader = ailake_file::AilakeFileReader::new(
612 file_bytes,
613 &self.policy.column_name,
614 self.policy.dim,
615 );
616 let header = reader.read_header()?;
617 let ailk_start = reader.ailk_offset()?;
618 let index_abs_offset = ailk_start + header.hnsw_offset;
619 let index_len = header.hnsw_len;
620
621 let mut entry = make_data_file_entry(
622 &file_path,
623 embeddings.len() as u64,
624 file_size,
625 ¢roid,
626 VectorIndexInfo {
627 column: &self.policy.column_name,
628 dim: self.policy.dim,
629 hnsw_offset: index_abs_offset,
630 hnsw_len: index_len,
631 },
632 );
633 entry.embedding_model = self
634 .policy
635 .embedding_model
636 .as_ref()
637 .map(|m| m.to_property_value());
638 entry.partition_value =
639 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
640 entry.column_stats = column_stats;
641 self.pending_files.push(entry);
642 Ok(())
643 }
644
645 pub async fn write_batch_multi(
650 &mut self,
651 batch: &RecordBatch,
652 columns: &[MultiVectorBatch<'_>],
653 ) -> AilakeResult<()> {
654 use ailake_core::AilakeError;
655 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
656 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
657 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
658 }
659
660 if columns.is_empty() {
661 return Err(AilakeError::InvalidArgument(
662 "write_batch_multi requires at least one column".into(),
663 ));
664 }
665
666 for col in columns {
667 Self::validate_embedding_dim_for_policy(col.embeddings, &col.policy)?;
668 }
669
670 let file_path = self.next_part_path();
671
672 let col_batches: Vec<VectorColumnBatch<'_>> = columns
673 .iter()
674 .map(|c| VectorColumnBatch {
675 policy: &c.policy,
676 embeddings: c.embeddings,
677 })
678 .collect();
679
680 let primary_policy = &columns[0].policy;
681 let mut file_writer = AilakeFileWriter::new(primary_policy.clone());
682 if let Some(ref fts_cfg) = self.fts_config {
683 file_writer = file_writer.with_fts(fts_cfg.clone());
684 }
685 let file_bytes: Bytes = file_writer.write_multi(batch, &col_batches)?;
686 let file_size = file_bytes.len() as u64;
687 let vector_col_names: Vec<&str> = columns
688 .iter()
689 .map(|c| c.policy.column_name.as_str())
690 .collect();
691 let column_stats = column_stats_json(&file_bytes, &vector_col_names);
692
693 self.store.put(&file_path, file_bytes.clone()).await?;
694
695 let primary_centroid =
697 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
698
699 let reader = ailake_file::AilakeFileReader::new(
701 file_bytes.clone(),
702 &primary_policy.column_name,
703 primary_policy.dim,
704 );
705 let primary_ailk_start = reader.ailk_offset()?;
706 let primary_header = {
707 use ailake_file::HEADER_SIZE;
708 let start = primary_ailk_start as usize;
709 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
710 .try_into()
711 .map_err(|_| AilakeError::NotAnAilakeFile)?;
712 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
713 };
714 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
715
716 let mut extra: Vec<ExtraVectorIndex> = Vec::new();
718 for col in columns.iter().skip(1) {
719 let col_ailk_start = reader.ailk_offset_for_column(&col.policy.column_name)?;
720 let col_header = {
721 use ailake_file::HEADER_SIZE;
722 let start = col_ailk_start as usize;
723 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
724 .try_into()
725 .map_err(|_| AilakeError::NotAnAilakeFile)?;
726 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
727 };
728 let col_centroid = compute_centroid_and_radius(col.embeddings, col.policy.metric);
729 extra.push(ExtraVectorIndex {
730 column: col.policy.column_name.clone(),
731 dim: col.policy.dim,
732 hnsw_offset: col_ailk_start + col_header.hnsw_offset,
733 hnsw_len: col_header.hnsw_len,
734 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
735 radius: Some(col_centroid.radius),
736 });
737 }
738
739 let mut entry = make_multi_column_data_file_entry(
740 &file_path,
741 columns[0].embeddings.len() as u64,
742 file_size,
743 &primary_centroid,
744 VectorIndexInfo {
745 column: &primary_policy.column_name,
746 dim: primary_policy.dim,
747 hnsw_offset: primary_hnsw_abs,
748 hnsw_len: primary_header.hnsw_len,
749 },
750 &extra,
751 );
752 entry.embedding_model = self
753 .policy
754 .embedding_model
755 .as_ref()
756 .map(|m| m.to_property_value());
757 entry.partition_value =
758 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
759 entry.column_stats = column_stats;
760 self.pending_files.push(entry);
761 Ok(())
762 }
763
764 pub async fn write_batch_multi_deferred(
777 &mut self,
778 batch: &RecordBatch,
779 columns: &[MultiVectorBatch<'_>],
780 ) -> AilakeResult<()> {
781 use ailake_core::AilakeError;
782 self.ensure_deferred_supported()?;
783 if columns.is_empty() {
784 return Err(AilakeError::InvalidArgument(
785 "write_batch_multi_deferred requires at least one column".into(),
786 ));
787 }
788 self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
789 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
790 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
791 }
792
793 let file_path = self.next_part_path();
794
795 let primary_policy = &columns[0].policy;
797 let file_writer = AilakeFileWriter::new(primary_policy.clone());
798 let parquet_bytes = file_writer.write_parquet_only(batch, columns[0].embeddings)?;
799 let file_size = parquet_bytes.len() as u64;
800 let column_stats =
806 column_stats_json(&parquet_bytes, &[primary_policy.column_name.as_str()]);
807 self.store.put(&file_path, parquet_bytes).await?;
808
809 let primary_centroid =
811 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
812 let mut entry = make_data_file_entry_indexing(
813 &file_path,
814 columns[0].embeddings.len() as u64,
815 file_size,
816 &primary_centroid,
817 &primary_policy.column_name,
818 primary_policy.dim,
819 );
820 entry.extra_vector_indexes = columns[1..]
823 .iter()
824 .map(|c| {
825 let col_centroid = compute_centroid_and_radius(c.embeddings, c.policy.metric);
826 ExtraVectorIndex {
827 column: c.policy.column_name.clone(),
828 dim: c.policy.dim,
829 hnsw_offset: 0,
830 hnsw_len: 0,
831 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
832 radius: Some(col_centroid.radius),
833 }
834 })
835 .collect();
836 entry.embedding_model = self
837 .policy
838 .embedding_model
839 .as_ref()
840 .map(|m| m.to_property_value());
841 entry.partition_value =
842 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
843 entry.column_stats = column_stats;
844 self.pending_files.push(entry);
845
846 let all_policies: Vec<VectorStoragePolicy> =
848 columns.iter().map(|c| c.policy.clone()).collect();
849 let all_embeddings: Vec<Vec<Vec<f32>>> =
850 columns.iter().map(|c| c.embeddings.to_vec()).collect();
851 let store = self.store.clone();
852 let catalog = self.catalog.clone();
853 let table = self.table.clone();
854 let fp = file_path.clone();
855 tokio::spawn(async move {
856 if let Err(e) = build_and_patch_multi_index(
857 store,
858 catalog.clone(),
859 all_policies,
860 table.clone(),
861 fp.clone(),
862 all_embeddings,
863 )
864 .await
865 {
866 error!(
867 "ailake: deferred multi-column HNSW build failed for {fp}: {e}; \
868 marking IndexStatus::Failed — compaction will rebuild"
869 );
870 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
871 }
872 });
873
874 Ok(())
875 }
876
877 fn build_bloom_for_file(&mut self, batch: &RecordBatch, file_path: &str) {
886 use arrow_array::cast::AsArray;
887 let col_name = match &self.bm25_text_column {
888 Some(c) => c.clone(),
889 None => return,
890 };
891 let col = match batch.column_by_name(&col_name) {
892 Some(c) => c,
893 None => return,
894 };
895 let str_arr = match col.as_string_opt::<i32>() {
896 Some(a) => a,
897 None => return,
898 };
899 let cap = (batch.num_rows() * 10).max(128);
901 let mut bloom = crate::bloom::BloomFilter::with_capacity(cap, 0.01);
902 for i in 0..str_arr.len() {
903 if str_arr.is_valid(i) {
904 for term in crate::bm25::tokenize(str_arr.value(i)) {
905 bloom.insert(&term);
906 }
907 }
908 }
909 self.pending_blooms
910 .push((file_path.to_string(), bloom.to_bytes()));
911 }
912
913 async fn update_bm25_stats_from_batch(&self, batch: &RecordBatch) -> AilakeResult<()> {
919 use arrow_array::cast::AsArray;
920
921 let col_name = match &self.bm25_text_column {
922 Some(c) => c.as_str(),
923 None => return Ok(()),
924 };
925 let col = match batch.column_by_name(col_name) {
926 Some(c) => c,
927 None => {
928 tracing::warn!(
929 "ailake: BM25 text column '{}' not found in batch — skipping IDF update",
930 col_name
931 );
932 return Ok(());
933 }
934 };
935 let str_arr = match col.as_string_opt::<i32>() {
936 Some(a) => a,
937 None => {
938 tracing::warn!(
939 "ailake: BM25 text column '{}' is not a Utf8 column — skipping",
940 col_name
941 );
942 return Ok(());
943 }
944 };
945
946 let texts: Vec<&str> = (0..str_arr.len())
947 .filter(|&i| str_arr.is_valid(i))
948 .map(|i| str_arr.value(i))
949 .collect();
950
951 let stats_path = crate::bm25::BM25_STATS_FILE;
953 let mut stats: crate::bm25::IdfStats = match self.store.get(stats_path).await {
954 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
955 Err(_) => crate::bm25::IdfStats::default(),
956 };
957
958 stats.merge_batch(&texts);
959
960 let bytes = stats.to_bytes()?;
961 self.store
962 .put(stats_path, bytes::Bytes::from(bytes))
963 .await?;
964 Ok(())
965 }
966
967 pub async fn commit(mut self) -> AilakeResult<SnapshotId> {
968 if self.pending_files.is_empty() {
969 let current = self
970 .catalog
971 .load_table(&self.table)
972 .await
973 .ok()
974 .and_then(|m| m.current_snapshot_id)
975 .unwrap_or(0);
976 return Ok(current);
977 }
978 let iceberg_schema = self
979 .captured_schema
980 .as_deref()
981 .map(|s| arrow_schema_to_iceberg_update(s, &self.policy, &self.extra_vec_policies));
982 let mut extra_properties = std::collections::HashMap::new();
985 if let Some(ref fts_cfg) = self.fts_config {
986 extra_properties.insert("ailake.fts.enabled".to_string(), "true".to_string());
987 extra_properties.insert(
988 "ailake.fts.text-columns".to_string(),
989 fts_cfg.text_columns.join(","),
990 );
991 extra_properties.insert(
992 "ailake.fts.tokenizer".to_string(),
993 fts_cfg.tokenizer.clone(),
994 );
995 }
996 for ep in &self.extra_vec_policies {
997 extra_properties.insert(format!("ailake.dim-{}", ep.column_name), ep.dim.to_string());
998 extra_properties.insert(
999 format!("ailake.metric-{}", ep.column_name),
1000 ailake_parquet::schema::metric_str(ep.metric).to_string(),
1001 );
1002 if let Some(modality) = ep.modality {
1003 extra_properties.insert(
1004 format!("ailake.modality-{}", ep.column_name),
1005 modality.as_str().to_string(),
1006 );
1007 }
1008 }
1009 let snapshot = NewSnapshot {
1010 snapshot_id: new_snapshot_id(),
1011 parent_snapshot_id: self.parent_snapshot_id,
1012 files: std::mem::take(&mut self.pending_files),
1013 operation: SnapshotOperation::Append,
1014 iceberg_schema,
1015 extra_properties,
1016 bloom_filters: std::mem::take(&mut self.pending_blooms),
1017 equality_delete_files: vec![],
1018 };
1019 self.catalog.commit_snapshot(&self.table, snapshot).await
1020 }
1021
1022 pub async fn create_or_open(
1024 catalog: Arc<dyn CatalogProvider>,
1025 store: Arc<dyn Store>,
1026 policy: VectorStoragePolicy,
1027 table: TableIdent,
1028 format_version: u8,
1029 ) -> AilakeResult<Self> {
1030 match catalog.load_table(&table).await {
1035 Ok(existing_meta) => {
1036 if let Some(stored_dim_str) = existing_meta.properties.get("ailake.vector-dim") {
1040 if let Ok(stored_dim) = stored_dim_str.parse::<u32>() {
1041 if stored_dim != policy.dim {
1042 let table_model = policy
1043 .embedding_model
1044 .as_ref()
1045 .map(|m| m.to_property_value())
1046 .unwrap_or_else(|| format!("dim={}", stored_dim));
1047 return Err(AilakeError::ModelMismatch {
1048 table_model,
1049 table_dim: stored_dim,
1050 batch_model: format!("dim={}", policy.dim),
1051 batch_dim: policy.dim,
1052 });
1053 }
1054 }
1055 }
1056 if let Some(incoming) = &policy.embedding_model {
1060 if let Some(stored_val) = existing_meta
1061 .properties
1062 .get(EmbeddingModelInfo::property_key())
1063 {
1064 let stored = EmbeddingModelInfo::from_property_value(stored_val);
1065 if stored.name != incoming.name {
1066 warn!(
1067 "ailake: embedding model name changed: table has '{}', writing with '{}' \
1068 (dim={}). Vectors may be incompatible for similarity search.",
1069 stored.name, incoming.name, policy.dim
1070 );
1071 }
1072 }
1073 }
1074 }
1075 Err(_) => {
1076 catalog
1077 .create_table(
1078 &table,
1079 &TableProperties {
1080 partition_column_type: policy.partition_column_type.clone(),
1081 policy: policy.clone(),
1082 extra: std::collections::HashMap::new(),
1083 format_version,
1084 },
1085 )
1086 .await?;
1087 }
1088 }
1089 let parent_snapshot_id = catalog
1090 .load_table(&table)
1091 .await
1092 .ok()
1093 .and_then(|m| m.current_snapshot_id);
1094 let mut writer = Self::new(catalog, store, policy, table);
1095 writer.parent_snapshot_id = parent_snapshot_id;
1096 Ok(writer)
1097 }
1098}
1099
1100fn arrow_schema_to_iceberg_update(
1107 schema: &arrow_schema::Schema,
1108 policy: &VectorStoragePolicy,
1109 extra_vec_policies: &[VectorStoragePolicy],
1110) -> IcebergSchemaUpdate {
1111 let bytes_per_dim = policy.precision.bytes_per_element() as u32;
1112 let vec_fixed_len = policy.dim * bytes_per_dim;
1113
1114 let has_primary_in_batch = schema
1116 .fields()
1117 .iter()
1118 .any(|f| f.name() == &policy.column_name);
1119 let vec_cols: Vec<(String, u32)> = {
1120 let mut v = Vec::new();
1121 if !has_primary_in_batch {
1122 v.push((policy.column_name.clone(), vec_fixed_len));
1123 }
1124 for ep in extra_vec_policies {
1125 let ep_fixed_len = ep.dim * ep.precision.bytes_per_element() as u32;
1126 if !schema.fields().iter().any(|f| f.name() == &ep.column_name) {
1127 v.push((ep.column_name.clone(), ep_fixed_len));
1128 }
1129 }
1130 v
1131 };
1132
1133 let top_level_count = schema.fields().len() + vec_cols.len();
1135 let mut nested_id = top_level_count as i32;
1137
1138 let mut fields: Vec<serde_json::Value> = Vec::new();
1139 let mut name_mapping: Vec<serde_json::Value> = Vec::new();
1140
1141 for (idx, field) in schema.fields().iter().enumerate() {
1142 let field_id = (idx + 1) as i32;
1143 let iceberg_type = arrow_type_to_iceberg(field.data_type(), &mut nested_id);
1144 fields.push(serde_json::json!({
1145 "id": field_id,
1146 "name": field.name(),
1147 "required": false,
1148 "type": iceberg_type,
1149 }));
1150 name_mapping.push(serde_json::json!({
1151 "field-id": field_id,
1152 "names": [field.name()],
1153 }));
1154 }
1155
1156 for (i, (col_name, fixed_len)) in vec_cols.iter().enumerate() {
1158 let field_id = (schema.fields().len() + 1 + i) as i32;
1159 fields.push(serde_json::json!({
1160 "id": field_id,
1161 "name": col_name,
1162 "required": false,
1163 "type": format!("fixed[{fixed_len}]"),
1164 }));
1165 name_mapping.push(serde_json::json!({
1166 "field-id": field_id,
1167 "names": [col_name],
1168 }));
1169 }
1170
1171 let last_column_id = nested_id;
1172 let name_mapping_json = serde_json::to_string(&name_mapping).unwrap_or_else(|_| "[]".into());
1173
1174 IcebergSchemaUpdate {
1175 fields,
1176 last_column_id,
1177 name_mapping_json,
1178 }
1179}
1180
1181fn arrow_type_to_iceberg(dt: &arrow_schema::DataType, nested_id: &mut i32) -> serde_json::Value {
1186 use arrow_schema::DataType;
1187 match dt {
1188 DataType::Boolean => serde_json::json!("boolean"),
1189 DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
1190 serde_json::json!("int")
1191 }
1192 DataType::Int64 | DataType::UInt32 | DataType::UInt64 => serde_json::json!("long"),
1193 DataType::Float16 | DataType::Float32 => serde_json::json!("float"),
1194 DataType::Float64 => serde_json::json!("double"),
1195 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => serde_json::json!("string"),
1196 DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
1197 serde_json::json!("binary")
1198 }
1199 DataType::Date32 | DataType::Date64 => serde_json::json!("date"),
1200 DataType::Timestamp(_, Some(_)) => serde_json::json!("timestamptz"),
1202 DataType::Timestamp(_, None) => serde_json::json!("timestamp"),
1203 DataType::Time32(_) | DataType::Time64(_) => serde_json::json!("time"),
1204 DataType::FixedSizeBinary(n) => serde_json::json!(format!("fixed[{n}]")),
1205 DataType::Decimal128(p, s) | DataType::Decimal256(p, s) => {
1206 serde_json::json!(format!("decimal({p}, {s})"))
1207 }
1208 DataType::List(inner)
1209 | DataType::LargeList(inner)
1210 | DataType::ListView(inner)
1211 | DataType::FixedSizeList(inner, _) => {
1212 *nested_id += 1;
1213 let element_id = *nested_id;
1214 let element_type = arrow_type_to_iceberg(inner.data_type(), nested_id);
1215 serde_json::json!({
1216 "type": "list",
1217 "element-id": element_id,
1218 "element": element_type,
1219 "element-required": !inner.is_nullable(),
1220 })
1221 }
1222 DataType::Struct(arrow_fields) => {
1223 let struct_fields: Vec<serde_json::Value> = arrow_fields
1224 .iter()
1225 .map(|f| {
1226 *nested_id += 1;
1227 let fid = *nested_id;
1228 let ftype = arrow_type_to_iceberg(f.data_type(), nested_id);
1229 serde_json::json!({
1230 "id": fid,
1231 "name": f.name(),
1232 "required": !f.is_nullable(),
1233 "type": ftype,
1234 })
1235 })
1236 .collect();
1237 serde_json::json!({ "type": "struct", "fields": struct_fields })
1238 }
1239 DataType::Map(entries, _) => {
1240 *nested_id += 1;
1242 let key_id = *nested_id;
1243 *nested_id += 1;
1244 let val_id = *nested_id;
1245 if let DataType::Struct(kv_fields) = entries.data_type() {
1246 let key_f = kv_fields
1247 .iter()
1248 .find(|f| f.name() == "key" || f.name() == "keys");
1249 let val_f = kv_fields
1250 .iter()
1251 .find(|f| f.name() == "value" || f.name() == "values");
1252 let key_type = key_f
1253 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1254 .unwrap_or(serde_json::json!("binary"));
1255 let val_type = val_f
1256 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1257 .unwrap_or(serde_json::json!("binary"));
1258 let val_required = val_f.map(|f| !f.is_nullable()).unwrap_or(false);
1259 serde_json::json!({
1260 "type": "map",
1261 "key-id": key_id,
1262 "key": key_type,
1263 "value-id": val_id,
1264 "value": val_type,
1265 "value-required": val_required,
1266 })
1267 } else {
1268 serde_json::json!("binary")
1269 }
1270 }
1271 _ => serde_json::json!("binary"),
1272 }
1273}
1274
1275async fn patch_index_failed(
1280 catalog: Arc<dyn CatalogProvider>,
1281 table: &TableIdent,
1282 file_path: &str,
1283 reason: &str,
1284) {
1285 let Ok(table_meta) = catalog.load_table(table).await else {
1286 return;
1287 };
1288 let parent_snapshot_id = table_meta.current_snapshot_id;
1289 let Ok(mut files) = catalog.list_files(table, None).await else {
1290 return;
1291 };
1292 for f in &mut files {
1293 if f.path == file_path {
1294 f.index_status = IndexStatus::Failed;
1295 f.index_error = Some(reason.to_string());
1296 break;
1297 }
1298 }
1299 let _ = catalog
1300 .commit_snapshot(
1301 table,
1302 NewSnapshot {
1303 snapshot_id: new_snapshot_id(),
1304 parent_snapshot_id,
1305 files,
1306 operation: SnapshotOperation::Replace,
1307 iceberg_schema: None,
1308 extra_properties: std::collections::HashMap::new(),
1309 bloom_filters: vec![],
1310 equality_delete_files: vec![],
1311 },
1312 )
1313 .await
1314 .map_err(|e| {
1315 error!(
1316 "ailake: failed to write IndexStatus::Failed for {file_path}: {e}; \
1317 file will remain Indexing until compaction"
1318 )
1319 });
1320}
1321
1322pub(crate) async fn build_and_patch_index(
1323 store: Arc<dyn Store>,
1324 catalog: Arc<dyn CatalogProvider>,
1325 policy: VectorStoragePolicy,
1326 table: TableIdent,
1327 file_path: String,
1328) -> AilakeResult<()> {
1329 let parquet_bytes = store.get(&file_path).await?;
1331 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1332 let (batch, embeddings) = reader.read_parquet()?;
1333
1334 let full_bytes = tokio::task::spawn_blocking({
1337 let policy = policy.clone();
1338 move || {
1339 let file_writer = AilakeFileWriter::new(policy);
1340 file_writer.write(&batch, &embeddings)
1341 }
1342 })
1343 .await
1344 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1345
1346 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1348 let header = full_reader.read_header()?;
1349 let ailk_start = full_reader.ailk_offset()?;
1350 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1351 let hnsw_len = header.hnsw_len;
1352
1353 full_reader.verify_integrity()?;
1358
1359 store.put(&file_path, full_bytes).await?;
1361
1362 let mut committed = false;
1365 for _ in 0..120u32 {
1366 match catalog.load_table(&table).await {
1367 Ok(meta) if meta.current_snapshot_id.is_some() => {
1368 committed = true;
1369 break;
1370 }
1371 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1372 }
1373 }
1374 if !committed {
1375 return Err(ailake_core::AilakeError::Store(format!(
1376 "deferred HNSW build: no snapshot committed for {file_path} after 60 s — \
1377 did you call TableWriter::commit()?"
1378 )));
1379 }
1380
1381 for attempt in 0..50u32 {
1386 let table_meta = catalog.load_table(&table).await?;
1387 let parent_snapshot_id = table_meta.current_snapshot_id;
1388 let mut files = catalog.list_files(&table, None).await?;
1389
1390 if files
1392 .iter()
1393 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1394 {
1395 break;
1396 }
1397
1398 for f in &mut files {
1399 if f.path == file_path {
1400 f.hnsw_offset = Some(hnsw_abs_offset);
1401 f.hnsw_len = Some(hnsw_len);
1402 f.index_status = IndexStatus::Ready;
1403 break;
1404 }
1405 }
1406 catalog
1407 .commit_snapshot(
1408 &table,
1409 NewSnapshot {
1410 snapshot_id: new_snapshot_id(),
1411 parent_snapshot_id,
1412 files,
1413 operation: SnapshotOperation::Replace,
1414 iceberg_schema: None,
1415 extra_properties: std::collections::HashMap::new(),
1416 bloom_filters: vec![],
1417 equality_delete_files: vec![],
1418 },
1419 )
1420 .await?;
1421
1422 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1424
1425 let verify = catalog.list_files(&table, None).await?;
1426 if verify
1427 .iter()
1428 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1429 {
1430 break;
1431 }
1432 }
1434
1435 info!(
1436 "ailake: deferred HNSW index built for {} (offset={}, len={})",
1437 file_path, hnsw_abs_offset, hnsw_len
1438 );
1439 Ok(())
1440}
1441
1442async fn build_ivf_pq_and_patch_index(
1447 store: Arc<dyn Store>,
1448 catalog: Arc<dyn CatalogProvider>,
1449 policy: VectorStoragePolicy,
1450 table: TableIdent,
1451 file_path: String,
1452 ivf_config: IvfPqConfig,
1453 codebook_cell: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
1454) -> AilakeResult<()> {
1455 let parquet_bytes = store.get(&file_path).await?;
1456 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1457 let (batch, embeddings) = reader.read_parquet()?;
1458
1459 let codebook = codebook_cell
1461 .get_or_try_init(|| async {
1462 let vecs = embeddings.clone();
1463 let metric = policy.metric;
1464 let cfg = ivf_config.clone();
1465 tokio::task::spawn_blocking(move || {
1466 ailake_index::IvfPqIndex::train_codebook(&vecs, metric, &cfg)
1467 })
1468 .await
1469 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))?
1470 })
1471 .await?;
1472
1473 let full_bytes = tokio::task::spawn_blocking({
1474 let policy = policy.clone();
1475 let codebook = codebook.clone();
1476 move || {
1477 let file_writer = AilakeFileWriter::new(policy)
1478 .with_index_type(IndexType::IvfPq(ivf_config))
1479 .with_shared_ivf_codebook(Arc::new(codebook));
1480 file_writer.write(&batch, &embeddings)
1481 }
1482 })
1483 .await
1484 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1485
1486 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1487 let header = full_reader.read_header()?;
1488 let ailk_start = full_reader.ailk_offset()?;
1489 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1490 let hnsw_len = header.hnsw_len;
1491
1492 store.put(&file_path, full_bytes).await?;
1493
1494 let mut committed = false;
1496 for _ in 0..120u32 {
1497 match catalog.load_table(&table).await {
1498 Ok(meta) if meta.current_snapshot_id.is_some() => {
1499 committed = true;
1500 break;
1501 }
1502 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1503 }
1504 }
1505 if !committed {
1506 return Err(ailake_core::AilakeError::Store(format!(
1507 "deferred IVF-PQ build: no snapshot committed for {file_path} after 60 s — \
1508 did you call TableWriter::commit()?"
1509 )));
1510 }
1511
1512 for attempt in 0..50u32 {
1513 let table_meta = catalog.load_table(&table).await?;
1514 let parent_snapshot_id = table_meta.current_snapshot_id;
1515 let mut files = catalog.list_files(&table, None).await?;
1516
1517 if files
1518 .iter()
1519 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1520 {
1521 break;
1522 }
1523
1524 for f in &mut files {
1525 if f.path == file_path {
1526 f.hnsw_offset = Some(hnsw_abs_offset);
1527 f.hnsw_len = Some(hnsw_len);
1528 f.index_status = IndexStatus::Ready;
1529 break;
1530 }
1531 }
1532 catalog
1533 .commit_snapshot(
1534 &table,
1535 NewSnapshot {
1536 snapshot_id: new_snapshot_id(),
1537 parent_snapshot_id,
1538 files,
1539 operation: SnapshotOperation::Replace,
1540 iceberg_schema: None,
1541 extra_properties: std::collections::HashMap::new(),
1542 bloom_filters: vec![],
1543 equality_delete_files: vec![],
1544 },
1545 )
1546 .await?;
1547
1548 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1549
1550 let verify = catalog.list_files(&table, None).await?;
1551 if verify
1552 .iter()
1553 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1554 {
1555 break;
1556 }
1557 }
1558
1559 info!(
1560 "ailake: deferred IVF-PQ index built for {} (offset={}, len={})",
1561 file_path, hnsw_abs_offset, hnsw_len
1562 );
1563 Ok(())
1564}
1565
1566async fn build_and_patch_multi_index(
1572 store: Arc<dyn Store>,
1573 catalog: Arc<dyn CatalogProvider>,
1574 policies: Vec<VectorStoragePolicy>,
1575 table: TableIdent,
1576 file_path: String,
1577 all_embeddings: Vec<Vec<Vec<f32>>>,
1578) -> AilakeResult<()> {
1579 let parquet_bytes = store.get(&file_path).await?;
1581 let primary_reader =
1582 AilakeFileReader::new(parquet_bytes, &policies[0].column_name, policies[0].dim);
1583 let (batch, _) = primary_reader.read_parquet()?;
1584
1585 let full_bytes = tokio::task::spawn_blocking({
1587 let policies = policies.clone();
1588 let all_embeddings = all_embeddings.clone();
1589 move || {
1590 let col_batches: Vec<VectorColumnBatch<'_>> = policies
1591 .iter()
1592 .zip(all_embeddings.iter())
1593 .map(|(p, embs)| VectorColumnBatch {
1594 policy: p,
1595 embeddings: embs.as_slice(),
1596 })
1597 .collect();
1598 let file_writer = AilakeFileWriter::new(policies[0].clone());
1599 file_writer.write_multi(&batch, &col_batches)
1600 }
1601 })
1602 .await
1603 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1604
1605 let primary_reader = AilakeFileReader::new(
1607 full_bytes.clone(),
1608 &policies[0].column_name,
1609 policies[0].dim,
1610 );
1611 let primary_header = primary_reader.read_header()?;
1612 let primary_ailk_start = primary_reader.ailk_offset()?;
1613 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
1614 let primary_hnsw_len = primary_header.hnsw_len;
1615
1616 let mut extra_offsets: Vec<(u64, u64)> = Vec::with_capacity(policies.len().saturating_sub(1));
1621 for col_policy in policies.iter().skip(1) {
1622 let col_reader =
1623 AilakeFileReader::new(full_bytes.clone(), &col_policy.column_name, col_policy.dim);
1624 let col_ailk_start = col_reader.ailk_offset_for_column(&col_policy.column_name)?;
1625 let col_header = col_reader.read_header_for_column(&col_policy.column_name)?;
1626 extra_offsets.push((col_ailk_start + col_header.hnsw_offset, col_header.hnsw_len));
1627 }
1628
1629 store.put(&file_path, full_bytes).await?;
1631
1632 let mut committed = false;
1634 for _ in 0..120u32 {
1635 match catalog.load_table(&table).await {
1636 Ok(meta) if meta.current_snapshot_id.is_some() => {
1637 committed = true;
1638 break;
1639 }
1640 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1641 }
1642 }
1643 if !committed {
1644 return Err(ailake_core::AilakeError::Store(format!(
1645 "deferred index build: no snapshot committed for {file_path} after 60 s — \
1646 did you call TableWriter::commit()?"
1647 )));
1648 }
1649
1650 for attempt in 0..50u32 {
1652 let table_meta = catalog.load_table(&table).await?;
1653 let parent_snapshot_id = table_meta.current_snapshot_id;
1654 let mut files = catalog.list_files(&table, None).await?;
1655
1656 if files
1657 .iter()
1658 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1659 {
1660 break;
1661 }
1662
1663 for f in &mut files {
1664 if f.path == file_path {
1665 f.hnsw_offset = Some(primary_hnsw_abs);
1666 f.hnsw_len = Some(primary_hnsw_len);
1667 f.index_status = IndexStatus::Ready;
1668 for (i, &(off, len)) in extra_offsets.iter().enumerate() {
1669 if let Some(xi) = f.extra_vector_indexes.get_mut(i) {
1670 xi.hnsw_offset = off;
1671 xi.hnsw_len = len;
1672 }
1673 }
1674 break;
1675 }
1676 }
1677 catalog
1678 .commit_snapshot(
1679 &table,
1680 NewSnapshot {
1681 snapshot_id: new_snapshot_id(),
1682 parent_snapshot_id,
1683 files,
1684 operation: SnapshotOperation::Replace,
1685 iceberg_schema: None,
1686 extra_properties: std::collections::HashMap::new(),
1687 bloom_filters: vec![],
1688 equality_delete_files: vec![],
1689 },
1690 )
1691 .await?;
1692
1693 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1694
1695 let verify = catalog.list_files(&table, None).await?;
1696 if verify
1697 .iter()
1698 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1699 {
1700 break;
1701 }
1702 }
1703
1704 info!(
1705 "ailake: deferred multi-column HNSW built for {} ({} cols, primary offset={})",
1706 file_path,
1707 policies.len(),
1708 primary_hnsw_abs
1709 );
1710 Ok(())
1711}
1712
1713#[cfg(test)]
1714mod tests {
1715 use super::*;
1716 use ailake_core::{VectorMetric, VectorPrecision};
1717 use arrow_schema::{DataType, Field, Schema, TimeUnit};
1718
1719 fn policy(col: &str, dim: u32) -> VectorStoragePolicy {
1720 VectorStoragePolicy {
1721 column_name: col.to_string(),
1722 dim,
1723 metric: VectorMetric::Cosine,
1724 precision: VectorPrecision::F16,
1725 pq: None,
1726 keep_raw_for_reranking: true,
1727 pre_normalize: false,
1728 hnsw_m: None,
1729 hnsw_ef_construction: None,
1730 ivf_residual: false,
1731 embedding_model: None,
1732 modality: None,
1733 partition_by: None,
1734 partition_value: None,
1735 partition_column_type: None,
1736 partition_fields: vec![],
1737 }
1738 }
1739
1740 fn update_for(schema: &Schema, pol: &VectorStoragePolicy) -> IcebergSchemaUpdate {
1741 arrow_schema_to_iceberg_update(schema, pol, &[])
1742 }
1743
1744 #[test]
1745 fn validate_embedding_dim_for_policy_rejects_nan() {
1746 let pol = policy("embedding", 3);
1747 let embeddings = vec![vec![1.0, 2.0, 3.0], vec![1.0, f32::NAN, 3.0]];
1748 let err = TableWriter::validate_embedding_dim_for_policy(&embeddings, &pol).unwrap_err();
1749 assert!(err.to_string().contains("non-finite"), "{err}");
1750 }
1751
1752 #[test]
1753 fn validate_embedding_dim_for_policy_rejects_infinity() {
1754 let pol = policy("embedding", 3);
1755 let embeddings = vec![vec![1.0, f32::INFINITY, 3.0]];
1756 let err = TableWriter::validate_embedding_dim_for_policy(&embeddings, &pol).unwrap_err();
1757 assert!(err.to_string().contains("non-finite"), "{err}");
1758 }
1759
1760 #[test]
1761 fn simple_schema_produces_correct_fields() {
1762 let schema = Schema::new(vec![
1763 Field::new("id", DataType::Int32, false),
1764 Field::new("text", DataType::Utf8, false),
1765 ]);
1766 let pol = policy("embedding", 8);
1767 let upd = update_for(&schema, &pol);
1768
1769 assert_eq!(upd.fields.len(), 3);
1770 assert_eq!(upd.fields[0]["id"], 1);
1771 assert_eq!(upd.fields[0]["type"], "int");
1772 assert_eq!(upd.fields[1]["id"], 2);
1773 assert_eq!(upd.fields[1]["type"], "string");
1774 assert_eq!(upd.fields[2]["id"], 3);
1775 assert_eq!(upd.fields[2]["type"], "fixed[16]"); let nm: Vec<serde_json::Value> = serde_json::from_str(&upd.name_mapping_json).unwrap();
1778 assert_eq!(nm.len(), 3);
1779 assert_eq!(nm[2]["field-id"], 3);
1780 assert_eq!(nm[2]["names"][0], "embedding");
1781 assert_eq!(upd.last_column_id, 3);
1782 }
1783
1784 #[test]
1785 fn timestamp_without_tz_maps_to_timestamp_not_timestamptz() {
1786 let schema = Schema::new(vec![
1787 Field::new(
1788 "created_at",
1789 DataType::Timestamp(TimeUnit::Microsecond, None),
1790 true,
1791 ),
1792 Field::new(
1793 "updated_at",
1794 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
1795 true,
1796 ),
1797 ]);
1798 let pol = policy("vec", 4);
1799 let upd = update_for(&schema, &pol);
1800
1801 assert_eq!(upd.fields[0]["type"], "timestamp");
1802 assert_eq!(upd.fields[1]["type"], "timestamptz");
1803 }
1804
1805 #[test]
1806 fn list_type_produces_iceberg_list_object() {
1807 let schema = Schema::new(vec![Field::new(
1808 "tags",
1809 DataType::List(std::sync::Arc::new(Field::new(
1810 "item",
1811 DataType::Utf8,
1812 true,
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"], "list");
1821 assert_eq!(t["element"], "string");
1822 assert!(t["element-id"].as_i64().unwrap() > 2);
1824 }
1825
1826 #[test]
1827 fn struct_type_produces_nested_fields() {
1828 let schema = Schema::new(vec![Field::new(
1829 "meta",
1830 DataType::Struct(
1831 vec![
1832 Field::new("key", DataType::Utf8, false),
1833 Field::new("val", DataType::Int64, false),
1834 ]
1835 .into(),
1836 ),
1837 true,
1838 )]);
1839 let pol = policy("vec", 4);
1840 let upd = update_for(&schema, &pol);
1841
1842 let t = &upd.fields[0]["type"];
1843 assert_eq!(t["type"], "struct");
1844 let nested = t["fields"].as_array().unwrap();
1845 assert_eq!(nested.len(), 2);
1846 assert_eq!(nested[0]["name"], "key");
1847 assert_eq!(nested[0]["type"], "string");
1848 assert_eq!(nested[1]["name"], "val");
1849 assert_eq!(nested[1]["type"], "long");
1850 assert!(nested[0]["id"].as_i64().unwrap() > 2);
1852 }
1853
1854 #[test]
1855 fn no_duplicate_vec_column_when_already_in_batch() {
1856 let schema = Schema::new(vec![
1858 Field::new("id", DataType::Int32, false),
1859 Field::new("embedding", DataType::FixedSizeBinary(16), false),
1860 ]);
1861 let pol = policy("embedding", 8);
1862 let upd = update_for(&schema, &pol);
1863
1864 assert_eq!(upd.fields.len(), 2, "should not add embedding twice");
1865 let names: Vec<&str> = upd
1866 .fields
1867 .iter()
1868 .map(|f| f["name"].as_str().unwrap())
1869 .collect();
1870 assert_eq!(names.iter().filter(|&&n| n == "embedding").count(), 1);
1871 }
1872
1873 #[test]
1874 fn multi_vec_policies_all_appended() {
1875 let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
1876 let primary = policy("embedding", 4);
1877 let extra = vec![policy("context_embedding", 4)];
1878 let upd = arrow_schema_to_iceberg_update(&schema, &primary, &extra);
1879
1880 assert_eq!(upd.fields.len(), 3); let names: Vec<&str> = upd
1882 .fields
1883 .iter()
1884 .map(|f| f["name"].as_str().unwrap())
1885 .collect();
1886 assert!(names.contains(&"embedding"));
1887 assert!(names.contains(&"context_embedding"));
1888 }
1889
1890 #[test]
1891 fn top_level_field_ids_match_parquet_stamp_sequence() {
1892 let schema = Schema::new(vec![
1894 Field::new("id", DataType::Int64, false),
1895 Field::new(
1896 "tags",
1897 DataType::List(std::sync::Arc::new(Field::new(
1898 "item",
1899 DataType::Utf8,
1900 true,
1901 ))),
1902 true,
1903 ),
1904 ]);
1905 let pol = policy("vec", 4);
1906 let upd = update_for(&schema, &pol);
1907
1908 assert_eq!(upd.fields[0]["id"], 1);
1910 assert_eq!(upd.fields[1]["id"], 2);
1911 assert_eq!(upd.fields[2]["id"], 3);
1912
1913 assert!(upd.fields[1]["type"]["element-id"].as_i64().unwrap() > 3);
1915 }
1916
1917 #[test]
1922 fn merge_schema_accumulates_columns_across_batches() {
1923 let first = Arc::new(Schema::new(vec![
1924 Field::new("id", DataType::Int32, false),
1925 Field::new("text", DataType::Utf8, false),
1926 ]));
1927 let second = Arc::new(Schema::new(vec![
1928 Field::new("id", DataType::Int32, false),
1929 Field::new("text", DataType::Utf8, false),
1930 Field::new("author", DataType::Utf8, true),
1931 ]));
1932
1933 let merged = merge_schema(None, &first);
1934 assert_eq!(merged.fields().len(), 2);
1935 let merged = merge_schema(Some(merged), &second);
1936
1937 assert_eq!(
1938 merged.fields().len(),
1939 3,
1940 "author from the second batch must survive"
1941 );
1942 assert!(merged.field_with_name("id").is_ok());
1943 assert!(merged.field_with_name("text").is_ok());
1944 assert!(merged.field_with_name("author").is_ok());
1945
1946 let third = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1948 let merged = merge_schema(Some(merged), &third);
1949 assert_eq!(
1950 merged.fields().len(),
1951 3,
1952 "shrinking batch must not drop prior columns"
1953 );
1954 }
1955
1956 #[tokio::test]
1959 async fn write_batch_auto_deferred_stages_file() {
1960 use ailake_catalog::{HadoopCatalog, TableIdent};
1961 use ailake_store::LocalStore;
1962 use arrow_schema::{DataType, Field, Schema};
1963
1964 let dir = tempfile::tempdir().unwrap();
1965 let store: std::sync::Arc<dyn ailake_store::Store> =
1966 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1967 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1968 let pol = policy("embedding", 4);
1969 let ident = TableIdent::new("default", "t");
1970
1971 let mut writer = TableWriter::create_or_open(catalog, store, pol, ident, 2)
1972 .await
1973 .unwrap();
1974
1975 let schema =
1976 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1977 let batch = arrow_array::RecordBatch::try_new(
1978 schema,
1979 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1980 "hello",
1981 ]))],
1982 )
1983 .unwrap();
1984 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
1985
1986 writer
1987 .write_batch_auto_deferred(&batch, &embeddings)
1988 .await
1989 .unwrap();
1990
1991 assert_eq!(writer.pending_files.len(), 1);
1993 }
1994
1995 #[tokio::test]
1998 async fn write_batch_multi_deferred_stages_file_with_extra_indexes() {
1999 use ailake_catalog::{HadoopCatalog, IndexStatus, TableIdent};
2000 use ailake_store::LocalStore;
2001 use arrow_schema::{DataType, Field, Schema};
2002
2003 let dir = tempfile::tempdir().unwrap();
2004 let store: std::sync::Arc<dyn ailake_store::Store> =
2005 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
2006 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
2007 let primary_pol = policy("embedding", 4);
2008 let ident = TableIdent::new("default", "t");
2009
2010 let mut writer = TableWriter::create_or_open(catalog, store, primary_pol, ident, 2)
2011 .await
2012 .unwrap();
2013
2014 let schema =
2015 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
2016 let batch = arrow_array::RecordBatch::try_new(
2017 schema,
2018 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
2019 "hello", "world",
2020 ]))],
2021 )
2022 .unwrap();
2023
2024 let text_embs = vec![vec![1.0f32, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
2025 let img_embs = vec![vec![1.0f32, 0.0], vec![0.0, 1.0]];
2026
2027 let columns = vec![
2028 MultiVectorBatch {
2029 policy: policy("embedding", 4),
2030 embeddings: &text_embs,
2031 },
2032 MultiVectorBatch {
2033 policy: policy("img_embedding", 2),
2034 embeddings: &img_embs,
2035 },
2036 ];
2037
2038 writer
2039 .write_batch_multi_deferred(&batch, &columns)
2040 .await
2041 .unwrap();
2042
2043 assert_eq!(writer.pending_files.len(), 1);
2044 let entry = &writer.pending_files[0];
2045 assert_eq!(entry.index_status, IndexStatus::Indexing);
2047 assert!(entry.centroid_b64.is_some());
2049 assert_eq!(entry.extra_vector_indexes.len(), 1);
2051 let xi = &entry.extra_vector_indexes[0];
2052 assert_eq!(xi.column, "img_embedding");
2053 assert_eq!(xi.dim, 2);
2054 assert_eq!(xi.hnsw_offset, 0); assert_eq!(xi.hnsw_len, 0); assert!(xi.centroid_b64.is_some());
2057 }
2058
2059 #[tokio::test]
2066 async fn part_paths_unique_across_sessions_after_compaction() {
2067 use ailake_catalog::{
2068 new_snapshot_id, HadoopCatalog, NewSnapshot, SnapshotOperation, TableIdent,
2069 };
2070 use ailake_store::LocalStore;
2071 use arrow_schema::{DataType, Field, Schema};
2072
2073 let dir = tempfile::tempdir().unwrap();
2074 let store: std::sync::Arc<dyn ailake_store::Store> =
2075 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
2076 let catalog: std::sync::Arc<dyn CatalogProvider> =
2077 std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
2078 let ident = TableIdent::new("default", "t");
2079
2080 let schema =
2081 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
2082 let batch = arrow_array::RecordBatch::try_new(
2083 schema,
2084 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
2085 "hello",
2086 ]))],
2087 )
2088 .unwrap();
2089 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
2090
2091 let mut w1 = TableWriter::create_or_open(
2093 catalog.clone(),
2094 store.clone(),
2095 policy("embedding", 4),
2096 ident.clone(),
2097 2,
2098 )
2099 .await
2100 .unwrap();
2101 w1.write_batch(&batch, &embeddings).await.unwrap();
2102 w1.write_batch(&batch, &embeddings).await.unwrap();
2103 w1.commit().await.unwrap();
2104 let session1_paths: Vec<String> = catalog
2105 .list_files(&ident, None)
2106 .await
2107 .unwrap()
2108 .iter()
2109 .map(|f| f.path.clone())
2110 .collect();
2111 assert_eq!(session1_paths.len(), 2);
2112
2113 let mut merged = catalog.list_files(&ident, None).await.unwrap()[0].clone();
2116 merged.path = "data/compacted-test.parquet".to_string();
2117 store
2118 .put(&merged.path, store.get(&session1_paths[0]).await.unwrap())
2119 .await
2120 .unwrap();
2121 let parent = catalog
2122 .load_table(&ident)
2123 .await
2124 .unwrap()
2125 .current_snapshot_id;
2126 catalog
2127 .commit_snapshot(
2128 &ident,
2129 NewSnapshot {
2130 snapshot_id: new_snapshot_id(),
2131 parent_snapshot_id: parent,
2132 files: vec![merged],
2133 operation: SnapshotOperation::Replace,
2134 iceberg_schema: None,
2135 extra_properties: std::collections::HashMap::new(),
2136 bloom_filters: vec![],
2137 equality_delete_files: vec![],
2138 },
2139 )
2140 .await
2141 .unwrap();
2142
2143 let mut w2 = TableWriter::create_or_open(
2145 catalog.clone(),
2146 store.clone(),
2147 policy("embedding", 4),
2148 ident.clone(),
2149 2,
2150 )
2151 .await
2152 .unwrap();
2153 w2.write_batch(&batch, &embeddings).await.unwrap();
2154 w2.commit().await.unwrap();
2155
2156 let final_paths: Vec<String> = catalog
2157 .list_files(&ident, None)
2158 .await
2159 .unwrap()
2160 .iter()
2161 .map(|f| f.path.clone())
2162 .collect();
2163 let new_part = final_paths
2164 .iter()
2165 .find(|p| p.starts_with("data/part-"))
2166 .expect("session 2 must have committed a part file");
2167 assert!(
2168 !session1_paths.contains(new_part),
2169 "session 2 reused a retired part name: {new_part} (session 1 wrote {session1_paths:?})"
2170 );
2171 }
2172
2173 #[tokio::test]
2180 async fn write_batch_idempotent_no_ops_after_source_file_compacted() {
2181 use ailake_catalog::{
2182 new_snapshot_id, DataFileEntry, HadoopCatalog, NewSnapshot, SnapshotOperation,
2183 TableIdent,
2184 };
2185 use ailake_store::LocalStore;
2186 use arrow_schema::{DataType, Field, Schema};
2187
2188 let dir = tempfile::tempdir().unwrap();
2189 let store: std::sync::Arc<dyn ailake_store::Store> =
2190 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
2191 let catalog: std::sync::Arc<dyn CatalogProvider> =
2192 std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
2193 let ident = TableIdent::new("default", "t");
2194
2195 let schema =
2196 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
2197 let batch = arrow_array::RecordBatch::try_new(
2198 schema,
2199 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
2200 "hello",
2201 ]))],
2202 )
2203 .unwrap();
2204 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
2205
2206 let mut w1 = TableWriter::create_or_open(
2208 catalog.clone(),
2209 store.clone(),
2210 policy("embedding", 4),
2211 ident.clone(),
2212 2,
2213 )
2214 .await
2215 .unwrap();
2216 w1.write_batch_idempotent(&batch, &embeddings, "k-a")
2217 .await
2218 .unwrap();
2219 w1.commit().await.unwrap();
2220
2221 let mut w2 = TableWriter::create_or_open(
2222 catalog.clone(),
2223 store.clone(),
2224 policy("embedding", 4),
2225 ident.clone(),
2226 2,
2227 )
2228 .await
2229 .unwrap();
2230 w2.write_batch_idempotent(&batch, &embeddings, "k-b")
2231 .await
2232 .unwrap();
2233 w2.commit().await.unwrap();
2234
2235 let sources = catalog.list_files(&ident, None).await.unwrap();
2236 assert_eq!(sources.len(), 2);
2237
2238 let mut merged = sources[0].clone();
2241 merged.path = "data/compacted-test.parquet".to_string();
2242 merged.record_count = sources.iter().map(|f| f.record_count).sum();
2243 merged.batch_id = DataFileEntry::merge_batch_ids(&sources);
2244 store
2245 .put(&merged.path, store.get(&sources[0].path).await.unwrap())
2246 .await
2247 .unwrap();
2248 let parent = catalog
2249 .load_table(&ident)
2250 .await
2251 .unwrap()
2252 .current_snapshot_id;
2253 catalog
2254 .commit_snapshot(
2255 &ident,
2256 NewSnapshot {
2257 snapshot_id: new_snapshot_id(),
2258 parent_snapshot_id: parent,
2259 files: vec![merged],
2260 operation: SnapshotOperation::Replace,
2261 iceberg_schema: None,
2262 extra_properties: std::collections::HashMap::new(),
2263 bloom_filters: vec![],
2264 equality_delete_files: vec![],
2265 },
2266 )
2267 .await
2268 .unwrap();
2269 assert_eq!(catalog.list_files(&ident, None).await.unwrap().len(), 1);
2270
2271 let mut w3 = TableWriter::create_or_open(
2274 catalog.clone(),
2275 store.clone(),
2276 policy("embedding", 4),
2277 ident.clone(),
2278 2,
2279 )
2280 .await
2281 .unwrap();
2282 w3.write_batch_idempotent(&batch, &embeddings, "k-a")
2283 .await
2284 .unwrap();
2285 assert!(
2286 w3.pending_files.is_empty(),
2287 "retry of an already-compacted batch_id must not stage a new file"
2288 );
2289 w3.commit().await.unwrap();
2290
2291 let final_files = catalog.list_files(&ident, None).await.unwrap();
2292 assert_eq!(
2293 final_files.len(),
2294 1,
2295 "retry after compaction must not re-insert — table must still have exactly 1 file"
2296 );
2297 }
2298}