1use std::sync::Arc;
3use tracing::{debug, error, info, warn};
4
5use ailake_catalog::{
6 make_data_file_entry, make_data_file_entry_indexing, CatalogProvider, DataFileEntry,
7 NewSnapshot, SnapshotOperation, TableIdent, VectorIndexInfo,
8};
9use ailake_core::{AilakeResult, RowId, VectorStoragePolicy};
10use ailake_file::{AilakeFileReader, AilakeFileWriter};
11use ailake_store::Store;
12use ailake_vec::compute_centroid_and_radius;
13use arrow_array::RecordBatch;
14use arrow_schema::SchemaRef;
15use bytes::Bytes;
16use futures::future::try_join_all;
17
18use crate::writer::build_and_patch_index;
19
20#[derive(Debug, Clone, Default)]
22pub enum CompactionIndexStrategy {
23 #[default]
26 Auto,
27 ForceHnsw,
29 ForceIvfPq,
34}
35
36#[derive(Debug, Clone)]
37pub struct CompactionConfig {
38 pub min_files_to_compact: usize,
40 pub target_file_size_bytes: u64,
42 pub index_strategy: CompactionIndexStrategy,
44 pub max_files_per_pass: usize,
51}
52
53impl Default for CompactionConfig {
54 fn default() -> Self {
55 Self {
56 min_files_to_compact: 4,
57 target_file_size_bytes: 128 * 1024 * 1024, index_strategy: CompactionIndexStrategy::Auto,
59 max_files_per_pass: 20,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy)]
65pub enum CompactionMode {
66 Full, Partial, }
69
70pub struct CompactionPlanner {
71 config: CompactionConfig,
72}
73
74impl CompactionPlanner {
75 pub fn new(config: CompactionConfig) -> Self {
76 Self { config }
77 }
78
79 pub fn plan(&self, files: &[DataFileEntry]) -> Vec<DataFileEntry> {
86 let mut candidates: Vec<DataFileEntry> = files
87 .iter()
88 .filter(|f| f.file_size_bytes < self.config.target_file_size_bytes)
89 .cloned()
90 .collect();
91 if candidates.len() < self.config.min_files_to_compact {
92 debug!(
93 "ailake: compaction skipped — {} eligible files < min_files_to_compact={}",
94 candidates.len(),
95 self.config.min_files_to_compact
96 );
97 return vec![];
98 }
99 candidates.sort_unstable_by_key(|f| f.file_size_bytes);
102 candidates.truncate(self.config.max_files_per_pass);
103 let total_bytes: u64 = candidates.iter().map(|f| f.file_size_bytes).sum();
104 info!(
105 "ailake: compaction plan — {} files ({} bytes) → 1 merged file",
106 candidates.len(),
107 total_bytes
108 );
109 candidates
110 }
111}
112
113#[derive(Clone)]
124pub struct CompactionExecutor {
125 store: Arc<dyn Store>,
126 policy: VectorStoragePolicy,
127 index_strategy: CompactionIndexStrategy,
128 fts_config: Option<ailake_fts::FtsConfig>,
130}
131
132impl CompactionExecutor {
133 pub fn new(store: Arc<dyn Store>, policy: VectorStoragePolicy) -> Self {
134 Self {
135 store,
136 policy,
137 index_strategy: CompactionIndexStrategy::Auto,
138 fts_config: None,
139 }
140 }
141
142 pub fn with_index_strategy(mut self, strategy: CompactionIndexStrategy) -> Self {
144 self.index_strategy = strategy;
145 self
146 }
147
148 pub fn with_fts_config(mut self, cfg: ailake_fts::FtsConfig) -> Self {
150 self.fts_config = Some(cfg);
151 self
152 }
153
154 fn with_effective_fts(
161 &self,
162 table_props: &std::collections::HashMap<String, String>,
163 ) -> std::borrow::Cow<'_, Self> {
164 if self.fts_config.is_some() {
165 return std::borrow::Cow::Borrowed(self);
166 }
167 match ailake_fts::FtsConfig::from_table_props(table_props) {
168 Some(cfg) => {
169 let mut cloned = self.clone();
170 cloned.fts_config = Some(cfg);
171 std::borrow::Cow::Owned(cloned)
172 }
173 None => std::borrow::Cow::Borrowed(self),
174 }
175 }
176
177 async fn read_files_parallel(
179 &self,
180 files: &[DataFileEntry],
181 ) -> AilakeResult<Vec<(RecordBatch, Vec<Vec<f32>>)>> {
182 let futs = files.iter().map(|entry| {
183 let store = self.store.clone();
184 let path = entry.path.clone();
185 let column = self.policy.column_name.clone();
186 let dim = self.policy.dim;
187 async move {
188 let bytes: Bytes = store.get(&path).await?;
189 let reader = AilakeFileReader::new(bytes, &column, dim);
190 if !reader.is_ailake_file() {
191 debug!("ailake: compaction skipping {} — not an AI-Lake file", path);
192 return Ok::<Option<(RecordBatch, Vec<Vec<f32>>)>, ailake_core::AilakeError>(
193 None,
194 );
195 }
196 let pair = reader.read_parquet()?;
197 Ok(Some(pair))
198 }
199 });
200 let results = try_join_all(futs).await?;
201 Ok(results.into_iter().flatten().collect())
202 }
203
204 pub async fn compact(
213 &self,
214 files: &[DataFileEntry],
215 output_path: &str,
216 ) -> AilakeResult<DataFileEntry> {
217 if files.is_empty() {
218 return Err(ailake_core::AilakeError::Catalog(
219 "compact: no files provided".into(),
220 ));
221 }
222
223 let pairs = self.read_files_parallel(files).await?;
224
225 if pairs.is_empty() {
226 return Err(ailake_core::AilakeError::Catalog(
227 "compact: no valid AI-Lake files in input".into(),
228 ));
229 }
230
231 let schema: SchemaRef = pairs[0].0.schema();
232 let (all_batches, all_embeddings): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
233 let all_embeddings: Vec<Vec<f32>> = all_embeddings.into_iter().flatten().collect();
234
235 let merged_batch = concat_batches(schema, &all_batches)?;
237 let record_count = merged_batch.num_rows() as u64;
238
239 let writer = {
241 let base = AilakeFileWriter::new(self.policy.clone());
242 let base = match &self.index_strategy {
243 CompactionIndexStrategy::Auto => base.with_auto_index(),
244 CompactionIndexStrategy::ForceHnsw => base,
245 CompactionIndexStrategy::ForceIvfPq => {
246 let cfg = ailake_index::IvfPqConfig::for_dataset(
247 self.policy.dim as usize,
248 all_embeddings.len(),
249 );
250 base.with_ivf_pq(cfg)
251 }
252 };
253 if let Some(ref fts_cfg) = self.fts_config {
254 match ailake_fts::merge_fts_blobs(fts_cfg, &merged_batch) {
255 Ok(blob) => base.with_prebuilt_fts_blob(blob),
256 Err(e) => {
257 warn!("ailake: FTS re-index during compaction failed: {e}");
258 base
259 }
260 }
261 } else {
262 base
263 }
264 };
265 let file_bytes = writer.write(&merged_batch, &all_embeddings)?;
266 let file_size = file_bytes.len() as u64;
267 self.store.put(output_path, file_bytes.clone()).await?;
268
269 let centroid = compute_centroid_and_radius(&all_embeddings, self.policy.metric);
271 let reader = AilakeFileReader::new(file_bytes, &self.policy.column_name, self.policy.dim);
272 let header = reader.read_header()?;
273 let ailk_start = reader.ailk_offset()?;
274
275 let source_first_row_id = files.iter().filter_map(|f| f.first_row_id).min();
278
279 let mut entry = make_data_file_entry(
280 output_path,
281 record_count,
282 file_size,
283 ¢roid,
284 VectorIndexInfo {
285 column: &self.policy.column_name,
286 dim: self.policy.dim,
287 hnsw_offset: ailk_start + header.hnsw_offset,
288 hnsw_len: header.hnsw_len,
289 },
290 );
291 entry.first_row_id = source_first_row_id;
292 Ok(entry)
293 }
294
295 pub async fn compact_incremental(
314 &self,
315 files: &[DataFileEntry],
316 output_path: &str,
317 ) -> AilakeResult<DataFileEntry> {
318 const DOMINANT_RATIO: f64 = 0.40;
319
320 if files.is_empty() {
321 return Err(ailake_core::AilakeError::Catalog(
322 "compact_incremental: no files provided".into(),
323 ));
324 }
325
326 let total_rows: u64 = files.iter().map(|f| f.record_count).sum();
328 let dom_idx = files
329 .iter()
330 .enumerate()
331 .max_by_key(|(_, f)| f.record_count)
332 .map(|(i, _)| i)
333 .unwrap_or(0);
334 let dom_rows = files[dom_idx].record_count;
335
336 if (dom_rows as f64 / total_rows as f64) < DOMINANT_RATIO {
337 debug!(
338 "ailake: compact_incremental — no dominant file ({}/{} rows < {:.0}% threshold), \
339 falling back to full rebuild",
340 dom_rows,
341 total_rows,
342 DOMINANT_RATIO * 100.0
343 );
344 return self.compact(files, output_path).await;
345 }
346
347 let column = self.policy.column_name.clone();
348 let dim = self.policy.dim;
349 let dom_path = files[dom_idx].path.clone();
350
351 let futs: Vec<_> = files
354 .iter()
355 .map(|entry| {
356 let store = self.store.clone();
357 let path = entry.path.clone();
358 let col = column.clone();
359 let is_dom = path == dom_path;
360 async move {
361 let bytes: Bytes = store.get(&path).await?;
362 let reader = AilakeFileReader::new(bytes.clone(), &col, dim);
363 if !reader.is_ailake_file() {
364 debug!(
365 "ailake: compact_incremental skipping {} — not an AI-Lake file",
366 path
367 );
368 return Ok::<
369 Option<(RecordBatch, Vec<Vec<f32>>, bool, Option<Bytes>)>,
370 ailake_core::AilakeError,
371 >(None);
372 }
373 let (batch, vecs) = reader.read_parquet()?;
374 let retained = if is_dom { Some(bytes) } else { None };
375 Ok(Some((batch, vecs, is_dom, retained)))
376 }
377 })
378 .collect();
379
380 #[allow(clippy::type_complexity)]
381 let raw: Vec<(RecordBatch, Vec<Vec<f32>>, bool, Option<Bytes>)> =
382 try_join_all(futs).await?.into_iter().flatten().collect();
383
384 if raw.is_empty() {
385 return Err(ailake_core::AilakeError::Catalog(
386 "compact_incremental: no valid AI-Lake files in input".into(),
387 ));
388 }
389
390 let mut dom_batch: Option<RecordBatch> = None;
392 let mut dom_vecs: Vec<Vec<f32>> = Vec::new();
393 let mut dom_bytes_found: Option<Bytes> = None;
394 let mut other_batches: Vec<RecordBatch> = Vec::new();
395 let mut other_vecs: Vec<Vec<f32>> = Vec::new();
396
397 for (batch, vecs, is_dom, retained) in raw {
398 if is_dom {
399 dom_batch = Some(batch);
400 dom_vecs = vecs;
401 dom_bytes_found = retained;
402 } else {
403 other_batches.push(batch);
404 other_vecs.extend(vecs);
405 }
406 }
407
408 let (dom_batch, dom_bytes) = match (dom_batch, dom_bytes_found) {
409 (Some(b), Some(byt)) => (b, byt),
410 _ => {
411 debug!(
412 "ailake: compact_incremental — dominant file missing from read results, \
413 falling back to full rebuild"
414 );
415 return self.compact(files, output_path).await;
416 }
417 };
418
419 let dom_reader = AilakeFileReader::new(dom_bytes, &column, dim);
421 let mut hnsw = match dom_reader.load_index() {
422 Ok(idx) => idx,
423 Err(e) => {
424 debug!(
425 "ailake: compact_incremental — cannot load dominant HNSW ({}), \
426 falling back to full rebuild",
427 e
428 );
429 return self.compact(files, output_path).await;
430 }
431 };
432
433 let dom_count = dom_batch.num_rows() as u64;
434
435 for (j, vec) in other_vecs.iter().enumerate() {
438 hnsw.insert_node(RowId::new(dom_count + j as u64), vec.clone());
439 }
440 hnsw.quantize_to_f16();
441
442 let schema: SchemaRef = dom_batch.schema();
444 let mut all_batches = vec![dom_batch];
445 all_batches.extend(other_batches);
446 let merged_batch = concat_batches(schema, &all_batches)?;
447 let record_count = merged_batch.num_rows() as u64;
448
449 let mut all_embeddings = dom_vecs;
450 all_embeddings.extend(other_vecs);
451
452 let writer = {
455 let base = AilakeFileWriter::new(self.policy.clone());
456 if let Some(ref fts_cfg) = self.fts_config {
457 match ailake_fts::merge_fts_blobs(fts_cfg, &merged_batch) {
458 Ok(blob) => base.with_prebuilt_fts_blob(blob),
459 Err(e) => {
460 warn!("ailake: FTS re-index during incremental compaction failed: {e}");
461 base
462 }
463 }
464 } else {
465 base
466 }
467 };
468 let file_bytes = writer.write_with_prebuilt_hnsw(&merged_batch, &all_embeddings, &hnsw)?;
469 let file_size = file_bytes.len() as u64;
470 self.store.put(output_path, file_bytes.clone()).await?;
471
472 let centroid = compute_centroid_and_radius(&all_embeddings, self.policy.metric);
473 let reader = AilakeFileReader::new(file_bytes, &self.policy.column_name, self.policy.dim);
474 let header = reader.read_header()?;
475 let ailk_start = reader.ailk_offset()?;
476
477 let source_first_row_id = files[dom_idx].first_row_id;
481
482 let mut entry = make_data_file_entry(
483 output_path,
484 record_count,
485 file_size,
486 ¢roid,
487 VectorIndexInfo {
488 column: &self.policy.column_name,
489 dim: self.policy.dim,
490 hnsw_offset: ailk_start + header.hnsw_offset,
491 hnsw_len: header.hnsw_len,
492 },
493 );
494 entry.first_row_id = source_first_row_id;
495
496 info!(
497 "ailake: compact_incremental — merged {} files into {} \
498 ({} rows from dominant + {} inserted incrementally)",
499 files.len(),
500 output_path,
501 dom_count,
502 record_count - dom_count
503 );
504
505 Ok(entry)
506 }
507
508 pub async fn compact_deferred(
518 &self,
519 files: &[DataFileEntry],
520 output_path: &str,
521 catalog: Arc<dyn CatalogProvider>,
522 table: &TableIdent,
523 ) -> AilakeResult<DataFileEntry> {
524 if files.is_empty() {
525 return Err(ailake_core::AilakeError::Catalog(
526 "compact_deferred: no files provided".into(),
527 ));
528 }
529
530 let pairs = self.read_files_parallel(files).await?;
531
532 if pairs.is_empty() {
533 return Err(ailake_core::AilakeError::Catalog(
534 "compact_deferred: no valid AI-Lake files in input".into(),
535 ));
536 }
537
538 let schema: SchemaRef = pairs[0].0.schema();
539 let (all_batches, all_embeddings): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
540 let all_embeddings: Vec<Vec<f32>> = all_embeddings.into_iter().flatten().collect();
541
542 let merged_batch = concat_batches(schema, &all_batches)?;
543 let record_count = merged_batch.num_rows() as u64;
544
545 let file_writer = AilakeFileWriter::new(self.policy.clone());
547 let parquet_bytes = file_writer.write_parquet_only(&merged_batch, &all_embeddings)?;
548 let file_size = parquet_bytes.len() as u64;
549 self.store.put(output_path, parquet_bytes).await?;
550
551 let centroid = compute_centroid_and_radius(&all_embeddings, self.policy.metric);
553 let source_first_row_id = files.iter().filter_map(|f| f.first_row_id).min();
554 let mut entry = make_data_file_entry_indexing(
555 output_path,
556 record_count,
557 file_size,
558 ¢roid,
559 &self.policy.column_name,
560 self.policy.dim,
561 );
562 entry.first_row_id = source_first_row_id;
563
564 let store = self.store.clone();
566 let policy = self.policy.clone();
567 let table_id = table.clone();
568 let fp = output_path.to_string();
569 tokio::spawn(async move {
570 if let Err(e) = build_and_patch_index(store, catalog, policy, table_id, fp).await {
571 error!(
572 "ailake: compaction deferred HNSW build failed — file indexed as \
573 Parquet-only until next compaction rebuilds the index: {}",
574 e
575 );
576 }
577 });
578
579 Ok(entry)
580 }
581
582 pub async fn run(
585 &self,
586 planner: &CompactionPlanner,
587 table: &TableIdent,
588 catalog: Arc<dyn CatalogProvider>,
589 output_prefix: &str,
590 ) -> AilakeResult<Option<DataFileEntry>> {
591 let all_files = catalog.list_files(table, None).await?;
592 let to_compact = planner.plan(&all_files);
593 if to_compact.is_empty() {
594 return Ok(None);
595 }
596
597 let meta_props = catalog
600 .load_table(table)
601 .await
602 .map(|m| m.properties)
603 .unwrap_or_default();
604 let executor = self.with_effective_fts(&meta_props);
605
606 let ts = std::time::SystemTime::now()
607 .duration_since(std::time::UNIX_EPOCH)
608 .unwrap_or_else(|e| e.duration())
609 .as_millis();
610 let output_path = format!("{output_prefix}/compacted-{ts}.parquet");
611
612 let merged = executor
614 .compact_incremental(&to_compact, &output_path)
615 .await?;
616
617 let snapshot = NewSnapshot {
619 snapshot_id: ailake_catalog::new_snapshot_id(),
620 parent_snapshot_id: None,
621 files: vec![merged.clone()],
622 operation: SnapshotOperation::Replace,
623 iceberg_schema: None,
624 extra_properties: std::collections::HashMap::new(),
625 bloom_filters: vec![],
626 equality_delete_files: vec![],
627 };
628 catalog.commit_snapshot(table, snapshot).await?;
629
630 info!(
631 "ailake: compaction committed — merged {} files into {}",
632 to_compact.len(),
633 output_path
634 );
635
636 delete_old_files(&self.store, &to_compact).await;
637
638 Ok(Some(merged))
639 }
640
641 pub async fn run_deferred(
651 &self,
652 planner: &CompactionPlanner,
653 table: &TableIdent,
654 catalog: Arc<dyn CatalogProvider>,
655 output_prefix: &str,
656 ) -> AilakeResult<Option<DataFileEntry>> {
657 let all_files = catalog.list_files(table, None).await?;
658 let to_compact = planner.plan(&all_files);
659 if to_compact.is_empty() {
660 return Ok(None);
661 }
662
663 let ts = std::time::SystemTime::now()
664 .duration_since(std::time::UNIX_EPOCH)
665 .unwrap_or_else(|e| e.duration())
666 .as_millis();
667 let output_path = format!("{output_prefix}/compacted-{ts}.parquet");
668
669 let merged = self
670 .compact_deferred(&to_compact, &output_path, catalog.clone(), table)
671 .await?;
672
673 let snapshot = NewSnapshot {
675 snapshot_id: ailake_catalog::new_snapshot_id(),
676 parent_snapshot_id: None,
677 files: vec![merged.clone()],
678 operation: SnapshotOperation::Replace,
679 iceberg_schema: None,
680 extra_properties: std::collections::HashMap::new(),
681 bloom_filters: vec![],
682 equality_delete_files: vec![],
683 };
684 catalog.commit_snapshot(table, snapshot).await?;
685
686 info!(
687 "ailake: compaction committed (deferred) — merged {} files into {} \
688 (index building in background)",
689 to_compact.len(),
690 output_path
691 );
692
693 delete_old_files(&self.store, &to_compact).await;
694
695 Ok(Some(merged))
696 }
697}
698
699async fn delete_old_files(store: &Arc<dyn Store>, files: &[DataFileEntry]) {
700 for entry in files {
701 if let Err(e) = store.delete(&entry.path).await {
702 error!(
703 "ailake: compaction cleanup failed — could not delete {}: {} \
704 (orphan file in object store after successful catalog commit; \
705 delete manually to reclaim storage)",
706 entry.path, e
707 );
708 }
709 }
710}
711
712fn concat_batches(schema: SchemaRef, batches: &[RecordBatch]) -> AilakeResult<RecordBatch> {
713 arrow_select::concat::concat_batches(&schema, batches)
714 .map_err(|e| ailake_core::AilakeError::Arrow(e.to_string()))
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use ailake_catalog::IndexStatus;
721
722 #[test]
723 fn plan_returns_empty_if_too_few_files() {
724 let planner = CompactionPlanner::new(CompactionConfig {
725 min_files_to_compact: 4,
726 target_file_size_bytes: 1024 * 1024,
727 ..Default::default()
728 });
729 let files: Vec<DataFileEntry> = (0..3)
730 .map(|i| DataFileEntry {
731 path: format!("file-{i}.parquet"),
732 record_count: 10,
733 file_size_bytes: 100,
734 centroid_b64: None,
735 radius: None,
736 hnsw_offset: None,
737 hnsw_len: None,
738 vector_column: None,
739 vector_dim: None,
740 extra_vector_indexes: vec![],
741 index_status: IndexStatus::Ready,
742 index_error: None,
743 batch_id: None,
744 embedding_model: None,
745 partition_value: None,
746 deletion_vector: None,
747 first_row_id: None,
748 })
749 .collect();
750 assert!(planner.plan(&files).is_empty());
751 }
752
753 #[test]
754 fn plan_selects_small_files() {
755 let planner = CompactionPlanner::new(CompactionConfig {
756 min_files_to_compact: 2,
757 target_file_size_bytes: 1000,
758 ..Default::default()
759 });
760 let files = vec![
761 DataFileEntry {
762 path: "small.parquet".into(),
763 record_count: 5,
764 file_size_bytes: 500,
765 centroid_b64: None,
766 radius: None,
767 hnsw_offset: None,
768 hnsw_len: None,
769 vector_column: None,
770 vector_dim: None,
771 extra_vector_indexes: vec![],
772 index_status: IndexStatus::Ready,
773 index_error: None,
774 batch_id: None,
775 embedding_model: None,
776 partition_value: None,
777 deletion_vector: None,
778 first_row_id: None,
779 },
780 DataFileEntry {
781 path: "large.parquet".into(),
782 record_count: 5000,
783 file_size_bytes: 200_000_000,
784 centroid_b64: None,
785 radius: None,
786 hnsw_offset: None,
787 hnsw_len: None,
788 vector_column: None,
789 vector_dim: None,
790 extra_vector_indexes: vec![],
791 index_status: IndexStatus::Ready,
792 index_error: None,
793 batch_id: None,
794 embedding_model: None,
795 partition_value: None,
796 deletion_vector: None,
797 first_row_id: None,
798 },
799 DataFileEntry {
800 path: "also-small.parquet".into(),
801 record_count: 5,
802 file_size_bytes: 800,
803 centroid_b64: None,
804 radius: None,
805 hnsw_offset: None,
806 hnsw_len: None,
807 vector_column: None,
808 vector_dim: None,
809 extra_vector_indexes: vec![],
810 index_status: IndexStatus::Ready,
811 index_error: None,
812 batch_id: None,
813 embedding_model: None,
814 partition_value: None,
815 deletion_vector: None,
816 first_row_id: None,
817 },
818 ];
819 let selected = planner.plan(&files);
820 assert_eq!(selected.len(), 2);
821 assert!(selected.iter().any(|f| f.path == "small.parquet"));
822 assert!(selected.iter().any(|f| f.path == "also-small.parquet"));
823 }
824
825 #[test]
826 fn plan_respects_max_files_per_pass() {
827 let planner = CompactionPlanner::new(CompactionConfig {
828 min_files_to_compact: 2,
829 target_file_size_bytes: 1_000_000,
830 max_files_per_pass: 3,
831 ..Default::default()
832 });
833 let files: Vec<DataFileEntry> = (0..5)
834 .map(|i| DataFileEntry {
835 path: format!("f{i}.parquet"),
836 record_count: 10,
837 file_size_bytes: 100 + i as u64 * 100,
838 centroid_b64: None,
839 radius: None,
840 hnsw_offset: None,
841 hnsw_len: None,
842 vector_column: None,
843 vector_dim: None,
844 extra_vector_indexes: vec![],
845 index_status: IndexStatus::Ready,
846 index_error: None,
847 batch_id: None,
848 embedding_model: None,
849 partition_value: None,
850 deletion_vector: None,
851 first_row_id: None,
852 })
853 .collect();
854 let selected = planner.plan(&files);
855 assert_eq!(selected.len(), 3);
856 assert_eq!(selected[0].file_size_bytes, 100);
857 assert_eq!(selected[1].file_size_bytes, 200);
858 assert_eq!(selected[2].file_size_bytes, 300);
859 }
860
861 #[test]
862 fn plan_sorts_smallest_first() {
863 let planner = CompactionPlanner::new(CompactionConfig {
864 min_files_to_compact: 2,
865 target_file_size_bytes: 10_000,
866 max_files_per_pass: 4,
867 ..Default::default()
868 });
869 let files = vec![
870 DataFileEntry {
871 path: "c.parquet".into(),
872 record_count: 1,
873 file_size_bytes: 300,
874 centroid_b64: None,
875 radius: None,
876 hnsw_offset: None,
877 hnsw_len: None,
878 vector_column: None,
879 vector_dim: None,
880 extra_vector_indexes: vec![],
881 index_status: IndexStatus::Ready,
882 index_error: None,
883 batch_id: None,
884 embedding_model: None,
885 partition_value: None,
886 deletion_vector: None,
887 first_row_id: None,
888 },
889 DataFileEntry {
890 path: "a.parquet".into(),
891 record_count: 1,
892 file_size_bytes: 100,
893 centroid_b64: None,
894 radius: None,
895 hnsw_offset: None,
896 hnsw_len: None,
897 vector_column: None,
898 vector_dim: None,
899 extra_vector_indexes: vec![],
900 index_status: IndexStatus::Ready,
901 index_error: None,
902 batch_id: None,
903 embedding_model: None,
904 partition_value: None,
905 deletion_vector: None,
906 first_row_id: None,
907 },
908 DataFileEntry {
909 path: "b.parquet".into(),
910 record_count: 1,
911 file_size_bytes: 200,
912 centroid_b64: None,
913 radius: None,
914 hnsw_offset: None,
915 hnsw_len: None,
916 vector_column: None,
917 vector_dim: None,
918 extra_vector_indexes: vec![],
919 index_status: IndexStatus::Ready,
920 index_error: None,
921 batch_id: None,
922 embedding_model: None,
923 partition_value: None,
924 deletion_vector: None,
925 first_row_id: None,
926 },
927 ];
928 let selected = planner.plan(&files);
929 assert_eq!(selected[0].file_size_bytes, 100);
930 assert_eq!(selected[1].file_size_bytes, 200);
931 assert_eq!(selected[2].file_size_bytes, 300);
932 }
933
934 #[tokio::test]
935 async fn compact_merges_two_files() {
936 use ailake_core::{VectorMetric, VectorPrecision};
937 use ailake_store::LocalStore;
938 use arrow_array::{Int32Array, RecordBatch};
939 use arrow_schema::{DataType, Field, Schema};
940 use std::sync::Arc;
941 use tempfile::TempDir;
942
943 let dir = TempDir::new().unwrap();
944 let store = Arc::new(LocalStore::new(dir.path()));
945 let policy = VectorStoragePolicy {
946 column_name: "embedding".into(),
947 dim: 4,
948 metric: VectorMetric::Cosine,
949 precision: VectorPrecision::F16,
950 pq: None,
951 keep_raw_for_reranking: true,
952 pre_normalize: false,
953 hnsw_m: None,
954 hnsw_ef_construction: None,
955 ivf_residual: false,
956 embedding_model: None,
957 modality: None,
958 partition_by: None,
959 partition_value: None,
960 partition_column_type: None,
961 partition_fields: vec![],
962 };
963
964 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
965 let embs_a: Vec<Vec<f32>> = vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
966 let embs_b: Vec<Vec<f32>> = vec![vec![0.0, 0.0, 1.0, 0.0], vec![0.0, 0.0, 0.0, 1.0]];
967
968 let batch_a = RecordBatch::try_new(
969 schema.clone(),
970 vec![Arc::new(Int32Array::from(vec![0i32, 1]))],
971 )
972 .unwrap();
973 let batch_b = RecordBatch::try_new(
974 schema.clone(),
975 vec![Arc::new(Int32Array::from(vec![2i32, 3]))],
976 )
977 .unwrap();
978
979 let writer_a = AilakeFileWriter::new(policy.clone());
980 let bytes_a = writer_a.write(&batch_a, &embs_a).unwrap();
981 let writer_b = AilakeFileWriter::new(policy.clone());
982 let bytes_b = writer_b.write(&batch_b, &embs_b).unwrap();
983
984 store.put("data/a.parquet", bytes_a.clone()).await.unwrap();
985 store.put("data/b.parquet", bytes_b.clone()).await.unwrap();
986
987 let entries = vec![
988 DataFileEntry {
989 path: "data/a.parquet".into(),
990 record_count: 2,
991 file_size_bytes: bytes_a.len() as u64,
992 centroid_b64: None,
993 radius: None,
994 hnsw_offset: None,
995 hnsw_len: None,
996 vector_column: None,
997 vector_dim: None,
998 extra_vector_indexes: vec![],
999 index_status: IndexStatus::Ready,
1000 index_error: None,
1001 batch_id: None,
1002 embedding_model: None,
1003 partition_value: None,
1004 deletion_vector: None,
1005 first_row_id: None,
1006 },
1007 DataFileEntry {
1008 path: "data/b.parquet".into(),
1009 record_count: 2,
1010 file_size_bytes: bytes_b.len() as u64,
1011 centroid_b64: None,
1012 radius: None,
1013 hnsw_offset: None,
1014 hnsw_len: None,
1015 vector_column: None,
1016 vector_dim: None,
1017 extra_vector_indexes: vec![],
1018 index_status: IndexStatus::Ready,
1019 index_error: None,
1020 batch_id: None,
1021 embedding_model: None,
1022 partition_value: None,
1023 deletion_vector: None,
1024 first_row_id: None,
1025 },
1026 ];
1027
1028 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1029 let merged = executor
1030 .compact(&entries, "data/merged.parquet")
1031 .await
1032 .unwrap();
1033
1034 assert_eq!(merged.record_count, 4);
1035 assert_eq!(merged.path, "data/merged.parquet");
1036
1037 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1038 let reader = AilakeFileReader::new(merged_bytes, "embedding", 4);
1039 reader.verify_integrity().unwrap();
1040 let (batch, embs) = reader.read_parquet().unwrap();
1041 assert_eq!(batch.num_rows(), 4);
1042 assert_eq!(embs.len(), 4);
1043 }
1044
1045 #[tokio::test]
1046 async fn compact_incremental_merges_dominant_plus_small() {
1047 use ailake_core::{RowId, VectorMetric, VectorPrecision};
1048 use ailake_store::LocalStore;
1049 use arrow_array::{Int32Array, RecordBatch};
1050 use arrow_schema::{DataType, Field, Schema};
1051 use std::sync::Arc;
1052 use tempfile::TempDir;
1053
1054 let dir = TempDir::new().unwrap();
1055 let store = Arc::new(LocalStore::new(dir.path()));
1056 let policy = VectorStoragePolicy {
1057 column_name: "embedding".into(),
1058 dim: 4,
1059 metric: VectorMetric::Cosine,
1060 precision: VectorPrecision::F16,
1061 pq: None,
1062 keep_raw_for_reranking: true,
1063 pre_normalize: false,
1064 hnsw_m: None,
1065 hnsw_ef_construction: None,
1066 ivf_residual: false,
1067 embedding_model: None,
1068 modality: None,
1069 partition_by: None,
1070 partition_value: None,
1071 partition_column_type: None,
1072 partition_fields: vec![],
1073 };
1074
1075 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1076
1077 let embs_dom: Vec<Vec<f32>> = vec![
1079 vec![1.0, 0.0, 0.0, 0.0],
1080 vec![0.0, 1.0, 0.0, 0.0],
1081 vec![0.0, 0.0, 1.0, 0.0],
1082 vec![0.7, 0.7, 0.0, 0.0],
1083 vec![0.0, 0.7, 0.7, 0.0],
1084 vec![0.0, 0.0, 0.7, 0.7],
1085 ];
1086 let batch_dom = RecordBatch::try_new(
1087 schema.clone(),
1088 vec![Arc::new(Int32Array::from(vec![0i32, 1, 2, 3, 4, 5]))],
1089 )
1090 .unwrap();
1091
1092 let embs_small: Vec<Vec<f32>> = vec![vec![0.0, 0.0, 0.0, 1.0], vec![0.5, 0.5, 0.5, 0.5]];
1094 let batch_small = RecordBatch::try_new(
1095 schema.clone(),
1096 vec![Arc::new(Int32Array::from(vec![6i32, 7]))],
1097 )
1098 .unwrap();
1099
1100 let bytes_dom = AilakeFileWriter::new(policy.clone())
1101 .write(&batch_dom, &embs_dom)
1102 .unwrap();
1103 let bytes_small = AilakeFileWriter::new(policy.clone())
1104 .write(&batch_small, &embs_small)
1105 .unwrap();
1106
1107 store
1108 .put("data/dominant.parquet", bytes_dom.clone())
1109 .await
1110 .unwrap();
1111 store
1112 .put("data/small.parquet", bytes_small.clone())
1113 .await
1114 .unwrap();
1115
1116 let entries = vec![
1117 DataFileEntry {
1118 path: "data/dominant.parquet".into(),
1119 record_count: 6,
1120 file_size_bytes: bytes_dom.len() as u64,
1121 centroid_b64: None,
1122 radius: None,
1123 hnsw_offset: None,
1124 hnsw_len: None,
1125 vector_column: None,
1126 vector_dim: None,
1127 extra_vector_indexes: vec![],
1128 index_status: IndexStatus::Ready,
1129 index_error: None,
1130 batch_id: None,
1131 embedding_model: None,
1132 partition_value: None,
1133 deletion_vector: None,
1134 first_row_id: None,
1135 },
1136 DataFileEntry {
1137 path: "data/small.parquet".into(),
1138 record_count: 2,
1139 file_size_bytes: bytes_small.len() as u64,
1140 centroid_b64: None,
1141 radius: None,
1142 hnsw_offset: None,
1143 hnsw_len: None,
1144 vector_column: None,
1145 vector_dim: None,
1146 extra_vector_indexes: vec![],
1147 index_status: IndexStatus::Ready,
1148 index_error: None,
1149 batch_id: None,
1150 embedding_model: None,
1151 partition_value: None,
1152 deletion_vector: None,
1153 first_row_id: None,
1154 },
1155 ];
1156
1157 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1158 let merged = executor
1159 .compact_incremental(&entries, "data/merged.parquet")
1160 .await
1161 .unwrap();
1162
1163 assert_eq!(merged.record_count, 8);
1165 assert_eq!(merged.path, "data/merged.parquet");
1166
1167 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1169 let reader = AilakeFileReader::new(merged_bytes, "embedding", 4);
1170 reader.verify_integrity().unwrap();
1171
1172 let (batch, embs) = reader.read_parquet().unwrap();
1173 assert_eq!(batch.num_rows(), 8);
1174 assert_eq!(embs.len(), 8);
1175
1176 for f in &embs[..6] {
1178 assert_eq!(f.len(), 4);
1179 }
1180
1181 let hnsw = reader.load_index().unwrap();
1183 assert_eq!(hnsw.node_count(), 8);
1184
1185 let results = hnsw.search(&[1.0, 0.0, 0.0, 0.0], 1, 50);
1187 assert_eq!(results[0].0, RowId::new(0));
1188
1189 let results = hnsw.search(&[0.0, 0.0, 0.0, 1.0], 1, 50);
1192 assert_eq!(results[0].0, RowId::new(6));
1193 }
1194
1195 #[tokio::test]
1196 async fn compact_incremental_falls_back_when_no_dominant() {
1197 use ailake_core::{VectorMetric, VectorPrecision};
1198 use ailake_store::LocalStore;
1199 use arrow_array::{Int32Array, RecordBatch};
1200 use arrow_schema::{DataType, Field, Schema};
1201 use std::sync::Arc;
1202 use tempfile::TempDir;
1203
1204 let dir = TempDir::new().unwrap();
1205 let store = Arc::new(LocalStore::new(dir.path()));
1206 let policy = VectorStoragePolicy {
1207 column_name: "embedding".into(),
1208 dim: 4,
1209 metric: VectorMetric::Cosine,
1210 precision: VectorPrecision::F16,
1211 pq: None,
1212 keep_raw_for_reranking: true,
1213 pre_normalize: false,
1214 hnsw_m: None,
1215 hnsw_ef_construction: None,
1216 ivf_residual: false,
1217 embedding_model: None,
1218 modality: None,
1219 partition_by: None,
1220 partition_value: None,
1221 partition_column_type: None,
1222 partition_fields: vec![],
1223 };
1224
1225 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1226
1227 let make_batch = |ids: Vec<i32>, embs: Vec<Vec<f32>>| {
1229 let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(ids))])
1230 .unwrap();
1231 AilakeFileWriter::new(policy.clone())
1232 .write(&batch, &embs)
1233 .unwrap()
1234 };
1235
1236 let embs_a: Vec<Vec<f32>> = vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
1237 let embs_b: Vec<Vec<f32>> = vec![vec![0.0, 0.0, 1.0, 0.0], vec![0.0, 0.0, 0.0, 1.0]];
1238 let bytes_a = make_batch(vec![0, 1], embs_a);
1239 let bytes_b = make_batch(vec![2, 3], embs_b);
1240
1241 store.put("data/a.parquet", bytes_a.clone()).await.unwrap();
1242 store.put("data/b.parquet", bytes_b.clone()).await.unwrap();
1243
1244 let entries = vec![
1245 DataFileEntry {
1246 path: "data/a.parquet".into(),
1247 record_count: 2,
1248 file_size_bytes: bytes_a.len() as u64,
1249 centroid_b64: None,
1250 radius: None,
1251 hnsw_offset: None,
1252 hnsw_len: None,
1253 vector_column: None,
1254 vector_dim: None,
1255 extra_vector_indexes: vec![],
1256 index_status: IndexStatus::Ready,
1257 index_error: None,
1258 batch_id: None,
1259 embedding_model: None,
1260 partition_value: None,
1261 deletion_vector: None,
1262 first_row_id: None,
1263 },
1264 DataFileEntry {
1265 path: "data/b.parquet".into(),
1266 record_count: 2,
1267 file_size_bytes: bytes_b.len() as u64,
1268 centroid_b64: None,
1269 radius: None,
1270 hnsw_offset: None,
1271 hnsw_len: None,
1272 vector_column: None,
1273 vector_dim: None,
1274 extra_vector_indexes: vec![],
1275 index_status: IndexStatus::Ready,
1276 index_error: None,
1277 batch_id: None,
1278 embedding_model: None,
1279 partition_value: None,
1280 deletion_vector: None,
1281 first_row_id: None,
1282 },
1283 ];
1284
1285 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1286 let merged = executor
1288 .compact_incremental(&entries, "data/merged.parquet")
1289 .await
1290 .unwrap();
1291
1292 assert_eq!(merged.record_count, 4);
1293
1294 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1295 let reader = AilakeFileReader::new(merged_bytes, "embedding", 4);
1296 reader.verify_integrity().unwrap();
1297 }
1298
1299 #[tokio::test]
1300 async fn compact_deferred_produces_parquet_only_file() {
1301 use ailake_catalog::HadoopCatalog;
1302 use ailake_core::{VectorMetric, VectorPrecision};
1303 use ailake_store::LocalStore;
1304 use arrow_array::{Int32Array, RecordBatch};
1305 use arrow_schema::{DataType, Field, Schema};
1306 use std::sync::Arc;
1307 use tempfile::TempDir;
1308
1309 let dir = TempDir::new().unwrap();
1310 let store = Arc::new(LocalStore::new(dir.path()));
1311 let catalog_dir = TempDir::new().unwrap();
1312 let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
1313 let catalog = Arc::new(HadoopCatalog::new(catalog_store, ""));
1314 let table = TableIdent {
1315 namespace: "ns".into(),
1316 name: "tbl".into(),
1317 };
1318
1319 let policy = VectorStoragePolicy {
1320 column_name: "embedding".into(),
1321 dim: 4,
1322 metric: VectorMetric::Cosine,
1323 precision: VectorPrecision::F16,
1324 pq: None,
1325 keep_raw_for_reranking: true,
1326 pre_normalize: false,
1327 hnsw_m: None,
1328 hnsw_ef_construction: None,
1329 ivf_residual: false,
1330 embedding_model: None,
1331 modality: None,
1332 partition_by: None,
1333 partition_value: None,
1334 partition_column_type: None,
1335 partition_fields: vec![],
1336 };
1337
1338 use ailake_catalog::TableProperties;
1339 catalog
1340 .create_table(
1341 &table,
1342 &TableProperties {
1343 policy: policy.clone(),
1344 extra: std::collections::HashMap::new(),
1345 format_version: 2,
1346 partition_column_type: None,
1347 },
1348 )
1349 .await
1350 .unwrap();
1351
1352 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1353 let embs_a: Vec<Vec<f32>> = vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
1354 let batch_a = RecordBatch::try_new(
1355 schema.clone(),
1356 vec![Arc::new(Int32Array::from(vec![0i32, 1]))],
1357 )
1358 .unwrap();
1359 let bytes_a = AilakeFileWriter::new(policy.clone())
1360 .write(&batch_a, &embs_a)
1361 .unwrap();
1362 store.put("data/a.parquet", bytes_a.clone()).await.unwrap();
1363
1364 let embs_b: Vec<Vec<f32>> = vec![vec![0.0, 0.0, 1.0, 0.0], vec![0.0, 0.0, 0.0, 1.0]];
1365 let batch_b = RecordBatch::try_new(
1366 schema.clone(),
1367 vec![Arc::new(Int32Array::from(vec![2i32, 3]))],
1368 )
1369 .unwrap();
1370 let bytes_b = AilakeFileWriter::new(policy.clone())
1371 .write(&batch_b, &embs_b)
1372 .unwrap();
1373 store.put("data/b.parquet", bytes_b.clone()).await.unwrap();
1374
1375 let entries = vec![
1376 DataFileEntry {
1377 path: "data/a.parquet".into(),
1378 record_count: 2,
1379 file_size_bytes: bytes_a.len() as u64,
1380 centroid_b64: None,
1381 radius: None,
1382 hnsw_offset: None,
1383 hnsw_len: None,
1384 vector_column: None,
1385 vector_dim: None,
1386 extra_vector_indexes: vec![],
1387 index_status: IndexStatus::Ready,
1388 index_error: None,
1389 batch_id: None,
1390 embedding_model: None,
1391 partition_value: None,
1392 deletion_vector: None,
1393 first_row_id: None,
1394 },
1395 DataFileEntry {
1396 path: "data/b.parquet".into(),
1397 record_count: 2,
1398 file_size_bytes: bytes_b.len() as u64,
1399 centroid_b64: None,
1400 radius: None,
1401 hnsw_offset: None,
1402 hnsw_len: None,
1403 vector_column: None,
1404 vector_dim: None,
1405 extra_vector_indexes: vec![],
1406 index_status: IndexStatus::Ready,
1407 index_error: None,
1408 batch_id: None,
1409 embedding_model: None,
1410 partition_value: None,
1411 deletion_vector: None,
1412 first_row_id: None,
1413 },
1414 ];
1415
1416 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1417 let entry = executor
1418 .compact_deferred(&entries, "data/merged.parquet", catalog.clone(), &table)
1419 .await
1420 .unwrap();
1421
1422 assert_eq!(entry.index_status, IndexStatus::Indexing);
1424 assert_eq!(entry.record_count, 4);
1425
1426 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1428 let pq_reader = ailake_parquet::ParquetVectorReader::new(merged_bytes, "embedding");
1429 let count = pq_reader.record_count().unwrap();
1430 assert_eq!(count, 4);
1431 }
1432}