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 Self::validate_embedding_dim_for_policy(embeddings, &self.policy)
311 }
312
313 fn validate_embedding_dim_for_policy(
314 embeddings: &[Vec<f32>],
315 policy: &VectorStoragePolicy,
316 ) -> AilakeResult<()> {
317 for emb in embeddings {
318 let actual = emb.len() as u32;
319 if actual != policy.dim {
320 let table_model = policy
321 .embedding_model
322 .as_ref()
323 .map(|m| m.to_property_value())
324 .unwrap_or_else(|| format!("dim={}", policy.dim));
325 return Err(AilakeError::ModelMismatch {
326 table_model,
327 table_dim: policy.dim,
328 batch_model: format!("dim={}", actual),
329 batch_dim: actual,
330 });
331 }
332 }
333 Ok(())
334 }
335
336 pub async fn write_batch(
337 &mut self,
338 batch: &RecordBatch,
339 embeddings: &[Vec<f32>],
340 ) -> AilakeResult<()> {
341 self.write_batch_with_id(batch, embeddings, None).await
342 }
343
344 async fn write_batch_with_id(
345 &mut self,
346 batch: &RecordBatch,
347 embeddings: &[Vec<f32>],
348 batch_id: Option<String>,
349 ) -> AilakeResult<()> {
350 self.validate_embedding_dim(embeddings)?;
351 if self.captured_schema.is_none() {
352 self.captured_schema = Some(batch.schema());
353 }
354 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
355 let file_path = format!("data/part-{:05}.parquet", part_num);
356
357 let mut file_writer = AilakeFileWriter::new(self.policy.clone());
359 if let Some(ref fts_cfg) = self.fts_config {
360 file_writer = file_writer.with_fts(fts_cfg.clone());
361 }
362 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
363 let file_size = file_bytes.len() as u64;
364
365 self.store.put(&file_path, file_bytes.clone()).await?;
367
368 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
370
371 let reader = ailake_file::AilakeFileReader::new(
373 file_bytes,
374 &self.policy.column_name,
375 self.policy.dim,
376 );
377 let header = reader.read_header()?;
378 let ailk_start = reader.ailk_offset()?;
379 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
380 let hnsw_len = header.hnsw_len;
381
382 let mut entry = make_data_file_entry(
383 &file_path,
384 embeddings.len() as u64,
385 file_size,
386 ¢roid,
387 VectorIndexInfo {
388 column: &self.policy.column_name,
389 dim: self.policy.dim,
390 hnsw_offset: hnsw_abs_offset,
391 hnsw_len,
392 },
393 );
394 entry.batch_id = batch_id;
395 entry.embedding_model = self
396 .policy
397 .embedding_model
398 .as_ref()
399 .map(|m| m.to_property_value());
400 entry.partition_value =
401 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
402 self.pending_files.push(entry);
403
404 if self.bm25_text_column.is_some() {
406 self.update_bm25_stats_from_batch(batch).await?;
407 self.build_bloom_for_file(batch, &file_path);
408 }
409 Ok(())
410 }
411
412 pub async fn write_batch_auto(
418 &mut self,
419 batch: &RecordBatch,
420 embeddings: &[Vec<f32>],
421 ) -> AilakeResult<()> {
422 let profile = ailake_index::HardwareProfile::detect();
423 if profile.recommend_ivf_pq(embeddings.len()) {
424 let mut ivf_config =
425 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
426 if self.policy.ivf_residual {
427 ivf_config = ivf_config.with_residual();
428 }
429 self.write_batch_ivf_pq(batch, embeddings, ivf_config).await
430 } else {
431 self.write_batch(batch, embeddings).await
432 }
433 }
434
435 pub async fn write_batch_auto_deferred(
446 &mut self,
447 batch: &RecordBatch,
448 embeddings: &[Vec<f32>],
449 ) -> AilakeResult<()> {
450 let profile = ailake_index::HardwareProfile::detect();
451 if profile.recommend_ivf_pq(embeddings.len()) {
452 let mut ivf_config =
453 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
454 if self.policy.ivf_residual {
455 ivf_config = ivf_config.with_residual();
456 }
457 self.write_batch_ivf_pq_deferred(batch, embeddings, ivf_config)
458 .await
459 } else {
460 self.write_batch_deferred(batch, embeddings).await
461 }
462 }
463
464 pub async fn write_batch_ivf_pq(
468 &mut self,
469 batch: &RecordBatch,
470 embeddings: &[Vec<f32>],
471 ivf_config: IvfPqConfig,
472 ) -> AilakeResult<()> {
473 if self.captured_schema.is_none() {
474 self.captured_schema = Some(batch.schema());
475 }
476 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
477 let file_path = format!("data/part-{:05}.parquet", part_num);
478
479 if self.cached_ivf_codebook.is_none() {
483 let codebook = tokio::task::spawn_blocking({
484 let embeddings = embeddings.to_vec();
485 let metric = self.policy.metric;
486 let config = ivf_config.clone();
487 move || ailake_index::IvfPqIndex::train_codebook(&embeddings, metric, &config)
488 })
489 .await
490 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
491 self.cached_ivf_codebook = Some(Arc::new(codebook));
492 }
493 let codebook = self
495 .cached_ivf_codebook
496 .as_ref()
497 .expect("IVF-PQ codebook must be Some after training block")
498 .clone();
499
500 let file_writer = AilakeFileWriter::new(self.policy.clone())
501 .with_index_type(IndexType::IvfPq(ivf_config))
502 .with_shared_ivf_codebook(codebook);
503 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
504 let file_size = file_bytes.len() as u64;
505
506 self.store.put(&file_path, file_bytes.clone()).await?;
507
508 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
509
510 let reader = ailake_file::AilakeFileReader::new(
511 file_bytes,
512 &self.policy.column_name,
513 self.policy.dim,
514 );
515 let header = reader.read_header()?;
516 let ailk_start = reader.ailk_offset()?;
517 let index_abs_offset = ailk_start + header.hnsw_offset;
518 let index_len = header.hnsw_len;
519
520 let mut entry = make_data_file_entry(
521 &file_path,
522 embeddings.len() as u64,
523 file_size,
524 ¢roid,
525 VectorIndexInfo {
526 column: &self.policy.column_name,
527 dim: self.policy.dim,
528 hnsw_offset: index_abs_offset,
529 hnsw_len: index_len,
530 },
531 );
532 entry.embedding_model = self
533 .policy
534 .embedding_model
535 .as_ref()
536 .map(|m| m.to_property_value());
537 entry.partition_value =
538 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
539 self.pending_files.push(entry);
540 Ok(())
541 }
542
543 pub async fn write_batch_multi(
548 &mut self,
549 batch: &RecordBatch,
550 columns: &[MultiVectorBatch<'_>],
551 ) -> AilakeResult<()> {
552 use ailake_core::AilakeError;
553 if self.captured_schema.is_none() {
554 self.captured_schema = Some(batch.schema());
555 }
556 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
557 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
558 }
559
560 if columns.is_empty() {
561 return Err(AilakeError::InvalidArgument(
562 "write_batch_multi requires at least one column".into(),
563 ));
564 }
565
566 for col in columns {
567 Self::validate_embedding_dim_for_policy(col.embeddings, &col.policy)?;
568 }
569
570 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
571 let file_path = format!("data/part-{:05}.parquet", part_num);
572
573 let col_batches: Vec<VectorColumnBatch<'_>> = columns
574 .iter()
575 .map(|c| VectorColumnBatch {
576 policy: &c.policy,
577 embeddings: c.embeddings,
578 })
579 .collect();
580
581 let primary_policy = &columns[0].policy;
582 let mut file_writer = AilakeFileWriter::new(primary_policy.clone());
583 if let Some(ref fts_cfg) = self.fts_config {
584 file_writer = file_writer.with_fts(fts_cfg.clone());
585 }
586 let file_bytes: Bytes = file_writer.write_multi(batch, &col_batches)?;
587 let file_size = file_bytes.len() as u64;
588
589 self.store.put(&file_path, file_bytes.clone()).await?;
590
591 let primary_centroid =
593 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
594
595 let reader = ailake_file::AilakeFileReader::new(
597 file_bytes.clone(),
598 &primary_policy.column_name,
599 primary_policy.dim,
600 );
601 let primary_ailk_start = reader.ailk_offset()?;
602 let primary_header = {
603 use ailake_file::HEADER_SIZE;
604 let start = primary_ailk_start as usize;
605 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
606 .try_into()
607 .map_err(|_| AilakeError::NotAnAilakeFile)?;
608 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
609 };
610 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
611
612 let mut extra: Vec<ExtraVectorIndex> = Vec::new();
614 for col in columns.iter().skip(1) {
615 let col_ailk_start = reader.ailk_offset_for_column(&col.policy.column_name)?;
616 let col_header = {
617 use ailake_file::HEADER_SIZE;
618 let start = col_ailk_start as usize;
619 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
620 .try_into()
621 .map_err(|_| AilakeError::NotAnAilakeFile)?;
622 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
623 };
624 let col_centroid = compute_centroid_and_radius(col.embeddings, col.policy.metric);
625 extra.push(ExtraVectorIndex {
626 column: col.policy.column_name.clone(),
627 dim: col.policy.dim,
628 hnsw_offset: col_ailk_start + col_header.hnsw_offset,
629 hnsw_len: col_header.hnsw_len,
630 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
631 radius: Some(col_centroid.radius),
632 });
633 }
634
635 let mut entry = make_multi_column_data_file_entry(
636 &file_path,
637 columns[0].embeddings.len() as u64,
638 file_size,
639 &primary_centroid,
640 VectorIndexInfo {
641 column: &primary_policy.column_name,
642 dim: primary_policy.dim,
643 hnsw_offset: primary_hnsw_abs,
644 hnsw_len: primary_header.hnsw_len,
645 },
646 &extra,
647 );
648 entry.embedding_model = self
649 .policy
650 .embedding_model
651 .as_ref()
652 .map(|m| m.to_property_value());
653 entry.partition_value =
654 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
655 self.pending_files.push(entry);
656 Ok(())
657 }
658
659 pub async fn write_batch_multi_deferred(
672 &mut self,
673 batch: &RecordBatch,
674 columns: &[MultiVectorBatch<'_>],
675 ) -> AilakeResult<()> {
676 use ailake_core::AilakeError;
677 if columns.is_empty() {
678 return Err(AilakeError::InvalidArgument(
679 "write_batch_multi_deferred requires at least one column".into(),
680 ));
681 }
682 if self.captured_schema.is_none() {
683 self.captured_schema = Some(batch.schema());
684 }
685 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
686 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
687 }
688
689 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
690 let file_path = format!("data/part-{:05}.parquet", part_num);
691
692 let primary_policy = &columns[0].policy;
694 let file_writer = AilakeFileWriter::new(primary_policy.clone());
695 let parquet_bytes = file_writer.write_parquet_only(batch, columns[0].embeddings)?;
696 let file_size = parquet_bytes.len() as u64;
697 self.store.put(&file_path, parquet_bytes).await?;
698
699 let primary_centroid =
701 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
702 let mut entry = make_data_file_entry_indexing(
703 &file_path,
704 columns[0].embeddings.len() as u64,
705 file_size,
706 &primary_centroid,
707 &primary_policy.column_name,
708 primary_policy.dim,
709 );
710 entry.extra_vector_indexes = columns[1..]
713 .iter()
714 .map(|c| {
715 let col_centroid = compute_centroid_and_radius(c.embeddings, c.policy.metric);
716 ExtraVectorIndex {
717 column: c.policy.column_name.clone(),
718 dim: c.policy.dim,
719 hnsw_offset: 0,
720 hnsw_len: 0,
721 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
722 radius: Some(col_centroid.radius),
723 }
724 })
725 .collect();
726 entry.embedding_model = self
727 .policy
728 .embedding_model
729 .as_ref()
730 .map(|m| m.to_property_value());
731 entry.partition_value =
732 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
733 self.pending_files.push(entry);
734
735 let all_policies: Vec<VectorStoragePolicy> =
737 columns.iter().map(|c| c.policy.clone()).collect();
738 let all_embeddings: Vec<Vec<Vec<f32>>> =
739 columns.iter().map(|c| c.embeddings.to_vec()).collect();
740 let store = self.store.clone();
741 let catalog = self.catalog.clone();
742 let table = self.table.clone();
743 let fp = file_path.clone();
744 tokio::spawn(async move {
745 if let Err(e) = build_and_patch_multi_index(
746 store,
747 catalog.clone(),
748 all_policies,
749 table.clone(),
750 fp.clone(),
751 all_embeddings,
752 )
753 .await
754 {
755 error!(
756 "ailake: deferred multi-column HNSW build failed for {fp}: {e}; \
757 marking IndexStatus::Failed — compaction will rebuild"
758 );
759 patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
760 }
761 });
762
763 Ok(())
764 }
765
766 fn build_bloom_for_file(&mut self, batch: &RecordBatch, file_path: &str) {
775 use arrow_array::cast::AsArray;
776 let col_name = match &self.bm25_text_column {
777 Some(c) => c.clone(),
778 None => return,
779 };
780 let col = match batch.column_by_name(&col_name) {
781 Some(c) => c,
782 None => return,
783 };
784 let str_arr = match col.as_string_opt::<i32>() {
785 Some(a) => a,
786 None => return,
787 };
788 let cap = (batch.num_rows() * 10).max(128);
790 let mut bloom = crate::bloom::BloomFilter::with_capacity(cap, 0.01);
791 for i in 0..str_arr.len() {
792 if str_arr.is_valid(i) {
793 for term in crate::bm25::tokenize(str_arr.value(i)) {
794 bloom.insert(&term);
795 }
796 }
797 }
798 self.pending_blooms
799 .push((file_path.to_string(), bloom.to_bytes()));
800 }
801
802 async fn update_bm25_stats_from_batch(&self, batch: &RecordBatch) -> AilakeResult<()> {
808 use arrow_array::cast::AsArray;
809
810 let col_name = match &self.bm25_text_column {
811 Some(c) => c.as_str(),
812 None => return Ok(()),
813 };
814 let col = match batch.column_by_name(col_name) {
815 Some(c) => c,
816 None => {
817 tracing::warn!(
818 "ailake: BM25 text column '{}' not found in batch — skipping IDF update",
819 col_name
820 );
821 return Ok(());
822 }
823 };
824 let str_arr = match col.as_string_opt::<i32>() {
825 Some(a) => a,
826 None => {
827 tracing::warn!(
828 "ailake: BM25 text column '{}' is not a Utf8 column — skipping",
829 col_name
830 );
831 return Ok(());
832 }
833 };
834
835 let texts: Vec<&str> = (0..str_arr.len())
836 .filter(|&i| str_arr.is_valid(i))
837 .map(|i| str_arr.value(i))
838 .collect();
839
840 let stats_path = crate::bm25::BM25_STATS_FILE;
842 let mut stats: crate::bm25::IdfStats = match self.store.get(stats_path).await {
843 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
844 Err(_) => crate::bm25::IdfStats::default(),
845 };
846
847 stats.merge_batch(&texts);
848
849 let bytes = stats.to_bytes()?;
850 self.store
851 .put(stats_path, bytes::Bytes::from(bytes))
852 .await?;
853 Ok(())
854 }
855
856 pub async fn commit(mut self) -> AilakeResult<SnapshotId> {
857 if self.pending_files.is_empty() {
858 let current = self
859 .catalog
860 .load_table(&self.table)
861 .await
862 .ok()
863 .and_then(|m| m.current_snapshot_id)
864 .unwrap_or(0);
865 return Ok(current);
866 }
867 let iceberg_schema = self
868 .captured_schema
869 .as_deref()
870 .map(|s| arrow_schema_to_iceberg_update(s, &self.policy, &self.extra_vec_policies));
871 let mut extra_properties = std::collections::HashMap::new();
874 if let Some(ref fts_cfg) = self.fts_config {
875 extra_properties.insert("ailake.fts.enabled".to_string(), "true".to_string());
876 extra_properties.insert(
877 "ailake.fts.text-columns".to_string(),
878 fts_cfg.text_columns.join(","),
879 );
880 extra_properties.insert(
881 "ailake.fts.tokenizer".to_string(),
882 fts_cfg.tokenizer.clone(),
883 );
884 }
885 for ep in &self.extra_vec_policies {
886 extra_properties.insert(format!("ailake.dim-{}", ep.column_name), ep.dim.to_string());
887 extra_properties.insert(
888 format!("ailake.metric-{}", ep.column_name),
889 ailake_parquet::schema::metric_str(ep.metric).to_string(),
890 );
891 if let Some(modality) = ep.modality {
892 extra_properties.insert(
893 format!("ailake.modality-{}", ep.column_name),
894 modality.as_str().to_string(),
895 );
896 }
897 }
898 let snapshot = NewSnapshot {
899 snapshot_id: new_snapshot_id(),
900 parent_snapshot_id: self.parent_snapshot_id,
901 files: std::mem::take(&mut self.pending_files),
902 operation: SnapshotOperation::Append,
903 iceberg_schema,
904 extra_properties,
905 bloom_filters: std::mem::take(&mut self.pending_blooms),
906 equality_delete_files: vec![],
907 };
908 self.catalog.commit_snapshot(&self.table, snapshot).await
909 }
910
911 pub async fn create_or_open(
913 catalog: Arc<dyn CatalogProvider>,
914 store: Arc<dyn Store>,
915 policy: VectorStoragePolicy,
916 table: TableIdent,
917 format_version: u8,
918 ) -> AilakeResult<Self> {
919 let existing_file_count: u32;
922
923 match catalog.load_table(&table).await {
924 Ok(existing_meta) => {
925 if let Some(stored_dim_str) = existing_meta.properties.get("ailake.vector-dim") {
929 if let Ok(stored_dim) = stored_dim_str.parse::<u32>() {
930 if stored_dim != policy.dim {
931 let table_model = policy
932 .embedding_model
933 .as_ref()
934 .map(|m| m.to_property_value())
935 .unwrap_or_else(|| format!("dim={}", stored_dim));
936 return Err(AilakeError::ModelMismatch {
937 table_model,
938 table_dim: stored_dim,
939 batch_model: format!("dim={}", policy.dim),
940 batch_dim: policy.dim,
941 });
942 }
943 }
944 }
945 if let Some(incoming) = &policy.embedding_model {
949 if let Some(stored_val) = existing_meta
950 .properties
951 .get(EmbeddingModelInfo::property_key())
952 {
953 let stored = EmbeddingModelInfo::from_property_value(stored_val);
954 if stored.name != incoming.name {
955 warn!(
956 "ailake: embedding model name changed: table has '{}', writing with '{}' \
957 (dim={}). Vectors may be incompatible for similarity search.",
958 stored.name, incoming.name, policy.dim
959 );
960 }
961 }
962 }
963 existing_file_count = catalog
964 .list_files(&table, None)
965 .await
966 .unwrap_or_default()
967 .len() as u32;
968 }
969 Err(_) => {
970 catalog
971 .create_table(
972 &table,
973 &TableProperties {
974 partition_column_type: policy.partition_column_type.clone(),
975 policy: policy.clone(),
976 extra: std::collections::HashMap::new(),
977 format_version,
978 },
979 )
980 .await?;
981 existing_file_count = 0;
982 }
983 }
984 let mut writer = Self::new(catalog, store, policy, table);
985 writer.part_counter = Arc::new(AtomicU32::new(existing_file_count));
986 Ok(writer)
987 }
988}
989
990fn arrow_schema_to_iceberg_update(
997 schema: &arrow_schema::Schema,
998 policy: &VectorStoragePolicy,
999 extra_vec_policies: &[VectorStoragePolicy],
1000) -> IcebergSchemaUpdate {
1001 let bytes_per_dim = policy.precision.bytes_per_element() as u32;
1002 let vec_fixed_len = policy.dim * bytes_per_dim;
1003
1004 let has_primary_in_batch = schema
1006 .fields()
1007 .iter()
1008 .any(|f| f.name() == &policy.column_name);
1009 let vec_cols: Vec<(String, u32)> = {
1010 let mut v = Vec::new();
1011 if !has_primary_in_batch {
1012 v.push((policy.column_name.clone(), vec_fixed_len));
1013 }
1014 for ep in extra_vec_policies {
1015 let ep_fixed_len = ep.dim * ep.precision.bytes_per_element() as u32;
1016 if !schema.fields().iter().any(|f| f.name() == &ep.column_name) {
1017 v.push((ep.column_name.clone(), ep_fixed_len));
1018 }
1019 }
1020 v
1021 };
1022
1023 let top_level_count = schema.fields().len() + vec_cols.len();
1025 let mut nested_id = top_level_count as i32;
1027
1028 let mut fields: Vec<serde_json::Value> = Vec::new();
1029 let mut name_mapping: Vec<serde_json::Value> = Vec::new();
1030
1031 for (idx, field) in schema.fields().iter().enumerate() {
1032 let field_id = (idx + 1) as i32;
1033 let iceberg_type = arrow_type_to_iceberg(field.data_type(), &mut nested_id);
1034 fields.push(serde_json::json!({
1035 "id": field_id,
1036 "name": field.name(),
1037 "required": false,
1038 "type": iceberg_type,
1039 }));
1040 name_mapping.push(serde_json::json!({
1041 "field-id": field_id,
1042 "names": [field.name()],
1043 }));
1044 }
1045
1046 for (i, (col_name, fixed_len)) in vec_cols.iter().enumerate() {
1048 let field_id = (schema.fields().len() + 1 + i) as i32;
1049 fields.push(serde_json::json!({
1050 "id": field_id,
1051 "name": col_name,
1052 "required": false,
1053 "type": format!("fixed[{fixed_len}]"),
1054 }));
1055 name_mapping.push(serde_json::json!({
1056 "field-id": field_id,
1057 "names": [col_name],
1058 }));
1059 }
1060
1061 let last_column_id = nested_id;
1062 let name_mapping_json = serde_json::to_string(&name_mapping).unwrap_or_else(|_| "[]".into());
1063
1064 IcebergSchemaUpdate {
1065 fields,
1066 last_column_id,
1067 name_mapping_json,
1068 }
1069}
1070
1071fn arrow_type_to_iceberg(dt: &arrow_schema::DataType, nested_id: &mut i32) -> serde_json::Value {
1076 use arrow_schema::DataType;
1077 match dt {
1078 DataType::Boolean => serde_json::json!("boolean"),
1079 DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
1080 serde_json::json!("int")
1081 }
1082 DataType::Int64 | DataType::UInt32 | DataType::UInt64 => serde_json::json!("long"),
1083 DataType::Float16 | DataType::Float32 => serde_json::json!("float"),
1084 DataType::Float64 => serde_json::json!("double"),
1085 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => serde_json::json!("string"),
1086 DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
1087 serde_json::json!("binary")
1088 }
1089 DataType::Date32 | DataType::Date64 => serde_json::json!("date"),
1090 DataType::Timestamp(_, Some(_)) => serde_json::json!("timestamptz"),
1092 DataType::Timestamp(_, None) => serde_json::json!("timestamp"),
1093 DataType::Time32(_) | DataType::Time64(_) => serde_json::json!("time"),
1094 DataType::FixedSizeBinary(n) => serde_json::json!(format!("fixed[{n}]")),
1095 DataType::Decimal128(p, s) | DataType::Decimal256(p, s) => {
1096 serde_json::json!(format!("decimal({p}, {s})"))
1097 }
1098 DataType::List(inner)
1099 | DataType::LargeList(inner)
1100 | DataType::ListView(inner)
1101 | DataType::FixedSizeList(inner, _) => {
1102 *nested_id += 1;
1103 let element_id = *nested_id;
1104 let element_type = arrow_type_to_iceberg(inner.data_type(), nested_id);
1105 serde_json::json!({
1106 "type": "list",
1107 "element-id": element_id,
1108 "element": element_type,
1109 "element-required": !inner.is_nullable(),
1110 })
1111 }
1112 DataType::Struct(arrow_fields) => {
1113 let struct_fields: Vec<serde_json::Value> = arrow_fields
1114 .iter()
1115 .map(|f| {
1116 *nested_id += 1;
1117 let fid = *nested_id;
1118 let ftype = arrow_type_to_iceberg(f.data_type(), nested_id);
1119 serde_json::json!({
1120 "id": fid,
1121 "name": f.name(),
1122 "required": !f.is_nullable(),
1123 "type": ftype,
1124 })
1125 })
1126 .collect();
1127 serde_json::json!({ "type": "struct", "fields": struct_fields })
1128 }
1129 DataType::Map(entries, _) => {
1130 *nested_id += 1;
1132 let key_id = *nested_id;
1133 *nested_id += 1;
1134 let val_id = *nested_id;
1135 if let DataType::Struct(kv_fields) = entries.data_type() {
1136 let key_f = kv_fields
1137 .iter()
1138 .find(|f| f.name() == "key" || f.name() == "keys");
1139 let val_f = kv_fields
1140 .iter()
1141 .find(|f| f.name() == "value" || f.name() == "values");
1142 let key_type = key_f
1143 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1144 .unwrap_or(serde_json::json!("binary"));
1145 let val_type = val_f
1146 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1147 .unwrap_or(serde_json::json!("binary"));
1148 let val_required = val_f.map(|f| !f.is_nullable()).unwrap_or(false);
1149 serde_json::json!({
1150 "type": "map",
1151 "key-id": key_id,
1152 "key": key_type,
1153 "value-id": val_id,
1154 "value": val_type,
1155 "value-required": val_required,
1156 })
1157 } else {
1158 serde_json::json!("binary")
1159 }
1160 }
1161 _ => serde_json::json!("binary"),
1162 }
1163}
1164
1165async fn patch_index_failed(
1170 catalog: Arc<dyn CatalogProvider>,
1171 table: &TableIdent,
1172 file_path: &str,
1173 reason: &str,
1174) {
1175 let Ok(table_meta) = catalog.load_table(table).await else {
1176 return;
1177 };
1178 let parent_snapshot_id = table_meta.current_snapshot_id;
1179 let Ok(mut files) = catalog.list_files(table, None).await else {
1180 return;
1181 };
1182 for f in &mut files {
1183 if f.path == file_path {
1184 f.index_status = IndexStatus::Failed;
1185 f.index_error = Some(reason.to_string());
1186 break;
1187 }
1188 }
1189 let _ = catalog
1190 .commit_snapshot(
1191 table,
1192 NewSnapshot {
1193 snapshot_id: new_snapshot_id(),
1194 parent_snapshot_id,
1195 files,
1196 operation: SnapshotOperation::Replace,
1197 iceberg_schema: None,
1198 extra_properties: std::collections::HashMap::new(),
1199 bloom_filters: vec![],
1200 equality_delete_files: vec![],
1201 },
1202 )
1203 .await
1204 .map_err(|e| {
1205 error!(
1206 "ailake: failed to write IndexStatus::Failed for {file_path}: {e}; \
1207 file will remain Indexing until compaction"
1208 )
1209 });
1210}
1211
1212pub(crate) async fn build_and_patch_index(
1213 store: Arc<dyn Store>,
1214 catalog: Arc<dyn CatalogProvider>,
1215 policy: VectorStoragePolicy,
1216 table: TableIdent,
1217 file_path: String,
1218) -> AilakeResult<()> {
1219 let parquet_bytes = store.get(&file_path).await?;
1221 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1222 let (batch, embeddings) = reader.read_parquet()?;
1223
1224 let full_bytes = tokio::task::spawn_blocking({
1227 let policy = policy.clone();
1228 move || {
1229 let file_writer = AilakeFileWriter::new(policy);
1230 file_writer.write(&batch, &embeddings)
1231 }
1232 })
1233 .await
1234 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1235
1236 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1238 let header = full_reader.read_header()?;
1239 let ailk_start = full_reader.ailk_offset()?;
1240 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1241 let hnsw_len = header.hnsw_len;
1242
1243 store.put(&file_path, full_bytes).await?;
1245
1246 let mut committed = false;
1249 for _ in 0..120u32 {
1250 match catalog.load_table(&table).await {
1251 Ok(meta) if meta.current_snapshot_id.is_some() => {
1252 committed = true;
1253 break;
1254 }
1255 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1256 }
1257 }
1258 if !committed {
1259 return Err(ailake_core::AilakeError::Store(format!(
1260 "deferred HNSW build: no snapshot committed for {file_path} after 60 s — \
1261 did you call TableWriter::commit()?"
1262 )));
1263 }
1264
1265 for attempt in 0..50u32 {
1270 let table_meta = catalog.load_table(&table).await?;
1271 let parent_snapshot_id = table_meta.current_snapshot_id;
1272 let mut files = catalog.list_files(&table, None).await?;
1273
1274 if files
1276 .iter()
1277 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1278 {
1279 break;
1280 }
1281
1282 for f in &mut files {
1283 if f.path == file_path {
1284 f.hnsw_offset = Some(hnsw_abs_offset);
1285 f.hnsw_len = Some(hnsw_len);
1286 f.index_status = IndexStatus::Ready;
1287 break;
1288 }
1289 }
1290 catalog
1291 .commit_snapshot(
1292 &table,
1293 NewSnapshot {
1294 snapshot_id: new_snapshot_id(),
1295 parent_snapshot_id,
1296 files,
1297 operation: SnapshotOperation::Replace,
1298 iceberg_schema: None,
1299 extra_properties: std::collections::HashMap::new(),
1300 bloom_filters: vec![],
1301 equality_delete_files: vec![],
1302 },
1303 )
1304 .await?;
1305
1306 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1308
1309 let verify = catalog.list_files(&table, None).await?;
1310 if verify
1311 .iter()
1312 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1313 {
1314 break;
1315 }
1316 }
1318
1319 info!(
1320 "ailake: deferred HNSW index built for {} (offset={}, len={})",
1321 file_path, hnsw_abs_offset, hnsw_len
1322 );
1323 Ok(())
1324}
1325
1326async fn build_ivf_pq_and_patch_index(
1331 store: Arc<dyn Store>,
1332 catalog: Arc<dyn CatalogProvider>,
1333 policy: VectorStoragePolicy,
1334 table: TableIdent,
1335 file_path: String,
1336 ivf_config: IvfPqConfig,
1337 codebook_cell: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
1338) -> AilakeResult<()> {
1339 let parquet_bytes = store.get(&file_path).await?;
1340 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1341 let (batch, embeddings) = reader.read_parquet()?;
1342
1343 let codebook = codebook_cell
1345 .get_or_try_init(|| async {
1346 let vecs = embeddings.clone();
1347 let metric = policy.metric;
1348 let cfg = ivf_config.clone();
1349 tokio::task::spawn_blocking(move || {
1350 ailake_index::IvfPqIndex::train_codebook(&vecs, metric, &cfg)
1351 })
1352 .await
1353 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))?
1354 })
1355 .await?;
1356
1357 let full_bytes = tokio::task::spawn_blocking({
1358 let policy = policy.clone();
1359 let codebook = codebook.clone();
1360 move || {
1361 let file_writer = AilakeFileWriter::new(policy)
1362 .with_index_type(IndexType::IvfPq(ivf_config))
1363 .with_shared_ivf_codebook(Arc::new(codebook));
1364 file_writer.write(&batch, &embeddings)
1365 }
1366 })
1367 .await
1368 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1369
1370 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1371 let header = full_reader.read_header()?;
1372 let ailk_start = full_reader.ailk_offset()?;
1373 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1374 let hnsw_len = header.hnsw_len;
1375
1376 store.put(&file_path, full_bytes).await?;
1377
1378 let mut committed = false;
1380 for _ in 0..120u32 {
1381 match catalog.load_table(&table).await {
1382 Ok(meta) if meta.current_snapshot_id.is_some() => {
1383 committed = true;
1384 break;
1385 }
1386 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1387 }
1388 }
1389 if !committed {
1390 return Err(ailake_core::AilakeError::Store(format!(
1391 "deferred IVF-PQ build: no snapshot committed for {file_path} after 60 s — \
1392 did you call TableWriter::commit()?"
1393 )));
1394 }
1395
1396 for attempt in 0..50u32 {
1397 let table_meta = catalog.load_table(&table).await?;
1398 let parent_snapshot_id = table_meta.current_snapshot_id;
1399 let mut files = catalog.list_files(&table, None).await?;
1400
1401 if files
1402 .iter()
1403 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1404 {
1405 break;
1406 }
1407
1408 for f in &mut files {
1409 if f.path == file_path {
1410 f.hnsw_offset = Some(hnsw_abs_offset);
1411 f.hnsw_len = Some(hnsw_len);
1412 f.index_status = IndexStatus::Ready;
1413 break;
1414 }
1415 }
1416 catalog
1417 .commit_snapshot(
1418 &table,
1419 NewSnapshot {
1420 snapshot_id: new_snapshot_id(),
1421 parent_snapshot_id,
1422 files,
1423 operation: SnapshotOperation::Replace,
1424 iceberg_schema: None,
1425 extra_properties: std::collections::HashMap::new(),
1426 bloom_filters: vec![],
1427 equality_delete_files: vec![],
1428 },
1429 )
1430 .await?;
1431
1432 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1433
1434 let verify = catalog.list_files(&table, None).await?;
1435 if verify
1436 .iter()
1437 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1438 {
1439 break;
1440 }
1441 }
1442
1443 info!(
1444 "ailake: deferred IVF-PQ index built for {} (offset={}, len={})",
1445 file_path, hnsw_abs_offset, hnsw_len
1446 );
1447 Ok(())
1448}
1449
1450async fn build_and_patch_multi_index(
1456 store: Arc<dyn Store>,
1457 catalog: Arc<dyn CatalogProvider>,
1458 policies: Vec<VectorStoragePolicy>,
1459 table: TableIdent,
1460 file_path: String,
1461 all_embeddings: Vec<Vec<Vec<f32>>>,
1462) -> AilakeResult<()> {
1463 let parquet_bytes = store.get(&file_path).await?;
1465 let primary_reader =
1466 AilakeFileReader::new(parquet_bytes, &policies[0].column_name, policies[0].dim);
1467 let (batch, _) = primary_reader.read_parquet()?;
1468
1469 let full_bytes = tokio::task::spawn_blocking({
1471 let policies = policies.clone();
1472 let all_embeddings = all_embeddings.clone();
1473 move || {
1474 let col_batches: Vec<VectorColumnBatch<'_>> = policies
1475 .iter()
1476 .zip(all_embeddings.iter())
1477 .map(|(p, embs)| VectorColumnBatch {
1478 policy: p,
1479 embeddings: embs.as_slice(),
1480 })
1481 .collect();
1482 let file_writer = AilakeFileWriter::new(policies[0].clone());
1483 file_writer.write_multi(&batch, &col_batches)
1484 }
1485 })
1486 .await
1487 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1488
1489 let primary_reader = AilakeFileReader::new(
1491 full_bytes.clone(),
1492 &policies[0].column_name,
1493 policies[0].dim,
1494 );
1495 let primary_header = primary_reader.read_header()?;
1496 let primary_ailk_start = primary_reader.ailk_offset()?;
1497 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
1498 let primary_hnsw_len = primary_header.hnsw_len;
1499
1500 let mut extra_offsets: Vec<(u64, u64)> = Vec::with_capacity(policies.len().saturating_sub(1));
1505 for col_policy in policies.iter().skip(1) {
1506 let col_reader =
1507 AilakeFileReader::new(full_bytes.clone(), &col_policy.column_name, col_policy.dim);
1508 let col_ailk_start = col_reader.ailk_offset_for_column(&col_policy.column_name)?;
1509 let col_header = col_reader.read_header_for_column(&col_policy.column_name)?;
1510 extra_offsets.push((col_ailk_start + col_header.hnsw_offset, col_header.hnsw_len));
1511 }
1512
1513 store.put(&file_path, full_bytes).await?;
1515
1516 let mut committed = false;
1518 for _ in 0..120u32 {
1519 match catalog.load_table(&table).await {
1520 Ok(meta) if meta.current_snapshot_id.is_some() => {
1521 committed = true;
1522 break;
1523 }
1524 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1525 }
1526 }
1527 if !committed {
1528 return Err(ailake_core::AilakeError::Store(format!(
1529 "deferred index build: no snapshot committed for {file_path} after 60 s — \
1530 did you call TableWriter::commit()?"
1531 )));
1532 }
1533
1534 for attempt in 0..50u32 {
1536 let table_meta = catalog.load_table(&table).await?;
1537 let parent_snapshot_id = table_meta.current_snapshot_id;
1538 let mut files = catalog.list_files(&table, None).await?;
1539
1540 if files
1541 .iter()
1542 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1543 {
1544 break;
1545 }
1546
1547 for f in &mut files {
1548 if f.path == file_path {
1549 f.hnsw_offset = Some(primary_hnsw_abs);
1550 f.hnsw_len = Some(primary_hnsw_len);
1551 f.index_status = IndexStatus::Ready;
1552 for (i, &(off, len)) in extra_offsets.iter().enumerate() {
1553 if let Some(xi) = f.extra_vector_indexes.get_mut(i) {
1554 xi.hnsw_offset = off;
1555 xi.hnsw_len = len;
1556 }
1557 }
1558 break;
1559 }
1560 }
1561 catalog
1562 .commit_snapshot(
1563 &table,
1564 NewSnapshot {
1565 snapshot_id: new_snapshot_id(),
1566 parent_snapshot_id,
1567 files,
1568 operation: SnapshotOperation::Replace,
1569 iceberg_schema: None,
1570 extra_properties: std::collections::HashMap::new(),
1571 bloom_filters: vec![],
1572 equality_delete_files: vec![],
1573 },
1574 )
1575 .await?;
1576
1577 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1578
1579 let verify = catalog.list_files(&table, None).await?;
1580 if verify
1581 .iter()
1582 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1583 {
1584 break;
1585 }
1586 }
1587
1588 info!(
1589 "ailake: deferred multi-column HNSW built for {} ({} cols, primary offset={})",
1590 file_path,
1591 policies.len(),
1592 primary_hnsw_abs
1593 );
1594 Ok(())
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599 use super::*;
1600 use ailake_core::{VectorMetric, VectorPrecision};
1601 use arrow_schema::{DataType, Field, Schema, TimeUnit};
1602
1603 fn policy(col: &str, dim: u32) -> VectorStoragePolicy {
1604 VectorStoragePolicy {
1605 column_name: col.to_string(),
1606 dim,
1607 metric: VectorMetric::Cosine,
1608 precision: VectorPrecision::F16,
1609 pq: None,
1610 keep_raw_for_reranking: true,
1611 pre_normalize: false,
1612 hnsw_m: None,
1613 hnsw_ef_construction: None,
1614 ivf_residual: false,
1615 embedding_model: None,
1616 modality: None,
1617 partition_by: None,
1618 partition_value: None,
1619 partition_column_type: None,
1620 partition_fields: vec![],
1621 }
1622 }
1623
1624 fn update_for(schema: &Schema, pol: &VectorStoragePolicy) -> IcebergSchemaUpdate {
1625 arrow_schema_to_iceberg_update(schema, pol, &[])
1626 }
1627
1628 #[test]
1629 fn simple_schema_produces_correct_fields() {
1630 let schema = Schema::new(vec![
1631 Field::new("id", DataType::Int32, false),
1632 Field::new("text", DataType::Utf8, false),
1633 ]);
1634 let pol = policy("embedding", 8);
1635 let upd = update_for(&schema, &pol);
1636
1637 assert_eq!(upd.fields.len(), 3);
1638 assert_eq!(upd.fields[0]["id"], 1);
1639 assert_eq!(upd.fields[0]["type"], "int");
1640 assert_eq!(upd.fields[1]["id"], 2);
1641 assert_eq!(upd.fields[1]["type"], "string");
1642 assert_eq!(upd.fields[2]["id"], 3);
1643 assert_eq!(upd.fields[2]["type"], "fixed[16]"); let nm: Vec<serde_json::Value> = serde_json::from_str(&upd.name_mapping_json).unwrap();
1646 assert_eq!(nm.len(), 3);
1647 assert_eq!(nm[2]["field-id"], 3);
1648 assert_eq!(nm[2]["names"][0], "embedding");
1649 assert_eq!(upd.last_column_id, 3);
1650 }
1651
1652 #[test]
1653 fn timestamp_without_tz_maps_to_timestamp_not_timestamptz() {
1654 let schema = Schema::new(vec![
1655 Field::new(
1656 "created_at",
1657 DataType::Timestamp(TimeUnit::Microsecond, None),
1658 true,
1659 ),
1660 Field::new(
1661 "updated_at",
1662 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
1663 true,
1664 ),
1665 ]);
1666 let pol = policy("vec", 4);
1667 let upd = update_for(&schema, &pol);
1668
1669 assert_eq!(upd.fields[0]["type"], "timestamp");
1670 assert_eq!(upd.fields[1]["type"], "timestamptz");
1671 }
1672
1673 #[test]
1674 fn list_type_produces_iceberg_list_object() {
1675 let schema = Schema::new(vec![Field::new(
1676 "tags",
1677 DataType::List(std::sync::Arc::new(Field::new(
1678 "item",
1679 DataType::Utf8,
1680 true,
1681 ))),
1682 true,
1683 )]);
1684 let pol = policy("vec", 4);
1685 let upd = update_for(&schema, &pol);
1686
1687 let t = &upd.fields[0]["type"];
1688 assert_eq!(t["type"], "list");
1689 assert_eq!(t["element"], "string");
1690 assert!(t["element-id"].as_i64().unwrap() > 2);
1692 }
1693
1694 #[test]
1695 fn struct_type_produces_nested_fields() {
1696 let schema = Schema::new(vec![Field::new(
1697 "meta",
1698 DataType::Struct(
1699 vec![
1700 Field::new("key", DataType::Utf8, false),
1701 Field::new("val", DataType::Int64, false),
1702 ]
1703 .into(),
1704 ),
1705 true,
1706 )]);
1707 let pol = policy("vec", 4);
1708 let upd = update_for(&schema, &pol);
1709
1710 let t = &upd.fields[0]["type"];
1711 assert_eq!(t["type"], "struct");
1712 let nested = t["fields"].as_array().unwrap();
1713 assert_eq!(nested.len(), 2);
1714 assert_eq!(nested[0]["name"], "key");
1715 assert_eq!(nested[0]["type"], "string");
1716 assert_eq!(nested[1]["name"], "val");
1717 assert_eq!(nested[1]["type"], "long");
1718 assert!(nested[0]["id"].as_i64().unwrap() > 2);
1720 }
1721
1722 #[test]
1723 fn no_duplicate_vec_column_when_already_in_batch() {
1724 let schema = Schema::new(vec![
1726 Field::new("id", DataType::Int32, false),
1727 Field::new("embedding", DataType::FixedSizeBinary(16), false),
1728 ]);
1729 let pol = policy("embedding", 8);
1730 let upd = update_for(&schema, &pol);
1731
1732 assert_eq!(upd.fields.len(), 2, "should not add embedding twice");
1733 let names: Vec<&str> = upd
1734 .fields
1735 .iter()
1736 .map(|f| f["name"].as_str().unwrap())
1737 .collect();
1738 assert_eq!(names.iter().filter(|&&n| n == "embedding").count(), 1);
1739 }
1740
1741 #[test]
1742 fn multi_vec_policies_all_appended() {
1743 let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
1744 let primary = policy("embedding", 4);
1745 let extra = vec![policy("context_embedding", 4)];
1746 let upd = arrow_schema_to_iceberg_update(&schema, &primary, &extra);
1747
1748 assert_eq!(upd.fields.len(), 3); let names: Vec<&str> = upd
1750 .fields
1751 .iter()
1752 .map(|f| f["name"].as_str().unwrap())
1753 .collect();
1754 assert!(names.contains(&"embedding"));
1755 assert!(names.contains(&"context_embedding"));
1756 }
1757
1758 #[test]
1759 fn top_level_field_ids_match_parquet_stamp_sequence() {
1760 let schema = Schema::new(vec![
1762 Field::new("id", DataType::Int64, false),
1763 Field::new(
1764 "tags",
1765 DataType::List(std::sync::Arc::new(Field::new(
1766 "item",
1767 DataType::Utf8,
1768 true,
1769 ))),
1770 true,
1771 ),
1772 ]);
1773 let pol = policy("vec", 4);
1774 let upd = update_for(&schema, &pol);
1775
1776 assert_eq!(upd.fields[0]["id"], 1);
1778 assert_eq!(upd.fields[1]["id"], 2);
1779 assert_eq!(upd.fields[2]["id"], 3);
1780
1781 assert!(upd.fields[1]["type"]["element-id"].as_i64().unwrap() > 3);
1783 }
1784
1785 #[tokio::test]
1788 async fn write_batch_auto_deferred_stages_file() {
1789 use ailake_catalog::{HadoopCatalog, TableIdent};
1790 use ailake_store::LocalStore;
1791 use arrow_schema::{DataType, Field, Schema};
1792
1793 let dir = tempfile::tempdir().unwrap();
1794 let store: std::sync::Arc<dyn ailake_store::Store> =
1795 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1796 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1797 let pol = policy("embedding", 4);
1798 let ident = TableIdent::new("default", "t");
1799
1800 let mut writer = TableWriter::create_or_open(catalog, store, pol, ident, 2)
1801 .await
1802 .unwrap();
1803
1804 let schema =
1805 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1806 let batch = arrow_array::RecordBatch::try_new(
1807 schema,
1808 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1809 "hello",
1810 ]))],
1811 )
1812 .unwrap();
1813 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
1814
1815 writer
1816 .write_batch_auto_deferred(&batch, &embeddings)
1817 .await
1818 .unwrap();
1819
1820 assert_eq!(writer.pending_files.len(), 1);
1822 }
1823
1824 #[tokio::test]
1827 async fn write_batch_multi_deferred_stages_file_with_extra_indexes() {
1828 use ailake_catalog::{HadoopCatalog, IndexStatus, TableIdent};
1829 use ailake_store::LocalStore;
1830 use arrow_schema::{DataType, Field, Schema};
1831
1832 let dir = tempfile::tempdir().unwrap();
1833 let store: std::sync::Arc<dyn ailake_store::Store> =
1834 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1835 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1836 let primary_pol = policy("embedding", 4);
1837 let ident = TableIdent::new("default", "t");
1838
1839 let mut writer = TableWriter::create_or_open(catalog, store, primary_pol, ident, 2)
1840 .await
1841 .unwrap();
1842
1843 let schema =
1844 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1845 let batch = arrow_array::RecordBatch::try_new(
1846 schema,
1847 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1848 "hello", "world",
1849 ]))],
1850 )
1851 .unwrap();
1852
1853 let text_embs = vec![vec![1.0f32, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
1854 let img_embs = vec![vec![1.0f32, 0.0], vec![0.0, 1.0]];
1855
1856 let columns = vec![
1857 MultiVectorBatch {
1858 policy: policy("embedding", 4),
1859 embeddings: &text_embs,
1860 },
1861 MultiVectorBatch {
1862 policy: policy("img_embedding", 2),
1863 embeddings: &img_embs,
1864 },
1865 ];
1866
1867 writer
1868 .write_batch_multi_deferred(&batch, &columns)
1869 .await
1870 .unwrap();
1871
1872 assert_eq!(writer.pending_files.len(), 1);
1873 let entry = &writer.pending_files[0];
1874 assert_eq!(entry.index_status, IndexStatus::Indexing);
1876 assert!(entry.centroid_b64.is_some());
1878 assert_eq!(entry.extra_vector_indexes.len(), 1);
1880 let xi = &entry.extra_vector_indexes[0];
1881 assert_eq!(xi.column, "img_embedding");
1882 assert_eq!(xi.dim, 2);
1883 assert_eq!(xi.hnsw_offset, 0); assert_eq!(xi.hnsw_len, 0); assert!(xi.centroid_b64.is_some());
1886 }
1887}