Skip to main content

ailake_query/
compaction.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use 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/// Index strategy for the merged file produced by compaction.
21#[derive(Debug, Clone, Default)]
22pub enum CompactionIndexStrategy {
23    /// Detect GPU / CPU cores at compaction time and pick the best index.
24    /// IVF-PQ on GPU/many-core machines; HNSW elsewhere. (default)
25    #[default]
26    Auto,
27    /// Always rebuild with HNSW — highest recall, larger index.
28    ForceHnsw,
29    /// Always rebuild with IVF-PQ — smaller index, better S3 throughput.
30    ///
31    /// Recommended for large compactions (N > 100 000) on CPU-only machines
32    /// where HNSW rebuild cost becomes prohibitive.
33    ForceIvfPq,
34}
35
36#[derive(Debug, Clone)]
37pub struct CompactionConfig {
38    /// Trigger compaction only if at least this many files are eligible.
39    pub min_files_to_compact: usize,
40    /// Target output file size in bytes. Files below this are merged.
41    pub target_file_size_bytes: u64,
42    /// Index algorithm for the merged output file.
43    pub index_strategy: CompactionIndexStrategy,
44    /// Maximum files merged in a single compaction pass.
45    ///
46    /// Candidates are sorted smallest-first; only the first `max_files_per_pass`
47    /// are compacted each run. This bounds peak RAM and HNSW rebuild CPU cost —
48    /// O(N log N) stays proportional to this limit rather than table size.
49    /// Default: 20. Set to `usize::MAX` to compact all eligible files at once.
50    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, // 128 MB
58            index_strategy: CompactionIndexStrategy::Auto,
59            max_files_per_pass: 20,
60        }
61    }
62}
63
64#[derive(Debug, Clone, Copy)]
65pub enum CompactionMode {
66    Full,    // compact all files below target size
67    Partial, // compact the smallest N files
68}
69
70pub struct CompactionPlanner {
71    config: CompactionConfig,
72}
73
74impl CompactionPlanner {
75    pub fn new(config: CompactionConfig) -> Self {
76        Self { config }
77    }
78
79    /// Select files to compact.
80    ///
81    /// Picks files smaller than `target_file_size_bytes`, sorts them smallest-first
82    /// (cheapest to read), and caps the selection at `max_files_per_pass`. This
83    /// tiered approach prevents a single pass from compacting the entire table into
84    /// memory when thousands of small files exist.
85    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        // Sort smallest-first so each pass handles the cheapest files first.
100        // This bounds peak RAM to max_files_per_pass * avg_small_file_size.
101        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/// Executes compaction plans: reads N small files, merges them into a single
114/// AI-Lake file with a rebuilt index, and commits to the catalog.
115///
116/// The index algorithm is chosen via `CompactionIndexStrategy` (default: `Auto`,
117/// which detects GPU / CPU cores at compaction time — the same heuristic used
118/// by `write_batch_auto`).
119///
120/// For large tables use `compact_deferred` / `run_deferred`: the merged Parquet
121/// is persisted immediately and the HNSW build runs in a background Tokio task,
122/// decoupling I/O cost from CPU cost.
123#[derive(Clone)]
124pub struct CompactionExecutor {
125    store: Arc<dyn Store>,
126    policy: VectorStoragePolicy,
127    index_strategy: CompactionIndexStrategy,
128    /// When set, rebuilds and embeds a Tantivy FTS index in the compacted output file.
129    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    /// Override the default (Auto) index strategy for this executor.
143    pub fn with_index_strategy(mut self, strategy: CompactionIndexStrategy) -> Self {
144        self.index_strategy = strategy;
145        self
146    }
147
148    /// Rebuild and embed a Tantivy FTS index in the compacted output file.
149    pub fn with_fts_config(mut self, cfg: ailake_fts::FtsConfig) -> Self {
150        self.fts_config = Some(cfg);
151        self
152    }
153
154    /// Return a clone of this executor whose `fts_config` is filled from table properties
155    /// when the caller did not explicitly call `with_fts_config`.
156    ///
157    /// This is the auto-detect path: if `ailake.fts.enabled=true` is present in the table
158    /// metadata but the operator forgot (or never knew to) set an FTS config, compaction
159    /// would silently drop the FTS index. This method prevents that loss.
160    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    /// Read all input files in parallel, returning ordered (batch, embeddings) pairs.
178    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    /// Merge `files` into a single new file at `output_path`.
205    ///
206    /// Reads all input files **in parallel** to minimise S3 latency, then
207    /// rebuilds the HNSW / IVF-PQ index synchronously. For very large merges
208    /// (N > 100 000 vectors) prefer `compact_deferred`, which offloads the
209    /// index build to a background Tokio task.
210    ///
211    /// Returns the DataFileEntry for the merged file.
212    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        // Concatenate all row groups into one batch
236        let merged_batch = concat_batches(schema, &all_batches)?;
237        let record_count = merged_batch.num_rows() as u64;
238
239        // Write merged file with adaptive index selection.
240        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        // Compute centroid and HNSW offsets for catalog entry
270        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        // Preserve row-ID continuity: merged file inherits the minimum first_row_id of
276        // its sources so commit_snapshot doesn't allocate fresh IDs and grow next_row_id.
277        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            &centroid,
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    /// Merge `files` into a single new file using incremental HNSW insertion.
296    ///
297    /// Identifies the **dominant file** — the file holding >= 40 % of the total
298    /// row count — loads its existing HNSW graph from the AILK section, then
299    /// calls `HnswIndex::insert_node` for every vector from the remaining files.
300    ///
301    /// **Complexity vs `compact`**:
302    /// - Full rebuild: O(N log N), N = total rows.
303    /// - Incremental (this method): O(N_dom) deserialization + O(N_small × log N_dom).
304    ///   For a 90 / 10 split (N = 1 M, N_dom = 900 k) the speedup is ~7×.
305    ///
306    /// **Fallbacks** (all degrade gracefully to `compact`):
307    /// - No file holds >= 40 % of rows.
308    /// - Dominant file's HNSW cannot be loaded (IVF-PQ, `IndexStatus::Indexing`, corrupt).
309    ///
310    /// **RowId contract**: dominant file's vectors are placed first in the merged
311    /// Parquet (positions 0..N_dom-1); other files follow. The existing RowIds from
312    /// the dominant HNSW remain valid; new nodes receive RowIds N_dom..N-1.
313    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        // Find the dominant file by record_count.
327        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        // Read all files in parallel. Retain raw bytes only for the dominant file
352        // (needed to load its HNSW without a second round-trip).
353        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        // Separate dominant from others; dominant goes first in the merged file.
391        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        // Load the dominant file's existing HNSW graph.
420        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        // Insert vectors from non-dominant files into the loaded graph.
436        // RowIds are assigned starting at dom_count to match positions in the merged Parquet.
437        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        // Assemble merged batch (dominant rows first) and all embeddings.
443        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        // Write the merged file using the pre-built index (no rebuild).
453        // Attach FTS blob when configured — data is already in merged_batch so cost is tokenization only.
454        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        // Dominant file goes first in the merged output, so the merged file's first
478        // logical row was the dominant file's first row.  Use its first_row_id so
479        // commit_snapshot doesn't grow next_row_id unnecessarily.
480        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            &centroid,
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    /// Merge `files` into a single new file at `output_path`, writing Parquet
509    /// immediately and building the HNSW / IVF-PQ index in a background Tokio task.
510    ///
511    /// The merged file appears in the catalog as `IndexStatus::Indexing` until
512    /// the background task completes; queries fall back to flat scan during that
513    /// window (same behaviour as `write_batch_deferred`).
514    ///
515    /// Returns the `DataFileEntry` with `IndexStatus::Indexing`. The entry
516    /// transitions to `Ready` automatically when the background build finishes.
517    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        // Write Parquet-only immediately — fast path, no HNSW build.
546        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        // Centroid available for geometric pruning during the build window.
552        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            &centroid,
559            &self.policy.column_name,
560            self.policy.dim,
561        );
562        entry.first_row_id = source_first_row_id;
563
564        // Spawn background index build; errors are logged, not propagated.
565        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    /// Full compaction workflow: plan, compact (synchronous HNSW rebuild),
583    /// drop old files from catalog, commit.
584    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        // Auto-detect FTS from table metadata so compaction never silently drops an FTS index
598        // that was present in the source files. Uses ailake.fts.* properties written at write time.
599        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        // Use incremental merge when a dominant file exists (falls back to full rebuild automatically).
613        let merged = executor
614            .compact_incremental(&to_compact, &output_path)
615            .await?;
616
617        // Commit: add merged file, remove input files (via Replace snapshot)
618        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    /// Full compaction workflow with deferred HNSW build: plan, write merged
642    /// Parquet immediately, commit as `Indexing`, spawn background index build.
643    ///
644    /// Use for large tables where inline HNSW rebuild blocks too long.
645    ///
646    /// Note: FTS index is **not** rebuilt in deferred mode — `compact_deferred` writes
647    /// Parquet-only immediately and the background task (`build_and_patch_index`) only
648    /// builds the HNSW/IVF-PQ index. Use `run` (synchronous) when FTS preservation
649    /// on compaction is required.
650    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        // Commit immediately: merged file in Indexing state replaces input files.
674        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        // Dominant file: 6 rows (75% of total 8 rows — above 40% threshold).
1068        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        // Small file: 2 rows.
1083        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        // Structural checks.
1152        assert_eq!(merged.record_count, 8);
1153        assert_eq!(merged.path, "data/merged.parquet");
1154
1155        // Load merged file and verify it's a valid AI-Lake file.
1156        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        // Dominant rows must come first (positions 0..5).
1165        for f in &embs[..6] {
1166            assert_eq!(f.len(), 4);
1167        }
1168
1169        // HNSW must be searchable and return the nearest neighbor for a known query.
1170        let hnsw = reader.load_index().unwrap();
1171        assert_eq!(hnsw.node_count(), 8);
1172
1173        // Query [1, 0, 0, 0] → nearest should be RowId 0 (embs_dom[0]).
1174        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        // Query [0, 0, 0, 1] → nearest should be RowId 6 (first row of small file,
1178        // inserted at position 6 in the merged file).
1179        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        // Two equal-sized files (50/50 split — no dominant, both below 40% threshold).
1216        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        // Should fall back to full rebuild without error.
1273        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        // Entry is Indexing — HNSW build pending in background
1407        assert_eq!(entry.index_status, IndexStatus::Indexing);
1408        assert_eq!(entry.record_count, 4);
1409
1410        // The written file must be valid Parquet (readable) even without HNSW
1411        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}