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 batch_id: None,
743 embedding_model: None,
744 partition_value: None,
745 deletion_vector: None,
746 first_row_id: None,
747 })
748 .collect();
749 assert!(planner.plan(&files).is_empty());
750 }
751
752 #[test]
753 fn plan_selects_small_files() {
754 let planner = CompactionPlanner::new(CompactionConfig {
755 min_files_to_compact: 2,
756 target_file_size_bytes: 1000,
757 ..Default::default()
758 });
759 let files = vec![
760 DataFileEntry {
761 path: "small.parquet".into(),
762 record_count: 5,
763 file_size_bytes: 500,
764 centroid_b64: None,
765 radius: None,
766 hnsw_offset: None,
767 hnsw_len: None,
768 vector_column: None,
769 vector_dim: None,
770 extra_vector_indexes: vec![],
771 index_status: IndexStatus::Ready,
772 batch_id: None,
773 embedding_model: None,
774 partition_value: None,
775 deletion_vector: None,
776 first_row_id: None,
777 },
778 DataFileEntry {
779 path: "large.parquet".into(),
780 record_count: 5000,
781 file_size_bytes: 200_000_000,
782 centroid_b64: None,
783 radius: None,
784 hnsw_offset: None,
785 hnsw_len: None,
786 vector_column: None,
787 vector_dim: None,
788 extra_vector_indexes: vec![],
789 index_status: IndexStatus::Ready,
790 batch_id: None,
791 embedding_model: None,
792 partition_value: None,
793 deletion_vector: None,
794 first_row_id: None,
795 },
796 DataFileEntry {
797 path: "also-small.parquet".into(),
798 record_count: 5,
799 file_size_bytes: 800,
800 centroid_b64: None,
801 radius: None,
802 hnsw_offset: None,
803 hnsw_len: None,
804 vector_column: None,
805 vector_dim: None,
806 extra_vector_indexes: vec![],
807 index_status: IndexStatus::Ready,
808 batch_id: None,
809 embedding_model: None,
810 partition_value: None,
811 deletion_vector: None,
812 first_row_id: None,
813 },
814 ];
815 let selected = planner.plan(&files);
816 assert_eq!(selected.len(), 2);
817 assert!(selected.iter().any(|f| f.path == "small.parquet"));
818 assert!(selected.iter().any(|f| f.path == "also-small.parquet"));
819 }
820
821 #[test]
822 fn plan_respects_max_files_per_pass() {
823 let planner = CompactionPlanner::new(CompactionConfig {
824 min_files_to_compact: 2,
825 target_file_size_bytes: 1_000_000,
826 max_files_per_pass: 3,
827 ..Default::default()
828 });
829 let files: Vec<DataFileEntry> = (0..5)
830 .map(|i| DataFileEntry {
831 path: format!("f{i}.parquet"),
832 record_count: 10,
833 file_size_bytes: 100 + i as u64 * 100,
834 centroid_b64: None,
835 radius: None,
836 hnsw_offset: None,
837 hnsw_len: None,
838 vector_column: None,
839 vector_dim: None,
840 extra_vector_indexes: vec![],
841 index_status: IndexStatus::Ready,
842 batch_id: None,
843 embedding_model: None,
844 partition_value: None,
845 deletion_vector: None,
846 first_row_id: None,
847 })
848 .collect();
849 let selected = planner.plan(&files);
850 assert_eq!(selected.len(), 3);
851 assert_eq!(selected[0].file_size_bytes, 100);
852 assert_eq!(selected[1].file_size_bytes, 200);
853 assert_eq!(selected[2].file_size_bytes, 300);
854 }
855
856 #[test]
857 fn plan_sorts_smallest_first() {
858 let planner = CompactionPlanner::new(CompactionConfig {
859 min_files_to_compact: 2,
860 target_file_size_bytes: 10_000,
861 max_files_per_pass: 4,
862 ..Default::default()
863 });
864 let files = vec![
865 DataFileEntry {
866 path: "c.parquet".into(),
867 record_count: 1,
868 file_size_bytes: 300,
869 centroid_b64: None,
870 radius: None,
871 hnsw_offset: None,
872 hnsw_len: None,
873 vector_column: None,
874 vector_dim: None,
875 extra_vector_indexes: vec![],
876 index_status: IndexStatus::Ready,
877 batch_id: None,
878 embedding_model: None,
879 partition_value: None,
880 deletion_vector: None,
881 first_row_id: None,
882 },
883 DataFileEntry {
884 path: "a.parquet".into(),
885 record_count: 1,
886 file_size_bytes: 100,
887 centroid_b64: None,
888 radius: None,
889 hnsw_offset: None,
890 hnsw_len: None,
891 vector_column: None,
892 vector_dim: None,
893 extra_vector_indexes: vec![],
894 index_status: IndexStatus::Ready,
895 batch_id: None,
896 embedding_model: None,
897 partition_value: None,
898 deletion_vector: None,
899 first_row_id: None,
900 },
901 DataFileEntry {
902 path: "b.parquet".into(),
903 record_count: 1,
904 file_size_bytes: 200,
905 centroid_b64: None,
906 radius: None,
907 hnsw_offset: None,
908 hnsw_len: None,
909 vector_column: None,
910 vector_dim: None,
911 extra_vector_indexes: vec![],
912 index_status: IndexStatus::Ready,
913 batch_id: None,
914 embedding_model: None,
915 partition_value: None,
916 deletion_vector: None,
917 first_row_id: None,
918 },
919 ];
920 let selected = planner.plan(&files);
921 assert_eq!(selected[0].file_size_bytes, 100);
922 assert_eq!(selected[1].file_size_bytes, 200);
923 assert_eq!(selected[2].file_size_bytes, 300);
924 }
925
926 #[tokio::test]
927 async fn compact_merges_two_files() {
928 use ailake_core::{VectorMetric, VectorPrecision};
929 use ailake_store::LocalStore;
930 use arrow_array::{Int32Array, RecordBatch};
931 use arrow_schema::{DataType, Field, Schema};
932 use std::sync::Arc;
933 use tempfile::TempDir;
934
935 let dir = TempDir::new().unwrap();
936 let store = Arc::new(LocalStore::new(dir.path()));
937 let policy = VectorStoragePolicy {
938 column_name: "embedding".into(),
939 dim: 4,
940 metric: VectorMetric::Cosine,
941 precision: VectorPrecision::F16,
942 pq: None,
943 keep_raw_for_reranking: true,
944 pre_normalize: false,
945 hnsw_m: None,
946 hnsw_ef_construction: None,
947 ivf_residual: false,
948 embedding_model: None,
949 modality: None,
950 partition_by: None,
951 partition_value: None,
952 partition_column_type: None,
953 partition_fields: vec![],
954 };
955
956 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
957 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]];
958 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]];
959
960 let batch_a = RecordBatch::try_new(
961 schema.clone(),
962 vec![Arc::new(Int32Array::from(vec![0i32, 1]))],
963 )
964 .unwrap();
965 let batch_b = RecordBatch::try_new(
966 schema.clone(),
967 vec![Arc::new(Int32Array::from(vec![2i32, 3]))],
968 )
969 .unwrap();
970
971 let writer_a = AilakeFileWriter::new(policy.clone());
972 let bytes_a = writer_a.write(&batch_a, &embs_a).unwrap();
973 let writer_b = AilakeFileWriter::new(policy.clone());
974 let bytes_b = writer_b.write(&batch_b, &embs_b).unwrap();
975
976 store.put("data/a.parquet", bytes_a.clone()).await.unwrap();
977 store.put("data/b.parquet", bytes_b.clone()).await.unwrap();
978
979 let entries = vec![
980 DataFileEntry {
981 path: "data/a.parquet".into(),
982 record_count: 2,
983 file_size_bytes: bytes_a.len() as u64,
984 centroid_b64: None,
985 radius: None,
986 hnsw_offset: None,
987 hnsw_len: None,
988 vector_column: None,
989 vector_dim: None,
990 extra_vector_indexes: vec![],
991 index_status: IndexStatus::Ready,
992 batch_id: None,
993 embedding_model: None,
994 partition_value: None,
995 deletion_vector: None,
996 first_row_id: None,
997 },
998 DataFileEntry {
999 path: "data/b.parquet".into(),
1000 record_count: 2,
1001 file_size_bytes: bytes_b.len() as u64,
1002 centroid_b64: None,
1003 radius: None,
1004 hnsw_offset: None,
1005 hnsw_len: None,
1006 vector_column: None,
1007 vector_dim: None,
1008 extra_vector_indexes: vec![],
1009 index_status: IndexStatus::Ready,
1010 batch_id: None,
1011 embedding_model: None,
1012 partition_value: None,
1013 deletion_vector: None,
1014 first_row_id: None,
1015 },
1016 ];
1017
1018 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1019 let merged = executor
1020 .compact(&entries, "data/merged.parquet")
1021 .await
1022 .unwrap();
1023
1024 assert_eq!(merged.record_count, 4);
1025 assert_eq!(merged.path, "data/merged.parquet");
1026
1027 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1028 let reader = AilakeFileReader::new(merged_bytes, "embedding", 4);
1029 reader.verify_integrity().unwrap();
1030 let (batch, embs) = reader.read_parquet().unwrap();
1031 assert_eq!(batch.num_rows(), 4);
1032 assert_eq!(embs.len(), 4);
1033 }
1034
1035 #[tokio::test]
1036 async fn compact_incremental_merges_dominant_plus_small() {
1037 use ailake_core::{RowId, VectorMetric, VectorPrecision};
1038 use ailake_store::LocalStore;
1039 use arrow_array::{Int32Array, RecordBatch};
1040 use arrow_schema::{DataType, Field, Schema};
1041 use std::sync::Arc;
1042 use tempfile::TempDir;
1043
1044 let dir = TempDir::new().unwrap();
1045 let store = Arc::new(LocalStore::new(dir.path()));
1046 let policy = VectorStoragePolicy {
1047 column_name: "embedding".into(),
1048 dim: 4,
1049 metric: VectorMetric::Cosine,
1050 precision: VectorPrecision::F16,
1051 pq: None,
1052 keep_raw_for_reranking: true,
1053 pre_normalize: false,
1054 hnsw_m: None,
1055 hnsw_ef_construction: None,
1056 ivf_residual: false,
1057 embedding_model: None,
1058 modality: None,
1059 partition_by: None,
1060 partition_value: None,
1061 partition_column_type: None,
1062 partition_fields: vec![],
1063 };
1064
1065 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1066
1067 let embs_dom: Vec<Vec<f32>> = vec![
1069 vec![1.0, 0.0, 0.0, 0.0],
1070 vec![0.0, 1.0, 0.0, 0.0],
1071 vec![0.0, 0.0, 1.0, 0.0],
1072 vec![0.7, 0.7, 0.0, 0.0],
1073 vec![0.0, 0.7, 0.7, 0.0],
1074 vec![0.0, 0.0, 0.7, 0.7],
1075 ];
1076 let batch_dom = RecordBatch::try_new(
1077 schema.clone(),
1078 vec![Arc::new(Int32Array::from(vec![0i32, 1, 2, 3, 4, 5]))],
1079 )
1080 .unwrap();
1081
1082 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]];
1084 let batch_small = RecordBatch::try_new(
1085 schema.clone(),
1086 vec![Arc::new(Int32Array::from(vec![6i32, 7]))],
1087 )
1088 .unwrap();
1089
1090 let bytes_dom = AilakeFileWriter::new(policy.clone())
1091 .write(&batch_dom, &embs_dom)
1092 .unwrap();
1093 let bytes_small = AilakeFileWriter::new(policy.clone())
1094 .write(&batch_small, &embs_small)
1095 .unwrap();
1096
1097 store
1098 .put("data/dominant.parquet", bytes_dom.clone())
1099 .await
1100 .unwrap();
1101 store
1102 .put("data/small.parquet", bytes_small.clone())
1103 .await
1104 .unwrap();
1105
1106 let entries = vec![
1107 DataFileEntry {
1108 path: "data/dominant.parquet".into(),
1109 record_count: 6,
1110 file_size_bytes: bytes_dom.len() as u64,
1111 centroid_b64: None,
1112 radius: None,
1113 hnsw_offset: None,
1114 hnsw_len: None,
1115 vector_column: None,
1116 vector_dim: None,
1117 extra_vector_indexes: vec![],
1118 index_status: IndexStatus::Ready,
1119 batch_id: None,
1120 embedding_model: None,
1121 partition_value: None,
1122 deletion_vector: None,
1123 first_row_id: None,
1124 },
1125 DataFileEntry {
1126 path: "data/small.parquet".into(),
1127 record_count: 2,
1128 file_size_bytes: bytes_small.len() as u64,
1129 centroid_b64: None,
1130 radius: None,
1131 hnsw_offset: None,
1132 hnsw_len: None,
1133 vector_column: None,
1134 vector_dim: None,
1135 extra_vector_indexes: vec![],
1136 index_status: IndexStatus::Ready,
1137 batch_id: None,
1138 embedding_model: None,
1139 partition_value: None,
1140 deletion_vector: None,
1141 first_row_id: None,
1142 },
1143 ];
1144
1145 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1146 let merged = executor
1147 .compact_incremental(&entries, "data/merged.parquet")
1148 .await
1149 .unwrap();
1150
1151 assert_eq!(merged.record_count, 8);
1153 assert_eq!(merged.path, "data/merged.parquet");
1154
1155 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1157 let reader = AilakeFileReader::new(merged_bytes, "embedding", 4);
1158 reader.verify_integrity().unwrap();
1159
1160 let (batch, embs) = reader.read_parquet().unwrap();
1161 assert_eq!(batch.num_rows(), 8);
1162 assert_eq!(embs.len(), 8);
1163
1164 for f in &embs[..6] {
1166 assert_eq!(f.len(), 4);
1167 }
1168
1169 let hnsw = reader.load_index().unwrap();
1171 assert_eq!(hnsw.node_count(), 8);
1172
1173 let results = hnsw.search(&[1.0, 0.0, 0.0, 0.0], 1, 50);
1175 assert_eq!(results[0].0, RowId::new(0));
1176
1177 let results = hnsw.search(&[0.0, 0.0, 0.0, 1.0], 1, 50);
1180 assert_eq!(results[0].0, RowId::new(6));
1181 }
1182
1183 #[tokio::test]
1184 async fn compact_incremental_falls_back_when_no_dominant() {
1185 use ailake_core::{VectorMetric, VectorPrecision};
1186 use ailake_store::LocalStore;
1187 use arrow_array::{Int32Array, RecordBatch};
1188 use arrow_schema::{DataType, Field, Schema};
1189 use std::sync::Arc;
1190 use tempfile::TempDir;
1191
1192 let dir = TempDir::new().unwrap();
1193 let store = Arc::new(LocalStore::new(dir.path()));
1194 let policy = VectorStoragePolicy {
1195 column_name: "embedding".into(),
1196 dim: 4,
1197 metric: VectorMetric::Cosine,
1198 precision: VectorPrecision::F16,
1199 pq: None,
1200 keep_raw_for_reranking: true,
1201 pre_normalize: false,
1202 hnsw_m: None,
1203 hnsw_ef_construction: None,
1204 ivf_residual: false,
1205 embedding_model: None,
1206 modality: None,
1207 partition_by: None,
1208 partition_value: None,
1209 partition_column_type: None,
1210 partition_fields: vec![],
1211 };
1212
1213 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1214
1215 let make_batch = |ids: Vec<i32>, embs: Vec<Vec<f32>>| {
1217 let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(ids))])
1218 .unwrap();
1219 AilakeFileWriter::new(policy.clone())
1220 .write(&batch, &embs)
1221 .unwrap()
1222 };
1223
1224 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]];
1225 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]];
1226 let bytes_a = make_batch(vec![0, 1], embs_a);
1227 let bytes_b = make_batch(vec![2, 3], embs_b);
1228
1229 store.put("data/a.parquet", bytes_a.clone()).await.unwrap();
1230 store.put("data/b.parquet", bytes_b.clone()).await.unwrap();
1231
1232 let entries = vec![
1233 DataFileEntry {
1234 path: "data/a.parquet".into(),
1235 record_count: 2,
1236 file_size_bytes: bytes_a.len() as u64,
1237 centroid_b64: None,
1238 radius: None,
1239 hnsw_offset: None,
1240 hnsw_len: None,
1241 vector_column: None,
1242 vector_dim: None,
1243 extra_vector_indexes: vec![],
1244 index_status: IndexStatus::Ready,
1245 batch_id: None,
1246 embedding_model: None,
1247 partition_value: None,
1248 deletion_vector: None,
1249 first_row_id: None,
1250 },
1251 DataFileEntry {
1252 path: "data/b.parquet".into(),
1253 record_count: 2,
1254 file_size_bytes: bytes_b.len() as u64,
1255 centroid_b64: None,
1256 radius: None,
1257 hnsw_offset: None,
1258 hnsw_len: None,
1259 vector_column: None,
1260 vector_dim: None,
1261 extra_vector_indexes: vec![],
1262 index_status: IndexStatus::Ready,
1263 batch_id: None,
1264 embedding_model: None,
1265 partition_value: None,
1266 deletion_vector: None,
1267 first_row_id: None,
1268 },
1269 ];
1270
1271 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1272 let merged = executor
1274 .compact_incremental(&entries, "data/merged.parquet")
1275 .await
1276 .unwrap();
1277
1278 assert_eq!(merged.record_count, 4);
1279
1280 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1281 let reader = AilakeFileReader::new(merged_bytes, "embedding", 4);
1282 reader.verify_integrity().unwrap();
1283 }
1284
1285 #[tokio::test]
1286 async fn compact_deferred_produces_parquet_only_file() {
1287 use ailake_catalog::HadoopCatalog;
1288 use ailake_core::{VectorMetric, VectorPrecision};
1289 use ailake_store::LocalStore;
1290 use arrow_array::{Int32Array, RecordBatch};
1291 use arrow_schema::{DataType, Field, Schema};
1292 use std::sync::Arc;
1293 use tempfile::TempDir;
1294
1295 let dir = TempDir::new().unwrap();
1296 let store = Arc::new(LocalStore::new(dir.path()));
1297 let catalog_dir = TempDir::new().unwrap();
1298 let catalog_store = Arc::new(LocalStore::new(catalog_dir.path()));
1299 let catalog = Arc::new(HadoopCatalog::new(catalog_store, ""));
1300 let table = TableIdent {
1301 namespace: "ns".into(),
1302 name: "tbl".into(),
1303 };
1304
1305 let policy = VectorStoragePolicy {
1306 column_name: "embedding".into(),
1307 dim: 4,
1308 metric: VectorMetric::Cosine,
1309 precision: VectorPrecision::F16,
1310 pq: None,
1311 keep_raw_for_reranking: true,
1312 pre_normalize: false,
1313 hnsw_m: None,
1314 hnsw_ef_construction: None,
1315 ivf_residual: false,
1316 embedding_model: None,
1317 modality: None,
1318 partition_by: None,
1319 partition_value: None,
1320 partition_column_type: None,
1321 partition_fields: vec![],
1322 };
1323
1324 use ailake_catalog::TableProperties;
1325 catalog
1326 .create_table(
1327 &table,
1328 &TableProperties {
1329 policy: policy.clone(),
1330 extra: std::collections::HashMap::new(),
1331 format_version: 2,
1332 partition_column_type: None,
1333 },
1334 )
1335 .await
1336 .unwrap();
1337
1338 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1339 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]];
1340 let batch_a = RecordBatch::try_new(
1341 schema.clone(),
1342 vec![Arc::new(Int32Array::from(vec![0i32, 1]))],
1343 )
1344 .unwrap();
1345 let bytes_a = AilakeFileWriter::new(policy.clone())
1346 .write(&batch_a, &embs_a)
1347 .unwrap();
1348 store.put("data/a.parquet", bytes_a.clone()).await.unwrap();
1349
1350 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]];
1351 let batch_b = RecordBatch::try_new(
1352 schema.clone(),
1353 vec![Arc::new(Int32Array::from(vec![2i32, 3]))],
1354 )
1355 .unwrap();
1356 let bytes_b = AilakeFileWriter::new(policy.clone())
1357 .write(&batch_b, &embs_b)
1358 .unwrap();
1359 store.put("data/b.parquet", bytes_b.clone()).await.unwrap();
1360
1361 let entries = vec![
1362 DataFileEntry {
1363 path: "data/a.parquet".into(),
1364 record_count: 2,
1365 file_size_bytes: bytes_a.len() as u64,
1366 centroid_b64: None,
1367 radius: None,
1368 hnsw_offset: None,
1369 hnsw_len: None,
1370 vector_column: None,
1371 vector_dim: None,
1372 extra_vector_indexes: vec![],
1373 index_status: IndexStatus::Ready,
1374 batch_id: None,
1375 embedding_model: None,
1376 partition_value: None,
1377 deletion_vector: None,
1378 first_row_id: None,
1379 },
1380 DataFileEntry {
1381 path: "data/b.parquet".into(),
1382 record_count: 2,
1383 file_size_bytes: bytes_b.len() as u64,
1384 centroid_b64: None,
1385 radius: None,
1386 hnsw_offset: None,
1387 hnsw_len: None,
1388 vector_column: None,
1389 vector_dim: None,
1390 extra_vector_indexes: vec![],
1391 index_status: IndexStatus::Ready,
1392 batch_id: None,
1393 embedding_model: None,
1394 partition_value: None,
1395 deletion_vector: None,
1396 first_row_id: None,
1397 },
1398 ];
1399
1400 let executor = CompactionExecutor::new(store.clone(), policy.clone());
1401 let entry = executor
1402 .compact_deferred(&entries, "data/merged.parquet", catalog.clone(), &table)
1403 .await
1404 .unwrap();
1405
1406 assert_eq!(entry.index_status, IndexStatus::Indexing);
1408 assert_eq!(entry.record_count, 4);
1409
1410 let merged_bytes = store.get("data/merged.parquet").await.unwrap();
1412 let pq_reader = ailake_parquet::ParquetVectorReader::new(merged_bytes, "embedding");
1413 let count = pq_reader.record_count().unwrap();
1414 assert_eq!(count, 4);
1415 }
1416}