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