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(store, catalog, policy, table, fp).await {
186 error!(
187 "ailake: deferred HNSW build failed — file is indexed as Parquet-only until \
188 next compaction rebuilds the index: {}",
189 e
190 );
191 }
192 });
193
194 if self.bm25_text_column.is_some() {
196 self.update_bm25_stats_from_batch(batch).await?;
197 self.build_bloom_for_file(batch, &file_path);
198 }
199
200 Ok(())
201 }
202
203 pub async fn write_batch_ivf_pq_deferred(
209 &mut self,
210 batch: &RecordBatch,
211 embeddings: &[Vec<f32>],
212 ivf_config: IvfPqConfig,
213 ) -> AilakeResult<()> {
214 if self.captured_schema.is_none() {
215 self.captured_schema = Some(batch.schema());
216 }
217 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
218 let file_path = format!("data/part-{:05}.parquet", part_num);
219
220 let file_writer = AilakeFileWriter::new(self.policy.clone());
221 let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
222 let file_size = parquet_bytes.len() as u64;
223 self.store.put(&file_path, parquet_bytes).await?;
224
225 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
226 let mut entry = make_data_file_entry_indexing(
227 &file_path,
228 embeddings.len() as u64,
229 file_size,
230 ¢roid,
231 &self.policy.column_name,
232 self.policy.dim,
233 );
234 entry.embedding_model = self
235 .policy
236 .embedding_model
237 .as_ref()
238 .map(|m| m.to_property_value());
239 entry.partition_value =
240 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
241 self.pending_files.push(entry);
242
243 let store = self.store.clone();
244 let catalog = self.catalog.clone();
245 let policy = self.policy.clone();
246 let table = self.table.clone();
247 let fp = file_path.clone();
248 let codebook_cell = self.deferred_ivf_codebook.clone();
249 tokio::spawn(async move {
250 if let Err(e) = build_ivf_pq_and_patch_index(
251 store,
252 catalog,
253 policy,
254 table,
255 fp,
256 ivf_config,
257 codebook_cell,
258 )
259 .await
260 {
261 error!(
262 "ailake: deferred IVF-PQ build failed — file is indexed as Parquet-only until \
263 next compaction rebuilds the index: {}",
264 e
265 );
266 }
267 });
268
269 Ok(())
270 }
271
272 pub async fn write_batch_idempotent(
281 &mut self,
282 batch: &RecordBatch,
283 embeddings: &[Vec<f32>],
284 batch_id: &str,
285 ) -> AilakeResult<()> {
286 let existing = self.catalog.list_files(&self.table, None).await?;
287 if existing
288 .iter()
289 .any(|f| f.batch_id.as_deref() == Some(batch_id))
290 {
291 return Ok(());
292 }
293 self.write_batch_with_id(batch, embeddings, Some(batch_id.to_string()))
294 .await
295 }
296
297 fn validate_embedding_dim(&self, embeddings: &[Vec<f32>]) -> AilakeResult<()> {
302 if let Some(first) = embeddings.first() {
303 let actual = first.len() as u32;
304 if actual != self.policy.dim {
305 let table_model = self
306 .policy
307 .embedding_model
308 .as_ref()
309 .map(|m| m.to_property_value())
310 .unwrap_or_else(|| format!("dim={}", self.policy.dim));
311 return Err(AilakeError::ModelMismatch {
312 table_model,
313 table_dim: self.policy.dim,
314 batch_model: format!("dim={}", actual),
315 batch_dim: actual,
316 });
317 }
318 }
319 Ok(())
320 }
321
322 pub async fn write_batch(
323 &mut self,
324 batch: &RecordBatch,
325 embeddings: &[Vec<f32>],
326 ) -> AilakeResult<()> {
327 self.write_batch_with_id(batch, embeddings, None).await
328 }
329
330 async fn write_batch_with_id(
331 &mut self,
332 batch: &RecordBatch,
333 embeddings: &[Vec<f32>],
334 batch_id: Option<String>,
335 ) -> AilakeResult<()> {
336 self.validate_embedding_dim(embeddings)?;
337 if self.captured_schema.is_none() {
338 self.captured_schema = Some(batch.schema());
339 }
340 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
341 let file_path = format!("data/part-{:05}.parquet", part_num);
342
343 let mut file_writer = AilakeFileWriter::new(self.policy.clone());
345 if let Some(ref fts_cfg) = self.fts_config {
346 file_writer = file_writer.with_fts(fts_cfg.clone());
347 }
348 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
349 let file_size = file_bytes.len() as u64;
350
351 self.store.put(&file_path, file_bytes.clone()).await?;
353
354 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
356
357 let reader = ailake_file::AilakeFileReader::new(
359 file_bytes,
360 &self.policy.column_name,
361 self.policy.dim,
362 );
363 let header = reader.read_header()?;
364 let ailk_start = reader.ailk_offset()?;
365 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
366 let hnsw_len = header.hnsw_len;
367
368 let mut entry = make_data_file_entry(
369 &file_path,
370 embeddings.len() as u64,
371 file_size,
372 ¢roid,
373 VectorIndexInfo {
374 column: &self.policy.column_name,
375 dim: self.policy.dim,
376 hnsw_offset: hnsw_abs_offset,
377 hnsw_len,
378 },
379 );
380 entry.batch_id = batch_id;
381 entry.embedding_model = self
382 .policy
383 .embedding_model
384 .as_ref()
385 .map(|m| m.to_property_value());
386 entry.partition_value =
387 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
388 self.pending_files.push(entry);
389
390 if self.bm25_text_column.is_some() {
392 self.update_bm25_stats_from_batch(batch).await?;
393 self.build_bloom_for_file(batch, &file_path);
394 }
395 Ok(())
396 }
397
398 pub async fn write_batch_auto(
404 &mut self,
405 batch: &RecordBatch,
406 embeddings: &[Vec<f32>],
407 ) -> AilakeResult<()> {
408 let profile = ailake_index::HardwareProfile::detect();
409 if profile.recommend_ivf_pq(embeddings.len()) {
410 let mut ivf_config =
411 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
412 if self.policy.ivf_residual {
413 ivf_config = ivf_config.with_residual();
414 }
415 self.write_batch_ivf_pq(batch, embeddings, ivf_config).await
416 } else {
417 self.write_batch(batch, embeddings).await
418 }
419 }
420
421 pub async fn write_batch_auto_deferred(
432 &mut self,
433 batch: &RecordBatch,
434 embeddings: &[Vec<f32>],
435 ) -> AilakeResult<()> {
436 let profile = ailake_index::HardwareProfile::detect();
437 if profile.recommend_ivf_pq(embeddings.len()) {
438 let mut ivf_config =
439 ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
440 if self.policy.ivf_residual {
441 ivf_config = ivf_config.with_residual();
442 }
443 self.write_batch_ivf_pq_deferred(batch, embeddings, ivf_config)
444 .await
445 } else {
446 self.write_batch_deferred(batch, embeddings).await
447 }
448 }
449
450 pub async fn write_batch_ivf_pq(
454 &mut self,
455 batch: &RecordBatch,
456 embeddings: &[Vec<f32>],
457 ivf_config: IvfPqConfig,
458 ) -> AilakeResult<()> {
459 if self.captured_schema.is_none() {
460 self.captured_schema = Some(batch.schema());
461 }
462 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
463 let file_path = format!("data/part-{:05}.parquet", part_num);
464
465 if self.cached_ivf_codebook.is_none() {
469 let codebook = tokio::task::spawn_blocking({
470 let embeddings = embeddings.to_vec();
471 let metric = self.policy.metric;
472 let config = ivf_config.clone();
473 move || ailake_index::IvfPqIndex::train_codebook(&embeddings, metric, &config)
474 })
475 .await
476 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
477 self.cached_ivf_codebook = Some(Arc::new(codebook));
478 }
479 let codebook = self
481 .cached_ivf_codebook
482 .as_ref()
483 .expect("IVF-PQ codebook must be Some after training block")
484 .clone();
485
486 let file_writer = AilakeFileWriter::new(self.policy.clone())
487 .with_index_type(IndexType::IvfPq(ivf_config))
488 .with_shared_ivf_codebook(codebook);
489 let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
490 let file_size = file_bytes.len() as u64;
491
492 self.store.put(&file_path, file_bytes.clone()).await?;
493
494 let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
495
496 let reader = ailake_file::AilakeFileReader::new(
497 file_bytes,
498 &self.policy.column_name,
499 self.policy.dim,
500 );
501 let header = reader.read_header()?;
502 let ailk_start = reader.ailk_offset()?;
503 let index_abs_offset = ailk_start + header.hnsw_offset;
504 let index_len = header.hnsw_len;
505
506 let mut entry = make_data_file_entry(
507 &file_path,
508 embeddings.len() as u64,
509 file_size,
510 ¢roid,
511 VectorIndexInfo {
512 column: &self.policy.column_name,
513 dim: self.policy.dim,
514 hnsw_offset: index_abs_offset,
515 hnsw_len: index_len,
516 },
517 );
518 entry.embedding_model = self
519 .policy
520 .embedding_model
521 .as_ref()
522 .map(|m| m.to_property_value());
523 entry.partition_value =
524 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
525 self.pending_files.push(entry);
526 Ok(())
527 }
528
529 pub async fn write_batch_multi(
534 &mut self,
535 batch: &RecordBatch,
536 columns: &[MultiVectorBatch<'_>],
537 ) -> AilakeResult<()> {
538 use ailake_core::AilakeError;
539 if self.captured_schema.is_none() {
540 self.captured_schema = Some(batch.schema());
541 }
542 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
543 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
544 }
545
546 if columns.is_empty() {
547 return Err(AilakeError::InvalidArgument(
548 "write_batch_multi requires at least one column".into(),
549 ));
550 }
551
552 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
553 let file_path = format!("data/part-{:05}.parquet", part_num);
554
555 let col_batches: Vec<VectorColumnBatch<'_>> = columns
556 .iter()
557 .map(|c| VectorColumnBatch {
558 policy: &c.policy,
559 embeddings: c.embeddings,
560 })
561 .collect();
562
563 let primary_policy = &columns[0].policy;
564 let mut file_writer = AilakeFileWriter::new(primary_policy.clone());
565 if let Some(ref fts_cfg) = self.fts_config {
566 file_writer = file_writer.with_fts(fts_cfg.clone());
567 }
568 let file_bytes: Bytes = file_writer.write_multi(batch, &col_batches)?;
569 let file_size = file_bytes.len() as u64;
570
571 self.store.put(&file_path, file_bytes.clone()).await?;
572
573 let primary_centroid =
575 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
576
577 let reader = ailake_file::AilakeFileReader::new(
579 file_bytes.clone(),
580 &primary_policy.column_name,
581 primary_policy.dim,
582 );
583 let primary_ailk_start = reader.ailk_offset()?;
584 let primary_header = {
585 use ailake_file::HEADER_SIZE;
586 let start = primary_ailk_start as usize;
587 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
588 .try_into()
589 .map_err(|_| AilakeError::NotAnAilakeFile)?;
590 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
591 };
592 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
593
594 let mut extra: Vec<ExtraVectorIndex> = Vec::new();
596 for col in columns.iter().skip(1) {
597 let col_ailk_start = reader.ailk_offset_for_column(&col.policy.column_name)?;
598 let col_header = {
599 use ailake_file::HEADER_SIZE;
600 let start = col_ailk_start as usize;
601 let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
602 .try_into()
603 .map_err(|_| AilakeError::NotAnAilakeFile)?;
604 ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
605 };
606 let col_centroid = compute_centroid_and_radius(col.embeddings, col.policy.metric);
607 extra.push(ExtraVectorIndex {
608 column: col.policy.column_name.clone(),
609 dim: col.policy.dim,
610 hnsw_offset: col_ailk_start + col_header.hnsw_offset,
611 hnsw_len: col_header.hnsw_len,
612 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
613 radius: Some(col_centroid.radius),
614 });
615 }
616
617 let mut entry = make_multi_column_data_file_entry(
618 &file_path,
619 columns[0].embeddings.len() as u64,
620 file_size,
621 &primary_centroid,
622 VectorIndexInfo {
623 column: &primary_policy.column_name,
624 dim: primary_policy.dim,
625 hnsw_offset: primary_hnsw_abs,
626 hnsw_len: primary_header.hnsw_len,
627 },
628 &extra,
629 );
630 entry.embedding_model = self
631 .policy
632 .embedding_model
633 .as_ref()
634 .map(|m| m.to_property_value());
635 entry.partition_value =
636 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
637 self.pending_files.push(entry);
638 Ok(())
639 }
640
641 pub async fn write_batch_multi_deferred(
654 &mut self,
655 batch: &RecordBatch,
656 columns: &[MultiVectorBatch<'_>],
657 ) -> AilakeResult<()> {
658 use ailake_core::AilakeError;
659 if columns.is_empty() {
660 return Err(AilakeError::InvalidArgument(
661 "write_batch_multi_deferred requires at least one column".into(),
662 ));
663 }
664 if self.captured_schema.is_none() {
665 self.captured_schema = Some(batch.schema());
666 }
667 if self.extra_vec_policies.is_empty() && columns.len() > 1 {
668 self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
669 }
670
671 let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
672 let file_path = format!("data/part-{:05}.parquet", part_num);
673
674 let primary_policy = &columns[0].policy;
676 let file_writer = AilakeFileWriter::new(primary_policy.clone());
677 let parquet_bytes = file_writer.write_parquet_only(batch, columns[0].embeddings)?;
678 let file_size = parquet_bytes.len() as u64;
679 self.store.put(&file_path, parquet_bytes).await?;
680
681 let primary_centroid =
683 compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
684 let mut entry = make_data_file_entry_indexing(
685 &file_path,
686 columns[0].embeddings.len() as u64,
687 file_size,
688 &primary_centroid,
689 &primary_policy.column_name,
690 primary_policy.dim,
691 );
692 entry.extra_vector_indexes = columns[1..]
695 .iter()
696 .map(|c| {
697 let col_centroid = compute_centroid_and_radius(c.embeddings, c.policy.metric);
698 ExtraVectorIndex {
699 column: c.policy.column_name.clone(),
700 dim: c.policy.dim,
701 hnsw_offset: 0,
702 hnsw_len: 0,
703 centroid_b64: Some(encode_centroid_b64(&col_centroid)),
704 radius: Some(col_centroid.radius),
705 }
706 })
707 .collect();
708 entry.embedding_model = self
709 .policy
710 .embedding_model
711 .as_ref()
712 .map(|m| m.to_property_value());
713 entry.partition_value =
714 apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
715 self.pending_files.push(entry);
716
717 let all_policies: Vec<VectorStoragePolicy> =
719 columns.iter().map(|c| c.policy.clone()).collect();
720 let all_embeddings: Vec<Vec<Vec<f32>>> =
721 columns.iter().map(|c| c.embeddings.to_vec()).collect();
722 let store = self.store.clone();
723 let catalog = self.catalog.clone();
724 let table = self.table.clone();
725 let fp = file_path.clone();
726 tokio::spawn(async move {
727 if let Err(e) =
728 build_and_patch_multi_index(store, catalog, all_policies, table, fp, all_embeddings)
729 .await
730 {
731 error!(
732 "ailake: deferred multi-column HNSW build failed — shard stays in flat-scan \
733 mode until next compaction rebuilds the index: {}",
734 e
735 );
736 }
737 });
738
739 Ok(())
740 }
741
742 fn build_bloom_for_file(&mut self, batch: &RecordBatch, file_path: &str) {
751 use arrow_array::cast::AsArray;
752 let col_name = match &self.bm25_text_column {
753 Some(c) => c.clone(),
754 None => return,
755 };
756 let col = match batch.column_by_name(&col_name) {
757 Some(c) => c,
758 None => return,
759 };
760 let str_arr = match col.as_string_opt::<i32>() {
761 Some(a) => a,
762 None => return,
763 };
764 let cap = (batch.num_rows() * 10).max(128);
766 let mut bloom = crate::bloom::BloomFilter::with_capacity(cap, 0.01);
767 for i in 0..str_arr.len() {
768 if str_arr.is_valid(i) {
769 for term in crate::bm25::tokenize(str_arr.value(i)) {
770 bloom.insert(&term);
771 }
772 }
773 }
774 self.pending_blooms
775 .push((file_path.to_string(), bloom.to_bytes()));
776 }
777
778 async fn update_bm25_stats_from_batch(&self, batch: &RecordBatch) -> AilakeResult<()> {
784 use arrow_array::cast::AsArray;
785
786 let col_name = match &self.bm25_text_column {
787 Some(c) => c.as_str(),
788 None => return Ok(()),
789 };
790 let col = match batch.column_by_name(col_name) {
791 Some(c) => c,
792 None => {
793 tracing::warn!(
794 "ailake: BM25 text column '{}' not found in batch — skipping IDF update",
795 col_name
796 );
797 return Ok(());
798 }
799 };
800 let str_arr = match col.as_string_opt::<i32>() {
801 Some(a) => a,
802 None => {
803 tracing::warn!(
804 "ailake: BM25 text column '{}' is not a Utf8 column — skipping",
805 col_name
806 );
807 return Ok(());
808 }
809 };
810
811 let texts: Vec<&str> = (0..str_arr.len())
812 .filter(|&i| str_arr.is_valid(i))
813 .map(|i| str_arr.value(i))
814 .collect();
815
816 let stats_path = crate::bm25::BM25_STATS_FILE;
818 let mut stats: crate::bm25::IdfStats = match self.store.get(stats_path).await {
819 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
820 Err(_) => crate::bm25::IdfStats::default(),
821 };
822
823 stats.merge_batch(&texts);
824
825 let bytes = stats.to_bytes()?;
826 self.store
827 .put(stats_path, bytes::Bytes::from(bytes))
828 .await?;
829 Ok(())
830 }
831
832 pub async fn commit(mut self) -> AilakeResult<SnapshotId> {
833 if self.pending_files.is_empty() {
834 let current = self
835 .catalog
836 .load_table(&self.table)
837 .await
838 .ok()
839 .and_then(|m| m.current_snapshot_id)
840 .unwrap_or(0);
841 return Ok(current);
842 }
843 let iceberg_schema = self
844 .captured_schema
845 .as_deref()
846 .map(|s| arrow_schema_to_iceberg_update(s, &self.policy, &self.extra_vec_policies));
847 let mut extra_properties = std::collections::HashMap::new();
850 if let Some(ref fts_cfg) = self.fts_config {
851 extra_properties.insert("ailake.fts.enabled".to_string(), "true".to_string());
852 extra_properties.insert(
853 "ailake.fts.text-columns".to_string(),
854 fts_cfg.text_columns.join(","),
855 );
856 extra_properties.insert(
857 "ailake.fts.tokenizer".to_string(),
858 fts_cfg.tokenizer.clone(),
859 );
860 }
861 for ep in &self.extra_vec_policies {
862 extra_properties.insert(format!("ailake.dim-{}", ep.column_name), ep.dim.to_string());
863 extra_properties.insert(
864 format!("ailake.metric-{}", ep.column_name),
865 ailake_parquet::schema::metric_str(ep.metric).to_string(),
866 );
867 if let Some(modality) = ep.modality {
868 extra_properties.insert(
869 format!("ailake.modality-{}", ep.column_name),
870 modality.as_str().to_string(),
871 );
872 }
873 }
874 let snapshot = NewSnapshot {
875 snapshot_id: new_snapshot_id(),
876 parent_snapshot_id: self.parent_snapshot_id,
877 files: std::mem::take(&mut self.pending_files),
878 operation: SnapshotOperation::Append,
879 iceberg_schema,
880 extra_properties,
881 bloom_filters: std::mem::take(&mut self.pending_blooms),
882 equality_delete_files: vec![],
883 };
884 self.catalog.commit_snapshot(&self.table, snapshot).await
885 }
886
887 pub async fn create_or_open(
889 catalog: Arc<dyn CatalogProvider>,
890 store: Arc<dyn Store>,
891 policy: VectorStoragePolicy,
892 table: TableIdent,
893 format_version: u8,
894 ) -> AilakeResult<Self> {
895 let existing_file_count: u32;
898
899 match catalog.load_table(&table).await {
900 Ok(existing_meta) => {
901 if let Some(stored_dim_str) = existing_meta.properties.get("ailake.vector-dim") {
905 if let Ok(stored_dim) = stored_dim_str.parse::<u32>() {
906 if stored_dim != policy.dim {
907 let table_model = policy
908 .embedding_model
909 .as_ref()
910 .map(|m| m.to_property_value())
911 .unwrap_or_else(|| format!("dim={}", stored_dim));
912 return Err(AilakeError::ModelMismatch {
913 table_model,
914 table_dim: stored_dim,
915 batch_model: format!("dim={}", policy.dim),
916 batch_dim: policy.dim,
917 });
918 }
919 }
920 }
921 if let Some(incoming) = &policy.embedding_model {
925 if let Some(stored_val) = existing_meta
926 .properties
927 .get(EmbeddingModelInfo::property_key())
928 {
929 let stored = EmbeddingModelInfo::from_property_value(stored_val);
930 if stored.name != incoming.name {
931 warn!(
932 "ailake: embedding model name changed: table has '{}', writing with '{}' \
933 (dim={}). Vectors may be incompatible for similarity search.",
934 stored.name, incoming.name, policy.dim
935 );
936 }
937 }
938 }
939 existing_file_count = catalog
940 .list_files(&table, None)
941 .await
942 .unwrap_or_default()
943 .len() as u32;
944 }
945 Err(_) => {
946 catalog
947 .create_table(
948 &table,
949 &TableProperties {
950 partition_column_type: policy.partition_column_type.clone(),
951 policy: policy.clone(),
952 extra: std::collections::HashMap::new(),
953 format_version,
954 },
955 )
956 .await?;
957 existing_file_count = 0;
958 }
959 }
960 let mut writer = Self::new(catalog, store, policy, table);
961 writer.part_counter = Arc::new(AtomicU32::new(existing_file_count));
962 Ok(writer)
963 }
964}
965
966fn arrow_schema_to_iceberg_update(
973 schema: &arrow_schema::Schema,
974 policy: &VectorStoragePolicy,
975 extra_vec_policies: &[VectorStoragePolicy],
976) -> IcebergSchemaUpdate {
977 let bytes_per_dim = policy.precision.bytes_per_element() as u32;
978 let vec_fixed_len = policy.dim * bytes_per_dim;
979
980 let has_primary_in_batch = schema
982 .fields()
983 .iter()
984 .any(|f| f.name() == &policy.column_name);
985 let vec_cols: Vec<(String, u32)> = {
986 let mut v = Vec::new();
987 if !has_primary_in_batch {
988 v.push((policy.column_name.clone(), vec_fixed_len));
989 }
990 for ep in extra_vec_policies {
991 let ep_fixed_len = ep.dim * ep.precision.bytes_per_element() as u32;
992 if !schema.fields().iter().any(|f| f.name() == &ep.column_name) {
993 v.push((ep.column_name.clone(), ep_fixed_len));
994 }
995 }
996 v
997 };
998
999 let top_level_count = schema.fields().len() + vec_cols.len();
1001 let mut nested_id = top_level_count as i32;
1003
1004 let mut fields: Vec<serde_json::Value> = Vec::new();
1005 let mut name_mapping: Vec<serde_json::Value> = Vec::new();
1006
1007 for (idx, field) in schema.fields().iter().enumerate() {
1008 let field_id = (idx + 1) as i32;
1009 let iceberg_type = arrow_type_to_iceberg(field.data_type(), &mut nested_id);
1010 fields.push(serde_json::json!({
1011 "id": field_id,
1012 "name": field.name(),
1013 "required": false,
1014 "type": iceberg_type,
1015 }));
1016 name_mapping.push(serde_json::json!({
1017 "field-id": field_id,
1018 "names": [field.name()],
1019 }));
1020 }
1021
1022 for (i, (col_name, fixed_len)) in vec_cols.iter().enumerate() {
1024 let field_id = (schema.fields().len() + 1 + i) as i32;
1025 fields.push(serde_json::json!({
1026 "id": field_id,
1027 "name": col_name,
1028 "required": false,
1029 "type": format!("fixed[{fixed_len}]"),
1030 }));
1031 name_mapping.push(serde_json::json!({
1032 "field-id": field_id,
1033 "names": [col_name],
1034 }));
1035 }
1036
1037 let last_column_id = nested_id;
1038 let name_mapping_json = serde_json::to_string(&name_mapping).unwrap_or_else(|_| "[]".into());
1039
1040 IcebergSchemaUpdate {
1041 fields,
1042 last_column_id,
1043 name_mapping_json,
1044 }
1045}
1046
1047fn arrow_type_to_iceberg(dt: &arrow_schema::DataType, nested_id: &mut i32) -> serde_json::Value {
1052 use arrow_schema::DataType;
1053 match dt {
1054 DataType::Boolean => serde_json::json!("boolean"),
1055 DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
1056 serde_json::json!("int")
1057 }
1058 DataType::Int64 | DataType::UInt32 | DataType::UInt64 => serde_json::json!("long"),
1059 DataType::Float16 | DataType::Float32 => serde_json::json!("float"),
1060 DataType::Float64 => serde_json::json!("double"),
1061 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => serde_json::json!("string"),
1062 DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
1063 serde_json::json!("binary")
1064 }
1065 DataType::Date32 | DataType::Date64 => serde_json::json!("date"),
1066 DataType::Timestamp(_, Some(_)) => serde_json::json!("timestamptz"),
1068 DataType::Timestamp(_, None) => serde_json::json!("timestamp"),
1069 DataType::Time32(_) | DataType::Time64(_) => serde_json::json!("time"),
1070 DataType::FixedSizeBinary(n) => serde_json::json!(format!("fixed[{n}]")),
1071 DataType::Decimal128(p, s) | DataType::Decimal256(p, s) => {
1072 serde_json::json!(format!("decimal({p}, {s})"))
1073 }
1074 DataType::List(inner)
1075 | DataType::LargeList(inner)
1076 | DataType::ListView(inner)
1077 | DataType::FixedSizeList(inner, _) => {
1078 *nested_id += 1;
1079 let element_id = *nested_id;
1080 let element_type = arrow_type_to_iceberg(inner.data_type(), nested_id);
1081 serde_json::json!({
1082 "type": "list",
1083 "element-id": element_id,
1084 "element": element_type,
1085 "element-required": !inner.is_nullable(),
1086 })
1087 }
1088 DataType::Struct(arrow_fields) => {
1089 let struct_fields: Vec<serde_json::Value> = arrow_fields
1090 .iter()
1091 .map(|f| {
1092 *nested_id += 1;
1093 let fid = *nested_id;
1094 let ftype = arrow_type_to_iceberg(f.data_type(), nested_id);
1095 serde_json::json!({
1096 "id": fid,
1097 "name": f.name(),
1098 "required": !f.is_nullable(),
1099 "type": ftype,
1100 })
1101 })
1102 .collect();
1103 serde_json::json!({ "type": "struct", "fields": struct_fields })
1104 }
1105 DataType::Map(entries, _) => {
1106 *nested_id += 1;
1108 let key_id = *nested_id;
1109 *nested_id += 1;
1110 let val_id = *nested_id;
1111 if let DataType::Struct(kv_fields) = entries.data_type() {
1112 let key_f = kv_fields
1113 .iter()
1114 .find(|f| f.name() == "key" || f.name() == "keys");
1115 let val_f = kv_fields
1116 .iter()
1117 .find(|f| f.name() == "value" || f.name() == "values");
1118 let key_type = key_f
1119 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1120 .unwrap_or(serde_json::json!("binary"));
1121 let val_type = val_f
1122 .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1123 .unwrap_or(serde_json::json!("binary"));
1124 let val_required = val_f.map(|f| !f.is_nullable()).unwrap_or(false);
1125 serde_json::json!({
1126 "type": "map",
1127 "key-id": key_id,
1128 "key": key_type,
1129 "value-id": val_id,
1130 "value": val_type,
1131 "value-required": val_required,
1132 })
1133 } else {
1134 serde_json::json!("binary")
1135 }
1136 }
1137 _ => serde_json::json!("binary"),
1138 }
1139}
1140
1141pub(crate) async fn build_and_patch_index(
1143 store: Arc<dyn Store>,
1144 catalog: Arc<dyn CatalogProvider>,
1145 policy: VectorStoragePolicy,
1146 table: TableIdent,
1147 file_path: String,
1148) -> AilakeResult<()> {
1149 let parquet_bytes = store.get(&file_path).await?;
1151 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1152 let (batch, embeddings) = reader.read_parquet()?;
1153
1154 let full_bytes = tokio::task::spawn_blocking({
1157 let policy = policy.clone();
1158 move || {
1159 let file_writer = AilakeFileWriter::new(policy);
1160 file_writer.write(&batch, &embeddings)
1161 }
1162 })
1163 .await
1164 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1165
1166 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1168 let header = full_reader.read_header()?;
1169 let ailk_start = full_reader.ailk_offset()?;
1170 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1171 let hnsw_len = header.hnsw_len;
1172
1173 store.put(&file_path, full_bytes).await?;
1175
1176 let mut committed = false;
1179 for _ in 0..120u32 {
1180 match catalog.load_table(&table).await {
1181 Ok(meta) if meta.current_snapshot_id.is_some() => {
1182 committed = true;
1183 break;
1184 }
1185 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1186 }
1187 }
1188 if !committed {
1189 return Err(ailake_core::AilakeError::Store(format!(
1190 "deferred HNSW build: no snapshot committed for {file_path} after 60 s — \
1191 did you call TableWriter::commit()?"
1192 )));
1193 }
1194
1195 for attempt in 0..50u32 {
1200 let table_meta = catalog.load_table(&table).await?;
1201 let parent_snapshot_id = table_meta.current_snapshot_id;
1202 let mut files = catalog.list_files(&table, None).await?;
1203
1204 if files
1206 .iter()
1207 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1208 {
1209 break;
1210 }
1211
1212 for f in &mut files {
1213 if f.path == file_path {
1214 f.hnsw_offset = Some(hnsw_abs_offset);
1215 f.hnsw_len = Some(hnsw_len);
1216 f.index_status = IndexStatus::Ready;
1217 break;
1218 }
1219 }
1220 catalog
1221 .commit_snapshot(
1222 &table,
1223 NewSnapshot {
1224 snapshot_id: new_snapshot_id(),
1225 parent_snapshot_id,
1226 files,
1227 operation: SnapshotOperation::Replace,
1228 iceberg_schema: None,
1229 extra_properties: std::collections::HashMap::new(),
1230 bloom_filters: vec![],
1231 equality_delete_files: vec![],
1232 },
1233 )
1234 .await?;
1235
1236 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1238
1239 let verify = catalog.list_files(&table, None).await?;
1240 if verify
1241 .iter()
1242 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1243 {
1244 break;
1245 }
1246 }
1248
1249 info!(
1250 "ailake: deferred HNSW index built for {} (offset={}, len={})",
1251 file_path, hnsw_abs_offset, hnsw_len
1252 );
1253 Ok(())
1254}
1255
1256async fn build_ivf_pq_and_patch_index(
1261 store: Arc<dyn Store>,
1262 catalog: Arc<dyn CatalogProvider>,
1263 policy: VectorStoragePolicy,
1264 table: TableIdent,
1265 file_path: String,
1266 ivf_config: IvfPqConfig,
1267 codebook_cell: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
1268) -> AilakeResult<()> {
1269 let parquet_bytes = store.get(&file_path).await?;
1270 let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1271 let (batch, embeddings) = reader.read_parquet()?;
1272
1273 let codebook = codebook_cell
1275 .get_or_try_init(|| async {
1276 let vecs = embeddings.clone();
1277 let metric = policy.metric;
1278 let cfg = ivf_config.clone();
1279 tokio::task::spawn_blocking(move || {
1280 ailake_index::IvfPqIndex::train_codebook(&vecs, metric, &cfg)
1281 })
1282 .await
1283 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))?
1284 })
1285 .await?;
1286
1287 let full_bytes = tokio::task::spawn_blocking({
1288 let policy = policy.clone();
1289 let codebook = codebook.clone();
1290 move || {
1291 let file_writer = AilakeFileWriter::new(policy)
1292 .with_index_type(IndexType::IvfPq(ivf_config))
1293 .with_shared_ivf_codebook(Arc::new(codebook));
1294 file_writer.write(&batch, &embeddings)
1295 }
1296 })
1297 .await
1298 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1299
1300 let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1301 let header = full_reader.read_header()?;
1302 let ailk_start = full_reader.ailk_offset()?;
1303 let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1304 let hnsw_len = header.hnsw_len;
1305
1306 store.put(&file_path, full_bytes).await?;
1307
1308 let mut committed = false;
1310 for _ in 0..120u32 {
1311 match catalog.load_table(&table).await {
1312 Ok(meta) if meta.current_snapshot_id.is_some() => {
1313 committed = true;
1314 break;
1315 }
1316 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1317 }
1318 }
1319 if !committed {
1320 return Err(ailake_core::AilakeError::Store(format!(
1321 "deferred IVF-PQ build: no snapshot committed for {file_path} after 60 s — \
1322 did you call TableWriter::commit()?"
1323 )));
1324 }
1325
1326 for attempt in 0..50u32 {
1327 let table_meta = catalog.load_table(&table).await?;
1328 let parent_snapshot_id = table_meta.current_snapshot_id;
1329 let mut files = catalog.list_files(&table, None).await?;
1330
1331 if files
1332 .iter()
1333 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1334 {
1335 break;
1336 }
1337
1338 for f in &mut files {
1339 if f.path == file_path {
1340 f.hnsw_offset = Some(hnsw_abs_offset);
1341 f.hnsw_len = Some(hnsw_len);
1342 f.index_status = IndexStatus::Ready;
1343 break;
1344 }
1345 }
1346 catalog
1347 .commit_snapshot(
1348 &table,
1349 NewSnapshot {
1350 snapshot_id: new_snapshot_id(),
1351 parent_snapshot_id,
1352 files,
1353 operation: SnapshotOperation::Replace,
1354 iceberg_schema: None,
1355 extra_properties: std::collections::HashMap::new(),
1356 bloom_filters: vec![],
1357 equality_delete_files: vec![],
1358 },
1359 )
1360 .await?;
1361
1362 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1363
1364 let verify = catalog.list_files(&table, None).await?;
1365 if verify
1366 .iter()
1367 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1368 {
1369 break;
1370 }
1371 }
1372
1373 info!(
1374 "ailake: deferred IVF-PQ index built for {} (offset={}, len={})",
1375 file_path, hnsw_abs_offset, hnsw_len
1376 );
1377 Ok(())
1378}
1379
1380async fn build_and_patch_multi_index(
1386 store: Arc<dyn Store>,
1387 catalog: Arc<dyn CatalogProvider>,
1388 policies: Vec<VectorStoragePolicy>,
1389 table: TableIdent,
1390 file_path: String,
1391 all_embeddings: Vec<Vec<Vec<f32>>>,
1392) -> AilakeResult<()> {
1393 let parquet_bytes = store.get(&file_path).await?;
1395 let primary_reader =
1396 AilakeFileReader::new(parquet_bytes, &policies[0].column_name, policies[0].dim);
1397 let (batch, _) = primary_reader.read_parquet()?;
1398
1399 let full_bytes = tokio::task::spawn_blocking({
1401 let policies = policies.clone();
1402 let all_embeddings = all_embeddings.clone();
1403 move || {
1404 let col_batches: Vec<VectorColumnBatch<'_>> = policies
1405 .iter()
1406 .zip(all_embeddings.iter())
1407 .map(|(p, embs)| VectorColumnBatch {
1408 policy: p,
1409 embeddings: embs.as_slice(),
1410 })
1411 .collect();
1412 let file_writer = AilakeFileWriter::new(policies[0].clone());
1413 file_writer.write_multi(&batch, &col_batches)
1414 }
1415 })
1416 .await
1417 .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1418
1419 let primary_reader = AilakeFileReader::new(
1421 full_bytes.clone(),
1422 &policies[0].column_name,
1423 policies[0].dim,
1424 );
1425 let primary_header = primary_reader.read_header()?;
1426 let primary_ailk_start = primary_reader.ailk_offset()?;
1427 let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
1428 let primary_hnsw_len = primary_header.hnsw_len;
1429
1430 let mut extra_offsets: Vec<(u64, u64)> = Vec::with_capacity(policies.len().saturating_sub(1));
1435 for col_policy in policies.iter().skip(1) {
1436 let col_reader =
1437 AilakeFileReader::new(full_bytes.clone(), &col_policy.column_name, col_policy.dim);
1438 let col_ailk_start = col_reader.ailk_offset_for_column(&col_policy.column_name)?;
1439 let col_header = col_reader.read_header_for_column(&col_policy.column_name)?;
1440 extra_offsets.push((col_ailk_start + col_header.hnsw_offset, col_header.hnsw_len));
1441 }
1442
1443 store.put(&file_path, full_bytes).await?;
1445
1446 let mut committed = false;
1448 for _ in 0..120u32 {
1449 match catalog.load_table(&table).await {
1450 Ok(meta) if meta.current_snapshot_id.is_some() => {
1451 committed = true;
1452 break;
1453 }
1454 _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1455 }
1456 }
1457 if !committed {
1458 return Err(ailake_core::AilakeError::Store(format!(
1459 "deferred index build: no snapshot committed for {file_path} after 60 s — \
1460 did you call TableWriter::commit()?"
1461 )));
1462 }
1463
1464 for attempt in 0..50u32 {
1466 let table_meta = catalog.load_table(&table).await?;
1467 let parent_snapshot_id = table_meta.current_snapshot_id;
1468 let mut files = catalog.list_files(&table, None).await?;
1469
1470 if files
1471 .iter()
1472 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1473 {
1474 break;
1475 }
1476
1477 for f in &mut files {
1478 if f.path == file_path {
1479 f.hnsw_offset = Some(primary_hnsw_abs);
1480 f.hnsw_len = Some(primary_hnsw_len);
1481 f.index_status = IndexStatus::Ready;
1482 for (i, &(off, len)) in extra_offsets.iter().enumerate() {
1483 if let Some(xi) = f.extra_vector_indexes.get_mut(i) {
1484 xi.hnsw_offset = off;
1485 xi.hnsw_len = len;
1486 }
1487 }
1488 break;
1489 }
1490 }
1491 catalog
1492 .commit_snapshot(
1493 &table,
1494 NewSnapshot {
1495 snapshot_id: new_snapshot_id(),
1496 parent_snapshot_id,
1497 files,
1498 operation: SnapshotOperation::Replace,
1499 iceberg_schema: None,
1500 extra_properties: std::collections::HashMap::new(),
1501 bloom_filters: vec![],
1502 equality_delete_files: vec![],
1503 },
1504 )
1505 .await?;
1506
1507 tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1508
1509 let verify = catalog.list_files(&table, None).await?;
1510 if verify
1511 .iter()
1512 .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1513 {
1514 break;
1515 }
1516 }
1517
1518 info!(
1519 "ailake: deferred multi-column HNSW built for {} ({} cols, primary offset={})",
1520 file_path,
1521 policies.len(),
1522 primary_hnsw_abs
1523 );
1524 Ok(())
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530 use ailake_core::{VectorMetric, VectorPrecision};
1531 use arrow_schema::{DataType, Field, Schema, TimeUnit};
1532
1533 fn policy(col: &str, dim: u32) -> VectorStoragePolicy {
1534 VectorStoragePolicy {
1535 column_name: col.to_string(),
1536 dim,
1537 metric: VectorMetric::Cosine,
1538 precision: VectorPrecision::F16,
1539 pq: None,
1540 keep_raw_for_reranking: true,
1541 pre_normalize: false,
1542 hnsw_m: None,
1543 hnsw_ef_construction: None,
1544 ivf_residual: false,
1545 embedding_model: None,
1546 modality: None,
1547 partition_by: None,
1548 partition_value: None,
1549 partition_column_type: None,
1550 partition_fields: vec![],
1551 }
1552 }
1553
1554 fn update_for(schema: &Schema, pol: &VectorStoragePolicy) -> IcebergSchemaUpdate {
1555 arrow_schema_to_iceberg_update(schema, pol, &[])
1556 }
1557
1558 #[test]
1559 fn simple_schema_produces_correct_fields() {
1560 let schema = Schema::new(vec![
1561 Field::new("id", DataType::Int32, false),
1562 Field::new("text", DataType::Utf8, false),
1563 ]);
1564 let pol = policy("embedding", 8);
1565 let upd = update_for(&schema, &pol);
1566
1567 assert_eq!(upd.fields.len(), 3);
1568 assert_eq!(upd.fields[0]["id"], 1);
1569 assert_eq!(upd.fields[0]["type"], "int");
1570 assert_eq!(upd.fields[1]["id"], 2);
1571 assert_eq!(upd.fields[1]["type"], "string");
1572 assert_eq!(upd.fields[2]["id"], 3);
1573 assert_eq!(upd.fields[2]["type"], "fixed[16]"); let nm: Vec<serde_json::Value> = serde_json::from_str(&upd.name_mapping_json).unwrap();
1576 assert_eq!(nm.len(), 3);
1577 assert_eq!(nm[2]["field-id"], 3);
1578 assert_eq!(nm[2]["names"][0], "embedding");
1579 assert_eq!(upd.last_column_id, 3);
1580 }
1581
1582 #[test]
1583 fn timestamp_without_tz_maps_to_timestamp_not_timestamptz() {
1584 let schema = Schema::new(vec![
1585 Field::new(
1586 "created_at",
1587 DataType::Timestamp(TimeUnit::Microsecond, None),
1588 true,
1589 ),
1590 Field::new(
1591 "updated_at",
1592 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
1593 true,
1594 ),
1595 ]);
1596 let pol = policy("vec", 4);
1597 let upd = update_for(&schema, &pol);
1598
1599 assert_eq!(upd.fields[0]["type"], "timestamp");
1600 assert_eq!(upd.fields[1]["type"], "timestamptz");
1601 }
1602
1603 #[test]
1604 fn list_type_produces_iceberg_list_object() {
1605 let schema = Schema::new(vec![Field::new(
1606 "tags",
1607 DataType::List(std::sync::Arc::new(Field::new(
1608 "item",
1609 DataType::Utf8,
1610 true,
1611 ))),
1612 true,
1613 )]);
1614 let pol = policy("vec", 4);
1615 let upd = update_for(&schema, &pol);
1616
1617 let t = &upd.fields[0]["type"];
1618 assert_eq!(t["type"], "list");
1619 assert_eq!(t["element"], "string");
1620 assert!(t["element-id"].as_i64().unwrap() > 2);
1622 }
1623
1624 #[test]
1625 fn struct_type_produces_nested_fields() {
1626 let schema = Schema::new(vec![Field::new(
1627 "meta",
1628 DataType::Struct(
1629 vec![
1630 Field::new("key", DataType::Utf8, false),
1631 Field::new("val", DataType::Int64, false),
1632 ]
1633 .into(),
1634 ),
1635 true,
1636 )]);
1637 let pol = policy("vec", 4);
1638 let upd = update_for(&schema, &pol);
1639
1640 let t = &upd.fields[0]["type"];
1641 assert_eq!(t["type"], "struct");
1642 let nested = t["fields"].as_array().unwrap();
1643 assert_eq!(nested.len(), 2);
1644 assert_eq!(nested[0]["name"], "key");
1645 assert_eq!(nested[0]["type"], "string");
1646 assert_eq!(nested[1]["name"], "val");
1647 assert_eq!(nested[1]["type"], "long");
1648 assert!(nested[0]["id"].as_i64().unwrap() > 2);
1650 }
1651
1652 #[test]
1653 fn no_duplicate_vec_column_when_already_in_batch() {
1654 let schema = Schema::new(vec![
1656 Field::new("id", DataType::Int32, false),
1657 Field::new("embedding", DataType::FixedSizeBinary(16), false),
1658 ]);
1659 let pol = policy("embedding", 8);
1660 let upd = update_for(&schema, &pol);
1661
1662 assert_eq!(upd.fields.len(), 2, "should not add embedding twice");
1663 let names: Vec<&str> = upd
1664 .fields
1665 .iter()
1666 .map(|f| f["name"].as_str().unwrap())
1667 .collect();
1668 assert_eq!(names.iter().filter(|&&n| n == "embedding").count(), 1);
1669 }
1670
1671 #[test]
1672 fn multi_vec_policies_all_appended() {
1673 let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
1674 let primary = policy("embedding", 4);
1675 let extra = vec![policy("context_embedding", 4)];
1676 let upd = arrow_schema_to_iceberg_update(&schema, &primary, &extra);
1677
1678 assert_eq!(upd.fields.len(), 3); let names: Vec<&str> = upd
1680 .fields
1681 .iter()
1682 .map(|f| f["name"].as_str().unwrap())
1683 .collect();
1684 assert!(names.contains(&"embedding"));
1685 assert!(names.contains(&"context_embedding"));
1686 }
1687
1688 #[test]
1689 fn top_level_field_ids_match_parquet_stamp_sequence() {
1690 let schema = Schema::new(vec![
1692 Field::new("id", DataType::Int64, false),
1693 Field::new(
1694 "tags",
1695 DataType::List(std::sync::Arc::new(Field::new(
1696 "item",
1697 DataType::Utf8,
1698 true,
1699 ))),
1700 true,
1701 ),
1702 ]);
1703 let pol = policy("vec", 4);
1704 let upd = update_for(&schema, &pol);
1705
1706 assert_eq!(upd.fields[0]["id"], 1);
1708 assert_eq!(upd.fields[1]["id"], 2);
1709 assert_eq!(upd.fields[2]["id"], 3);
1710
1711 assert!(upd.fields[1]["type"]["element-id"].as_i64().unwrap() > 3);
1713 }
1714
1715 #[tokio::test]
1718 async fn write_batch_auto_deferred_stages_file() {
1719 use ailake_catalog::{HadoopCatalog, TableIdent};
1720 use ailake_store::LocalStore;
1721 use arrow_schema::{DataType, Field, Schema};
1722
1723 let dir = tempfile::tempdir().unwrap();
1724 let store: std::sync::Arc<dyn ailake_store::Store> =
1725 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1726 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1727 let pol = policy("embedding", 4);
1728 let ident = TableIdent::new("default", "t");
1729
1730 let mut writer = TableWriter::create_or_open(catalog, store, pol, ident, 2)
1731 .await
1732 .unwrap();
1733
1734 let schema =
1735 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1736 let batch = arrow_array::RecordBatch::try_new(
1737 schema,
1738 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1739 "hello",
1740 ]))],
1741 )
1742 .unwrap();
1743 let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
1744
1745 writer
1746 .write_batch_auto_deferred(&batch, &embeddings)
1747 .await
1748 .unwrap();
1749
1750 assert_eq!(writer.pending_files.len(), 1);
1752 }
1753
1754 #[tokio::test]
1757 async fn write_batch_multi_deferred_stages_file_with_extra_indexes() {
1758 use ailake_catalog::{HadoopCatalog, IndexStatus, TableIdent};
1759 use ailake_store::LocalStore;
1760 use arrow_schema::{DataType, Field, Schema};
1761
1762 let dir = tempfile::tempdir().unwrap();
1763 let store: std::sync::Arc<dyn ailake_store::Store> =
1764 std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1765 let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1766 let primary_pol = policy("embedding", 4);
1767 let ident = TableIdent::new("default", "t");
1768
1769 let mut writer = TableWriter::create_or_open(catalog, store, primary_pol, ident, 2)
1770 .await
1771 .unwrap();
1772
1773 let schema =
1774 std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1775 let batch = arrow_array::RecordBatch::try_new(
1776 schema,
1777 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1778 "hello", "world",
1779 ]))],
1780 )
1781 .unwrap();
1782
1783 let text_embs = vec![vec![1.0f32, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
1784 let img_embs = vec![vec![1.0f32, 0.0], vec![0.0, 1.0]];
1785
1786 let columns = vec![
1787 MultiVectorBatch {
1788 policy: policy("embedding", 4),
1789 embeddings: &text_embs,
1790 },
1791 MultiVectorBatch {
1792 policy: policy("img_embedding", 2),
1793 embeddings: &img_embs,
1794 },
1795 ];
1796
1797 writer
1798 .write_batch_multi_deferred(&batch, &columns)
1799 .await
1800 .unwrap();
1801
1802 assert_eq!(writer.pending_files.len(), 1);
1803 let entry = &writer.pending_files[0];
1804 assert_eq!(entry.index_status, IndexStatus::Indexing);
1806 assert!(entry.centroid_b64.is_some());
1808 assert_eq!(entry.extra_vector_indexes.len(), 1);
1810 let xi = &entry.extra_vector_indexes[0];
1811 assert_eq!(xi.column, "img_embedding");
1812 assert_eq!(xi.dim, 2);
1813 assert_eq!(xi.hnsw_offset, 0); assert_eq!(xi.hnsw_len, 0); assert!(xi.centroid_b64.is_some());
1816 }
1817}