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