Skip to main content

lance/dataset/
optimize.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Table maintenance for optimizing table layout.
5//!
6//! As a table is updated, its layout can become suboptimal. For example, if
7//! a series of small streaming appends are performed, eventually there will be
8//! a large number of small files. This imposes an overhead to track the large
9//! number of files and for very small files can make it harder to read data
10//! efficiently. In this case, files can be compacted into fewer larger files.
11//!
12//! To compact files in a table, use the [compact_files] method. This currently
13//! can compact in two cases:
14//!
15//! 1. If a fragment has fewer rows than the target number of rows per fragment.
16//!    The fragment must also have neighbors that are also candidates for
17//!    compaction.
18//! 2. If a fragment has a higher percentage of deleted rows than the provided
19//!    threshold.
20//!
21//! In addition to the rules above there may be restrictions due to indexes.
22//! When a fragment is compacted its row ids change and any index that contained
23//! that fragment will be remapped.  However, we cannot combine indexed fragments
24//! with unindexed fragments.
25//!
26//! ```rust
27//! # use std::sync::Arc;
28//! # use tokio::runtime::Runtime;
29//! # use arrow_array::{RecordBatch, RecordBatchIterator, Int64Array};
30//! # use arrow_schema::{Schema, Field, DataType};
31//! use lance::{dataset::WriteParams, Dataset, dataset::optimize::compact_files};
32//! // Remapping indices is ignored in this example.
33//! use lance::dataset::optimize::IgnoreRemap;
34//!
35//! # let mut rt = Runtime::new().unwrap();
36//! # rt.block_on(async {
37//! #
38//! # let test_dir = lance_core::utils::tempfile::TempStrDir::default();
39//! # let uri = test_dir.to_string();
40//! let schema = Arc::new(Schema::new(vec![Field::new("test", DataType::Int64, false)]));
41//! let data = RecordBatch::try_new(
42//!     schema.clone(),
43//!     vec![Arc::new(Int64Array::from_iter_values(0..10_000))]
44//! ).unwrap();
45//! let reader = RecordBatchIterator::new(vec![Ok(data)], schema);
46//!
47//! // Write 100 small files
48//! let write_params = WriteParams { max_rows_per_file: 100, ..Default::default()};
49//! let mut dataset = Dataset::write(reader, &uri, Some(write_params)).await.unwrap();
50//! assert_eq!(dataset.get_fragments().len(), 100);
51//!
52//! // Use compact_files() to consolidate the data to 1 fragment
53//! let metrics = compact_files(&mut dataset, Default::default(), None).await.unwrap();
54//! assert_eq!(metrics.fragments_removed, 100);
55//! assert_eq!(metrics.fragments_added, 1);
56//! assert_eq!(dataset.get_fragments().len(), 1);
57//! # })
58//! ```
59//!
60//! ## Distributed execution
61//!
62//! The [compact_files] method internally can use multiple threads, but
63//! sometimes you might want to run it across multiple machines. To do this,
64//! use the task API.
65//!
66//! ```text
67//!                                      ┌──► CompactionTask.execute() ─► RewriteResult ─┐
68//! plan_compaction() ─► CompactionPlan ─┼──► CompactionTask.execute() ─► RewriteResult ─┼─► commit_compaction()
69//!                                      └──► CompactionTask.execute() ─► RewriteResult ─┘
70//! ```
71//!
72//! [plan_compaction()] produces a [CompactionPlan]. This can be split into multiple
73//! [CompactionTask], which can be serialized and sent to other machines. Calling
74//! [CompactionTask::execute()] performs the compaction and returns a [RewriteResult].
75//! The [RewriteResult] can be sent back to the coordinator, which can then call
76//! [commit_compaction()] to commit the changes to the dataset.
77//!
78//! It's not required that all tasks are passed to [commit_compaction]. If some
79//! didn't complete successfully or before a deadline, they can be omitted and
80//! the successful tasks can be committed. You can also commit in batches if
81//! you wish. As long as the tasks don't rewrite any of the same fragments,
82//! they can be committed in any order.
83use lance_core::utils::row_addr_remap::{GroupInput, RowAddrRemap};
84use std::borrow::Cow;
85use std::collections::{HashMap, HashSet};
86use std::io::Cursor;
87use std::ops::{AddAssign, Range};
88use std::sync::Arc;
89
90use super::fragment::FileFragment;
91use super::index::{DatasetIndexRemapperOptions, load_indices_for_remapping};
92use super::rowids::RowVersionKind;
93use super::rowids::{
94    RowLineage, RowLineagePlan, RowLineageSpill, inline_row_lineage_max_bytes,
95    load_row_id_sequences, load_row_version_sequence, place_row_lineage, plan_row_lineage_spill,
96};
97use super::transaction::{
98    Operation, RewriteGroup, RewrittenIndex, Transaction, TransactionBuilder,
99};
100use super::utils::make_rowid_capture_stream;
101use super::versions;
102use super::{
103    WriteMode, WriteParams, cleanup_data_fragments,
104    write::write_fragments_internal_with_file_row_counts,
105};
106use crate::Dataset;
107use crate::Result;
108use crate::dataset::utils::CapturedRowIds;
109use crate::index::{DatasetIndexInternalExt, index_is_usable, load_all_indices};
110use crate::io::commit::{commit_transaction, default_commit_retry_timeout, migrate_fragments};
111use arrow::array::AsArray;
112use arrow::datatypes::{UInt8Type, UInt32Type, UInt64Type};
113use arrow_array::builder::{LargeBinaryBuilder, PrimitiveBuilder, StringBuilder};
114use arrow_array::{
115    Array, ArrayRef, GenericListArray, OffsetSizeTrait, RecordBatch, StructArray, UInt32Array,
116};
117use arrow_buffer::{OffsetBuffer, ScalarBuffer};
118use arrow_schema::{
119    DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef,
120};
121use datafusion::physical_plan::SendableRecordBatchStream;
122use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
123use futures::future::BoxFuture;
124use futures::{FutureExt, StreamExt, TryStreamExt};
125use lance_arrow::{list::ListArrayExt, r#struct::StructArrayExt};
126use lance_core::Error;
127use lance_core::datatypes::{
128    BLOB_V2_LOGICAL_FIELDS, BLOB_V2_LOGICAL_TYPE, BlobHandling, BlobKind, BlobV2Layout,
129    Field as LanceField,
130};
131use lance_core::utils::tokio::get_num_compute_intensive_cpus;
132use lance_core::utils::tracing::{DATASET_COMPACTING_EVENT, TRACE_DATASET_EVENTS};
133use lance_file::version::{ConcreteFileVersion, LanceFileVersion};
134use lance_index::frag_reuse::{FRAG_REUSE_INDEX_NAME, FragReuseGroup};
135use lance_index::is_system_index;
136use lance_index::metrics::NoOpMetricsCollector;
137use lance_table::format::overlay::TOMBSTONE_FIELD_ID;
138use lance_table::format::{
139    Fragment, IndexMetadata, ROW_CREATED_AT_VERSION_FIELD_ID, ROW_ID_FIELD_ID,
140    ROW_LAST_UPDATED_AT_VERSION_FIELD_ID, RowDatasetVersionSequence,
141};
142use lance_table::rowids::RowIdSequence;
143use roaring::{RoaringBitmap, RoaringTreemap};
144use serde::{Deserialize, Serialize};
145use tracing::{info, warn};
146
147pub(super) mod binary_copy;
148pub mod remapping;
149
150use crate::index::frag_reuse::build_new_frag_reuse_index;
151use crate::io::deletion::read_dataset_deletion_file;
152pub use remapping::{IgnoreRemap, IndexRemapper, IndexRemapperOptions, RemappedIndex};
153
154/// Controls how data is rewritten during compaction.
155#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
156pub enum CompactionMode {
157    /// Decode and re-encode data (default).
158    Reencode,
159    /// Try binary copy if fragments are compatible, fall back to [`Reencode`](CompactionMode::Reencode) otherwise.
160    TryBinaryCopy,
161    /// Use binary copy or fail if fragments are not compatible.
162    ForceBinaryCopy,
163}
164
165impl TryFrom<&str> for CompactionMode {
166    type Error = Error;
167
168    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
169        match value.to_lowercase().as_str() {
170            "reencode" => Ok(Self::Reencode),
171            "try_binary_copy" => Ok(Self::TryBinaryCopy),
172            "force_binary_copy" => Ok(Self::ForceBinaryCopy),
173            _ => Err(Error::invalid_input(format!(
174                "Invalid compaction mode \"{}\". Valid values: \"reencode\", \"try_binary_copy\", \"force_binary_copy\"",
175                value
176            ))),
177        }
178    }
179}
180
181/// Controls how the old-to-new row-address mapping is built when remapping
182/// indices during compaction.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
184pub enum IndexRemapMode {
185    /// Store a compact remap and compute row-address mappings during lookup.
186    ///
187    /// Best for large compactions where peak memory is the constraint. Uses
188    /// less memory, but each lookup does extra bitmap/range computation.
189    Compact,
190    /// Store the full row-address remap in memory for fast direct lookups.
191    ///
192    /// Best when the remap fits comfortably in memory and remap speed is the
193    /// priority. Uses more peak memory because every rewritten/deleted row has
194    /// a materialized mapping entry.
195    #[default]
196    Direct,
197}
198
199impl TryFrom<&str> for IndexRemapMode {
200    type Error = Error;
201
202    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
203        match value.to_lowercase().as_str() {
204            "compact" => Ok(Self::Compact),
205            "direct" => Ok(Self::Direct),
206            _ => Err(Error::invalid_input(format!(
207                "Invalid index remap mode \"{}\". Valid values: \"compact\", \"direct\"",
208                value
209            ))),
210        }
211    }
212}
213
214/// Options to be passed to [compact_files].
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct CompactionOptions {
217    /// Target number of rows per file. Defaults to 1 million.
218    ///
219    /// This is used to determine which fragments need compaction, as any
220    /// fragments that have fewer rows than this value will be candidates for
221    /// compaction.
222    pub target_rows_per_fragment: usize,
223    /// Max number of rows per group
224    ///
225    /// This does not affect which fragments need compaction, but does affect
226    /// how they are re-written if selected.
227    pub max_rows_per_group: usize,
228    /// Max number of bytes per file
229    ///
230    /// This does not affect which frgamnets need compaction, but does affect
231    /// how they are re-written if selected.
232    ///
233    /// If not specified then the default (see [`WriteParams`]) will be used.
234    pub max_bytes_per_file: Option<usize>,
235    /// Whether to compact fragments with deletions so there are no deletions.
236    /// Defaults to true.
237    pub materialize_deletions: bool,
238    /// The fraction of rows that need to be deleted in a fragment before
239    /// materializing the deletions. Defaults to 10% (0.1). Setting to zero (or
240    /// lower) will materialize deletions for all fragments with deletions.
241    /// Setting above 1.0 will never materialize deletions.
242    pub materialize_deletions_threshold: f32,
243    /// The number of threads to use (how many compaction tasks to run in parallel).
244    /// Defaults to the number of compute-intensive CPUs.  Not used when running
245    /// tasks manually using [`plan_compaction`]
246    pub num_threads: Option<usize>,
247    /// The batch size to use when scanning the input fragments.  If not
248    /// specified then the default (see
249    /// [`crate::dataset::Scanner::batch_size`]) will be used.
250    pub batch_size: Option<usize>,
251    /// The number of bytes to allow to queue up in the I/O buffer when scanning
252    /// the input fragments.  If not specified then the default (see
253    /// [`crate::dataset::Scanner::io_buffer_size`]) will be used.
254    ///
255    /// Increasing this can avoid a deadlock that occurs when a single batch of
256    /// data is larger than the I/O buffer size.
257    pub io_buffer_size: Option<u64>,
258    /// Whether to defer remapping indices during compaction. If true, indices will
259    /// not be remapped during this compaction operation. Instead, the fragment reuse index
260    /// is updated and will be used to perform remapping later.
261    pub defer_index_remap: bool,
262    /// How the old-to-new row-address mapping used to remap indices is built.
263    /// Defaults to [`IndexRemapMode::Direct`].
264    #[serde(default)]
265    pub index_remap_mode: IndexRemapMode,
266    /// The compaction mode to use. When set, this takes priority over the
267    /// deprecated `enable_binary_copy` and `enable_binary_copy_force` fields.
268    ///
269    /// Defaults to `None` (falls back to legacy boolean fields).
270    pub compaction_mode: Option<CompactionMode>,
271    /// Deprecated: use `compaction_mode` instead.
272    #[deprecated(note = "Use `compaction_mode` instead")]
273    pub enable_binary_copy: bool,
274    /// Deprecated: use `compaction_mode` instead.
275    #[deprecated(note = "Use `compaction_mode` instead")]
276    pub enable_binary_copy_force: bool,
277    /// The batch size in bytes for reading during binary copy operations.
278    /// Controls how much data is read at once when performing binary copy.
279    /// Defaults to 16MB (16 * 1024 * 1024).
280    pub binary_copy_read_batch_bytes: Option<usize>,
281    /// Maximum number of source fragments to compact in a single run. When set,
282    /// tasks are included in the plan until adding the next task would exceed
283    /// this limit. This allows for incremental compaction (e.g., compact 20
284    /// fragments at a time).
285    /// Defaults to `None` (no limit, all eligible fragments are compacted).
286    pub max_source_fragments: Option<usize>,
287    /// Maximum number of source rows to compact in a single run. Rows are
288    /// counted as live rows (physical rows minus soft-deleted rows). When
289    /// set, tasks are included in the plan until adding the next task would
290    /// exceed this limit.
291    /// Defaults to `None` (no limit).
292    pub max_source_rows: Option<usize>,
293    /// Maximum number of source bytes to compact in a single run, measured as
294    /// the total size of the source fragments' data and overlay files. When
295    /// set, tasks are included in the plan until adding the next task would
296    /// exceed this limit.
297    /// Blob v2 payloads live in separate blob files and are not counted, so
298    /// this is not a cap on total compaction I/O for datasets with blob
299    /// columns.
300    /// Defaults to `None` (no limit).
301    pub max_source_bytes: Option<u64>,
302    /// Fragment IDs to exclude from compaction planning.
303    ///
304    /// Excluded fragments act as boundaries between adjacent compaction candidates,
305    /// so fragments on opposite sides of an exclusion are never combined into the
306    /// same task. IDs that are duplicated or absent from the dataset are ignored.
307    /// Defaults to an empty list.
308    #[serde(default)]
309    pub excluded_fragment_ids: Vec<u32>,
310    /// Maximum number of data overlay files a fragment may carry before it is
311    /// fully compacted. When set, any fragment with more than this many overlays
312    /// is rewritten into a fresh fragment with its overlays (and deletions)
313    /// materialized into the base data, dropping the fragment from any index
314    /// left stale by those overlays.
315    /// Defaults to `Some(10)`. Set to `Some(0)` to compact every fragment that
316    /// carries any overlay, or `None` to disable the overlay-count trigger
317    /// entirely.
318    pub max_overlays_per_fragment: Option<usize>,
319    /// Exact data file version for compacted output.
320    ///
321    /// If omitted, use the dataset's default write version without changing it.
322    /// The planner resolves release selectors before distributing tasks.
323    /// Targets cannot cross the V1/V2 boundary.
324    ///
325    /// ```
326    /// # use lance::dataset::optimize::CompactionOptions;
327    /// # use lance_file::version::LanceFileVersion;
328    /// let options = CompactionOptions {
329    ///     data_storage_version: Some(LanceFileVersion::V2_2),
330    ///     ..Default::default()
331    /// };
332    /// ```
333    pub data_storage_version: Option<LanceFileVersion>,
334    /// Transaction properties to store with this commit.
335    ///
336    /// These key-value pairs are stored in the transaction file
337    /// and can be read later to identify the source of the commit
338    /// (e.g., job_id for tracking completed compaction jobs).
339    #[serde(skip)]
340    pub transaction_properties: Option<Arc<HashMap<String, String>>>,
341}
342
343#[allow(deprecated)]
344impl Default for CompactionOptions {
345    fn default() -> Self {
346        Self {
347            // Matching defaults for WriteParams
348            target_rows_per_fragment: 1024 * 1024,
349            max_rows_per_group: 1024,
350            materialize_deletions: true,
351            materialize_deletions_threshold: 0.1,
352            num_threads: None,
353            max_bytes_per_file: None,
354            batch_size: None,
355            io_buffer_size: None,
356            defer_index_remap: false,
357            index_remap_mode: IndexRemapMode::Direct,
358            compaction_mode: None,
359            enable_binary_copy: false,
360            enable_binary_copy_force: false,
361            binary_copy_read_batch_bytes: Some(16 * 1024 * 1024),
362            max_source_fragments: None,
363            max_source_rows: None,
364            max_source_bytes: None,
365            excluded_fragment_ids: Vec::new(),
366            max_overlays_per_fragment: Some(10),
367            data_storage_version: None,
368            transaction_properties: None,
369        }
370    }
371}
372
373/// Config key prefix for compaction options stored in the dataset manifest.
374pub const COMPACTION_CONFIG_PREFIX: &str = "lance.compaction.";
375
376#[allow(deprecated)]
377impl CompactionOptions {
378    /// Create [`CompactionOptions`] by starting with defaults and applying any
379    /// overrides found in the dataset manifest config.
380    ///
381    /// Config keys are prefixed with `lance.compaction.` and map to fields:
382    /// - `lance.compaction.target_rows_per_fragment`
383    /// - `lance.compaction.max_rows_per_group`
384    /// - `lance.compaction.max_bytes_per_file`
385    /// - `lance.compaction.materialize_deletions`
386    /// - `lance.compaction.materialize_deletions_threshold`
387    /// - `lance.compaction.defer_index_remap`
388    /// - `lance.compaction.index_remap_mode`
389    /// - `lance.compaction.batch_size`
390    /// - `lance.compaction.io_buffer_size`
391    /// - `lance.compaction.compaction_mode`
392    /// - `lance.compaction.binary_copy_read_batch_bytes`
393    /// - `lance.compaction.max_source_fragments`
394    /// - `lance.compaction.max_source_rows`
395    /// - `lance.compaction.max_source_bytes`
396    /// - `lance.compaction.max_overlays_per_fragment`
397    /// - `lance.compaction.data_storage_version`
398    pub fn from_dataset_config(config: &HashMap<String, String>) -> Result<Self> {
399        let mut opts = Self::default();
400        opts.apply_dataset_config(config)?;
401        Ok(opts)
402    }
403
404    /// Apply overrides from the dataset manifest config to this options struct.
405    ///
406    /// Only fields with corresponding config keys are modified; other fields
407    /// retain their current values.
408    pub fn apply_dataset_config(&mut self, config: &HashMap<String, String>) -> Result<()> {
409        for (key, value) in config {
410            let Some(field) = key.strip_prefix(COMPACTION_CONFIG_PREFIX) else {
411                continue;
412            };
413            match field {
414                "target_rows_per_fragment" => {
415                    self.target_rows_per_fragment = value.parse().map_err(|_| {
416                        Error::invalid_input(format!(
417                            "Invalid value for {}: '{}' (expected a non-negative integer)",
418                            key, value
419                        ))
420                    })?;
421                }
422                "max_rows_per_group" => {
423                    self.max_rows_per_group = value.parse().map_err(|_| {
424                        Error::invalid_input(format!(
425                            "Invalid value for {}: '{}' (expected a non-negative integer)",
426                            key, value
427                        ))
428                    })?;
429                }
430                "max_bytes_per_file" => {
431                    self.max_bytes_per_file = Some(value.parse().map_err(|_| {
432                        Error::invalid_input(format!(
433                            "Invalid value for {}: '{}' (expected a non-negative integer)",
434                            key, value
435                        ))
436                    })?);
437                }
438                "materialize_deletions" => {
439                    self.materialize_deletions = match value.to_lowercase().as_str() {
440                        "true" => true,
441                        "false" => false,
442                        _ => {
443                            return Err(Error::invalid_input(format!(
444                                "Invalid value for {}: '{}' (expected 'true' or 'false')",
445                                key, value
446                            )));
447                        }
448                    };
449                }
450                "materialize_deletions_threshold" => {
451                    self.materialize_deletions_threshold = value.parse().map_err(|_| {
452                        Error::invalid_input(format!(
453                            "Invalid value for {}: '{}' (expected a float between 0.0 and 1.0)",
454                            key, value
455                        ))
456                    })?;
457                }
458                "defer_index_remap" => {
459                    self.defer_index_remap = match value.to_lowercase().as_str() {
460                        "true" => true,
461                        "false" => false,
462                        _ => {
463                            return Err(Error::invalid_input(format!(
464                                "Invalid value for {}: '{}' (expected 'true' or 'false')",
465                                key, value
466                            )));
467                        }
468                    };
469                }
470                "index_remap_mode" => {
471                    self.index_remap_mode = IndexRemapMode::try_from(value.as_str())?;
472                }
473                "batch_size" => {
474                    self.batch_size = Some(value.parse().map_err(|_| {
475                        Error::invalid_input(format!(
476                            "Invalid value for {}: '{}' (expected a non-negative integer)",
477                            key, value
478                        ))
479                    })?);
480                }
481                "io_buffer_size" => {
482                    self.io_buffer_size = Some(value.parse().map_err(|_| {
483                        Error::invalid_input(format!(
484                            "Invalid value for {}: '{}' (expected a non-negative integer)",
485                            key, value
486                        ))
487                    })?);
488                }
489                "compaction_mode" => {
490                    self.compaction_mode = Some(CompactionMode::try_from(value.as_str())?);
491                }
492                "data_storage_version" => {
493                    self.data_storage_version = Some(value.parse().map_err(|error| {
494                        Error::invalid_input(format!(
495                            "Invalid value for {}: '{}': {}",
496                            key, value, error
497                        ))
498                    })?);
499                }
500                "binary_copy_read_batch_bytes" => {
501                    self.binary_copy_read_batch_bytes = Some(value.parse().map_err(|_| {
502                        Error::invalid_input(format!(
503                            "Invalid value for {}: '{}' (expected a non-negative integer)",
504                            key, value
505                        ))
506                    })?);
507                }
508                "max_source_fragments" => {
509                    self.max_source_fragments = Some(value.parse().map_err(|_| {
510                        Error::invalid_input(format!(
511                            "Invalid value for {}: '{}' (expected a non-negative integer)",
512                            key, value
513                        ))
514                    })?);
515                }
516                "max_source_rows" => {
517                    self.max_source_rows = Some(value.parse().map_err(|_| {
518                        Error::invalid_input(format!(
519                            "Invalid value for {}: '{}' (expected a non-negative integer)",
520                            key, value
521                        ))
522                    })?);
523                }
524                "max_source_bytes" => {
525                    self.max_source_bytes = Some(value.parse().map_err(|_| {
526                        Error::invalid_input(format!(
527                            "Invalid value for {}: '{}' (expected a non-negative integer)",
528                            key, value
529                        ))
530                    })?);
531                }
532                "max_overlays_per_fragment" => {
533                    // The default is `Some(10)`, so an explicit "none" is the only
534                    // way to disable the trigger through the manifest config.
535                    self.max_overlays_per_fragment = match value.to_ascii_lowercase().as_str() {
536                        "none" => None,
537                        _ => Some(value.parse().map_err(|_| {
538                            Error::invalid_input(format!(
539                                "Invalid value for {}: '{}' (expected a non-negative integer or 'none')",
540                                key, value
541                            ))
542                        })?),
543                    };
544                }
545                _ => {
546                    warn!("Ignoring unknown compaction config key: {}", key);
547                }
548            }
549        }
550        Ok(())
551    }
552
553    pub fn validate(&mut self) -> Result<()> {
554        // If threshold is 100%, same as turning off deletion materialization.
555        if self.materialize_deletions && self.materialize_deletions_threshold >= 1.0 {
556            self.materialize_deletions = false;
557        }
558
559        for (name, value) in [
560            (
561                "max_source_fragments",
562                self.max_source_fragments.map(|v| v as u64),
563            ),
564            ("max_source_rows", self.max_source_rows.map(|v| v as u64)),
565            ("max_source_bytes", self.max_source_bytes),
566        ] {
567            if value == Some(0) {
568                return Err(Error::invalid_input(format!(
569                    "CompactionOptions::{} must be greater than 0 (use None for no limit)",
570                    name
571                )));
572            }
573        }
574        Ok(())
575    }
576
577    /// Returns the effective [`CompactionMode`], preferring the new
578    /// `compaction_mode` field and falling back to the deprecated boolean
579    /// fields for backwards compatibility.
580    pub fn compaction_mode(&self) -> CompactionMode {
581        if let Some(mode) = self.compaction_mode {
582            return mode;
583        }
584        // Fall back to deprecated booleans
585        match (self.enable_binary_copy, self.enable_binary_copy_force) {
586            (true, true) => CompactionMode::ForceBinaryCopy,
587            (true, false) => CompactionMode::TryBinaryCopy,
588            _ => CompactionMode::Reencode,
589        }
590    }
591
592    /// Set transaction properties to store in the commit manifest.
593    pub fn transaction_properties(mut self, properties: HashMap<String, String>) -> Self {
594        self.transaction_properties = Some(Arc::new(properties));
595        self
596    }
597
598    fn write_version(&self, dataset: &Dataset) -> ConcreteFileVersion {
599        self.data_storage_version
600            .map(LanceFileVersion::resolve)
601            .unwrap_or_else(|| dataset.manifest.data_storage_format.lance_file_format())
602    }
603}
604
605/// Determine if page-level binary copy can safely merge the provided fragments.
606///
607/// Preconditions checked in order:
608/// - Compaction mode is not `Reencode`
609/// - Dataset storage format is non-legacy
610/// - Fragment list is non-empty
611/// - All data files share identical Lance file versions
612/// - No fragment has a deletion file
613///   TODO: Need to support schema evolution case like add column and drop column
614/// - All data files use an identical schema mapping (`fields`, `column_indices`) in dataset schema
615///   order
616/// - Input data files must not contain extra global buffers (beyond schema / file descriptor)
617async fn can_use_binary_copy(
618    dataset: &Dataset,
619    options: &CompactionOptions,
620    fragments: &[Fragment],
621) -> bool {
622    let version = options.write_version(dataset);
623    versions::can_use_binary_copy(version, dataset, options, fragments)
624        .await
625        .unwrap_or_else(|err| {
626            log::warn!("Binary copy disabled due to error: {}", err);
627            false
628        })
629}
630
631fn first_binary_copy_version_mismatch(
632    fragments: &[Fragment],
633    target: ConcreteFileVersion,
634) -> Result<Option<(String, ConcreteFileVersion)>> {
635    for fragment in fragments {
636        for data_file in fragment.referenced_lance_files() {
637            let actual = data_file.file_version()?;
638            if actual != target {
639                return Ok(Some((data_file.path.clone(), actual)));
640            }
641        }
642    }
643    Ok(None)
644}
645
646pub(super) async fn can_use_binary_copy_current(
647    dataset: &Dataset,
648    options: &CompactionOptions,
649    fragments: &[Fragment],
650) -> Result<bool> {
651    use lance_file::reader::FileReader as LFReader;
652    use lance_io::scheduler::{ScanScheduler, SchedulerConfig};
653
654    if matches!(options.compaction_mode(), CompactionMode::Reencode) {
655        log::debug!("Binary copy disabled: compaction mode is Reencode");
656        return Ok(false);
657    }
658
659    let has_blob_columns = dataset
660        .schema()
661        .fields_pre_order()
662        .any(|field| field.is_blob());
663    if has_blob_columns {
664        log::debug!("Binary copy disabled: dataset contains blob columns");
665        return Ok(false);
666    }
667
668    if fragments.is_empty() {
669        log::debug!("Binary copy disabled: no fragments to compact");
670        return Ok(false);
671    }
672
673    if fragments[0].files.is_empty() {
674        log::debug!(
675            "Binary copy disabled: fragment {} has no data files",
676            fragments[0].id
677        );
678        return Ok(false);
679    }
680    let ref_fields = &fragments[0].files[0].fields;
681    let ref_cols = &fragments[0].files[0].column_indices;
682    let version = dataset.manifest.data_storage_format.lance_file_format();
683    let (schema_fields, schema_column_indices) =
684        lance_file::versions::data_file_columns(version, dataset.schema());
685    if ref_fields.as_ref() != schema_fields.as_slice()
686        || ref_cols.as_ref() != schema_column_indices.as_slice()
687    {
688        log::debug!(
689            "Binary copy disabled: data files do not use the dataset schema's physical column order"
690        );
691        return Ok(false);
692    }
693    for fragment in fragments {
694        // Binary copy only reads base files; overlays must be materialized by the scanner.
695        if !fragment.overlays.is_empty() {
696            log::debug!(
697                "Binary copy disabled: fragment {} has data overlays",
698                fragment.id
699            );
700            return Ok(false);
701        }
702        if fragment.deletion_file.is_some() {
703            log::debug!(
704                "Binary copy disabled: fragment {} has a deletion file",
705                fragment.id
706            );
707            return Ok(false);
708        }
709
710        for data_file in &fragment.files {
711            if data_file.fields != *ref_fields || data_file.column_indices != *ref_cols {
712                return Ok(false);
713            }
714
715            // check file global buffer
716            let object_store = match data_file.base_id {
717                Some(base_id) => dataset.object_store(Some(base_id)).await?,
718                None => dataset.object_store.clone(),
719            };
720            let full_path = dataset
721                .data_file_dir(data_file)?
722                .clone()
723                .join(data_file.path.as_str());
724            let scan_scheduler = ScanScheduler::new(
725                object_store.clone(),
726                SchedulerConfig::max_bandwidth(&object_store),
727            );
728            let file_scheduler = scan_scheduler
729                .open_file_with_priority(&full_path, 0, &data_file.file_size_bytes)
730                .await?;
731            let file_meta = LFReader::read_all_metadata(&file_scheduler).await?;
732            // Binary copy only preserves page and column-buffer bytes. The output file's footer
733            // (including global buffers) is re-generated, not copied from inputs.
734            //
735            // Therefore, we reject input files that contain any additional global buffers beyond
736            // the required schema / file descriptor global buffer (global buffer index 0).
737            if file_meta.file_buffers.len() > 1 {
738                log::debug!(
739                    "Binary copy disabled: data file has extra global buffers (len={})",
740                    file_meta.file_buffers.len()
741                );
742                return Ok(false);
743            }
744        }
745    }
746
747    Ok(true)
748}
749
750/// Metrics returned by [compact_files].
751#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
752pub struct CompactionMetrics {
753    /// The number of fragments that have been overwritten.
754    pub fragments_removed: usize,
755    /// The number of new fragments that have been added.
756    pub fragments_added: usize,
757    /// The number of files that have been removed, including deletion files.
758    pub files_removed: usize,
759    /// The number of files that have been added, which is always equal to the
760    /// number of fragments.
761    pub files_added: usize,
762}
763
764impl AddAssign for CompactionMetrics {
765    fn add_assign(&mut self, rhs: Self) {
766        self.fragments_removed += rhs.fragments_removed;
767        self.fragments_added += rhs.fragments_added;
768        self.files_removed += rhs.files_removed;
769        self.files_added += rhs.files_added;
770    }
771}
772
773/// Trait for implementing custom compaction planning strategies.
774///
775/// This trait allows users to define their own compaction strategies by implementing
776/// the `plan` method. The default implementation is provided by [`DefaultCompactionPlanner`].
777#[async_trait::async_trait]
778pub trait CompactionPlanner: Send + Sync {
779    /// Build compaction plan.
780    ///
781    /// This method analyzes the dataset's fragments and generates a [`CompactionPlan`]
782    /// containing a list of compaction tasks to execute.
783    ///
784    /// # Arguments
785    ///
786    /// * `dataset` - Reference to the dataset to be compacted
787    async fn plan(&self, dataset: &Dataset) -> Result<CompactionPlan>;
788}
789
790/// Formulate a plan to compact the files in a dataset
791///
792/// The compaction plan will contain a list of tasks to execute. Each task
793/// will contain approximately `target_rows_per_fragment` rows and will be
794/// rewriting fragments that are adjacent in the dataset's fragment list. Some
795/// tasks may contain a single fragment when that fragment has deletions that
796/// are being materialized and doesn't have any neighbors that need to be
797/// compacted.
798#[derive(Debug, Clone, Default)]
799pub struct DefaultCompactionPlanner {
800    options: CompactionOptions,
801    excluded_fragment_ids: RoaringBitmap,
802}
803
804impl DefaultCompactionPlanner {
805    pub fn new(mut options: CompactionOptions) -> Result<Self> {
806        options.validate()?;
807        let excluded_fragment_ids = options.excluded_fragment_ids.iter().copied().collect();
808        Ok(Self {
809            options,
810            excluded_fragment_ids,
811        })
812    }
813}
814
815#[async_trait::async_trait]
816impl CompactionPlanner for DefaultCompactionPlanner {
817    async fn plan(&self, dataset: &Dataset) -> Result<CompactionPlan> {
818        let write_version = self.options.write_version(dataset);
819        versions::validate_write_version(
820            dataset.manifest.data_storage_format.lance_file_format(),
821            write_version,
822        )?;
823        if self.options.defer_index_remap && dataset.manifest.uses_stable_row_ids() {
824            return Err(Error::invalid_input(
825                "defer_index_remap=true is not supported on datasets with stable row IDs: \
826                 stable row IDs do not require index remapping during compaction, so there \
827                 is nothing to defer."
828                    .to_string(),
829            ));
830        }
831
832        // get_fragments should be returning fragments in sorted order (by id)
833        // and fragment ids should be unique
834        let fragments = dataset.get_fragments();
835
836        debug_assert!(
837            fragments.windows(2).all(|w| w[0].id() < w[1].id()),
838            "fragments in manifest are not sorted"
839        );
840        // Without stable row ids a rewrite moves every row address, so the
841        // indices over the rewritten fragments have to be remapped in the same
842        // commit. An index this build cannot open cannot be remapped, so its
843        // fragments join the caller's own exclusions and are left uncompacted -
844        // taking the same path, which terminates the current bin rather than
845        // letting the candidates on either side of the gap be planned together.
846        let mut excluded_fragment_ids = self.excluded_fragment_ids.clone();
847        if !dataset.manifest.uses_stable_row_ids() && !self.options.defer_index_remap {
848            let unremappable = unremappable_index_coverage(dataset)
849                .await?
850                .into_iter()
851                .fold(RoaringBitmap::new(), |mut covered, (_, fragments)| {
852                    covered |= fragments;
853                    covered
854                });
855            if !unremappable.is_empty() {
856                // Otherwise a compaction that plans nothing looks like a
857                // compaction that found nothing to do.
858                log::info!(
859                    "holding {} fragment(s) back from compaction: they are covered by an index \
860                     this build cannot read, and so cannot be remapped here",
861                    unremappable.len(),
862                );
863            }
864            excluded_fragment_ids |= unremappable;
865        }
866        let excluded_fragment_ids = &excluded_fragment_ids;
867
868        let mut fragment_metrics = futures::stream::iter(fragments)
869            .map(|fragment| async move {
870                if u32::try_from(fragment.id())
871                    .is_ok_and(|fragment_id| excluded_fragment_ids.contains(fragment_id))
872                {
873                    Ok(None)
874                } else {
875                    collect_metrics(&fragment)
876                        .await
877                        .map(|metrics| Some((fragment.metadata, metrics)))
878                }
879            })
880            .buffered(dataset.object_store.as_ref().io_parallelism());
881
882        let index_fragmaps = load_index_fragmaps(dataset).await?;
883        let indices_containing_frag = |frag_id: u32| {
884            index_fragmaps
885                .iter()
886                .enumerate()
887                .filter(|(_, bitmap)| bitmap.contains(frag_id))
888                .map(|(pos, _)| pos)
889                .collect::<Vec<_>>()
890        };
891
892        let schema_field_ids = dataset
893            .schema()
894            .fields_pre_order()
895            .map(|field| field.id)
896            .collect::<HashSet<_>>();
897        // Only reencoding reclaims a lineage carrier whose user columns are
898        // gone: its fields are not the schema's, so binary copy never applies
899        // to it, and under `ForceBinaryCopy` its task would fail the whole run.
900        let is_binary_copy_only = matches!(
901            self.options.compaction_mode(),
902            CompactionMode::ForceBinaryCopy
903        );
904
905        let mut candidate_bins: Vec<CandidateBin> = Vec::new();
906        let mut current_bin: Option<CandidateBin> = None;
907        let mut i = 0;
908
909        while let Some(res) = fragment_metrics.next().await {
910            let Some((fragment, metrics)) = res? else {
911                // Exclusions preserve adjacency semantics: they terminate the
912                // current bin instead of allowing candidates on either side to
913                // be planned together.
914                if let Some(bin) = current_bin.take() {
915                    candidate_bins.push(bin);
916                }
917                i += 1;
918                continue;
919            };
920
921            let over_overlay_limit = self
922                .options
923                .max_overlays_per_fragment
924                .is_some_and(|max| fragment.overlays.len() > max);
925
926            let candidacy = if over_overlay_limit {
927                // Too many overlays: fully compact this fragment on its own,
928                // regardless of its size or deletion count.
929                Some(CompactionCandidacy::CompactItself)
930            } else if !is_binary_copy_only
931                && pins_dead_user_columns_for_lineage(&fragment, &schema_field_ids)
932            {
933                // Only a rewrite moves the row lineage out of a file whose
934                // user columns are all gone and lets the file go.
935                Some(CompactionCandidacy::CompactItself)
936            } else if self.options.materialize_deletions
937                && metrics.deletion_percentage() > self.options.materialize_deletions_threshold
938            {
939                Some(CompactionCandidacy::CompactItself)
940            } else if metrics.physical_rows < self.options.target_rows_per_fragment {
941                // Only want to compact if their are neighbors to compact such that
942                // we can get a larger fragment.
943                Some(CompactionCandidacy::CompactWithNeighbors)
944            } else {
945                // Not a candidate
946                None
947            };
948
949            let indices = indices_containing_frag(fragment.id as u32);
950
951            match (candidacy, &mut current_bin) {
952                (None, None) => {} // keep searching
953                (Some(candidacy), None) => {
954                    // Start a new bin
955                    current_bin = Some(CandidateBin {
956                        fragments: vec![fragment],
957                        pos_range: i..(i + 1),
958                        candidacy: vec![candidacy],
959                        row_counts: vec![metrics.num_rows()],
960                        indices,
961                    });
962                }
963                (Some(candidacy), Some(bin)) => {
964                    // We cannot mix "indexed" and "non-indexed" fragments and so we only consider
965                    // the existing bin if it contains the same indices
966                    if bin.indices == indices {
967                        // Add to current bin
968                        bin.fragments.push(fragment);
969                        bin.pos_range.end += 1;
970                        bin.candidacy.push(candidacy);
971                        bin.row_counts.push(metrics.num_rows());
972                    } else {
973                        // Index set is different.  Complete previous bin and start new one
974                        candidate_bins.push(current_bin.take().unwrap());
975                        current_bin = Some(CandidateBin {
976                            fragments: vec![fragment],
977                            pos_range: i..(i + 1),
978                            candidacy: vec![candidacy],
979                            row_counts: vec![metrics.num_rows()],
980                            indices,
981                        });
982                    }
983                }
984                (None, Some(_)) => {
985                    // Bin is complete
986                    candidate_bins.push(current_bin.take().unwrap());
987                }
988            }
989
990            i += 1;
991        }
992
993        // Flush the last bin
994        if let Some(bin) = current_bin {
995            candidate_bins.push(bin);
996        }
997
998        let all_tasks: Vec<(TaskData, usize)> = candidate_bins
999            .into_iter()
1000            .filter(|bin| !bin.is_noop())
1001            .flat_map(|bin| bin.split_for_size(self.options.target_rows_per_fragment))
1002            .map(|bin| {
1003                let live_rows = bin.row_counts.iter().sum();
1004                (
1005                    TaskData {
1006                        fragments: bin.fragments,
1007                    },
1008                    live_rows,
1009                )
1010            })
1011            .collect();
1012
1013        let tasks = limit_tasks_to_source_budget(&self.options, dataset.schema(), all_tasks)?;
1014
1015        let mut options = self.options.clone();
1016        options.data_storage_version = Some(write_version.to_selector());
1017        let mut compaction_plan = CompactionPlan::new(dataset.manifest.version, options);
1018        compaction_plan.extend_tasks(tasks);
1019
1020        Ok(compaction_plan)
1021    }
1022}
1023
1024/// Compacts the files in the dataset without reordering them.
1025///
1026/// By default, this does a few things:
1027///  * Removes deleted rows from fragments.
1028///  * Removes dropped columns from fragments.
1029///  * Merges fragments that are too small.
1030///
1031/// This method tries to preserve the insertion order of rows in the dataset.
1032///
1033/// If no compaction is needed, this method will not make a new version of the table.
1034pub async fn compact_files(
1035    dataset: &mut Dataset,
1036    options: CompactionOptions,
1037    remap_options: Option<Arc<dyn IndexRemapperOptions>>, // These will be deprecated later
1038) -> Result<CompactionMetrics> {
1039    info!(target: TRACE_DATASET_EVENTS, event=DATASET_COMPACTING_EVENT, uri = &dataset.uri);
1040    let planner = DefaultCompactionPlanner::new(options)?;
1041    compact_files_with_planner(dataset, remap_options, &planner).await
1042}
1043
1044pub async fn compact_files_with_planner(
1045    dataset: &mut Dataset,
1046    remap_options: Option<Arc<dyn IndexRemapperOptions>>, // These will be deprecated later
1047    planner: &dyn CompactionPlanner,
1048) -> Result<CompactionMetrics> {
1049    let compaction_plan: CompactionPlan = planner.plan(dataset).await?;
1050
1051    // A tagged FRI history is maintained by appending transitions to the
1052    // tagged entry, which only the deferred-remap commit path does; eager
1053    // remapping would rewrite provenance the tagged reader depends on.
1054    // Checked before any file is rewritten (commit_compaction re-checks for
1055    // callers that commit externally planned results).
1056    if !compaction_plan.options.defer_index_remap
1057        && dataset.manifest.writer_feature_flags
1058            & lance_table::feature_flags::FLAG_FRAGMENT_REUSE_INDEX
1059            != 0
1060        && crate::index::load_all_indices(dataset)
1061            .await?
1062            .iter()
1063            .any(lance_table::system_index::frag_reuse::metadata::is_tagged)
1064    {
1065        return Err(Error::not_supported(
1066            "Compacting a table with a tagged fragment reuse history requires \
1067             defer_index_remap; set defer_index_remap, or upgrade to a writer that \
1068             remaps tagged histories eagerly",
1069        ));
1070    }
1071
1072    // If nothing to compact, don't make a commit.
1073    if compaction_plan.tasks().is_empty() {
1074        return Ok(CompactionMetrics::default());
1075    }
1076
1077    let dataset_ref = &dataset.clone();
1078
1079    let result_stream = futures::stream::iter(compaction_plan.tasks)
1080        .map(|task| rewrite_files(Cow::Borrowed(dataset_ref), task, &compaction_plan.options))
1081        .buffer_unordered(
1082            compaction_plan
1083                .options
1084                .num_threads
1085                .unwrap_or_else(get_num_compute_intensive_cpus),
1086        );
1087
1088    let completed_tasks: Vec<RewriteResult> = result_stream.try_collect().await?;
1089    let remap_options = remap_options.unwrap_or(Arc::new(DatasetIndexRemapperOptions::default()));
1090    let metrics = commit_compaction(
1091        dataset,
1092        completed_tasks,
1093        remap_options,
1094        &compaction_plan.options,
1095    )
1096    .await?;
1097
1098    Ok(metrics)
1099}
1100
1101/// Information about a fragment used to decide its fate in compaction
1102#[derive(Debug)]
1103struct FragmentMetrics {
1104    /// The number of original rows in the fragment
1105    pub physical_rows: usize,
1106    /// The number of rows that have been deleted
1107    pub num_deletions: usize,
1108}
1109
1110impl FragmentMetrics {
1111    /// The fraction of rows that have been deleted
1112    fn deletion_percentage(&self) -> f32 {
1113        if self.physical_rows > 0 {
1114            self.num_deletions as f32 / self.physical_rows as f32
1115        } else {
1116            0.0
1117        }
1118    }
1119
1120    /// The number of rows that are still in the fragment
1121    fn num_rows(&self) -> usize {
1122        self.physical_rows - self.num_deletions
1123    }
1124}
1125
1126async fn collect_metrics(fragment: &FileFragment) -> Result<FragmentMetrics> {
1127    let physical_rows = fragment.physical_rows();
1128    let num_deletions = fragment.count_deletions();
1129    let (physical_rows, num_deletions) =
1130        futures::future::try_join(physical_rows, num_deletions).await?;
1131    Ok(FragmentMetrics {
1132        physical_rows,
1133        num_deletions,
1134    })
1135}
1136
1137/// Whether one of `fragment`'s data files is referenced only for the row
1138/// lineage columns it carries, while the user columns next to them have all
1139/// been dropped or replaced. `schema_field_ids` holds the ids of the schema's
1140/// fields.
1141///
1142/// Compaction writes spilled lineage into the data file of each fragment it
1143/// writes. Once every user column in that file is dropped, cast or replaced,
1144/// the lineage alone keeps the file, and its dead bytes, referenced: cleanup
1145/// cannot remove it and nothing else rewrites it. A lineage-only file, which
1146/// binary copy and update write, holds no dead user column and never matches;
1147/// nor does any file compaction writes, so compacting a fragment that matches
1148/// cannot leave one that matches again.
1149fn pins_dead_user_columns_for_lineage(
1150    fragment: &Fragment,
1151    schema_field_ids: &HashSet<i32>,
1152) -> bool {
1153    fragment.files.iter().any(|file| {
1154        let mut holds_dead_user_column = false;
1155        let mut holds_lineage = false;
1156        for field_id in file.fields.iter() {
1157            if schema_field_ids.contains(field_id) {
1158                return false;
1159            }
1160            match *field_id {
1161                ROW_ID_FIELD_ID
1162                | ROW_CREATED_AT_VERSION_FIELD_ID
1163                | ROW_LAST_UPDATED_AT_VERSION_FIELD_ID => holds_lineage = true,
1164                TOMBSTONE_FIELD_ID => holds_dead_user_column = true,
1165                // A user field the schema no longer has: dropping a column
1166                // leaves the ids of the files it keeps as they are.
1167                other => holds_dead_user_column |= other >= 0,
1168            }
1169        }
1170        holds_dead_user_column && holds_lineage
1171    })
1172}
1173
1174/// Truncates a planned task list to the configured per-run source budgets
1175/// (`max_source_fragments`, `max_source_rows`, `max_source_bytes`).
1176///
1177/// All configured budgets apply together: tasks are kept, in order, until
1178/// adding the next task would exceed any one of them. The budgets are hard
1179/// upper bounds, so if the first task already exceeds one of them the
1180/// returned plan is empty and a warning is logged, since compaction would
1181/// otherwise stall silently.
1182///
1183/// Each task is paired with the number of live rows in its source fragments.
1184fn limit_tasks_to_source_budget(
1185    options: &CompactionOptions,
1186    schema: &lance_core::datatypes::Schema,
1187    all_tasks: Vec<(TaskData, usize)>,
1188) -> Result<Vec<TaskData>> {
1189    if options.max_source_fragments.is_none()
1190        && options.max_source_rows.is_none()
1191        && options.max_source_bytes.is_none()
1192    {
1193        return Ok(all_tasks.into_iter().map(|(task, _)| task).collect());
1194    }
1195
1196    // Only needed for the bytes budget: files whose fields are all absent
1197    // from the current schema only back dropped columns, which compaction
1198    // does not read.
1199    let schema_field_ids: HashSet<i32> = if options.max_source_bytes.is_some() {
1200        schema.field_ids().into_iter().collect()
1201    } else {
1202        HashSet::new()
1203    };
1204
1205    let num_candidate_tasks = all_tasks.len();
1206    let mut total_fragments = 0_usize;
1207    let mut total_rows = 0_usize;
1208    let mut total_bytes = 0_u64;
1209    let mut tasks = Vec::with_capacity(all_tasks.len());
1210    for (task, live_rows) in all_tasks {
1211        total_fragments += task.fragments.len();
1212        total_rows = total_rows.saturating_add(live_rows);
1213        if options.max_source_bytes.is_some() {
1214            total_bytes = total_bytes.saturating_add(task_source_bytes(&task, &schema_field_ids)?);
1215        }
1216
1217        let over_budget = options
1218            .max_source_fragments
1219            .is_some_and(|max| total_fragments > max)
1220            || options.max_source_rows.is_some_and(|max| total_rows > max)
1221            || options
1222                .max_source_bytes
1223                .is_some_and(|max| total_bytes > max);
1224        if over_budget {
1225            break;
1226        }
1227
1228        tasks.push(task);
1229    }
1230
1231    if tasks.is_empty() && num_candidate_tasks > 0 {
1232        warn!(
1233            "Compaction plan is empty: the first of {} candidate tasks already exceeds a source \
1234             budget (max_source_fragments={:?}, max_source_rows={:?}, max_source_bytes={:?}); \
1235             compaction cannot make progress until the budget is raised",
1236            num_candidate_tasks,
1237            options.max_source_fragments,
1238            options.max_source_rows,
1239            options.max_source_bytes
1240        );
1241    }
1242
1243    Ok(tasks)
1244}
1245
1246/// Returns the total size in bytes of a task's source data and overlay files.
1247///
1248/// Files whose fields are all absent from `schema_field_ids` only back
1249/// dropped columns; compaction does not read them, so they are neither
1250/// counted nor required to have a recorded size.
1251/// Only sizes recorded in the manifest are used: a missing size is an error
1252/// rather than a metadata request against object storage, which would turn
1253/// planning into one round trip per file. Deletion files are not counted.
1254fn task_source_bytes(task: &TaskData, schema_field_ids: &HashSet<i32>) -> Result<u64> {
1255    let mut total_bytes = 0_u64;
1256    for fragment in &task.fragments {
1257        let overlay_files = fragment.overlays.iter().map(|overlay| &overlay.data_file);
1258        for data_file in fragment.files.iter().chain(overlay_files) {
1259            if !data_file
1260                .fields
1261                .iter()
1262                .any(|field_id| schema_field_ids.contains(field_id))
1263            {
1264                continue;
1265            }
1266            let size = data_file.file_size_bytes.get().ok_or_else(|| {
1267                Error::invalid_input(format!(
1268                    "max_source_bytes is set but file '{}' of fragment {} has no size recorded \
1269                     in the manifest; unset max_source_bytes to compact this dataset",
1270                    data_file.path, fragment.id
1271                ))
1272            })?;
1273            total_bytes = total_bytes.saturating_add(size.get());
1274        }
1275    }
1276    Ok(total_bytes)
1277}
1278
1279/// A plan for what groups of fragments to compact.
1280///
1281/// See [plan_compaction()] for more details.
1282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1283pub struct CompactionPlan {
1284    pub tasks: Vec<TaskData>,
1285    pub read_version: u64,
1286    pub options: CompactionOptions,
1287}
1288
1289impl CompactionPlan {
1290    /// Retrieve standalone tasks that be be executed in a distributed fashion.
1291    pub fn compaction_tasks(&self) -> impl Iterator<Item = CompactionTask> + '_ {
1292        let read_version = self.read_version;
1293        let options = self.options.clone();
1294        self.tasks.iter().map(move |task| CompactionTask {
1295            task: task.clone(),
1296            read_version,
1297            options: options.clone(),
1298        })
1299    }
1300
1301    /// The number of tasks in the plan.
1302    pub fn num_tasks(&self) -> usize {
1303        self.tasks.len()
1304    }
1305
1306    /// The version of the dataset that was read to produce this plan.
1307    pub fn read_version(&self) -> u64 {
1308        self.read_version
1309    }
1310
1311    /// The options used to produce this plan.
1312    pub fn options(&self) -> &CompactionOptions {
1313        &self.options
1314    }
1315}
1316
1317/// Classification for one blob v2 row during compaction.
1318///
1319/// - `Null`: NULL row.
1320/// - `External`: External blob referenced by URI.
1321/// - `DataBlob`: Inline/Packed/Dedicated blob stored in Lance files.
1322enum RowClass {
1323    Null,
1324    External,
1325    DataBlob,
1326}
1327
1328/// Column views for the 5 fields in a blob v2 descriptor struct.
1329struct BlobV2Descriptor<'a> {
1330    kind_col: &'a arrow::array::UInt8Array,
1331    position_col: &'a arrow::array::UInt64Array,
1332    size_col: &'a arrow::array::UInt64Array,
1333    blob_uri_col: &'a arrow::array::StringArray,
1334    blob_id_col: &'a arrow::array::UInt32Array,
1335}
1336
1337impl<'a> BlobV2Descriptor<'a> {
1338    /// Extract the 5 descriptor arrays from a blob v2 descriptor struct array.
1339    fn try_from_struct(struct_arr: &'a StructArray, column_name: &str) -> Result<Self> {
1340        if BlobV2Layout::classify(struct_arr.fields()) != Some(BlobV2Layout::Descriptor) {
1341            let actual = BlobV2Layout::classify(struct_arr.fields())
1342                .map(|layout| layout.to_string())
1343                .unwrap_or_else(|| format!("unrecognized ({:?})", struct_arr.fields()));
1344            return Err(Error::invalid_input(format!(
1345                "Blob v2 column '{column_name}' has {actual} layout; expected descriptor layout before conversion to logical"
1346            )));
1347        }
1348        let kind_col = struct_arr
1349            .column_by_name("kind")
1350            .ok_or_else(|| {
1351                Error::internal(format!(
1352                    "Blob v2 descriptor for column '{}' missing `kind` field",
1353                    column_name
1354                ))
1355            })?
1356            .as_primitive::<UInt8Type>();
1357        let position_col = struct_arr
1358            .column_by_name("position")
1359            .ok_or_else(|| {
1360                Error::internal(format!(
1361                    "Blob v2 descriptor for column '{}' missing `position` field",
1362                    column_name
1363                ))
1364            })?
1365            .as_primitive::<UInt64Type>();
1366        let size_col = struct_arr
1367            .column_by_name("size")
1368            .ok_or_else(|| {
1369                Error::internal(format!(
1370                    "Blob v2 descriptor for column '{}' missing `size` field",
1371                    column_name
1372                ))
1373            })?
1374            .as_primitive::<UInt64Type>();
1375        let blob_uri_col = struct_arr
1376            .column_by_name("blob_uri")
1377            .ok_or_else(|| {
1378                Error::internal(format!(
1379                    "Blob v2 descriptor for column '{}' missing `blob_uri` field",
1380                    column_name
1381                ))
1382            })?
1383            .as_string::<i32>();
1384        let blob_id_col = struct_arr
1385            .column_by_name("blob_id")
1386            .ok_or_else(|| {
1387                Error::internal(format!(
1388                    "Blob v2 descriptor for column '{}' missing `blob_id` field",
1389                    column_name
1390                ))
1391            })?
1392            .as_primitive::<UInt32Type>();
1393        Ok(Self {
1394            kind_col,
1395            position_col,
1396            size_col,
1397            blob_uri_col,
1398            blob_id_col,
1399        })
1400    }
1401}
1402
1403/// Result of row classification for a blob v2 rewrite.
1404struct RowClassification {
1405    row_classes: Vec<RowClass>,
1406    data_blob_indices: Vec<usize>,
1407}
1408
1409/// Classify each row of a blob v2 column as Null, External, or DataBlob.
1410fn classify_rows(
1411    struct_arr: &StructArray,
1412    descriptor: &BlobV2Descriptor<'_>,
1413    column_name: &str,
1414) -> Result<RowClassification> {
1415    let num_rows = struct_arr.len();
1416    let mut row_classes = Vec::with_capacity(num_rows);
1417    let mut data_blob_indices = Vec::with_capacity(num_rows);
1418
1419    for i in 0..num_rows {
1420        if struct_arr.is_null(i) || descriptor.kind_col.is_null(i) {
1421            row_classes.push(RowClass::Null);
1422        } else {
1423            let kind = BlobKind::try_from(descriptor.kind_col.value(i)).map_err(|e| {
1424                Error::internal(format!(
1425                    "Blob v2 column '{}' has invalid kind at row {}: {e}",
1426                    column_name, i
1427                ))
1428            })?;
1429            if kind == BlobKind::External {
1430                row_classes.push(RowClass::External);
1431            } else {
1432                row_classes.push(RowClass::DataBlob);
1433                data_blob_indices.push(i);
1434            }
1435        }
1436    }
1437
1438    Ok(RowClassification {
1439        row_classes,
1440        data_blob_indices,
1441    })
1442}
1443
1444/// Convert a blob v2 descriptor into the logical writer representation.
1445///
1446/// Reads blob data lazily using row addresses to avoid materializing all blob
1447/// payloads in memory at once.
1448async fn descriptor_to_logical_blob_array(
1449    dataset: &Arc<Dataset>,
1450    blob_field_id: u32,
1451    struct_arr: &StructArray,
1452    descriptor: &BlobV2Descriptor<'_>,
1453    classification: &RowClassification,
1454    row_addrs: &[u64],
1455    field_name: &str,
1456) -> Result<StructArray> {
1457    if struct_arr.len() != row_addrs.len() {
1458        return Err(Error::internal(format!(
1459            "Blob v2 field '{}' row count {} did not match row address count {}",
1460            field_name,
1461            struct_arr.len(),
1462            row_addrs.len()
1463        )));
1464    }
1465    let blob_files = if classification.data_blob_indices.is_empty() {
1466        Vec::new()
1467    } else {
1468        let indices = classification
1469            .data_blob_indices
1470            .iter()
1471            .map(|index| {
1472                u32::try_from(*index).map_err(|_| {
1473                    Error::internal(format!(
1474                        "Blob v2 row index {} in field '{}' does not fit in u32",
1475                        index, field_name
1476                    ))
1477                })
1478            })
1479            .collect::<Result<Vec<_>>>()?;
1480        let indices = UInt32Array::from(indices);
1481        let descriptions = arrow_select::take::take(struct_arr, &indices, None)?;
1482        let descriptions = descriptions.as_struct();
1483        let data_row_addrs = classification
1484            .data_blob_indices
1485            .iter()
1486            .map(|index| row_addrs[*index])
1487            .collect::<Vec<_>>();
1488        super::blob::collect_blob_v2_descriptor_files(
1489            dataset,
1490            blob_field_id,
1491            descriptions,
1492            &data_row_addrs,
1493        )
1494        .await?
1495    };
1496
1497    let data_capacity =
1498        classification
1499            .data_blob_indices
1500            .iter()
1501            .try_fold(0usize, |data_capacity, row_idx| {
1502                if descriptor.size_col.is_null(*row_idx) {
1503                    return Err(Error::internal(format!(
1504                        "Non-null blob row {} in field '{}' is missing its size",
1505                        row_idx, field_name
1506                    )));
1507                }
1508                let size = usize::try_from(descriptor.size_col.value(*row_idx)).map_err(|_| {
1509                    Error::internal(format!(
1510                        "Blob size {} at row {} in field '{}' does not fit in usize",
1511                        descriptor.size_col.value(*row_idx),
1512                        row_idx,
1513                        field_name
1514                    ))
1515                })?;
1516                data_capacity.checked_add(size).ok_or_else(|| {
1517                    Error::internal(format!(
1518                        "Total blob size in field '{}' exceeds usize",
1519                        field_name
1520                    ))
1521                })
1522            })?;
1523
1524    let num_rows = struct_arr.len();
1525    let mut data_builder = LargeBinaryBuilder::with_capacity(num_rows, data_capacity);
1526    let mut uri_builder = StringBuilder::with_capacity(num_rows, 0);
1527    let mut out_position_builder = PrimitiveBuilder::<UInt64Type>::with_capacity(num_rows);
1528    let mut out_size_builder = PrimitiveBuilder::<UInt64Type>::with_capacity(num_rows);
1529
1530    let mut blob_file_idx = 0;
1531    #[allow(clippy::needless_range_loop)]
1532    for i in 0..num_rows {
1533        match classification.row_classes[i] {
1534            RowClass::Null => {
1535                data_builder.append_null();
1536                uri_builder.append_null();
1537                out_position_builder.append_null();
1538                out_size_builder.append_null();
1539            }
1540            RowClass::External => {
1541                data_builder.append_null();
1542                let base_id = descriptor.blob_id_col.value(i);
1543                let uri_val = descriptor.blob_uri_col.value(i);
1544                if base_id == 0 {
1545                    uri_builder.append_value(uri_val);
1546                } else {
1547                    let base = dataset.manifest().base_paths.get(&base_id).ok_or_else(|| {
1548                        Error::internal(format!(
1549                            "External blob in column '{}' references unknown base_id {}",
1550                            field_name, base_id
1551                        ))
1552                    })?;
1553                    let absolute_uri = format!("{}/{}", base.path.trim_end_matches('/'), uri_val);
1554                    uri_builder.append_value(&absolute_uri);
1555                }
1556                let position =
1557                    (!descriptor.position_col.is_null(i)).then(|| descriptor.position_col.value(i));
1558                let size = (!descriptor.size_col.is_null(i)).then(|| descriptor.size_col.value(i));
1559                if position == Some(0) && size == Some(0) {
1560                    // Stable descriptors use (0, 0) for the complete external object.
1561                    // Logical input represents the same value by omitting the range.
1562                    out_position_builder.append_null();
1563                    out_size_builder.append_null();
1564                } else {
1565                    out_position_builder.append_option(position);
1566                    out_size_builder.append_option(size);
1567                }
1568            }
1569            RowClass::DataBlob => {
1570                let blob_file = blob_files
1571                    .get(blob_file_idx)
1572                    .and_then(Option::as_ref)
1573                    .ok_or_else(|| {
1574                        Error::internal(format!(
1575                            "Non-null blob row {} in field '{}' resolved to null",
1576                            i, field_name
1577                        ))
1578                    })?;
1579                let data = blob_file.read().await?;
1580                blob_file_idx += 1;
1581                data_builder.append_value(data.as_ref());
1582                uri_builder.append_null();
1583                out_position_builder.append_null();
1584                out_size_builder.append_null();
1585            }
1586        }
1587    }
1588
1589    Ok(StructArray::try_new(
1590        BLOB_V2_LOGICAL_FIELDS.clone(),
1591        vec![
1592            Arc::new(data_builder.finish()),
1593            Arc::new(uri_builder.finish()),
1594            Arc::new(out_position_builder.finish()),
1595            Arc::new(out_size_builder.finish()),
1596        ],
1597        struct_arr.nulls().cloned(),
1598    )?)
1599}
1600
1601fn transformed_arrow_field(field: &LanceField, data_type: ArrowDataType) -> Arc<ArrowField> {
1602    let arrow_field = ArrowField::from(field);
1603    arrow_field_with_data_type(&arrow_field, data_type)
1604}
1605
1606fn arrow_field_with_data_type(field: &ArrowField, data_type: ArrowDataType) -> Arc<ArrowField> {
1607    Arc::new(
1608        ArrowField::new(field.name(), data_type, field.is_nullable())
1609            .with_metadata(field.metadata().clone()),
1610    )
1611}
1612
1613pub(crate) fn field_contains_blob_v2(field: &LanceField) -> bool {
1614    field.is_blob_v2() || field.children.iter().any(field_contains_blob_v2)
1615}
1616
1617enum BlobV2FieldRewritePlan {
1618    Passthrough {
1619        output_field: Arc<ArrowField>,
1620    },
1621    Blob {
1622        field_id: u32,
1623        field_name: String,
1624        output_field: Arc<ArrowField>,
1625    },
1626    Struct {
1627        field_name: String,
1628        output_field: Arc<ArrowField>,
1629        children: Vec<Self>,
1630    },
1631    List {
1632        field_name: String,
1633        output_field: Arc<ArrowField>,
1634        child: Box<Self>,
1635    },
1636    LargeList {
1637        field_name: String,
1638        output_field: Arc<ArrowField>,
1639        child: Box<Self>,
1640    },
1641}
1642
1643impl BlobV2FieldRewritePlan {
1644    fn passthrough(field: &ArrowField) -> Self {
1645        Self::Passthrough {
1646            output_field: Arc::new(field.clone()),
1647        }
1648    }
1649
1650    fn try_new(field: &LanceField, input_field: &ArrowField) -> Result<Self> {
1651        if !field_contains_blob_v2(field) {
1652            return Ok(Self::passthrough(input_field));
1653        }
1654
1655        if field.is_blob_v2() {
1656            let field_id = u32::try_from(field.id).map_err(|_| {
1657                Error::internal(format!(
1658                    "Blob v2 field id {} for '{}' does not fit in u32",
1659                    field.id, field.name
1660                ))
1661            })?;
1662            let ArrowDataType::Struct(input_children) = input_field.data_type() else {
1663                return Err(Error::invalid_input(format!(
1664                    "Blob v2 field '{}' has non-struct input type {:?}",
1665                    field.name,
1666                    input_field.data_type()
1667                )));
1668            };
1669            let output_field = match BlobV2Layout::classify(input_children) {
1670                Some(BlobV2Layout::Logical) => Arc::new(input_field.clone()),
1671                Some(BlobV2Layout::Descriptor) => {
1672                    transformed_arrow_field(field, BLOB_V2_LOGICAL_TYPE.clone())
1673                }
1674                Some(actual) => {
1675                    return Err(Error::invalid_input(format!(
1676                        "Blob v2 field '{}' has {actual} input layout; expected logical or descriptor layout during rewrite",
1677                        field.name
1678                    )));
1679                }
1680                None => {
1681                    return Err(Error::invalid_input(format!(
1682                        "Blob v2 field '{}' has unrecognized input layout {:?}; expected logical or descriptor layout during rewrite",
1683                        field.name, input_children
1684                    )));
1685                }
1686            };
1687            return Ok(Self::Blob {
1688                field_id,
1689                field_name: field.name.clone(),
1690                output_field,
1691            });
1692        }
1693
1694        match (field.data_type(), input_field.data_type()) {
1695            (ArrowDataType::Struct(_), ArrowDataType::Struct(input_children)) => {
1696                if field.children.len() != input_children.len() {
1697                    return Err(Error::internal(format!(
1698                        "Struct field '{}' expected {} children in blob rewrite plan, got {}",
1699                        field.name,
1700                        field.children.len(),
1701                        input_children.len()
1702                    )));
1703                }
1704                let children = field
1705                    .children
1706                    .iter()
1707                    .zip(input_children.iter())
1708                    .map(|(child, input_child)| Self::try_new(child, input_child))
1709                    .collect::<Result<Vec<_>>>()?;
1710                let output_children = children
1711                    .iter()
1712                    .map(|child| child.output_field().clone())
1713                    .collect::<Vec<_>>();
1714                Ok(Self::Struct {
1715                    field_name: field.name.clone(),
1716                    output_field: arrow_field_with_data_type(
1717                        input_field,
1718                        ArrowDataType::Struct(output_children.into()),
1719                    ),
1720                    children,
1721                })
1722            }
1723            (ArrowDataType::List(_), ArrowDataType::List(input_child)) => {
1724                let child = field.children.first().ok_or_else(|| {
1725                    Error::internal(format!(
1726                        "List field '{}' is missing its child in blob rewrite plan",
1727                        field.name
1728                    ))
1729                })?;
1730                let child = Box::new(Self::try_new(child, input_child)?);
1731                Ok(Self::List {
1732                    field_name: field.name.clone(),
1733                    output_field: arrow_field_with_data_type(
1734                        input_field,
1735                        ArrowDataType::List(child.output_field().clone()),
1736                    ),
1737                    child,
1738                })
1739            }
1740            (ArrowDataType::LargeList(_), ArrowDataType::LargeList(input_child)) => {
1741                let child = field.children.first().ok_or_else(|| {
1742                    Error::internal(format!(
1743                        "Large list field '{}' is missing its child in blob rewrite plan",
1744                        field.name
1745                    ))
1746                })?;
1747                let child = Box::new(Self::try_new(child, input_child)?);
1748                Ok(Self::LargeList {
1749                    field_name: field.name.clone(),
1750                    output_field: arrow_field_with_data_type(
1751                        input_field,
1752                        ArrowDataType::LargeList(child.output_field().clone()),
1753                    ),
1754                    child,
1755                })
1756            }
1757            (logical_type, input_type) => Err(Error::invalid_input(format!(
1758                "Field '{}' contains blob v2 descendants but logical type {:?} and input type {:?} do not form a supported rewrite container",
1759                field.name, logical_type, input_type
1760            ))),
1761        }
1762    }
1763
1764    fn output_field(&self) -> &Arc<ArrowField> {
1765        match self {
1766            Self::Passthrough { output_field }
1767            | Self::Blob { output_field, .. }
1768            | Self::Struct { output_field, .. }
1769            | Self::List { output_field, .. }
1770            | Self::LargeList { output_field, .. } => output_field,
1771        }
1772    }
1773
1774    fn transform<'a>(
1775        &'a self,
1776        dataset: &'a Arc<Dataset>,
1777        array: ArrayRef,
1778        row_addrs: Arc<[u64]>,
1779    ) -> BoxFuture<'a, Result<ArrayRef>> {
1780        async move {
1781            match self {
1782                Self::Passthrough { .. } => Ok(array),
1783                Self::Blob {
1784                    field_id,
1785                    field_name,
1786                    ..
1787                } => {
1788                    let struct_arr = array.as_struct();
1789                    match BlobV2Layout::classify(struct_arr.fields()) {
1790                        Some(BlobV2Layout::Logical) => Ok(array),
1791                        Some(BlobV2Layout::Descriptor) => {
1792                            let descriptor =
1793                                BlobV2Descriptor::try_from_struct(struct_arr, field_name)?;
1794                            let classification =
1795                                classify_rows(struct_arr, &descriptor, field_name)?;
1796                            let logical = descriptor_to_logical_blob_array(
1797                                dataset,
1798                                *field_id,
1799                                struct_arr,
1800                                &descriptor,
1801                                &classification,
1802                                row_addrs.as_ref(),
1803                                field_name,
1804                            )
1805                            .await?;
1806                            Ok(Arc::new(logical) as ArrayRef)
1807                        }
1808                        Some(actual) => Err(Error::invalid_input(format!(
1809                            "Blob v2 field '{}' has {actual} layout; expected logical or descriptor layout during rewrite",
1810                            field_name
1811                        ))),
1812                        None => Err(Error::invalid_input(format!(
1813                            "Blob v2 field '{}' has unrecognized layout {:?}; expected logical or descriptor layout during rewrite",
1814                            field_name,
1815                            struct_arr.fields()
1816                        ))),
1817                    }
1818                }
1819                Self::Struct {
1820                    field_name,
1821                    output_field,
1822                    children,
1823                } => {
1824                    let struct_arr = array.as_struct().normalize_slicing()?;
1825                    let parent_nulls = struct_arr.nulls().cloned();
1826                    let struct_arr = struct_arr.pushdown_nulls()?;
1827                    if children.len() != struct_arr.num_columns() {
1828                        return Err(Error::internal(format!(
1829                            "Struct field '{}' expected {} children during blob rewrite, got {}",
1830                            field_name,
1831                            children.len(),
1832                            struct_arr.num_columns()
1833                        )));
1834                    }
1835                    let mut child_arrays = Vec::with_capacity(children.len());
1836                    for (child, child_array) in children.iter().zip(struct_arr.columns()) {
1837                        child_arrays.push(
1838                            child
1839                                .transform(dataset, child_array.clone(), row_addrs.clone())
1840                                .await?,
1841                        );
1842                    }
1843                    let ArrowDataType::Struct(output_fields) = output_field.data_type() else {
1844                        return Err(Error::internal(format!(
1845                            "Struct field '{}' rewrite plan has non-struct output type {:?}",
1846                            field_name,
1847                            output_field.data_type()
1848                        )));
1849                    };
1850                    Ok(Arc::new(StructArray::try_new(
1851                        output_fields.clone(),
1852                        child_arrays,
1853                        parent_nulls,
1854                    )?) as ArrayRef)
1855                }
1856                Self::List {
1857                    field_name, child, ..
1858                } => {
1859                    transform_blob_v2_list_array::<i32>(
1860                        dataset,
1861                        field_name,
1862                        child,
1863                        array.as_list::<i32>(),
1864                        row_addrs,
1865                    )
1866                    .await
1867                }
1868                Self::LargeList {
1869                    field_name, child, ..
1870                } => {
1871                    transform_blob_v2_list_array::<i64>(
1872                        dataset,
1873                        field_name,
1874                        child,
1875                        array.as_list::<i64>(),
1876                        row_addrs,
1877                    )
1878                    .await
1879                }
1880            }
1881        }
1882        .boxed()
1883    }
1884}
1885
1886async fn transform_blob_v2_list_array<O: OffsetSizeTrait>(
1887    dataset: &Arc<Dataset>,
1888    field_name: &str,
1889    child: &BlobV2FieldRewritePlan,
1890    list_array: &GenericListArray<O>,
1891    row_addrs: Arc<[u64]>,
1892) -> Result<ArrayRef> {
1893    let list_array = if list_array.null_count() > 0 {
1894        list_array.filter_garbage_nulls()
1895    } else {
1896        list_array.clone()
1897    };
1898    let offsets = list_array.value_offsets();
1899    let values_start = offsets[0].as_usize();
1900    let values_end = offsets[list_array.len()].as_usize();
1901    if values_end < values_start {
1902        return Err(Error::internal(format!(
1903            "List field '{}' has invalid offsets during blob rewrite",
1904            field_name
1905        )));
1906    }
1907
1908    let values_len = values_end - values_start;
1909    let mut normalized_offsets = Vec::with_capacity(list_array.len() + 1);
1910    normalized_offsets.push(O::usize_as(0));
1911    let mut child_row_addrs = Vec::with_capacity(values_len);
1912    for row_idx in 0..list_array.len() {
1913        let start = offsets[row_idx].as_usize();
1914        let end = offsets[row_idx + 1].as_usize();
1915        if end < start {
1916            return Err(Error::internal(format!(
1917                "List field '{}' has decreasing offsets during blob rewrite",
1918                field_name
1919            )));
1920        }
1921        let row_addr = row_addrs.get(row_idx).copied().ok_or_else(|| {
1922            Error::internal(format!(
1923                "List field '{}' row address count {} did not match row count {}",
1924                field_name,
1925                row_addrs.len(),
1926                list_array.len()
1927            ))
1928        })?;
1929        child_row_addrs.extend(std::iter::repeat_n(row_addr, end - start));
1930        normalized_offsets.push(O::usize_as(end - values_start));
1931    }
1932
1933    let values = list_array.values().slice(values_start, values_len);
1934    let values = child
1935        .transform(dataset, values, Arc::<[u64]>::from(child_row_addrs))
1936        .await?;
1937    let list_array = GenericListArray::<O>::try_new(
1938        child.output_field().clone(),
1939        OffsetBuffer::new(ScalarBuffer::from(normalized_offsets)),
1940        values,
1941        list_array.nulls().cloned(),
1942    )?;
1943    Ok(Arc::new(list_array))
1944}
1945
1946pub(crate) struct BlobV2BatchRewritePlan {
1947    row_addr_idx: usize,
1948    columns: Vec<(usize, BlobV2FieldRewritePlan)>,
1949    output_schema: SchemaRef,
1950}
1951
1952impl BlobV2BatchRewritePlan {
1953    pub(crate) fn try_new(
1954        schema: &lance_core::datatypes::Schema,
1955        input_schema: &ArrowSchema,
1956        keep_row_addr: bool,
1957    ) -> Result<Self> {
1958        let row_addr_idx = input_schema
1959            .column_with_name(lance_core::ROW_ADDR)
1960            .ok_or_else(|| {
1961                Error::internal(format!(
1962                    "_rowaddr column missing from batch for blob v2 rewrite, columns: {:?}",
1963                    input_schema
1964                        .fields()
1965                        .iter()
1966                        .map(|f| f.name())
1967                        .collect::<Vec<_>>()
1968                ))
1969            })?
1970            .0;
1971        let mut columns = Vec::with_capacity(input_schema.fields().len());
1972        let mut output_fields = Vec::with_capacity(input_schema.fields().len());
1973        for (column_idx, input_field) in input_schema.fields().iter().enumerate() {
1974            if input_field.name() == lance_core::ROW_ADDR && !keep_row_addr {
1975                continue;
1976            }
1977            let field_plan = if let Some(field) = schema.field(input_field.name()) {
1978                BlobV2FieldRewritePlan::try_new(field, input_field)?
1979            } else {
1980                BlobV2FieldRewritePlan::passthrough(input_field)
1981            };
1982            output_fields.push(field_plan.output_field().as_ref().clone());
1983            columns.push((column_idx, field_plan));
1984        }
1985
1986        Ok(Self {
1987            row_addr_idx,
1988            columns,
1989            output_schema: Arc::new(ArrowSchema::new_with_metadata(
1990                output_fields,
1991                input_schema.metadata().clone(),
1992            )),
1993        })
1994    }
1995
1996    pub(crate) fn output_schema(&self) -> &SchemaRef {
1997        &self.output_schema
1998    }
1999
2000    pub(crate) async fn transform_batch(
2001        &self,
2002        dataset: &Arc<Dataset>,
2003        batch: RecordBatch,
2004    ) -> Result<RecordBatch> {
2005        let row_addrs: Arc<[u64]> = batch
2006            .column(self.row_addr_idx)
2007            .as_primitive::<UInt64Type>()
2008            .values()
2009            .iter()
2010            .copied()
2011            .collect::<Vec<_>>()
2012            .into();
2013        let mut output_columns = Vec::with_capacity(self.columns.len());
2014        for (column_idx, field_plan) in &self.columns {
2015            output_columns.push(
2016                field_plan
2017                    .transform(
2018                        dataset,
2019                        batch.column(*column_idx).clone(),
2020                        row_addrs.clone(),
2021                    )
2022                    .await?,
2023            );
2024        }
2025        Ok(RecordBatch::try_new(
2026            self.output_schema.clone(),
2027            output_columns,
2028        )?)
2029    }
2030}
2031
2032pub(crate) async fn transform_blob_v2_batch(
2033    dataset: &Arc<Dataset>,
2034    schema: &lance_core::datatypes::Schema,
2035    batch: RecordBatch,
2036    keep_row_addr: bool,
2037) -> Result<RecordBatch> {
2038    let plan = BlobV2BatchRewritePlan::try_new(schema, batch.schema().as_ref(), keep_row_addr)?;
2039    plan.transform_batch(dataset, batch).await
2040}
2041
2042/// Build a scan reader for rewrite and optionally capture row IDs.
2043///
2044/// Parameters:
2045/// - `dataset`: Dataset handle used to create the scanner.
2046/// - `fragments`: When `with_frags` is true, restrict the scan to these old fragments
2047///   and preserve insertion order.
2048/// - `batch_size`: Optional batch size; if provided, set it on the scanner to control
2049///   read batching.
2050/// - `io_buffer_size`: Optional I/O buffer size in bytes; if provided, set it on the
2051///   scanner to control how much data is queued during reads.
2052/// - `with_frags`: Whether to scan only the specified old fragments and force
2053///   in-order reading.
2054/// - `capture_row_ids`: When index remapping is needed, include and capture the
2055///   `_rowid` column from the stream.
2056///
2057/// Returns:
2058/// - `SendableRecordBatchStream`: The batch stream (with `_rowid` removed if captured)
2059///   to feed the rewrite path.
2060/// - `Option<Receiver<CapturedRowIds>>`: A receiver to obtain captured row IDs after the
2061///   stream completes; `None` if not capturing.
2062/// - `bool`: Whether the dataset has blob v2 columns and the stream includes `_rowaddr`.
2063async fn prepare_reader(
2064    dataset: &Dataset,
2065    fragments: &[Fragment],
2066    batch_size: Option<usize>,
2067    io_buffer_size: Option<u64>,
2068    with_frags: bool,
2069    capture_row_ids: bool,
2070) -> Result<(
2071    SendableRecordBatchStream,
2072    Option<std::sync::mpsc::Receiver<CapturedRowIds>>,
2073    bool,
2074)> {
2075    let mut scanner = dataset.scan();
2076    let has_legacy_blob_columns = dataset
2077        .schema()
2078        .fields_pre_order()
2079        .any(|field| field.is_blob() && !field.is_blob_v2());
2080    if has_legacy_blob_columns {
2081        scanner.blob_handling(BlobHandling::AllBinary);
2082    }
2083    let has_blob_v2_columns = dataset
2084        .schema()
2085        .fields_pre_order()
2086        .any(|field| field.is_blob_v2());
2087    if has_blob_v2_columns {
2088        scanner.with_row_address();
2089    }
2090    if let Some(bs) = batch_size {
2091        scanner.batch_size(bs);
2092    }
2093    if let Some(io_buffer_size) = io_buffer_size {
2094        scanner.io_buffer_size(io_buffer_size);
2095    }
2096    if with_frags {
2097        scanner
2098            .with_fragments(fragments.to_vec())
2099            .scan_in_order(true);
2100    }
2101    if capture_row_ids {
2102        scanner.with_row_id();
2103        let data = SendableRecordBatchStream::from(scanner.try_into_stream().await?);
2104        let (data_no_row_ids, rx) =
2105            make_rowid_capture_stream(data, dataset.manifest.uses_stable_row_ids())?;
2106        Ok((data_no_row_ids, Some(rx), has_blob_v2_columns))
2107    } else {
2108        Ok((
2109            SendableRecordBatchStream::from(scanner.try_into_stream().await?),
2110            None,
2111            has_blob_v2_columns,
2112        ))
2113    }
2114}
2115
2116/// A single group of fragments to compact, which is a view into the compaction
2117/// plan. We keep the `replace_range` indices so we can map the result of the
2118/// compact back to the fragments it replaces.
2119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2120pub struct TaskData {
2121    /// The fragments to compact.
2122    pub fragments: Vec<Fragment>,
2123}
2124
2125/// A standalone task that can be serialized and sent to another machine for
2126/// execution.
2127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2128pub struct CompactionTask {
2129    pub task: TaskData,
2130    pub read_version: u64,
2131    pub options: CompactionOptions,
2132}
2133
2134impl CompactionTask {
2135    /// Run the compaction task and return the result.
2136    ///
2137    /// This result should be later passed to [commit_compaction()] to commit
2138    /// the changes to the dataset.
2139    ///
2140    /// Note: you should pass the version of the dataset that is the same as
2141    /// the read version for this task (the same version from which the
2142    /// compaction was planned).
2143    pub async fn execute(&self, dataset: &Dataset) -> Result<RewriteResult> {
2144        let dataset = if dataset.manifest.version == self.read_version {
2145            Cow::Borrowed(dataset)
2146        } else {
2147            Cow::Owned(dataset.checkout_version(self.read_version).await?)
2148        };
2149        rewrite_files(dataset, self.task.clone(), &self.options).await
2150    }
2151}
2152
2153impl CompactionPlan {
2154    fn new(read_version: u64, options: CompactionOptions) -> Self {
2155        Self {
2156            tasks: Vec::new(),
2157            read_version,
2158            options,
2159        }
2160    }
2161
2162    fn extend_tasks(&mut self, tasks: impl IntoIterator<Item = TaskData>) {
2163        self.tasks.extend(tasks);
2164    }
2165
2166    fn tasks(&self) -> &[TaskData] {
2167        &self.tasks
2168    }
2169}
2170
2171#[derive(Debug, Clone)]
2172enum CompactionCandidacy {
2173    /// Compact the fragment if it has neighbors that are also candidates
2174    CompactWithNeighbors,
2175    /// Compact the fragment regardless.
2176    CompactItself,
2177}
2178
2179/// Internal struct used for planning compaction.
2180struct CandidateBin {
2181    pub fragments: Vec<Fragment>,
2182    pub pos_range: Range<usize>,
2183    pub candidacy: Vec<CompactionCandidacy>,
2184    pub row_counts: Vec<usize>,
2185    pub indices: Vec<usize>,
2186}
2187
2188impl CandidateBin {
2189    /// Return true if compacting these fragments wouldn't do anything.
2190    fn is_noop(&self) -> bool {
2191        if self.fragments.is_empty() {
2192            return true;
2193        }
2194        // If there's only one fragment, it's a noop if it's not CompactItself
2195        if self.fragments.len() == 1 {
2196            matches!(self.candidacy[0], CompactionCandidacy::CompactWithNeighbors)
2197        } else {
2198            false
2199        }
2200    }
2201
2202    /// Split into one or more bins with at least `min_num_rows` in them.
2203    fn split_for_size(self, min_num_rows: usize) -> Vec<Self> {
2204        let total_rows = self.row_counts.iter().sum::<usize>();
2205        let mut remaining_rows = total_rows;
2206        let mut current_rows = 0;
2207        let mut current_len = 0;
2208        let mut split_lengths = Vec::new();
2209
2210        for row_count in &self.row_counts {
2211            current_rows += *row_count;
2212            current_len += 1;
2213            remaining_rows -= *row_count;
2214
2215            // Only split once the current bin is large enough and there is
2216            // enough left over to form another worthwhile non-empty bin.
2217            if current_rows >= min_num_rows && remaining_rows > 0 && remaining_rows >= min_num_rows
2218            {
2219                split_lengths.push(current_len);
2220                current_rows = 0;
2221                current_len = 0;
2222            }
2223        }
2224
2225        if split_lengths.is_empty() {
2226            return vec![self];
2227        }
2228
2229        let mut bins = Vec::with_capacity(split_lengths.len() + 1);
2230        let mut fragments = self.fragments.into_iter();
2231        let mut candidacy = self.candidacy.into_iter();
2232        let mut row_counts = self.row_counts.into_iter();
2233        let mut pos_start = self.pos_range.start;
2234
2235        for bin_len in split_lengths {
2236            bins.push(Self {
2237                fragments: fragments.by_ref().take(bin_len).collect(),
2238                pos_range: pos_start..(pos_start + bin_len),
2239                candidacy: candidacy.by_ref().take(bin_len).collect(),
2240                row_counts: row_counts.by_ref().take(bin_len).collect(),
2241                // By the time we are splitting for size we are done considering indices
2242                indices: Vec::new(),
2243            });
2244            pos_start += bin_len;
2245        }
2246
2247        bins.push(Self {
2248            fragments: fragments.collect(),
2249            pos_range: pos_start..self.pos_range.end,
2250            candidacy: candidacy.collect(),
2251            row_counts: row_counts.collect(),
2252            indices: self.indices,
2253        });
2254
2255        bins
2256    }
2257}
2258
2259async fn load_index_fragmaps(dataset: &Dataset) -> Result<Vec<RoaringBitmap>> {
2260    // Coverage, not usability: these bitmaps decide the rewrite groups. Under
2261    // stable row ids `Transaction::recalculate_fragment_bitmap` then rejects any
2262    // group that splits an index's coverage, and it walks every index the new
2263    // manifest carries - including the ones this build cannot read. Binning from
2264    // the filtered view fails that check outright on a dataset holding an index
2265    // written by a newer Lance. The same bitmaps also decide, through
2266    // `any_group_indexed`, whether a deferred compaction writes the
2267    // fragment-reuse index that a build which can read that index needs to
2268    // repair its coverage.
2269    let indices = load_all_indices(dataset).await?;
2270    let mut index_fragmaps = Vec::with_capacity(indices.len());
2271    // System indices (fragment-reuse, mem-wal) don't define data coverage and
2272    // aren't remapped per rewrite group, so they must not constrain compaction
2273    // bins -- otherwise deferred compaction's fragment-reuse index repeatedly
2274    // splits the small-fragment run and they never coalesce.
2275    for index in indices.iter().filter(|idx| !is_system_index(idx)) {
2276        index_fragmaps.push(index_fragment_coverage(dataset, index).await?);
2277    }
2278    Ok(index_fragmaps)
2279}
2280
2281/// The fragments an index segment covers, reconstructing the coverage of a
2282/// legacy segment that predates the bitmap from the dataset it was written
2283/// against.
2284async fn index_fragment_coverage(
2285    dataset: &Dataset,
2286    index: &IndexMetadata,
2287) -> Result<RoaringBitmap> {
2288    if let Some(fragment_bitmap) = index.fragment_bitmap.as_ref() {
2289        return Ok(fragment_bitmap.clone());
2290    }
2291    let dataset_at_index = dataset.checkout_version(index.dataset_version).await?;
2292    // max_fragment_id is inclusive (the highest id); +1 for an exclusive
2293    // upper bound so the last fragment is covered (None => empty range).
2294    let frags = 0..dataset_at_index
2295        .manifest
2296        .max_fragment_id
2297        .map_or(0, |m| m + 1);
2298    let mut coverage = RoaringBitmap::from_sorted_iter(frags).unwrap();
2299    // Reconstructed in the id space of the version the index was written
2300    // against, which a later compaction has already moved on from.
2301    // `load_all_indices` puts a stored bitmap into the current space by running
2302    // it through the fragment-reuse index and leaves a `None` one alone, so a
2303    // reconstruction has to take that step itself. Skipping it names the
2304    // fragments a deferred compaction moved these rows out of, which is a set
2305    // no rewrite can intersect - the guards below then wave through the rewrite
2306    // of the fragment the rows actually live in.
2307    if let Some(frag_reuse_index) = dataset.open_frag_reuse_index(&NoOpMetricsCollector).await? {
2308        frag_reuse_index.remap_fragment_bitmap(&mut coverage)?;
2309    }
2310    Ok(coverage)
2311}
2312
2313/// Each index this build has no reader for, by name, and the fragments it covers.
2314///
2315/// A rewrite moves every row address in the fragments it touches, and putting an
2316/// index back in step means opening it. A build that cannot open one cannot
2317/// remap it, so compacting the fragments it covers would leave it addressing
2318/// rows that are gone -- worse than the erase this whole path exists to prevent.
2319/// They are held out of the plan instead, and the rest of the table still
2320/// compacts.
2321///
2322/// Only the eager remap path needs this. Stable row ids keep the addresses
2323/// across a rewrite, and `defer_index_remap` hands the repair to a build that
2324/// can read the index, through the fragment-reuse index it writes.
2325async fn unremappable_index_coverage(dataset: &Dataset) -> Result<Vec<(String, RoaringBitmap)>> {
2326    let mut coverage = Vec::new();
2327    for index in load_all_indices(dataset).await?.iter() {
2328        if index_is_usable(index) {
2329            continue;
2330        }
2331        coverage.push((
2332            index.name.clone(),
2333            index_fragment_coverage(dataset, index).await?,
2334        ));
2335    }
2336    Ok(coverage)
2337}
2338
2339/// Refuse a plan that rewrites fragments an index this build cannot read covers.
2340///
2341/// [`DefaultCompactionPlanner`] keeps those fragments out of the plan, but
2342/// nothing forces a caller through it: `compact_files_with_planner` takes any
2343/// planner, [`CompactionPlan`] is public and serializable, and a distributed
2344/// driver hands [`commit_compaction`] results planned elsewhere. Committing such
2345/// a plan strands the index on fragment ids the rewrite deleted, so the commit
2346/// boundary refuses it rather than the planner alone.
2347async fn reject_unremappable_rewrite(
2348    dataset: &Dataset,
2349    completed_tasks: &[RewriteResult],
2350) -> Result<()> {
2351    let rewritten = completed_tasks
2352        .iter()
2353        .flat_map(|task| task.original_fragments.iter())
2354        .filter_map(|fragment| u32::try_from(fragment.id).ok())
2355        .collect::<RoaringBitmap>();
2356
2357    for (name, covered) in unremappable_index_coverage(dataset).await? {
2358        let blocked = covered & &rewritten;
2359        if !blocked.is_empty() {
2360            return Err(Error::invalid_input(format!(
2361                "compaction would rewrite fragment(s) {:?}, which index {:?} covers. This build \
2362                 has no reader for that index, so it cannot be remapped onto the rewritten \
2363                 fragments and the commit would leave it addressing rows that no longer exist. \
2364                 Plan with DefaultCompactionPlanner, which holds those fragments back, set \
2365                 defer_index_remap, or compact from a build that can read the index.",
2366                blocked.iter().collect::<Vec<_>>(),
2367                name,
2368            )));
2369        }
2370    }
2371    Ok(())
2372}
2373
2374pub async fn plan_compaction(
2375    dataset: &Dataset,
2376    options: &CompactionOptions,
2377) -> Result<CompactionPlan> {
2378    let planner = DefaultCompactionPlanner::new(options.clone())?;
2379    planner.plan(dataset).await
2380}
2381
2382/// The result of a single compaction task.
2383///
2384/// This should be passed to [commit_compaction()] to commit the operation.
2385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2386pub struct RewriteResult {
2387    pub metrics: CompactionMetrics,
2388    pub new_fragments: Vec<Fragment>,
2389    /// The version of the dataset that was read to perform this compaction.
2390    pub read_version: u64,
2391    /// The original fragments being replaced
2392    pub original_fragments: Vec<Fragment>,
2393    /// Serialized `RoaringTreemap` of the row addresses from the original
2394    /// fragments that were read during compaction.
2395    ///
2396    /// - `None` when configured with stable row IDs because the row ID
2397    ///   sequences are rechunked directly.
2398    /// - `Some` then these addresses are either (1) written to storage for
2399    ///   deferred index remap post-processing, or (2) used with reserved
2400    ///   fragment IDs to build old-to-new mappings.
2401    pub row_addrs: Option<Vec<u8>>,
2402}
2403
2404async fn reserve_fragment_ids(
2405    dataset: &Dataset,
2406    fragments: impl ExactSizeIterator<Item = &mut Fragment>,
2407) -> Result<()> {
2408    let transaction = Transaction::new(
2409        dataset.manifest.version,
2410        Operation::ReserveFragments {
2411            num_fragments: fragments.len() as u32,
2412        },
2413        None,
2414    );
2415
2416    let (manifest, _) = commit_transaction(
2417        dataset,
2418        dataset.object_store.as_ref(),
2419        dataset.commit_handler.as_ref(),
2420        &transaction,
2421        &Default::default(),
2422        &Default::default(),
2423        default_commit_retry_timeout(),
2424        dataset.manifest_location.naming_scheme,
2425        None,
2426    )
2427    .await?;
2428
2429    // Need +1 since max_fragment_id is inclusive in this case and ranges are exclusive
2430    let new_max_exclusive = manifest.max_fragment_id.unwrap_or(0) + 1;
2431    let reserved_ids = (new_max_exclusive - fragments.len() as u32)..(new_max_exclusive);
2432
2433    for (fragment, new_id) in fragments.zip(reserved_ids) {
2434        fragment.id = new_id as u64;
2435    }
2436
2437    Ok(())
2438}
2439
2440/// Rewrite the files in a single task.
2441///
2442/// This assumes that the dataset is the correct read version to be compacted.
2443async fn rewrite_files(
2444    dataset: Cow<'_, Dataset>,
2445    task: TaskData,
2446    options: &CompactionOptions,
2447) -> Result<RewriteResult> {
2448    let mut metrics = CompactionMetrics::default();
2449    let write_version = options.write_version(dataset.as_ref());
2450    versions::validate_write_version(
2451        dataset.manifest.data_storage_format.lance_file_format(),
2452        write_version,
2453    )?;
2454
2455    if task.fragments.is_empty() {
2456        return Ok(RewriteResult {
2457            metrics,
2458            new_fragments: Vec::new(),
2459            read_version: dataset.manifest.version,
2460            original_fragments: task.fragments,
2461            row_addrs: None,
2462        });
2463    }
2464
2465    let previous_writer_version = &dataset.manifest.writer_version;
2466    // The versions of Lance prior to when we started writing the writer version
2467    // sometimes wrote incorrect `Fragment.physical_rows` values, so we should
2468    // make sure to recompute them.
2469    // See: https://github.com/lance-format/lance/issues/1531
2470    let recompute_stats = previous_writer_version.is_none();
2471
2472    // It's possible the fragments are old and don't have physical rows or
2473    // num deletions recorded. If that's the case, we need to grab and set that
2474    // information.
2475    let fragments = migrate_fragments(dataset.as_ref(), &task.fragments, recompute_stats).await?;
2476    let num_rows = fragments
2477        .iter()
2478        .map(|f| f.physical_rows.unwrap() as u64)
2479        .sum::<u64>();
2480    // Capturing row addresses is only useful if something will consume them:
2481    // an index to remap now, or a deferred remap through the FRI.
2482    let capture_row_addrs = !dataset.manifest.uses_stable_row_ids()
2483        && (options.defer_index_remap
2484            || load_indices_for_remapping(dataset.as_ref())
2485                .await?
2486                .is_some());
2487    let mut new_fragments: Vec<Fragment>;
2488    let task_id = uuid::Uuid::new_v4();
2489    log::info!(
2490        "Compaction task {}: Begin compacting {} rows across {} fragments",
2491        task_id,
2492        num_rows,
2493        fragments.len()
2494    );
2495    let mode = options.compaction_mode();
2496    let can_binary_copy = can_use_binary_copy(dataset.as_ref(), options, &fragments).await;
2497    if !can_binary_copy && matches!(mode, CompactionMode::ForceBinaryCopy) {
2498        if let Some((path, actual)) = first_binary_copy_version_mismatch(&fragments, write_version)?
2499        {
2500            return Err(Error::not_supported_source(
2501                format!(
2502                    "compaction task {task_id}: binary copy target is {write_version}, but data file '{path}' uses {actual}"
2503                )
2504                .into(),
2505            ));
2506        }
2507        return Err(Error::not_supported_source(
2508            format!("compaction task {}: binary copy is not supported", task_id).into(),
2509        ));
2510    }
2511    let mut row_ids_rx: Option<std::sync::mpsc::Receiver<CapturedRowIds>> = None;
2512    let mut reader: Option<SendableRecordBatchStream> = None;
2513
2514    if !can_binary_copy {
2515        let (prepared_reader, rx_initial, has_blob_v2_columns) = prepare_reader(
2516            dataset.as_ref(),
2517            &fragments,
2518            options.batch_size,
2519            options.io_buffer_size,
2520            true,
2521            capture_row_addrs,
2522        )
2523        .await?;
2524        row_ids_rx = rx_initial;
2525
2526        let mut rows_read = 0;
2527        let schema = prepared_reader.schema();
2528        let reader_with_progress = prepared_reader.inspect_ok(move |batch| {
2529            rows_read += batch.num_rows();
2530            log::info!(
2531                "Compaction task {}: Read progress {}/{}",
2532                task_id,
2533                rows_read,
2534                num_rows,
2535            );
2536        });
2537
2538        if has_blob_v2_columns {
2539            let dataset_arc = Arc::new(dataset.as_ref().clone());
2540            let rewrite_plan = Arc::new(BlobV2BatchRewritePlan::try_new(
2541                dataset.schema(),
2542                schema.as_ref(),
2543                false,
2544            )?);
2545            let transformed_schema = rewrite_plan.output_schema.clone();
2546            let transformed = reader_with_progress.then(move |batch_result| {
2547                let dataset = dataset_arc.clone();
2548                let rewrite_plan = rewrite_plan.clone();
2549                async move {
2550                    let batch = batch_result?;
2551                    rewrite_plan
2552                        .transform_batch(&dataset, batch)
2553                        .await
2554                        .map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))
2555                }
2556            });
2557            reader = Some(Box::pin(RecordBatchStreamAdapter::new(
2558                transformed_schema,
2559                transformed,
2560            )));
2561        } else {
2562            reader = Some(Box::pin(RecordBatchStreamAdapter::new(
2563                schema,
2564                reader_with_progress,
2565            )));
2566        }
2567    }
2568
2569    let surviving_rows = fragments.iter().try_fold(0_u64, |total, fragment| {
2570        let fragment_rows = fragment.num_rows().ok_or_else(|| {
2571            Error::internal(format!(
2572                "Fragment {} is missing row count metadata after migration",
2573                fragment.id
2574            ))
2575        })?;
2576        total.checked_add(fragment_rows as u64).ok_or_else(|| {
2577            Error::internal("Compaction task surviving row count overflowed u64".to_string())
2578        })
2579    })?;
2580
2581    // Planner-sized tasks may exceed the target, but should remain one output
2582    // instead of producing a target-sized fragment plus a stranded tail. For
2583    // genuinely oversized tasks, choose a target-scale output count and spread
2584    // the tail across those outputs.
2585    let target_rows_per_fragment = options.target_rows_per_fragment as u64;
2586    let output_fragment_count = surviving_rows
2587        .checked_div(target_rows_per_fragment)
2588        .unwrap_or(1)
2589        .max(1);
2590    let output_fragment_count_usize = usize::try_from(output_fragment_count).map_err(|_| {
2591        Error::internal(format!(
2592            "Compaction output fragment count {output_fragment_count} does not fit in usize"
2593        ))
2594    })?;
2595    let base_rows_per_file = surviving_rows / output_fragment_count;
2596    let larger_file_count =
2597        usize::try_from(surviving_rows % output_fragment_count).map_err(|_| {
2598            Error::internal("Compaction larger output fragment count does not fit in usize")
2599        })?;
2600    let file_row_counts = if surviving_rows == 0 {
2601        Vec::new()
2602    } else {
2603        (0..output_fragment_count_usize)
2604            .map(|file_index| {
2605                let file_rows = base_rows_per_file + u64::from(file_index < larger_file_count);
2606                usize::try_from(file_rows).map_err(|_| {
2607                    Error::internal(format!(
2608                        "Compaction output row count {file_rows} does not fit in usize"
2609                    ))
2610                })
2611            })
2612            .collect::<Result<Vec<_>>>()?
2613    };
2614    let max_rows_per_file = file_row_counts.first().copied().unwrap_or(1);
2615    let mut params = WriteParams {
2616        max_rows_per_file,
2617        max_rows_per_group: options.max_rows_per_group,
2618        mode: WriteMode::Append,
2619        // External blobs may reference URIs outside the dataset's base_paths
2620        // (e.g. absolute file:// URIs with base_id == 0). Without this flag
2621        // the writer would reject such blobs.
2622        allow_external_blob_outside_bases: true,
2623        data_storage_version: Some(write_version.to_selector()),
2624        ..Default::default()
2625    };
2626    if let Some(max_bytes_per_file) = options.max_bytes_per_file {
2627        params.max_bytes_per_file = max_bytes_per_file;
2628    }
2629
2630    if dataset.manifest.uses_stable_row_ids() {
2631        params.enable_stable_row_ids = true;
2632    }
2633
2634    let lineage_plan = if can_binary_copy {
2635        new_fragments = versions::rewrite_files_binary_copy(
2636            write_version,
2637            dataset.as_ref(),
2638            &fragments,
2639            &params,
2640            options.binary_copy_read_batch_bytes,
2641        )
2642        .await?;
2643
2644        if new_fragments.is_empty() && matches!(mode, CompactionMode::ForceBinaryCopy) {
2645            return Err(Error::not_supported_source(
2646                format!("compaction task {}: binary copy is not supported", task_id).into(),
2647            ));
2648        }
2649
2650        if capture_row_addrs {
2651            let (tx, rx) = std::sync::mpsc::channel();
2652            let mut addrs = RoaringTreemap::new();
2653            for frag in &fragments {
2654                let frag_id = frag.id as u32;
2655                let count = u64::try_from(frag.physical_rows.unwrap_or(0)).map_err(|_| {
2656                    Error::internal(format!(
2657                        "Fragment {} has too many physical rows to represent as row addresses",
2658                        frag.id
2659                    ))
2660                })?;
2661                let start = u64::from(lance_core::utils::address::RowAddress::first_row(frag_id));
2662                addrs.insert_range(start..start + count);
2663            }
2664            let captured = CapturedRowIds::AddressStyle(addrs);
2665            let _ = tx.send(captured);
2666            row_ids_rx = Some(rx);
2667        }
2668        None
2669    } else {
2670        let mut stream = reader.expect("reader must be prepared for non-binary-copy path");
2671        let mut write_schema = dataset.schema().clone();
2672        // On a table that can spill, the output fragments' lineage is computed
2673        // before anything is written: the row ids and versions carry over from
2674        // the inputs, so a sequence that leaves the manifest can ride along as
2675        // a hidden column of the file being written rather than need a file of
2676        // its own. Every other table carries its lineage over after the write,
2677        // cut at the row counts actually written, and holds none of it while
2678        // writing. Binary copy cannot add columns to the files it copies, so
2679        // it always takes that path.
2680        let spill_budget = if dataset.manifest.uses_stable_row_ids() {
2681            inline_row_lineage_max_bytes(dataset.as_ref())?
2682        } else {
2683            None
2684        };
2685        let lineage_plan = match spill_budget {
2686            Some(limit) => {
2687                let planned_rows = file_row_counts
2688                    .iter()
2689                    .map(|rows| *rows as u64)
2690                    .collect::<Vec<_>>();
2691                let mut lineages =
2692                    compute_row_lineage(dataset.as_ref(), &fragments, &planned_rows).await?;
2693                let plan = plan_row_lineage_spill(limit, &lineages);
2694                if let RowLineagePlan::InFile(spill) = plan
2695                    && spill.any()
2696                {
2697                    let fields = spill.schema_fields()?;
2698                    let columns = spill.take_columns(&mut lineages);
2699                    stream = append_row_lineage_columns(stream, &fields, columns);
2700                    // The writer stores only the columns its schema lists.
2701                    write_schema.fields.extend(fields);
2702                }
2703                Some(PlannedRowLineage {
2704                    lineages,
2705                    planned_rows,
2706                    plan,
2707                })
2708            }
2709            None => None,
2710        };
2711        let (frags, _) = write_fragments_internal_with_file_row_counts(
2712            write_version,
2713            Some(dataset.as_ref()),
2714            dataset.object_store.clone(),
2715            &dataset.base,
2716            write_schema,
2717            stream,
2718            params,
2719            None,
2720            Some(file_row_counts),
2721        )
2722        .await?;
2723        new_fragments = frags;
2724        lineage_plan
2725    };
2726
2727    log::info!("Compaction task {}: file written", task_id);
2728
2729    // Wrap in an async block so `?` returns into `row_addrs_result` and we can
2730    // run cleanup before propagating the error.
2731    let row_addrs_result: Result<Option<Vec<u8>>> = async {
2732        if let Some(row_ids_rx) = row_ids_rx {
2733            let captured_ids = row_ids_rx
2734                .try_recv()
2735                .map_err(|err| Error::internal(format!("Failed to receive row ids: {}", err)))?;
2736            let mut row_addrs = captured_ids.row_addrs(None)?.into_owned();
2737            // Compaction reads whole fragments, so the captured addresses are
2738            // dense per-fragment ranges; run containers (standard roaring
2739            // format) shrink the persisted blob from O(rows) to O(runs) bytes.
2740            row_addrs.optimize();
2741            let mut serialized = Vec::with_capacity(row_addrs.serialized_size());
2742            row_addrs.serialize_into(&mut serialized)?;
2743            Ok(Some(serialized))
2744        } else {
2745            if dataset.manifest.uses_stable_row_ids() {
2746                log::info!("Compaction task {}: rechunking stable row ids", task_id);
2747                match lineage_plan {
2748                    Some(planned) => {
2749                        place_planned_row_lineage(dataset.as_ref(), &mut new_fragments, planned)
2750                            .await?
2751                    }
2752                    None => {
2753                        rechunk_row_lineage(dataset.as_ref(), &mut new_fragments, &fragments)
2754                            .await?
2755                    }
2756                }
2757            }
2758            Ok(None)
2759        }
2760    }
2761    .await;
2762
2763    let row_addrs = match row_addrs_result {
2764        Ok(v) => v,
2765        Err(e) => {
2766            cleanup_data_fragments(&dataset.object_store, &dataset.base, None, &new_fragments)
2767                .await;
2768            return Err(e);
2769        }
2770    };
2771
2772    metrics.files_removed = task
2773        .fragments
2774        .iter()
2775        .map(|f| f.files.len() + f.deletion_file.is_some() as usize)
2776        .sum();
2777    metrics.fragments_removed = task.fragments.len();
2778    metrics.fragments_added = new_fragments.len();
2779    metrics.files_added = new_fragments
2780        .iter()
2781        .map(|f| f.files.len() + f.deletion_file.is_some() as usize)
2782        .sum();
2783
2784    log::info!("Compaction task {}: completed", task_id);
2785
2786    Ok(RewriteResult {
2787        metrics,
2788        new_fragments,
2789        read_version: dataset.manifest.version,
2790        original_fragments: fragments,
2791        row_addrs,
2792    })
2793}
2794
2795/// Carry the stable row ids and per-row versions of `old_fragments` over to
2796/// output fragments of `chunk_sizes` rows each, in order, which hold the same
2797/// live rows in the same order.
2798async fn compute_row_lineage(
2799    dataset: &Dataset,
2800    old_fragments: &[Fragment],
2801    chunk_sizes: &[u64],
2802) -> Result<Vec<RowLineage>> {
2803    let mut old_sequences = load_row_id_sequences(dataset, old_fragments)
2804        .try_collect::<Vec<_>>()
2805        .await?;
2806    // Should sort them back into original order.
2807    old_sequences.sort_by_key(|(frag_id, _)| {
2808        old_fragments
2809            .iter()
2810            .position(|frag| frag.id as u32 == *frag_id)
2811            .expect("Fragment not found")
2812    });
2813
2814    // Load old per-row version sequences, defaulting the way readers do:
2815    // created-at to version 1, last-updated-at to created-at.
2816    let mut old_created_at_sequences = Vec::with_capacity(old_fragments.len());
2817    let mut old_last_updated_sequences = Vec::with_capacity(old_fragments.len());
2818    for (frag, (_, row_ids)) in old_fragments.iter().zip(old_sequences.iter()) {
2819        let created_at =
2820            match load_row_version_sequence(dataset, frag, RowVersionKind::CreatedAt).await? {
2821                Some(sequence) => sequence.as_ref().clone(),
2822                None => RowDatasetVersionSequence::from_uniform_row_count(row_ids.len(), 1),
2823            };
2824        let last_updated_at =
2825            match load_row_version_sequence(dataset, frag, RowVersionKind::LastUpdatedAt).await? {
2826                Some(sequence) => sequence.as_ref().clone(),
2827                None => created_at.clone(),
2828            };
2829        old_created_at_sequences.push(created_at);
2830        old_last_updated_sequences.push(last_updated_at);
2831    }
2832
2833    // Need to remove deleted rows
2834    for (index, frag) in old_fragments.iter().enumerate() {
2835        if let Some(deletion_file) = &frag.deletion_file {
2836            let deletions = read_dataset_deletion_file(dataset, frag.id, deletion_file).await?;
2837
2838            let mut new_seq = old_sequences[index].1.as_ref().clone();
2839            new_seq.mask(deletions.to_sorted_iter())?;
2840            old_sequences[index].1 = Arc::new(new_seq);
2841            old_created_at_sequences[index].mask(deletions.to_sorted_iter())?;
2842            old_last_updated_sequences[index].mask(deletions.to_sorted_iter())?;
2843        }
2844    }
2845
2846    debug_assert_eq!(
2847        { old_sequences.iter().map(|(_, seq)| seq.len()).sum::<u64>() },
2848        { chunk_sizes.iter().sum::<u64>() },
2849        "{:?}",
2850        old_sequences
2851    );
2852
2853    let new_row_ids = lance_table::rowids::rechunk_sequences(
2854        old_sequences
2855            .into_iter()
2856            .map(|(_, seq)| seq.as_ref().clone()),
2857        chunk_sizes.iter().copied(),
2858        false,
2859    )?;
2860    let new_created_at = lance_table::rowids::version::rechunk_version_sequences(
2861        old_created_at_sequences,
2862        chunk_sizes.iter().copied(),
2863        false,
2864    )?;
2865    let new_last_updated_at = lance_table::rowids::version::rechunk_version_sequences(
2866        old_last_updated_sequences,
2867        chunk_sizes.iter().copied(),
2868        false,
2869    )?;
2870
2871    Ok(new_row_ids
2872        .into_iter()
2873        .zip(new_created_at)
2874        .zip(new_last_updated_at)
2875        .map(|((row_ids, created_at), last_updated_at)| RowLineage {
2876            row_ids,
2877            created_at,
2878            last_updated_at,
2879        })
2880        .collect())
2881}
2882
2883/// Carry the lineage of `old_fragments` over to the already written
2884/// `new_fragments` and place each one's sequences inline or in a separate
2885/// lineage file, as the table's spill policy and their size call for. This is
2886/// the path for tables that cannot spill, and for binary copy, whose output
2887/// files cannot take the lineage as extra columns.
2888async fn rechunk_row_lineage(
2889    dataset: &Dataset,
2890    new_fragments: &mut [Fragment],
2891    old_fragments: &[Fragment],
2892) -> Result<()> {
2893    let chunk_sizes: Vec<u64> = new_fragments
2894        .iter()
2895        .map(|frag| frag.physical_rows.unwrap() as u64)
2896        .collect();
2897    let lineages = compute_row_lineage(dataset, old_fragments, &chunk_sizes).await?;
2898    for (fragment, lineage) in new_fragments.iter_mut().zip(lineages) {
2899        place_row_lineage(dataset, &lineage).await?.apply(fragment);
2900    }
2901    Ok(())
2902}
2903
2904/// The row lineage a compaction task computed for its planned output
2905/// fragments before writing them, and how the task places it.
2906struct PlannedRowLineage {
2907    /// Each planned output fragment's lineage, in order. The sequences the
2908    /// plan writes into the data files are empty: their values went into the
2909    /// written columns.
2910    lineages: Vec<RowLineage>,
2911    /// Each planned output fragment's row count, which the writer only
2912    /// follows until a byte limit closes a file early.
2913    planned_rows: Vec<u64>,
2914    plan: RowLineagePlan,
2915}
2916
2917/// Place the lineage compaction planned before the write on the fragments it
2918/// wrote.
2919async fn place_planned_row_lineage(
2920    dataset: &Dataset,
2921    new_fragments: &mut [Fragment],
2922    planned: PlannedRowLineage,
2923) -> Result<()> {
2924    let PlannedRowLineage {
2925        lineages,
2926        planned_rows,
2927        plan,
2928    } = planned;
2929    match plan {
2930        RowLineagePlan::InFile(spill) => {
2931            place_row_lineage_in_files(new_fragments, lineages, &planned_rows, spill)
2932        }
2933        RowLineagePlan::PerFragment => {
2934            let lineages = fit_row_lineage_to_written(
2935                new_fragments,
2936                lineages,
2937                &planned_rows,
2938                RowLineageSpill::default(),
2939            )?;
2940            for (fragment, lineage) in new_fragments.iter_mut().zip(lineages) {
2941                place_row_lineage(dataset, &lineage).await?.apply(fragment);
2942            }
2943            Ok(())
2944        }
2945    }
2946}
2947
2948/// Mark each written fragment's spilled sequences as living in its own data
2949/// file, which was written with them as hidden columns, and place the rest
2950/// inline.
2951fn place_row_lineage_in_files(
2952    new_fragments: &mut [Fragment],
2953    lineages: Vec<RowLineage>,
2954    planned_rows: &[u64],
2955    spill: RowLineageSpill,
2956) -> Result<()> {
2957    let lineages = fit_row_lineage_to_written(new_fragments, lineages, planned_rows, spill)?;
2958    for (fragment, lineage) in new_fragments.iter_mut().zip(&lineages) {
2959        if spill.any() {
2960            let [file] = fragment.files.as_slice() else {
2961                return Err(Error::internal(format!(
2962                    "compaction wrote {} data files for fragment {}; the row lineage columns \
2963                     are in exactly one",
2964                    fragment.files.len(),
2965                    fragment.id
2966                )));
2967            };
2968            let missing = spill
2969                .field_ids()
2970                .find(|field_id| !file.fields.contains(field_id));
2971            if let Some(field_id) = missing {
2972                return Err(Error::internal(format!(
2973                    "compaction wrote fragment {}'s data file {} without row lineage field {}",
2974                    fragment.id, file.path, field_id
2975                )));
2976            }
2977        }
2978        spill.place_in_file(lineage).apply(fragment);
2979    }
2980    Ok(())
2981}
2982
2983/// Fit the lineage planned for output fragments of `planned_rows` rows each to
2984/// the fragments the writer actually produced, one lineage per fragment.
2985///
2986/// A byte limit can close a file before its planned row count, after which the
2987/// writer spreads the remaining rows over files of other sizes, so the
2988/// sequences kept inline are cut again at the written row counts. The types
2989/// `in_file` names need nothing: their columns were appended to the written
2990/// rows one by one, so they follow any split, and their sequences are empty.
2991///
2992/// The in-file plan was made for the planned sizes. A written fragment is
2993/// never larger than the largest planned one, but it can join the tail of one
2994/// planned fragment to the head of the next, so a sequence that plan keeps
2995/// inline can end up over the budget, up to about twice it. That is acceptable
2996/// because the budget only bounds manifest growth and is not a format limit.
2997/// The per-fragment plan decides again on the cut sequences.
2998fn fit_row_lineage_to_written(
2999    new_fragments: &[Fragment],
3000    lineages: Vec<RowLineage>,
3001    planned_rows: &[u64],
3002    in_file: RowLineageSpill,
3003) -> Result<Vec<RowLineage>> {
3004    let written_rows = new_fragments
3005        .iter()
3006        .map(|fragment| match fragment.physical_rows {
3007            Some(rows) => Ok(rows as u64),
3008            None => Err(Error::internal(format!(
3009                "compaction wrote fragment {} without a physical row count",
3010                fragment.id
3011            ))),
3012        })
3013        .collect::<Result<Vec<_>>>()?;
3014    let planned_total = planned_rows.iter().sum::<u64>();
3015    let written_total = written_rows.iter().sum::<u64>();
3016    if written_total != planned_total {
3017        return Err(Error::internal(format!(
3018            "compaction wrote {written_total} rows, in fragments of {written_rows:?} rows, \
3019             but planned row lineage for {planned_total} rows, in fragments of \
3020             {planned_rows:?} rows"
3021        )));
3022    }
3023
3024    let lineages = if written_rows.as_slice() == planned_rows {
3025        lineages
3026    } else {
3027        let cut_error = |error: Error| {
3028            Error::internal(format!(
3029                "compaction could not cut the row lineage planned for fragments of \
3030                 {planned_rows:?} rows at the written fragments of {written_rows:?} rows: \
3031                 {error}"
3032            ))
3033        };
3034        let mut row_ids = Vec::with_capacity(lineages.len());
3035        let mut created_at = Vec::with_capacity(lineages.len());
3036        let mut last_updated_at = Vec::with_capacity(lineages.len());
3037        for lineage in lineages {
3038            row_ids.push(lineage.row_ids);
3039            created_at.push(lineage.created_at);
3040            last_updated_at.push(lineage.last_updated_at);
3041        }
3042        let fragment_count = written_rows.len();
3043        let written = || written_rows.iter().copied();
3044        let row_ids = if in_file.row_ids {
3045            vec![RowIdSequence::new(); fragment_count]
3046        } else {
3047            lance_table::rowids::rechunk_sequences(row_ids, written(), false).map_err(cut_error)?
3048        };
3049        let created_at = if in_file.created_at {
3050            vec![RowDatasetVersionSequence::new(); fragment_count]
3051        } else {
3052            lance_table::rowids::version::rechunk_version_sequences(created_at, written(), false)
3053                .map_err(cut_error)?
3054        };
3055        let last_updated_at = if in_file.last_updated_at {
3056            vec![RowDatasetVersionSequence::new(); fragment_count]
3057        } else {
3058            lance_table::rowids::version::rechunk_version_sequences(
3059                last_updated_at,
3060                written(),
3061                false,
3062            )
3063            .map_err(cut_error)?
3064        };
3065        row_ids
3066            .into_iter()
3067            .zip(created_at)
3068            .zip(last_updated_at)
3069            .map(|((row_ids, created_at), last_updated_at)| RowLineage {
3070                row_ids,
3071                created_at,
3072                last_updated_at,
3073            })
3074            .collect::<Vec<_>>()
3075    };
3076
3077    if lineages.len() != new_fragments.len() {
3078        return Err(Error::internal(format!(
3079            "compaction has row lineage for {} fragments but wrote {}",
3080            lineages.len(),
3081            new_fragments.len()
3082        )));
3083    }
3084    for ((fragment, &rows), lineage) in new_fragments.iter().zip(&written_rows).zip(&lineages) {
3085        let inline_lengths = [
3086            (in_file.row_ids, "row ids", lineage.row_ids.len()),
3087            (
3088                in_file.created_at,
3089                "created-at versions",
3090                lineage.created_at.len(),
3091            ),
3092            (
3093                in_file.last_updated_at,
3094                "last-updated-at versions",
3095                lineage.last_updated_at.len(),
3096            ),
3097        ];
3098        let mismatch = inline_lengths
3099            .into_iter()
3100            .find(|&(is_in_file, _, len)| !is_in_file && len != rows);
3101        if let Some((_, sequence, len)) = mismatch {
3102            return Err(Error::internal(format!(
3103                "compaction has {len} inline {sequence} for fragment {} of {rows} physical rows",
3104                fragment.id
3105            )));
3106        }
3107    }
3108    Ok(lineages)
3109}
3110
3111/// Append the hidden row lineage columns, `fields` with the matching entry of
3112/// `columns` as each one's values, to the batches of `stream`, in row order,
3113/// so the file writer stores them next to the user columns.
3114fn append_row_lineage_columns(
3115    stream: SendableRecordBatchStream,
3116    fields: &[LanceField],
3117    columns: Vec<Vec<u64>>,
3118) -> SendableRecordBatchStream {
3119    let mut arrow_fields = stream.schema().fields().to_vec();
3120    arrow_fields.extend(fields.iter().map(|field| Arc::new(ArrowField::from(field))));
3121    let schema = Arc::new(ArrowSchema::new(arrow_fields));
3122    let total_rows = columns.first().map_or(0, |values| values.len());
3123    let batch_schema = schema.clone();
3124    let mut offset = 0usize;
3125    let batches = stream.map(move |batch| {
3126        let batch = batch?;
3127        let rows = batch.num_rows();
3128        if offset + rows > total_rows {
3129            return Err(datafusion::error::DataFusionError::External(Box::new(
3130                Error::internal(format!(
3131                    "compaction read more rows than its row lineage covers ({total_rows})"
3132                )),
3133            )));
3134        }
3135        let mut arrays = batch.columns().to_vec();
3136        // Each batch gets a buffer of its own. A slice would share the whole
3137        // task's buffer, and the writer, which sizes pages by the memory an
3138        // array holds, would then cut a tiny page for every batch.
3139        for values in &columns {
3140            let batch_values = values[offset..offset + rows].to_vec();
3141            arrays.push(Arc::new(arrow_array::UInt64Array::from(batch_values)) as ArrayRef);
3142        }
3143        offset += rows;
3144        RecordBatch::try_new(batch_schema.clone(), arrays)
3145            .map_err(datafusion::error::DataFusionError::from)
3146    });
3147    Box::pin(RecordBatchStreamAdapter::new(schema, batches))
3148}
3149
3150/// Commit the results of file compaction.
3151///
3152/// It is not required that all tasks are passed to this method. If some failed,
3153/// they can be omitted and the successful tasks can be committed. However, once
3154/// some of the tasks have been committed, the remainder of the tasks will not
3155/// be able to be committed and should be considered cancelled.
3156pub async fn commit_compaction(
3157    dataset: &mut Dataset,
3158    completed_tasks: Vec<RewriteResult>,
3159    remap_options: Arc<dyn IndexRemapperOptions>,
3160    options: &CompactionOptions,
3161) -> Result<CompactionMetrics> {
3162    if completed_tasks.is_empty() {
3163        return Ok(CompactionMetrics::default());
3164    }
3165
3166    // A tagged FRI history stores row translation in the manifest entry and
3167    // depends on index bitmaps keeping their retired provenance, so a
3168    // compaction on a tagged table can only commit by appending transitions
3169    // (the deferred path below). Refuse the eager path up front, before any
3170    // remapping work runs into the commit gate.
3171    let tagged_fri = load_all_indices(dataset)
3172        .await?
3173        .iter()
3174        .any(lance_table::system_index::frag_reuse::metadata::is_tagged);
3175    if tagged_fri && !options.defer_index_remap {
3176        return Err(Error::not_supported(
3177            "Compacting a table with a tagged fragment reuse history requires \
3178             defer_index_remap; set defer_index_remap, or upgrade to a writer that \
3179             remaps tagged histories eagerly",
3180        ));
3181    }
3182
3183    // Before anything is written or committed. The condition is the planner's,
3184    // not `has_address_style`: a dataset whose only index is one this build
3185    // cannot read captures no row addresses at all, which is exactly the plan
3186    // that has to be refused here.
3187    if !dataset.manifest.uses_stable_row_ids() && !options.defer_index_remap {
3188        reject_unremappable_rewrite(dataset, &completed_tasks).await?;
3189    }
3190
3191    let has_address_style = completed_tasks.iter().any(|t| t.row_addrs.is_some());
3192    // Address-style results require immediate index remapping unless it is deferred.
3193    let needs_remapping =
3194        !dataset.manifest.uses_stable_row_ids() && !options.defer_index_remap && has_address_style;
3195
3196    // Confirm there is a remapper before materializing the potentially very large row address map.
3197    let index_remapper = if needs_remapping {
3198        remap_options.create_remapper(dataset).await?
3199    } else {
3200        None
3201    };
3202
3203    // Determine the earliest version at which compaction tasks were planned/executed.
3204    //
3205    // In distributed mode (e.g. Spark) the caller opens *two separate* Dataset
3206    // handles: one for `plan_compaction` (at version V) and a fresh one for
3207    // `commit_compaction` (at the latest version V+N).  Using `dataset.manifest.version`
3208    // (= V+N) as the transaction's `read_version` would cause the conflict checker to
3209    // scan only versions after V+N — finding nothing — and therefore silently skip any
3210    // concurrent DELETE/UPDATE that landed between V and V+N, resurrecting deleted rows.
3211    //
3212    // By anchoring `read_version` to the minimum version carried in the RewriteResults
3213    // we ensure the conflict checker covers the full range [V, V+N] and will reject the
3214    // commit with a retryable conflict error if a concurrent write touched the same
3215    // fragments.
3216    let tasks_read_version = completed_tasks
3217        .iter()
3218        .map(|t| t.read_version)
3219        .min()
3220        .unwrap_or(dataset.manifest.version);
3221
3222    let mut completed_tasks = completed_tasks;
3223
3224    // Collect the rewritten fragments' file paths up front so every failure
3225    // path below can clean them up (or deliberately keep them). Fragment ids
3226    // may still be reassigned by reserve_fragment_ids; cleanup only needs the
3227    // file paths, which never change.
3228    let all_new_fragments: Vec<Fragment> = completed_tasks
3229        .iter()
3230        .flat_map(|t| t.new_fragments.iter().cloned())
3231        .collect();
3232
3233    // Single reserve_fragment_ids for all address-style tasks
3234    if has_address_style {
3235        let frags: Vec<&mut Fragment> = completed_tasks
3236            .iter_mut()
3237            .filter(|t| t.row_addrs.is_some())
3238            .flat_map(|t| t.new_fragments.iter_mut())
3239            .collect();
3240        if let Err(e) = reserve_fragment_ids(dataset, frags.into_iter()).await {
3241            cleanup_compaction_files_after_reservation_failure(dataset, &all_new_fragments).await;
3242            return Err(e);
3243        }
3244    }
3245
3246    let mut rewrite_groups = Vec::with_capacity(completed_tasks.len());
3247    let mut metrics = CompactionMetrics::default();
3248
3249    let mut remap_group_inputs: Vec<GroupInput> = Vec::new();
3250    let mut direct_row_id_map: HashMap<u64, Option<u64>> = HashMap::default();
3251    let mut frag_reuse_groups: Vec<FragReuseGroup> = Vec::new();
3252    let mut new_fragment_bitmap: RoaringBitmap = RoaringBitmap::new();
3253
3254    // Write an FRI only when the compaction touches data an index must later
3255    // remap: a rewrite group covered by a data index, or by the existing FRI's new
3256    // fragments (the composed remap chain). Compacting only not-yet-indexed data
3257    // needs no FRI (one written for it is un-drainable). Decide all-or-nothing per
3258    // compaction, never per group -- a partial FRI is unsound: a concurrent reindex
3259    // can make a skipped fragment indexed and the conflict resolver's FRI-present
3260    // path won't re-check it.
3261    //
3262    // The same rule applies on tagged tables: a transition for fragments no
3263    // index covers and no lineage reaches would grow the history for rows no
3264    // reader ever has to translate, with no trim to reclaim it; when nothing
3265    // is covered the commit is a plain rewrite, which the tagged-table gate
3266    // admits exactly for this uncovered shape.
3267    let indexed_frags: RoaringBitmap = if options.defer_index_remap {
3268        let mut covered = RoaringBitmap::new();
3269        for bm in load_index_fragmaps(dataset).await? {
3270            covered |= bm;
3271        }
3272        // The stored listing works for v0 and tagged entries alike (the
3273        // filtered listing post-processes coverage on tagged tables).
3274        if let Some(bm) = load_all_indices(dataset)
3275            .await?
3276            .iter()
3277            .find(|idx| idx.name == FRAG_REUSE_INDEX_NAME)
3278            .and_then(|fri| fri.fragment_bitmap.clone())
3279        {
3280            covered |= bm;
3281        }
3282        covered
3283    } else {
3284        RoaringBitmap::new()
3285    };
3286    let mut any_group_indexed = false;
3287
3288    for task in completed_tasks {
3289        metrics += task.metrics;
3290        // One source of truth: the deferred branch normalizes the source
3291        // metadata (materialized physical and deleted row counts) and BOTH
3292        // the rewrite group and the reuse digests are built from it, so the
3293        // commit-side binding validation compares like with like.
3294        let old_fragments = if index_remapper.is_none() && options.defer_index_remap {
3295            normalize_source_fragments(dataset, &task.original_fragments).await?
3296        } else {
3297            task.original_fragments.clone()
3298        };
3299        let rewrite_group = RewriteGroup {
3300            old_fragments: old_fragments.clone(),
3301            new_fragments: task.new_fragments.clone(),
3302        };
3303
3304        if index_remapper.is_some() {
3305            if let Some(row_addrs_bytes) = task.row_addrs {
3306                let row_addrs =
3307                    RoaringTreemap::deserialize_from(&mut Cursor::new(&row_addrs_bytes))?;
3308                match options.index_remap_mode {
3309                    IndexRemapMode::Direct => {
3310                        let transposed = remapping::transpose_row_addrs(
3311                            row_addrs,
3312                            &task.original_fragments,
3313                            &task.new_fragments,
3314                        );
3315                        direct_row_id_map.extend(transposed);
3316                    }
3317                    IndexRemapMode::Compact => {
3318                        let new_frags = task
3319                            .new_fragments
3320                            .iter()
3321                            .map(|f| {
3322                                let physical_rows = f.physical_rows.ok_or_else(|| {
3323                                    Error::invalid_input(format!(
3324                                        "compacted fragment {} is missing physical_rows",
3325                                        f.id
3326                                    ))
3327                                })?;
3328                                let fragment_id = u32::try_from(f.id).map_err(|_| {
3329                                    Error::invalid_input(format!(
3330                                        "compacted fragment id {} is outside the row-address range",
3331                                        f.id
3332                                    ))
3333                                })?;
3334                                let physical_rows = u32::try_from(physical_rows).map_err(|_| {
3335                                    Error::invalid_input(format!(
3336                                        "compacted fragment {} has physical_rows={} outside the row-address range",
3337                                        f.id, physical_rows
3338                                    ))
3339                                })?;
3340                                Ok((fragment_id, physical_rows))
3341                            })
3342                            .collect::<Result<Vec<_>>>()?;
3343
3344                        remap_group_inputs.push(GroupInput {
3345                            rewritten_old_row_addrs: row_addrs,
3346                            old_frag_ids: task
3347                                .original_fragments
3348                                .iter()
3349                                .map(|f| {
3350                                    u32::try_from(f.id).map_err(|_| {
3351                                        Error::invalid_input(format!(
3352                                            "compacted source fragment id {} is outside the row-address range",
3353                                            f.id
3354                                        ))
3355                                    })
3356                                })
3357                                .collect::<Result<Vec<_>>>()?,
3358                            new_frags,
3359                        });
3360                    }
3361                }
3362            }
3363        } else if options.defer_index_remap {
3364            // Record every group; track whether any touches indexed/chain
3365            // data. Ids beyond the row-address range cannot appear in any
3366            // index bitmap, so they are uncovered by definition.
3367            if old_fragments
3368                .iter()
3369                .any(|f| u32::try_from(f.id).is_ok_and(|id| indexed_frags.contains(id)))
3370            {
3371                any_group_indexed = true;
3372            }
3373            let changed_row_addrs = task.row_addrs.ok_or_else(|| {
3374                Error::internal(
3375                    "defer_index_remap requires row_addrs but none were provided".to_string(),
3376                )
3377            })?;
3378            // Digest construction must not panic on incomplete metadata (the
3379            // tasks may be replayed results planned elsewhere); the counts
3380            // were materialized by `normalize_source_fragments` above, from
3381            // the same list the rewrite group carries.
3382            let digest =
3383                |frag: &Fragment| -> Result<lance_table::system_index::frag_reuse::FragDigest> {
3384                    Ok(lance_table::system_index::frag_reuse::FragDigest {
3385                        id: frag.id,
3386                        physical_rows: frag.physical_rows.ok_or_else(|| {
3387                            Error::invalid_input(format!(
3388                                "fragment {} has no physical row count for the fragment reuse record",
3389                                frag.id
3390                            ))
3391                        })?,
3392                        num_deleted_rows: frag
3393                            .deletion_file
3394                            .as_ref()
3395                            .map(|deletion| {
3396                                deletion.num_deleted_rows.ok_or_else(|| {
3397                                    Error::invalid_input(format!(
3398                                        "fragment {} has a deletion file without a materialized row count",
3399                                        frag.id
3400                                    ))
3401                                })
3402                            })
3403                            .transpose()?
3404                            .unwrap_or(0),
3405                    })
3406                };
3407            frag_reuse_groups.push(FragReuseGroup {
3408                changed_row_addrs,
3409                old_frags: old_fragments.iter().map(digest).collect::<Result<_>>()?,
3410                new_frags: task
3411                    .new_fragments
3412                    .iter()
3413                    .map(digest)
3414                    .collect::<Result<_>>()?,
3415            });
3416            for frag in task.new_fragments.iter() {
3417                new_fragment_bitmap.insert(u32::try_from(frag.id).map_err(|_| {
3418                    Error::invalid_input(format!(
3419                        "compacted fragment id {} is outside the row-address range",
3420                        frag.id
3421                    ))
3422                })?);
3423            }
3424        }
3425        rewrite_groups.push(rewrite_group);
3426    }
3427
3428    let rewritten_indices = if let Some(index_remapper) = index_remapper {
3429        let affected_ids = rewrite_groups
3430            .iter()
3431            .flat_map(|group| group.old_fragments.iter().map(|frag| frag.id))
3432            .collect::<Vec<_>>();
3433
3434        let remap = match options.index_remap_mode {
3435            IndexRemapMode::Direct => RowAddrRemap::direct(direct_row_id_map),
3436            IndexRemapMode::Compact => RowAddrRemap::compact(remap_group_inputs)?,
3437        };
3438        let remapped_indices = index_remapper.remap_indices(remap, &affected_ids).await?;
3439        remapped_indices
3440            .into_iter()
3441            .map(|rewritten| RewrittenIndex {
3442                old_id: rewritten.old_id,
3443                new_id: rewritten.new_id,
3444                new_index_details: rewritten.index_details,
3445                new_index_version: rewritten.index_version,
3446                new_index_files: rewritten.files,
3447            })
3448            .collect()
3449    } else if !options.defer_index_remap && !has_address_style {
3450        // We need to reserve fragment ids here so that the fragment bitmap
3451        // can be updated for each index. Only needed for stable row IDs
3452        // since address-style IDs were already reserved above.
3453        let new_fragments = rewrite_groups
3454            .iter_mut()
3455            .flat_map(|group| group.new_fragments.iter_mut())
3456            .collect::<Vec<_>>();
3457        if let Err(e) = reserve_fragment_ids(dataset, new_fragments.into_iter()).await {
3458            cleanup_compaction_files_after_reservation_failure(dataset, &all_new_fragments).await;
3459            return Err(e);
3460        }
3461        Vec::new()
3462    } else {
3463        Vec::new()
3464    };
3465
3466    // No indexed/chain data touched -> no record at all (all-or-nothing, see
3467    // above); the commit is a plain rewrite the tagged gate admits for this
3468    // uncovered shape. Otherwise the record's FORM -- a v0 entry, or tagged
3469    // transitions riding the in-memory rewrite intent that
3470    // `build_frag_reuse_rewrite_entry` assembles onto the entry at every
3471    // commit attempt -- is decided HERE, at commit time, from the freshest
3472    // state this handle has (the id reservations above advanced it), not
3473    // from a sample taken when the compaction started: another writer may
3474    // have tagged the table in between. The record's BASE, however, is the
3475    // snapshot at `tasks_read_version`: the commit path works out what the
3476    // entry adds by diffing it against the entry at the transaction's read
3477    // version, so an entry built from this later handle would fold every
3478    // transition other writers appended in between into this rewrite's own
3479    // additions and fail the commit for good instead of merging the
3480    // histories (`rewrite_reuse_state` refuses such an entry outright). The
3481    // read version itself stays at V: raising it to the latest version would
3482    // skip the concurrency checks against the writes that landed in between.
3483    // The remaining race onto the manifest CAS itself is handled by the
3484    // rebase, which converts a v0-shaped intent into tagged transitions when
3485    // the current entry turns out tagged (see `finish_rewrite`); the commit
3486    // gate still rejects a v0 entry spliced by a writer without that
3487    // conversion.
3488    let frag_reuse_index = if options.defer_index_remap && any_group_indexed {
3489        // Once a table is v1, it stays v1: the sticky
3490        // FLAG_FRAGMENT_REUSE_INDEX is the final authority
3491        // (`uses_tagged_fri`), never the entry's own index_version. With NO
3492        // entry -- for example a fully drained history whose entry a trim
3493        // deleted -- the flag alone decides, so a tagged table never
3494        // restarts its history in the v0 format; with a v0 entry UNDER the
3495        // flag (a legacy entry a concurrent upgrade left pending) the
3496        // tagged assembly lifts that entry byte-verbatim instead of
3497        // extending it in place. A table that never was tagged has neither
3498        // flag nor tagged entry, and keeps creating the v0 entry byte
3499        // identically.
3500        let stored = load_all_indices(dataset).await?;
3501        let tagged_at_commit = lance_table::system_index::frag_reuse::metadata::uses_tagged_fri(
3502            &dataset.manifest,
3503            stored.iter().find(|idx| idx.name == FRAG_REUSE_INDEX_NAME),
3504        );
3505        if tagged_at_commit {
3506            // Materializing a data overlay breaks the reuse premise that a
3507            // rewrite moves addresses, never values; on a tagged table the
3508            // stale-index handling that v0 applies (dropping destination
3509            // ids from swapped bitmaps) is a no-op because provenance never
3510            // contains the destinations. Refuse rather than record a
3511            // transition; see `build_frag_reuse_rewrite_entry` for the same
3512            // rule on the stable-partition path.
3513            if let Some(overlaid) = rewrite_groups
3514                .iter()
3515                .flat_map(|group| group.old_fragments.iter())
3516                .find(|frag| !frag.overlays.is_empty())
3517            {
3518                return Err(Error::not_supported(format!(
3519                    "source fragment {} carries data overlay files; deferred compaction on a \
3520                     tagged fragment reuse table would materialize the overlaid values while \
3521                     indices keep translated coverage over the old addresses. Compact the \
3522                     overlays away or rebuild the covering indices eagerly first",
3523                    overlaid.id
3524                )));
3525            }
3526            use lance_table::format::pb::fragment_reuse_index_details as pb_fri;
3527            let transitions = frag_reuse_groups
3528                .into_iter()
3529                .map(|group| pb_fri::Transition {
3530                    sources: group
3531                        .old_frags
3532                        .iter()
3533                        .map(pb_fri::FragmentDigest::from)
3534                        .collect(),
3535                    destinations: group
3536                        .new_frags
3537                        .iter()
3538                        .map(pb_fri::FragmentDigest::from)
3539                        .collect(),
3540                    mapping: Some(pb_fri::transition::Mapping::OrderedCompaction(
3541                        pb_fri::OrderedCompaction {
3542                            changed_row_addrs: group.changed_row_addrs,
3543                        },
3544                    )),
3545                })
3546                .collect();
3547            // The complete entry: the history at the read version plus this
3548            // compaction's own transitions, built from the same snapshot the
3549            // commit path diffs it against (see above). The commit path
3550            // merges the additions onto the entry current at commit.
3551            let read_snapshot = if dataset.manifest.version == tasks_read_version {
3552                Cow::Borrowed(&*dataset)
3553            } else {
3554                // A cleaned-up read version fails here, explicitly, rather
3555                // than guessing the base from a later snapshot.
3556                Cow::Owned(dataset.checkout_version(tasks_read_version).await?)
3557            };
3558            Some(
3559                crate::index::frag_reuse::frag_reuse_entry_appending(&read_snapshot, transitions)
3560                    .await?,
3561            )
3562        } else {
3563            Some(build_new_frag_reuse_index(dataset, frag_reuse_groups, new_fragment_bitmap).await?)
3564        }
3565    } else {
3566        if options.defer_index_remap {
3567            log::debug!(
3568                "skipping fragment reuse record: no rewritten fragments are covered by an index or the reuse lineage"
3569            );
3570        }
3571        None
3572    };
3573
3574    let transaction = TransactionBuilder::new(
3575        // Use the version at which the compaction tasks were *planned*, not the
3576        // version of the dataset handle passed to this function.  In distributed
3577        // mode the caller may open a fresh dataset at a later version (V+N), but
3578        // the tasks were executed against an older snapshot (V).  Anchoring the
3579        // transaction to V ensures the OCC conflict checker scans all writes that
3580        // landed between V and the commit point, detecting concurrent DELETE
3581        // transactions that would otherwise cause deleted rows to reappear.
3582        tasks_read_version,
3583        Operation::Rewrite {
3584            groups: rewrite_groups,
3585            rewritten_indices,
3586            frag_reuse_index,
3587        },
3588    )
3589    .transaction_properties(options.transaction_properties.clone())
3590    .build();
3591
3592    if let Err(e) = dataset
3593        .apply_commit(transaction, &Default::default(), &Default::default())
3594        .await
3595    {
3596        // RewriteResult is serializable and may be retried after an earlier
3597        // ambiguous success. A conflict on this call therefore does not prove
3598        // that the rewritten files are unreferenced. Leave them for dataset GC.
3599        log::warn!(
3600            "Compaction commit failed; leaving {} rewritten fragment(s) in place for GC: {}",
3601            all_new_fragments.len(),
3602            e
3603        );
3604        return Err(e);
3605    }
3606
3607    Ok(metrics)
3608}
3609
3610/// Materialize the counts the fragment reuse digests and conservation
3611/// validation depend on, without changing the fragment list. Unlike
3612/// [`migrate_fragments`], fully-deleted fragments are KEPT: the rewrite
3613/// group must still remove them from the manifest and the recorded
3614/// transition must still consume them (they contribute zero live rows), so
3615/// a silently shortened list would desynchronize the digests, the group and
3616/// the manifest.
3617async fn normalize_source_fragments(
3618    dataset: &Dataset,
3619    fragments: &[Fragment],
3620) -> Result<Vec<Fragment>> {
3621    let mut normalized = Vec::with_capacity(fragments.len());
3622    for fragment in fragments {
3623        let mut fragment = fragment.clone();
3624        if fragment.physical_rows.is_none() {
3625            let file_fragment = FileFragment::new(Arc::new(dataset.clone()), fragment.clone());
3626            fragment.physical_rows = Some(file_fragment.physical_rows().await?);
3627        }
3628        if fragment
3629            .deletion_file
3630            .as_ref()
3631            .is_some_and(|deletion| deletion.num_deleted_rows.is_none())
3632        {
3633            let deletion_file = fragment.deletion_file.as_ref().unwrap();
3634            let count = read_dataset_deletion_file(dataset, fragment.id, deletion_file)
3635                .await?
3636                .len();
3637            fragment.deletion_file.as_mut().unwrap().num_deleted_rows = Some(count);
3638        }
3639        normalized.push(fragment);
3640    }
3641    Ok(normalized)
3642}
3643
3644/// Remove rewritten files after fragment-id reservation fails. Reservation
3645/// commits do not reference the rewritten files, so they are still owned by
3646/// this uncommitted compaction attempt and are safe to delete.
3647async fn cleanup_compaction_files_after_reservation_failure(
3648    dataset: &Dataset,
3649    all_new_fragments: &[Fragment],
3650) {
3651    cleanup_data_fragments(
3652        &dataset.object_store,
3653        &dataset.base,
3654        None,
3655        all_new_fragments,
3656    )
3657    .await;
3658}
3659
3660#[cfg(test)]
3661mod tests {
3662
3663    mod binary_copy;
3664    use self::remapping::RemappedIndex;
3665    use super::*;
3666    use crate::dataset::WriteDestination;
3667    use crate::dataset::index::frag_reuse::cleanup_frag_reuse_index;
3668    use crate::dataset::optimize::remapping::{transpose_row_addrs, transpose_row_ids_from_digest};
3669    use crate::dataset::rowids::read_spilled_row_ids;
3670    use crate::dataset::rowids::{
3671        INLINE_ROW_LINEAGE_MAX_BYTES_CONFIG_KEY, SPILL_ROW_LINEAGE_CONFIG_KEY,
3672    };
3673    use crate::dataset::scanner::ColumnOrdering;
3674    use crate::index::DatasetIndexExt;
3675    use crate::index::frag_reuse::{load_frag_reuse_index_details, open_frag_reuse_index};
3676    use crate::index::vector::{StageParams, VectorIndexParams};
3677    use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount};
3678    use arrow_array::types::{Float32Type, Float64Type, Int32Type, Int64Type};
3679    use arrow_array::{
3680        ArrayRef, Float32Array, Int32Array, Int64Array, LargeBinaryArray, LargeStringArray,
3681        PrimitiveArray, RecordBatch, RecordBatchIterator,
3682    };
3683    use arrow_schema::{DataType, Field, Schema};
3684    use arrow_select::concat::concat_batches;
3685    use async_trait::async_trait;
3686    use lance_arrow::BLOB_META_KEY;
3687    use lance_core::Error;
3688    use lance_core::ROW_ID;
3689    use lance_core::ROW_ID_FIELD_ID;
3690    use lance_core::utils::address::RowAddress;
3691    use lance_core::utils::tempfile::TempStrDir;
3692    use lance_datagen::Dimension;
3693    use lance_file::version::LanceFileVersion;
3694    use lance_index::frag_reuse::CompactFragReuseIndexHandle;
3695    use lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME;
3696    use lance_index::scalar::{
3697        BuiltinIndexType, FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams,
3698    };
3699    use lance_index::vector::ivf::IvfBuildParams;
3700    use lance_index::vector::pq::PQBuildParams;
3701    use lance_index::{Index, IndexType};
3702    use lance_linalg::distance::{DistanceType, MetricType};
3703    use lance_table::format::RowIdMeta;
3704    use lance_table::io::manifest::read_manifest_indexes;
3705    use lance_table::rowids::read_row_ids;
3706    use lance_testing::datagen::{BatchGenerator, IncrementingInt32, RandomVector};
3707    use rstest::rstest;
3708    use std::collections::HashSet;
3709    use std::io::Cursor;
3710    use std::sync::Arc;
3711    use uuid::Uuid;
3712
3713    #[test]
3714    fn test_candidate_bin() {
3715        let empty_bin = CandidateBin {
3716            fragments: vec![],
3717            pos_range: 0..0,
3718            candidacy: vec![],
3719            row_counts: vec![],
3720            indices: vec![],
3721        };
3722        assert!(empty_bin.is_noop());
3723
3724        let fragment = Fragment {
3725            id: 0,
3726            files: vec![],
3727            overlays: vec![],
3728            deletion_file: None,
3729            row_id_meta: None,
3730            physical_rows: Some(0),
3731            last_updated_at_version_meta: None,
3732            created_at_version_meta: None,
3733        };
3734        let single_bin = CandidateBin {
3735            fragments: vec![fragment.clone()],
3736            pos_range: 0..1,
3737            candidacy: vec![CompactionCandidacy::CompactWithNeighbors],
3738            row_counts: vec![100],
3739            indices: vec![],
3740        };
3741        assert!(single_bin.is_noop());
3742
3743        let single_bin = CandidateBin {
3744            fragments: vec![fragment.clone()],
3745            pos_range: 0..1,
3746            candidacy: vec![CompactionCandidacy::CompactItself],
3747            row_counts: vec![100],
3748            indices: vec![],
3749        };
3750        // Not a no-op because it's CompactItself
3751        assert!(!single_bin.is_noop());
3752
3753        let big_bin = CandidateBin {
3754            fragments: std::iter::repeat_n(fragment, 8).collect(),
3755            pos_range: 0..8,
3756            candidacy: std::iter::repeat_n(CompactionCandidacy::CompactItself, 8).collect(),
3757            row_counts: vec![100, 400, 200, 200, 400, 300, 300, 100],
3758            indices: vec![],
3759            // Will group into: [[100, 400], [200, 200, 400], [300, 300, 100]]
3760            // with size = 500
3761        };
3762        assert!(!big_bin.is_noop());
3763        let split = big_bin.split_for_size(500);
3764        assert_eq!(split.len(), 3);
3765        assert_eq!(split[0].pos_range, 0..2);
3766        assert_eq!(split[1].pos_range, 2..5);
3767        assert_eq!(split[2].pos_range, 5..8);
3768
3769        let zero_min_split_bin = CandidateBin {
3770            fragments: std::iter::repeat_n(
3771                Fragment {
3772                    id: 0,
3773                    files: vec![],
3774                    overlays: vec![],
3775                    deletion_file: None,
3776                    row_id_meta: None,
3777                    physical_rows: Some(0),
3778                    last_updated_at_version_meta: None,
3779                    created_at_version_meta: None,
3780                },
3781                3,
3782            )
3783            .collect(),
3784            pos_range: 0..3,
3785            candidacy: std::iter::repeat_n(CompactionCandidacy::CompactItself, 3).collect(),
3786            row_counts: vec![100, 200, 300],
3787            indices: vec![],
3788        };
3789        let split = zero_min_split_bin.split_for_size(0);
3790        assert_eq!(split.len(), 3);
3791        assert!(split.iter().all(|bin| !bin.fragments.is_empty()));
3792        assert_eq!(split[0].pos_range, 0..1);
3793        assert_eq!(split[1].pos_range, 1..2);
3794        assert_eq!(split[2].pos_range, 2..3);
3795    }
3796
3797    fn sample_data() -> RecordBatch {
3798        let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
3799
3800        RecordBatch::try_new(
3801            Arc::new(schema),
3802            vec![Arc::new(Int64Array::from_iter_values(0..10_000))],
3803        )
3804        .unwrap()
3805    }
3806
3807    /// Build (or, with `replace`, rebuild) a scalar index named "scalar" on `col`.
3808    async fn create_scalar_index(dataset: &mut Dataset, col: &str, replace: bool) {
3809        dataset
3810            .create_index(
3811                &[col],
3812                IndexType::Scalar,
3813                Some("scalar".into()),
3814                &ScalarIndexParams::default(),
3815                replace,
3816            )
3817            .await
3818            .unwrap();
3819    }
3820
3821    #[derive(Debug, Default, Clone, PartialEq)]
3822    struct MockIndexRemapperExpectation {
3823        expected: HashMap<u64, Option<u64>>,
3824        answer: Vec<RemappedIndex>,
3825    }
3826
3827    #[derive(Debug, Default, Clone, PartialEq)]
3828    struct MockIndexRemapper {
3829        expectations: Vec<MockIndexRemapperExpectation>,
3830    }
3831
3832    impl MockIndexRemapper {
3833        fn stringify_map(map: &HashMap<u64, Option<u64>>) -> String {
3834            let mut sorted_keys = map.keys().collect::<Vec<_>>();
3835            sorted_keys.sort();
3836            let mut first_keys = sorted_keys
3837                .into_iter()
3838                .take(10)
3839                .map(|key| {
3840                    format!(
3841                        "{}:{:?}",
3842                        RowAddress::from(*key),
3843                        map[key].map(RowAddress::from)
3844                    )
3845                })
3846                .collect::<Vec<_>>()
3847                .join(",");
3848            if map.len() > 10 {
3849                first_keys.push_str(", ...");
3850            }
3851            let mut result_str = format!("(len={})", map.len());
3852            result_str.push_str(&first_keys);
3853            result_str
3854        }
3855
3856        fn in_any_order(expectations: &[Self]) -> Self {
3857            let expectations = expectations
3858                .iter()
3859                .flat_map(|item| item.expectations.clone())
3860                .collect::<Vec<_>>();
3861            Self { expectations }
3862        }
3863    }
3864
3865    #[async_trait]
3866    impl IndexRemapper for MockIndexRemapper {
3867        async fn remap_indices(
3868            &self,
3869            index_map: RowAddrRemap,
3870            _: &[u64],
3871        ) -> Result<Vec<RemappedIndex>> {
3872            for expectation in &self.expectations {
3873                let matches = match &index_map {
3874                    RowAddrRemap::Direct(map) => map == &expectation.expected,
3875                    RowAddrRemap::Compact(_) => {
3876                        let expected_frags: RoaringBitmap = expectation
3877                            .expected
3878                            .keys()
3879                            .map(|addr| (addr >> 32) as u32)
3880                            .collect();
3881                        index_map.affected_fragments() == expected_frags
3882                            && expectation
3883                                .expected
3884                                .iter()
3885                                .all(|(k, v)| index_map.get(*k) == Some(*v))
3886                    }
3887                };
3888                if matches {
3889                    return Ok(expectation.answer.clone());
3890                }
3891            }
3892            panic!(
3893                "Unexpected index map; expected one of:\n  {}",
3894                self.expectations
3895                    .iter()
3896                    .map(|expectation| Self::stringify_map(&expectation.expected))
3897                    .collect::<Vec<_>>()
3898                    .join("\n  ")
3899            );
3900        }
3901    }
3902
3903    #[async_trait]
3904    impl IndexRemapperOptions for MockIndexRemapper {
3905        async fn create_remapper(&self, _: &Dataset) -> Result<Option<Box<dyn IndexRemapper>>> {
3906            Ok(Some(Box::new(self.clone())))
3907        }
3908    }
3909
3910    #[rstest]
3911    #[tokio::test]
3912    async fn test_compact_empty(
3913        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
3914        data_storage_version: LanceFileVersion,
3915    ) {
3916        let test_dir = TempStrDir::default();
3917        let test_uri = &test_dir;
3918
3919        // Compact an empty table
3920        let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
3921
3922        let reader = RecordBatchIterator::new(vec![].into_iter().map(Ok), Arc::new(schema));
3923        let mut dataset = Dataset::write(
3924            reader,
3925            test_uri,
3926            Some(WriteParams {
3927                data_storage_version: Some(data_storage_version),
3928                ..Default::default()
3929            }),
3930        )
3931        .await
3932        .unwrap();
3933
3934        let plan = plan_compaction(&dataset, &CompactionOptions::default())
3935            .await
3936            .unwrap();
3937        assert_eq!(plan.tasks().len(), 0);
3938
3939        let metrics = compact_files(&mut dataset, CompactionOptions::default(), None)
3940            .await
3941            .unwrap();
3942
3943        assert_eq!(metrics, CompactionMetrics::default());
3944        assert_eq!(dataset.manifest.version, 1);
3945    }
3946
3947    #[tokio::test]
3948    async fn test_compact_v2_0_file_missing_evolved_struct_child() {
3949        let old_child = Arc::new(Field::new("old", DataType::Int32, true));
3950        let schema = Arc::new(Schema::new(vec![
3951            Field::new(
3952                "values",
3953                DataType::Struct(vec![old_child.clone()].into()),
3954                true,
3955            ),
3956            Field::new("id", DataType::Int32, false),
3957        ]));
3958        let batch = RecordBatch::try_new(
3959            schema.clone(),
3960            vec![
3961                Arc::new(StructArray::from(vec![(
3962                    old_child,
3963                    Arc::new(Int32Array::from(vec![10, 20, 30, 40])) as ArrayRef,
3964                )])),
3965                Arc::new(Int32Array::from(vec![0, 1, 2, 3])),
3966            ],
3967        )
3968        .unwrap();
3969        let mut dataset = Dataset::write(
3970            RecordBatchIterator::new([Ok(batch)], schema),
3971            "memory://",
3972            Some(WriteParams {
3973                data_storage_version: Some(LanceFileVersion::V2_0),
3974                max_rows_per_file: 1,
3975                ..Default::default()
3976            }),
3977        )
3978        .await
3979        .unwrap();
3980
3981        // Model a snapshot whose nullable struct retained its field ID while its
3982        // old child was replaced by a later-added child. Existing V2.0 files
3983        // still record the struct header and old child IDs.
3984        let mut evolved_schema = dataset.schema().clone();
3985        let parent_id = evolved_schema.fields[0].id;
3986        let mut later_child = LanceField::new_arrow("later", DataType::Int32, true).unwrap();
3987        later_child.id = evolved_schema.max_field_id().unwrap() + 1;
3988        later_child.parent_id = parent_id;
3989        evolved_schema.fields[0].children = vec![later_child];
3990        let read_version = dataset.version().version;
3991        dataset = Dataset::commit(
3992            WriteDestination::Dataset(Arc::new(dataset)),
3993            Operation::Project {
3994                schema: evolved_schema,
3995                preserves_nullability: true,
3996            },
3997            Some(read_version),
3998            None,
3999            None,
4000            Arc::new(Default::default()),
4001            false,
4002        )
4003        .await
4004        .unwrap();
4005
4006        let metrics = compact_files(&mut dataset, CompactionOptions::default(), None)
4007            .await
4008            .unwrap();
4009        assert_eq!(metrics.fragments_removed, 4);
4010        assert_eq!(metrics.fragments_added, 1);
4011
4012        let compacted = dataset.scan().try_into_batch().await.unwrap();
4013        assert_eq!(compacted.num_rows(), 4);
4014        assert_eq!(
4015            compacted["id"].as_ref(),
4016            &Int32Array::from(vec![0, 1, 2, 3])
4017        );
4018        let values = compacted["values"].as_struct();
4019        assert_eq!(values.null_count(), 0);
4020        assert_eq!(values.column_by_name("later").unwrap().null_count(), 4);
4021    }
4022
4023    #[rstest]
4024    #[case::default(None, LanceFileVersion::V2_0)]
4025    #[case::stable(Some(LanceFileVersion::Stable), LanceFileVersion::V2_2)]
4026    #[case::next(Some(LanceFileVersion::Next), LanceFileVersion::V2_3)]
4027    #[tokio::test]
4028    async fn plan_compaction_freezes_storage_version_selector(
4029        #[case] selector: Option<LanceFileVersion>,
4030        #[case] exact: LanceFileVersion,
4031    ) {
4032        let data = arrow_array::record_batch!(("id", Int32, [1, 2, 3, 4, 5, 6])).unwrap();
4033        let mut dataset = Dataset::write(
4034            RecordBatchIterator::new([Ok(data.clone())], data.schema()),
4035            "memory://",
4036            Some(WriteParams {
4037                max_rows_per_file: 2,
4038                data_storage_version: Some(LanceFileVersion::V2_0),
4039                ..Default::default()
4040            }),
4041        )
4042        .await
4043        .unwrap();
4044        let options = CompactionOptions {
4045            data_storage_version: selector,
4046            excluded_fragment_ids: vec![2],
4047            ..Default::default()
4048        };
4049
4050        let plan = plan_compaction(&dataset, &options).await.unwrap();
4051
4052        assert_eq!(plan.options.data_storage_version, Some(exact));
4053        let serialized = serde_json::to_value(&plan).unwrap();
4054        assert_eq!(
4055            serialized["options"]["data_storage_version"],
4056            exact.to_string()
4057        );
4058        assert_eq!(
4059            serde_json::from_value::<CompactionPlan>(serialized).unwrap(),
4060            plan
4061        );
4062        assert_eq!(plan.num_tasks(), 1);
4063        let retained = dataset.manifest.fragments[2].clone();
4064        let task = plan.compaction_tasks().next().unwrap();
4065        let task: CompactionTask =
4066            serde_json::from_slice(&serde_json::to_vec(&task).unwrap()).unwrap();
4067        let result = task.execute(&dataset).await.unwrap();
4068        assert!(
4069            result
4070                .new_fragments
4071                .iter()
4072                .flat_map(Fragment::referenced_lance_files)
4073                .all(|file| file.file_version().unwrap() == exact.resolve())
4074        );
4075        let result: RewriteResult =
4076            serde_json::from_slice(&serde_json::to_vec(&result).unwrap()).unwrap();
4077        commit_compaction(
4078            &mut dataset,
4079            vec![result],
4080            Arc::new(DatasetIndexRemapperOptions::default()),
4081            &options,
4082        )
4083        .await
4084        .unwrap();
4085        assert!(
4086            dataset
4087                .manifest
4088                .fragments
4089                .iter()
4090                .any(|fragment| fragment == &retained)
4091        );
4092        assert_eq!(
4093            dataset.manifest.data_storage_format.lance_file_format(),
4094            ConcreteFileVersion::V2_0
4095        );
4096        let actual = dataset
4097            .scan()
4098            .order_by(Some(vec![ColumnOrdering::asc_nulls_first("id".into())]))
4099            .unwrap()
4100            .try_into_batch()
4101            .await
4102            .unwrap();
4103        assert_eq!(actual, data);
4104    }
4105
4106    #[rstest]
4107    #[case(LanceFileVersion::Legacy, LanceFileVersion::V2_0)]
4108    #[case(LanceFileVersion::V2_0, LanceFileVersion::Legacy)]
4109    #[tokio::test]
4110    async fn compaction_rejects_cross_family_target(
4111        #[case] source: LanceFileVersion,
4112        #[case] target: LanceFileVersion,
4113    ) {
4114        let batch = arrow_array::record_batch!(("id", Int32, [1, 2])).unwrap();
4115        let dataset = Dataset::write(
4116            RecordBatchIterator::new([Ok(batch.clone())], batch.schema()),
4117            "memory://",
4118            Some(WriteParams {
4119                data_storage_version: Some(source),
4120                max_rows_per_file: 1,
4121                ..Default::default()
4122            }),
4123        )
4124        .await
4125        .unwrap();
4126        let options = CompactionOptions {
4127            data_storage_version: Some(target),
4128            ..Default::default()
4129        };
4130        let error = plan_compaction(&dataset, &options).await.unwrap_err();
4131        assert!(matches!(error, Error::InvalidInput { .. }));
4132        assert!(
4133            error
4134                .to_string()
4135                .contains("V1 and V2 storage versions cannot be mixed")
4136        );
4137        // Standalone distributed tasks also validate, independently of the planner.
4138        let mut task = plan_compaction(&dataset, &CompactionOptions::default())
4139            .await
4140            .unwrap()
4141            .compaction_tasks()
4142            .next()
4143            .unwrap();
4144        task.options = options;
4145        let error = task.execute(&dataset).await.unwrap_err();
4146        assert!(matches!(error, Error::InvalidInput { .. }));
4147        assert!(
4148            error
4149                .to_string()
4150                .contains("V1 and V2 storage versions cannot be mixed")
4151        );
4152    }
4153
4154    #[rstest]
4155    #[case("0.1", Some(LanceFileVersion::Legacy))]
4156    #[case("2.0", Some(LanceFileVersion::V2_0))]
4157    #[case("2.1", Some(LanceFileVersion::V2_1))]
4158    #[case("2.2", Some(LanceFileVersion::V2_2))]
4159    #[case("2.3", Some(LanceFileVersion::V2_3))]
4160    #[case("stable", Some(LanceFileVersion::Stable))]
4161    #[case("next", Some(LanceFileVersion::Next))]
4162    #[case("invalid", None)]
4163    fn compaction_storage_version_config(
4164        #[case] value: &str,
4165        #[case] expected: Option<LanceFileVersion>,
4166    ) {
4167        let config = HashMap::from([(
4168            "lance.compaction.data_storage_version".to_string(),
4169            value.to_string(),
4170        )]);
4171        let result = CompactionOptions::from_dataset_config(&config);
4172        if let Some(expected) = expected {
4173            let options = result.unwrap();
4174            assert_eq!(options.data_storage_version, Some(expected));
4175            let json = serde_json::to_value(&options).unwrap();
4176            assert_eq!(json["data_storage_version"], value);
4177            assert_eq!(
4178                serde_json::from_value::<CompactionOptions>(json).unwrap(),
4179                options
4180            );
4181        } else {
4182            let error = result.unwrap_err();
4183            assert!(matches!(error, Error::InvalidInput { .. }));
4184            assert!(
4185                error
4186                    .to_string()
4187                    .contains("lance.compaction.data_storage_version")
4188            );
4189            assert!(error.to_string().contains(value));
4190            let error =
4191                serde_json::from_value::<LanceFileVersion>(serde_json::json!(value)).unwrap_err();
4192            assert!(error.to_string().contains(value));
4193        }
4194    }
4195
4196    #[rstest]
4197    #[tokio::test]
4198    async fn test_compact_all_good(
4199        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
4200        data_storage_version: LanceFileVersion,
4201    ) {
4202        // Compact a table with nothing to do
4203        let test_dir = TempStrDir::default();
4204        let test_uri = &test_dir;
4205
4206        let data = sample_data();
4207        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
4208        // Just one file
4209        let write_params = WriteParams {
4210            max_rows_per_file: 10_000,
4211            data_storage_version: Some(data_storage_version),
4212            ..Default::default()
4213        };
4214        let dataset = Dataset::write(reader, test_uri, Some(write_params))
4215            .await
4216            .unwrap();
4217
4218        // There's only one file, so we can't compact any more if we wanted to.
4219        let plan = plan_compaction(&dataset, &CompactionOptions::default())
4220            .await
4221            .unwrap();
4222        assert_eq!(plan.tasks().len(), 0);
4223
4224        // Now split across multiple files
4225        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
4226        let write_params = WriteParams {
4227            max_rows_per_file: 3_000,
4228            max_rows_per_group: 1_000,
4229            data_storage_version: Some(data_storage_version),
4230            mode: WriteMode::Overwrite,
4231            ..Default::default()
4232        };
4233        let dataset = Dataset::write(reader, test_uri, Some(write_params))
4234            .await
4235            .unwrap();
4236
4237        let options = CompactionOptions {
4238            target_rows_per_fragment: 3_000,
4239            ..Default::default()
4240        };
4241        let plan = plan_compaction(&dataset, &options).await.unwrap();
4242        assert_eq!(plan.tasks().len(), 0);
4243    }
4244
4245    fn list_data_files(uri: &str) -> std::collections::BTreeSet<String> {
4246        std::fs::read_dir(std::path::Path::new(uri).join("data"))
4247            .map(|rd| {
4248                rd.filter_map(|e| e.ok())
4249                    .map(|e| e.file_name().to_string_lossy().into_owned())
4250                    .collect()
4251            })
4252            .unwrap_or_default()
4253    }
4254
4255    async fn execute_compaction_plan(
4256        dataset: &Dataset,
4257        options: &CompactionOptions,
4258    ) -> Vec<RewriteResult> {
4259        let plan = plan_compaction(dataset, options).await.unwrap();
4260        assert!(!plan.tasks.is_empty());
4261        let snapshot = dataset.clone();
4262        futures::stream::iter(plan.tasks)
4263            .map(|task| rewrite_files(Cow::Borrowed(&snapshot), task, options))
4264            .buffer_unordered(1)
4265            .try_collect()
4266            .await
4267            .unwrap()
4268    }
4269
4270    /// Distributed compaction on a tagged table: the tasks are planned at V,
4271    /// another writer's disjoint deferred compaction lands at V+1, and the
4272    /// driver commits the planned tasks from a fresh handle at the latest
4273    /// version. The reuse entry is built from the snapshot at V (the
4274    /// transaction's read version), so the two histories merge instead of
4275    /// the other writer's transition being taken for this compaction's own.
4276    #[tokio::test]
4277    async fn distributed_deferred_compaction_merges_with_a_disjoint_concurrent_rewrite() {
4278        use crate::index::DatasetIndexExt;
4279        use arrow_array::cast::AsArray;
4280        let test_dir = TempStrDir::default();
4281        let uri = test_dir.as_str();
4282        let fresh = |uri: &str| {
4283            crate::dataset::builder::DatasetBuilder::from_uri(uri)
4284                .with_session(Arc::new(crate::session::Session::default()))
4285                .load()
4286        };
4287        let mut dataset = lance_datagen::gen_batch()
4288            .col("i", lance_datagen::array::step::<Int32Type>())
4289            .into_dataset(uri, FragmentCount::from(4), FragmentRowCount::from(8))
4290            .await
4291            .unwrap();
4292        dataset
4293            .create_index(
4294                &["i"],
4295                IndexType::Scalar,
4296                Some("i_idx".into()),
4297                &lance_index::scalar::ScalarIndexParams::default(),
4298                false,
4299            )
4300            .await
4301            .unwrap();
4302        // Tag the table: the sticky flag decides the entry's form.
4303        let indices: Vec<IndexMetadata> = crate::index::load_all_indices(&dataset)
4304            .await
4305            .unwrap()
4306            .as_ref()
4307            .clone();
4308        {
4309            let manifest = Arc::make_mut(&mut dataset.manifest);
4310            manifest.reader_feature_flags |= lance_table::feature_flags::FLAG_FRAGMENT_REUSE_INDEX;
4311            manifest.writer_feature_flags |= lance_table::feature_flags::FLAG_FRAGMENT_REUSE_INDEX;
4312        }
4313        crate::index::frag_reuse_reader::tests::persist_fixture(&mut dataset, indices).await;
4314
4315        // Planned and executed at V over fragments 0 and 1.
4316        let ours = CompactionOptions {
4317            target_rows_per_fragment: 16,
4318            defer_index_remap: true,
4319            excluded_fragment_ids: vec![2, 3],
4320            ..Default::default()
4321        };
4322        let planned = fresh(uri).await.unwrap();
4323        let read_version = planned.manifest.version;
4324        let completed = execute_compaction_plan(&planned, &ours).await;
4325        assert_eq!(completed.len(), 1);
4326        assert_eq!(completed[0].read_version, read_version);
4327
4328        // Another writer compacts fragments 2 and 3 at V+1.
4329        let mut other = fresh(uri).await.unwrap();
4330        let theirs = CompactionOptions {
4331            target_rows_per_fragment: 16,
4332            defer_index_remap: true,
4333            excluded_fragment_ids: vec![0, 1],
4334            ..Default::default()
4335        };
4336        compact_files(&mut other, theirs, None).await.unwrap();
4337        // The other writer reserved ids and then committed its rewrite.
4338        assert!(other.manifest.version > read_version);
4339
4340        // The driver commits from a handle at the latest version.
4341        let mut driver = fresh(uri).await.unwrap();
4342        commit_compaction(
4343            &mut driver,
4344            completed,
4345            Arc::new(DatasetIndexRemapperOptions::default()),
4346            &ours,
4347        )
4348        .await
4349        .unwrap();
4350
4351        let committed = fresh(uri).await.unwrap();
4352        assert_eq!(committed.fragments().len(), 2);
4353        let stored = crate::index::load_all_indices(&committed).await.unwrap();
4354        let entry = stored
4355            .iter()
4356            .find(|idx| idx.name == lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME)
4357            .expect("both compactions recorded their transitions");
4358        let ledger = crate::index::frag_reuse::decode_frag_reuse_ledger(&committed, entry)
4359            .await
4360            .unwrap();
4361        assert_eq!(ledger.transitions().len(), 2);
4362        let batch = committed.scan().try_into_batch().await.unwrap();
4363        let mut values: Vec<i32> = batch["i"]
4364            .as_primitive::<Int32Type>()
4365            .iter()
4366            .map(|v| v.unwrap())
4367            .collect();
4368        values.sort_unstable();
4369        assert_eq!(values, (0..32).collect::<Vec<_>>());
4370        for predicate in ["i = 3", "i = 20", "i > 10"] {
4371            let indexed = committed
4372                .scan()
4373                .filter(predicate)
4374                .unwrap()
4375                .use_scalar_index(true)
4376                .try_into_batch()
4377                .await
4378                .unwrap();
4379            let scanned = committed
4380                .scan()
4381                .filter(predicate)
4382                .unwrap()
4383                .use_scalar_index(false)
4384                .try_into_batch()
4385                .await
4386                .unwrap();
4387            assert_eq!(indexed, scanned, "{predicate}");
4388        }
4389    }
4390
4391    /// When the compaction commit's status is unknown (the commit errored and
4392    /// verification was unavailable), the rewritten files must NOT be deleted:
4393    /// if the commit landed they are referenced by the new version, and
4394    /// deleting them would corrupt the table.
4395    #[tokio::test]
4396    async fn test_compaction_retry_after_ambiguous_success_preserves_live_files() {
4397        use crate::utils::test::{AmbiguousCommitHandler, AmbiguousFailure};
4398
4399        let test_dir = TempStrDir::default();
4400        let test_uri = test_dir.as_str();
4401        let data = sample_data();
4402        let num_rows = data.num_rows();
4403        let handler = Arc::new(AmbiguousCommitHandler::default());
4404
4405        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
4406        let write_params = WriteParams {
4407            max_rows_per_file: 3_000,
4408            enable_stable_row_ids: true,
4409            commit_handler: Some(handler.clone()),
4410            ..Default::default()
4411        };
4412        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
4413            .await
4414            .unwrap();
4415        let files_before = list_data_files(test_uri);
4416        let options = CompactionOptions::default();
4417        let completed = execute_compaction_plan(&dataset, &options).await;
4418        let serialized = serde_json::to_vec(&completed).unwrap();
4419        let retry_results: Vec<RewriteResult> = serde_json::from_slice(&serialized).unwrap();
4420        let rewritten_files = list_data_files(test_uri)
4421            .difference(&files_before)
4422            .cloned()
4423            .collect::<Vec<_>>();
4424        assert!(!rewritten_files.is_empty());
4425
4426        handler.fail_next_rewrite(AmbiguousFailure::LandAndError);
4427        handler
4428            .fail_resolve
4429            .store(true, std::sync::atomic::Ordering::SeqCst);
4430        let err = commit_compaction(
4431            &mut dataset,
4432            completed,
4433            Arc::new(DatasetIndexRemapperOptions::default()),
4434            &options,
4435        )
4436        .await
4437        .expect_err("unknown commit status must surface as an error");
4438        assert!(
4439            err.is_commit_status_unknown(),
4440            "expected CommitStatusUnknown, got: {:?}",
4441            err
4442        );
4443
4444        handler
4445            .fail_resolve
4446            .store(false, std::sync::atomic::Ordering::SeqCst);
4447
4448        // Retrying the same serialized RewriteResult now conflicts with the
4449        // Rewrite that already landed. The retry must not delete those files.
4450        let retry_error = commit_compaction(
4451            &mut dataset,
4452            retry_results,
4453            Arc::new(DatasetIndexRemapperOptions::default()),
4454            &options,
4455        )
4456        .await
4457        .expect_err("the replayed rewrite must conflict with its landed predecessor");
4458        assert!(
4459            matches!(retry_error, Error::RetryableCommitConflict { .. }),
4460            "expected RetryableCommitConflict, got: {retry_error:?}"
4461        );
4462        let files_after_retry = list_data_files(test_uri);
4463        assert!(
4464            rewritten_files
4465                .iter()
4466                .all(|file| files_after_retry.contains(file)),
4467            "a replayed RewriteResult must not delete files referenced by the landed rewrite"
4468        );
4469
4470        let ds = Dataset::open(test_uri).await.unwrap();
4471        assert_eq!(ds.count_rows(None).await.unwrap(), num_rows);
4472        let scanned = ds.scan().try_into_batch().await.unwrap();
4473        assert_eq!(scanned.num_rows(), num_rows);
4474    }
4475
4476    /// A deferred (v0 ReplaceEntry) compaction whose commit lands but reports
4477    /// a conflict must be recognized as our own commit. The frag reuse
4478    /// payload is intentionally not serialized, so commit-outcome
4479    /// verification has to compare durable forms; comparing the in-memory
4480    /// transaction would misclassify the landed commit as foreign, retry
4481    /// against ourselves, and delete the landed commit's transaction file.
4482    #[tokio::test]
4483    async fn test_deferred_compaction_recognizes_ambiguous_commit_as_own() {
4484        use crate::utils::test::{AmbiguousCommitHandler, AmbiguousFailure};
4485
4486        let handler = Arc::new(AmbiguousCommitHandler::default());
4487        let mut data_gen =
4488            BatchGenerator::new().col(Box::new(IncrementingInt32::new().named("i".to_owned())));
4489        let mut dataset = Dataset::write(
4490            data_gen.batch(600),
4491            "memory://test/deferred_ambiguous",
4492            Some(WriteParams {
4493                max_rows_per_file: 100, // 6 small files -> compaction has work
4494                commit_handler: Some(handler.clone()),
4495                ..Default::default()
4496            }),
4497        )
4498        .await
4499        .unwrap();
4500        // Indexed data, so the deferred compaction carries a frag reuse
4501        // (ReplaceEntry) payload on its Rewrite transaction.
4502        create_scalar_index(&mut dataset, "i", false).await;
4503        let fragments_before = dataset.get_fragments().len();
4504
4505        let options = CompactionOptions {
4506            target_rows_per_fragment: 100_000,
4507            defer_index_remap: true,
4508            ..Default::default()
4509        };
4510        let completed = execute_compaction_plan(&dataset, &options).await;
4511
4512        // The commit lands, but the store reports a conflict.
4513        handler.fail_next_rewrite(AmbiguousFailure::LandAndConflict);
4514        commit_compaction(
4515            &mut dataset,
4516            completed,
4517            Arc::new(DatasetIndexRemapperOptions::default()),
4518            &options,
4519        )
4520        .await
4521        .expect("verification must recognize the landed compaction as our own commit");
4522
4523        assert!(
4524            dataset.get_fragments().len() < fragments_before,
4525            "the landed compaction must be visible"
4526        );
4527        let fri = dataset
4528            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
4529            .await
4530            .unwrap()
4531            .expect("the deferred compaction installs the frag reuse entry");
4532        assert_eq!(fri.index_version, 0);
4533        assert_eq!(dataset.count_rows(None).await.unwrap(), 600);
4534
4535        // The landed manifest references its transaction file; it must not
4536        // have been deleted by the (spurious) conflict cleanup.
4537        let transaction_file = dataset
4538            .manifest
4539            .transaction_file
4540            .as_deref()
4541            .expect("the landed manifest records its transaction file");
4542        let path = dataset
4543            .base
4544            .clone()
4545            .join(crate::dataset::TRANSACTIONS_DIR)
4546            .join(transaction_file);
4547        assert!(dataset.object_store.exists(&path).await.unwrap());
4548    }
4549
4550    /// A failed ReserveFragments commit cannot reference the rewritten files,
4551    /// so both stable-row-id reservation paths must still clean them up.
4552    #[tokio::test]
4553    async fn test_compaction_cleans_up_files_when_fragment_reservation_fails() {
4554        use crate::utils::test::{AmbiguousCommitHandler, AmbiguousFailure};
4555
4556        let test_dir = TempStrDir::default();
4557        let test_uri = test_dir.as_str();
4558        let data = sample_data();
4559        let num_rows = data.num_rows();
4560        let handler = Arc::new(AmbiguousCommitHandler::default());
4561
4562        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
4563        let write_params = WriteParams {
4564            max_rows_per_file: 3_000,
4565            enable_stable_row_ids: true,
4566            commit_handler: Some(handler.clone()),
4567            ..Default::default()
4568        };
4569        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
4570            .await
4571            .unwrap();
4572        let files_before = list_data_files(test_uri);
4573        let options = CompactionOptions::default();
4574        let completed = execute_compaction_plan(&dataset, &options).await;
4575        assert!(list_data_files(test_uri).len() > files_before.len());
4576
4577        handler.fail_next_reserve(AmbiguousFailure::FailOutright);
4578        let err = commit_compaction(
4579            &mut dataset,
4580            completed,
4581            Arc::new(DatasetIndexRemapperOptions::default()),
4582            &options,
4583        )
4584        .await
4585        .expect_err("fragment reservation that did not land must fail");
4586        assert!(
4587            !err.is_commit_status_unknown(),
4588            "a verified-absent reservation is a definite failure, got: {:?}",
4589            err
4590        );
4591
4592        let files_after = list_data_files(test_uri);
4593        let leftover: Vec<_> = files_after.difference(&files_before).collect();
4594        assert!(
4595            leftover.is_empty(),
4596            "rewritten files must be cleaned up after reservation fails; leftover: {:?}",
4597            leftover
4598        );
4599        let ds = Dataset::open(test_uri).await.unwrap();
4600        assert_eq!(ds.count_rows(None).await.unwrap(), num_rows);
4601    }
4602
4603    #[tokio::test]
4604    async fn test_compact_blob_columns() {
4605        let test_dir = TempStrDir::default();
4606        let schema = Arc::new(Schema::new(vec![
4607            Field::new("id", DataType::Int32, false),
4608            Field::new("blob", DataType::LargeBinary, false)
4609                .with_metadata([(BLOB_META_KEY.to_string(), "true".to_string())].into()),
4610        ]));
4611        let expected_payload: Vec<Vec<u8>> =
4612            vec![vec![1, 2, 3], vec![4, 5, 6], vec![7, 8, 9, 10], vec![11]];
4613        let id_column: ArrayRef = Arc::new(Int32Array::from_iter_values(
4614            0..expected_payload.len() as i32,
4615        ));
4616        let blob_array: ArrayRef = Arc::new(LargeBinaryArray::from_iter(
4617            expected_payload.iter().map(|value| Some(value.as_slice())),
4618        ));
4619        let batch = RecordBatch::try_new(schema.clone(), vec![id_column, blob_array]).unwrap();
4620        let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone());
4621
4622        let mut dataset = Dataset::write(
4623            reader,
4624            &test_dir,
4625            Some(WriteParams {
4626                data_storage_version: Some(LanceFileVersion::V2_1),
4627                max_rows_per_file: 1,
4628                ..Default::default()
4629            }),
4630        )
4631        .await
4632        .unwrap();
4633        dataset.validate().await.unwrap();
4634        assert!(dataset.get_fragments().len() > 1);
4635
4636        compact_files(&mut dataset, CompactionOptions::default(), None)
4637            .await
4638            .unwrap();
4639        dataset.validate().await.unwrap();
4640        assert_eq!(dataset.get_fragments().len(), 1);
4641
4642        let dataset = Arc::new(dataset);
4643        let row_indices: Vec<u64> = (0..expected_payload.len() as u64).collect();
4644        let blobs = dataset
4645            .take_blobs_by_indices(&row_indices, "blob")
4646            .await
4647            .unwrap();
4648        assert_eq!(blobs.len(), expected_payload.len());
4649        for (blob, expected) in blobs.iter().zip(expected_payload.iter()) {
4650            let bytes = blob.as_ref().unwrap().read().await.unwrap();
4651            assert_eq!(bytes.as_ref(), expected.as_slice());
4652        }
4653    }
4654
4655    fn row_addrs(frag_idx: u32, offsets: Range<u32>) -> Range<u64> {
4656        let start = RowAddress::new_from_parts(frag_idx, offsets.start);
4657        let end = RowAddress::new_from_parts(frag_idx, offsets.end);
4658        start.into()..end.into()
4659    }
4660
4661    // The outer list has one item per new fragment
4662    // The inner list has ranges of old row ids that map to the new fragment, in order
4663    fn expect_remap(
4664        ranges: &[Vec<(Range<u64>, bool)>],
4665        starting_new_frag_idx: u32,
4666    ) -> MockIndexRemapper {
4667        let mut expected_remap: HashMap<u64, Option<u64>> = HashMap::default();
4668        expected_remap.reserve(ranges.iter().map(|r| r.len()).sum());
4669        for (new_frag_offset, new_frag_ranges) in ranges.iter().enumerate() {
4670            let new_frag_idx = starting_new_frag_idx + new_frag_offset as u32;
4671            let mut row_offset = 0;
4672            for (old_id_range, is_found) in new_frag_ranges.iter() {
4673                for old_id in old_id_range.clone() {
4674                    if *is_found {
4675                        let new_id = RowAddress::new_from_parts(new_frag_idx, row_offset);
4676                        expected_remap.insert(old_id, Some(new_id.into()));
4677                        row_offset += 1;
4678                    } else {
4679                        expected_remap.insert(old_id, None);
4680                    }
4681                }
4682            }
4683        }
4684        MockIndexRemapper {
4685            expectations: vec![MockIndexRemapperExpectation {
4686                expected: expected_remap,
4687                answer: vec![],
4688            }],
4689        }
4690    }
4691
4692    #[rstest]
4693    #[tokio::test]
4694    async fn test_compact_many(
4695        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
4696        data_storage_version: LanceFileVersion,
4697    ) {
4698        let test_dir = TempStrDir::default();
4699        let test_uri = &test_dir;
4700
4701        let data = sample_data();
4702
4703        // Create a table with 3 small fragments
4704        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 1200))], data.schema());
4705        let write_params = WriteParams {
4706            max_rows_per_file: 400,
4707            data_storage_version: Some(data_storage_version),
4708            ..Default::default()
4709        };
4710        Dataset::write(reader, test_uri, Some(write_params))
4711            .await
4712            .unwrap();
4713
4714        // Append 2 large fragments (1k rows)
4715        let reader = RecordBatchIterator::new(vec![Ok(data.slice(1200, 2000))], data.schema());
4716        let write_params = WriteParams {
4717            max_rows_per_file: 1000,
4718            data_storage_version: Some(data_storage_version),
4719            mode: WriteMode::Append,
4720            ..Default::default()
4721        };
4722        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
4723            .await
4724            .unwrap();
4725
4726        // Delete 1 row from first large fragment
4727        dataset.delete("a = 1300").await.unwrap();
4728
4729        // Delete 20% of rows from second large fragment
4730        dataset.delete("a >= 2400 AND a < 2600").await.unwrap();
4731
4732        // Append 2 small fragments
4733        let reader = RecordBatchIterator::new(vec![Ok(data.slice(3200, 600))], data.schema());
4734        let write_params = WriteParams {
4735            max_rows_per_file: 300,
4736            data_storage_version: Some(data_storage_version),
4737            mode: WriteMode::Append,
4738            ..Default::default()
4739        };
4740        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
4741            .await
4742            .unwrap();
4743
4744        let first_new_frag_idx = 7;
4745        // The tasks execute concurrently, so either one may reserve the first
4746        // output fragment id.
4747        let remap_a = expect_remap(
4748            &[
4749                vec![
4750                    // 3 small fragments are rewritten to frag 7
4751                    (row_addrs(0, 0..400), true),
4752                    (row_addrs(1, 0..400), true),
4753                    (row_addrs(2, 0..400), true),
4754                ],
4755                // frag 3 is skipped since it does not have enough missing data
4756                // Frags 4, 5, and 6 are rewritten to frag 8
4757                vec![
4758                    (row_addrs(4, 0..200), true),
4759                    (row_addrs(4, 200..400), false),
4760                    (row_addrs(4, 400..1000), true),
4761                    (row_addrs(5, 0..300), true),
4762                    (row_addrs(6, 0..300), true),
4763                ],
4764            ],
4765            first_new_frag_idx,
4766        );
4767        let remap_b = expect_remap(
4768            &[
4769                // Frags 4, 5, and 6 are rewritten to frag 7
4770                vec![
4771                    (row_addrs(4, 0..200), true),
4772                    (row_addrs(4, 200..400), false),
4773                    (row_addrs(4, 400..1000), true),
4774                    (row_addrs(5, 0..300), true),
4775                    (row_addrs(6, 0..300), true),
4776                ],
4777                // 3 small fragments rewritten to frag 8
4778                vec![
4779                    (row_addrs(0, 0..400), true),
4780                    (row_addrs(1, 0..400), true),
4781                    (row_addrs(2, 0..400), true),
4782                ],
4783            ],
4784            first_new_frag_idx,
4785        );
4786
4787        // Create compaction plan
4788        let options = CompactionOptions {
4789            target_rows_per_fragment: 1000,
4790            ..Default::default()
4791        };
4792        let plan = plan_compaction(&dataset, &options).await.unwrap();
4793        assert_eq!(plan.tasks().len(), 2);
4794        assert_eq!(plan.tasks()[0].fragments.len(), 3);
4795        assert_eq!(plan.tasks()[1].fragments.len(), 3);
4796
4797        assert_eq!(
4798            plan.tasks()[0]
4799                .fragments
4800                .iter()
4801                .map(|f| f.id)
4802                .collect::<Vec<_>>(),
4803            vec![0, 1, 2]
4804        );
4805        assert_eq!(
4806            plan.tasks()[1]
4807                .fragments
4808                .iter()
4809                .map(|f| f.id)
4810                .collect::<Vec<_>>(),
4811            vec![4, 5, 6]
4812        );
4813
4814        let mock_remapper = MockIndexRemapper::in_any_order(&[remap_a, remap_b]);
4815
4816        // Run compaction
4817        let metrics = compact_files(&mut dataset, options, Some(Arc::new(mock_remapper)))
4818            .await
4819            .unwrap();
4820
4821        // Assert on metrics
4822        assert_eq!(metrics.fragments_removed, 6);
4823        assert_eq!(metrics.fragments_added, 2);
4824        assert_eq!(metrics.files_removed, 7); // 6 data files + 1 deletion file
4825        assert_eq!(metrics.files_added, 2);
4826
4827        let fragment_ids = dataset
4828            .get_fragments()
4829            .iter()
4830            .map(|f| f.id())
4831            .collect::<Vec<_>>();
4832        assert_eq!(fragment_ids, vec![3, 7, 8]);
4833    }
4834
4835    #[rstest]
4836    #[tokio::test]
4837    async fn test_compaction_does_not_strand_small_remainders(
4838        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
4839        data_storage_version: LanceFileVersion,
4840    ) {
4841        let test_dir = TempStrDir::default();
4842        let data = sample_data().slice(0, 2_000);
4843        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
4844        let mut dataset = Dataset::write(
4845            reader,
4846            &test_dir,
4847            Some(WriteParams {
4848                max_rows_per_file: 200,
4849                data_storage_version: Some(data_storage_version),
4850                ..Default::default()
4851            }),
4852        )
4853        .await
4854        .unwrap();
4855
4856        let options = CompactionOptions {
4857            target_rows_per_fragment: 500,
4858            ..Default::default()
4859        };
4860        let metrics = compact_files(&mut dataset, options.clone(), None)
4861            .await
4862            .unwrap();
4863
4864        assert_eq!(metrics.fragments_removed, 10);
4865        assert_eq!(metrics.fragments_added, 3);
4866        let mut fragment_sizes = dataset
4867            .get_fragments()
4868            .iter()
4869            .map(|fragment| fragment.metadata.physical_rows.unwrap())
4870            .collect::<Vec<_>>();
4871        fragment_sizes.sort_unstable();
4872        assert_eq!(fragment_sizes, vec![600, 600, 800]);
4873
4874        let second_metrics = compact_files(&mut dataset, options, None).await.unwrap();
4875        assert_eq!(second_metrics, CompactionMetrics::default());
4876    }
4877
4878    #[rstest]
4879    #[case::legacy(LanceFileVersion::Legacy)]
4880    #[case::stable(LanceFileVersion::Stable)]
4881    #[tokio::test]
4882    async fn test_compaction_rebalances_oversized_task(
4883        #[case] data_storage_version: LanceFileVersion,
4884    ) {
4885        let test_dir = TempStrDir::default();
4886        let data = sample_data().slice(0, 5_100);
4887        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 5_000))], data.schema());
4888        let mut dataset = Dataset::write(
4889            reader,
4890            &test_dir,
4891            Some(WriteParams {
4892                max_rows_per_file: 5_000,
4893                data_storage_version: Some(data_storage_version),
4894                ..Default::default()
4895            }),
4896        )
4897        .await
4898        .unwrap();
4899        let reader = RecordBatchIterator::new(vec![Ok(data.slice(5_000, 100))], data.schema());
4900        dataset.append(reader, None).await.unwrap();
4901
4902        dataset.delete("a < 1000").await.unwrap();
4903
4904        let options = CompactionOptions {
4905            target_rows_per_fragment: 1_000,
4906            ..Default::default()
4907        };
4908        let plan = plan_compaction(&dataset, &options).await.unwrap();
4909        assert_eq!(plan.tasks.len(), 1);
4910        assert_eq!(plan.tasks[0].fragments.len(), 2);
4911
4912        let metrics = compact_files(&mut dataset, options.clone(), None)
4913            .await
4914            .unwrap();
4915        assert_eq!(metrics.fragments_removed, 2);
4916        assert_eq!(metrics.fragments_added, 4);
4917        assert_eq!(
4918            dataset
4919                .get_fragments()
4920                .iter()
4921                .map(|fragment| fragment.metadata.physical_rows.unwrap())
4922                .collect::<Vec<_>>(),
4923            vec![1_025; 4]
4924        );
4925
4926        let second_metrics = compact_files(&mut dataset, options, None).await.unwrap();
4927        assert_eq!(second_metrics, CompactionMetrics::default());
4928    }
4929
4930    #[tokio::test]
4931    async fn test_compaction_balances_non_divisible_stable_task() {
4932        let test_dir = TempStrDir::default();
4933        let data = sample_data().slice(0, 121);
4934        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
4935        let mut dataset = Dataset::write(
4936            reader,
4937            &test_dir,
4938            Some(WriteParams {
4939                max_rows_per_file: 121,
4940                data_storage_version: Some(LanceFileVersion::Stable),
4941                ..Default::default()
4942            }),
4943        )
4944        .await
4945        .unwrap();
4946        dataset.delete("a < 20").await.unwrap();
4947
4948        let options = CompactionOptions {
4949            target_rows_per_fragment: 10,
4950            ..Default::default()
4951        };
4952        let plan = plan_compaction(&dataset, &options).await.unwrap();
4953        assert_eq!(plan.tasks.len(), 1);
4954        assert_eq!(plan.tasks[0].fragments.len(), 1);
4955
4956        let metrics = compact_files(&mut dataset, options.clone(), None)
4957            .await
4958            .unwrap();
4959        assert_eq!(metrics.fragments_removed, 1);
4960        assert_eq!(metrics.fragments_added, 10);
4961        assert_eq!(
4962            dataset
4963                .get_fragments()
4964                .iter()
4965                .map(|fragment| fragment.metadata.physical_rows.unwrap())
4966                .collect::<Vec<_>>(),
4967            [vec![11], vec![10; 9]].concat()
4968        );
4969
4970        let second_metrics = compact_files(&mut dataset, options, None).await.unwrap();
4971        assert_eq!(second_metrics, CompactionMetrics::default());
4972    }
4973
4974    /// A byte limit can close a compaction output file before its planned row
4975    /// count, and the writer then spreads the remaining rows over files of
4976    /// other sizes. The lineage placed inline has to follow the written row
4977    /// counts, and a written total that differs from the planned one is an
4978    /// error rather than lineage shifted onto the wrong rows.
4979    #[rstest]
4980    #[case::extra_file(
4981        &[10, 10],
4982        &[9, 6, 5],
4983        RowLineageSpill { row_ids: true, ..Default::default() },
4984        None
4985    )]
4986    #[case::shifted(
4987        &[101, 100, 100],
4988        &[100, 101, 100],
4989        RowLineageSpill { row_ids: true, ..Default::default() },
4990        None
4991    )]
4992    #[case::shifted_all_inline(
4993        &[101, 100, 100],
4994        &[100, 101, 100],
4995        RowLineageSpill::default(),
4996        None
4997    )]
4998    #[case::mismatched_totals(
4999        &[10, 10],
5000        &[10, 9],
5001        RowLineageSpill { row_ids: true, ..Default::default() },
5002        Some("wrote 19 rows")
5003    )]
5004    fn place_row_lineage_in_files_follows_written_sizes(
5005        #[case] planned_rows: &[u64],
5006        #[case] written_rows: &[usize],
5007        #[case] spill: RowLineageSpill,
5008        #[case] expected_error: Option<&str>,
5009    ) {
5010        let total_rows = planned_rows.iter().sum::<u64>();
5011        // Every row has a version of its own, so a sequence cut at the wrong
5012        // row cannot pass for the right one.
5013        let versions = (1..=total_rows).collect::<Vec<_>>();
5014        let mut lineages = Vec::with_capacity(planned_rows.len());
5015        let mut start = 0_u64;
5016        for rows in planned_rows {
5017            let end = start + rows;
5018            let created_at =
5019                RowDatasetVersionSequence::from_versions(&versions[start as usize..end as usize]);
5020            lineages.push(RowLineage {
5021                row_ids: RowIdSequence::from(start..end),
5022                last_updated_at: created_at.clone(),
5023                created_at,
5024            });
5025            start = end;
5026        }
5027        let columns = spill.take_columns(&mut lineages);
5028        if spill.row_ids {
5029            assert_eq!(columns, vec![(0..total_rows).collect::<Vec<_>>()]);
5030        } else {
5031            assert!(columns.is_empty(), "{columns:?}");
5032        }
5033        // Each written file carries the user column and the spilled columns.
5034        let fields = std::iter::once(0)
5035            .chain(spill.field_ids())
5036            .collect::<Vec<_>>();
5037        let column_indices = (0..fields.len() as i32).collect::<Vec<_>>();
5038        let mut fragments = written_rows
5039            .iter()
5040            .enumerate()
5041            .map(|(id, rows)| {
5042                Fragment::new(id as u64)
5043                    .with_file(
5044                        format!("{id}.lance"),
5045                        fields.clone(),
5046                        column_indices.clone(),
5047                        ConcreteFileVersion::V2_0,
5048                        None,
5049                    )
5050                    .with_physical_rows(*rows)
5051            })
5052            .collect::<Vec<_>>();
5053
5054        let placed = place_row_lineage_in_files(&mut fragments, lineages, planned_rows, spill);
5055        if let Some(expected) = expected_error {
5056            let error = placed.unwrap_err();
5057            let Error::Internal { message, .. } = &error else {
5058                panic!("expected an internal error, got {error}");
5059            };
5060            assert!(message.contains(expected), "{message}");
5061            return;
5062        }
5063        placed.unwrap();
5064        let mut placed_row_ids = Vec::with_capacity(versions.len());
5065        let mut placed_created_at = Vec::with_capacity(versions.len());
5066        let mut placed_last_updated_at = Vec::with_capacity(versions.len());
5067        for fragment in &fragments {
5068            let physical_rows = fragment.physical_rows.unwrap() as u64;
5069            match &fragment.row_id_meta {
5070                Some(RowIdMeta::Column) if spill.row_ids => {}
5071                Some(RowIdMeta::Inline(bytes)) if !spill.row_ids => {
5072                    let row_ids = read_row_ids(bytes).unwrap();
5073                    assert_eq!(row_ids.len(), physical_rows);
5074                    placed_row_ids.extend(row_ids.iter());
5075                }
5076                other => panic!(
5077                    "fragment {} has row id meta {other:?} under {spill:?}",
5078                    fragment.id
5079                ),
5080            }
5081            for (meta, collected) in [
5082                (&fragment.created_at_version_meta, &mut placed_created_at),
5083                (
5084                    &fragment.last_updated_at_version_meta,
5085                    &mut placed_last_updated_at,
5086                ),
5087            ] {
5088                let sequence = meta.as_ref().unwrap().load_sequence().unwrap();
5089                assert_eq!(sequence.len(), physical_rows);
5090                collected.extend(sequence.versions());
5091            }
5092        }
5093        if !spill.row_ids {
5094            assert_eq!(placed_row_ids, (0..total_rows).collect::<Vec<_>>());
5095        }
5096        assert_eq!(placed_created_at, versions);
5097        assert_eq!(placed_last_updated_at, versions);
5098    }
5099
5100    /// A sequence type over the budget in only some output fragments sends the
5101    /// task down the per-fragment path: once the lineage is cut at the written
5102    /// sizes, a fragment whose row ids fit keeps them inline, and only the ones
5103    /// over the budget get a lineage file of their own.
5104    #[tokio::test]
5105    async fn per_fragment_plan_spills_only_the_fragments_over_budget() {
5106        let mut dataset = lance_datagen::gen_batch()
5107            .col("i", lance_datagen::array::step::<Int32Type>())
5108            .into_ram_dataset_with_params(
5109                FragmentCount::from(1),
5110                FragmentRowCount::from(10),
5111                Some(WriteParams {
5112                    enable_stable_row_ids: true,
5113                    max_rows_per_file: 10,
5114                    ..Default::default()
5115                }),
5116            )
5117            .await
5118            .unwrap();
5119        dataset
5120            .update_config([
5121                (SPILL_ROW_LINEAGE_CONFIG_KEY, "true"),
5122                (INLINE_ROW_LINEAGE_MAX_BYTES_CONFIG_KEY, "100"),
5123            ])
5124            .await
5125            .unwrap();
5126        // A range encodes to a few bytes at any length. The scattered ids, a
5127        // permutation of 500..1000 with no runs, take about two bytes a row,
5128        // so any 250 of them are well over the 100-byte budget.
5129        let scattered = (0..500_u64)
5130            .map(|i| 500 + (i * 7919) % 500)
5131            .collect::<Vec<_>>();
5132        let row_ids = [
5133            RowIdSequence::from(0..500),
5134            RowIdSequence::from(scattered.as_slice()),
5135        ];
5136        let lineages = row_ids
5137            .into_iter()
5138            .map(|row_ids| {
5139                let created_at =
5140                    RowDatasetVersionSequence::from_uniform_row_count(row_ids.len(), 1);
5141                RowLineage {
5142                    row_ids,
5143                    last_updated_at: created_at.clone(),
5144                    created_at,
5145                }
5146            })
5147            .collect::<Vec<_>>();
5148        let plan = plan_row_lineage_spill(100, &lineages);
5149        assert_eq!(plan, RowLineagePlan::PerFragment);
5150        // Written as [250, 500, 250] rather than the planned [500, 500], so
5151        // the middle fragment joins the tail of the range to the head of the
5152        // scattered ids.
5153        let mut fragments = [250, 500, 250]
5154            .into_iter()
5155            .enumerate()
5156            .map(|(id, rows)| {
5157                Fragment::new(id as u64)
5158                    .with_file(
5159                        format!("{id}.lance"),
5160                        vec![0],
5161                        vec![0],
5162                        ConcreteFileVersion::V2_0,
5163                        None,
5164                    )
5165                    .with_physical_rows(rows)
5166            })
5167            .collect::<Vec<_>>();
5168
5169        place_planned_row_lineage(
5170            &dataset,
5171            &mut fragments,
5172            PlannedRowLineage {
5173                lineages,
5174                planned_rows: vec![500, 500],
5175                plan,
5176            },
5177        )
5178        .await
5179        .unwrap();
5180
5181        assert!(
5182            matches!(fragments[0].row_id_meta, Some(RowIdMeta::Inline(_))),
5183            "a fragment whose row ids fit must keep them inline, got {:?}",
5184            fragments[0].row_id_meta
5185        );
5186        assert_eq!(fragments[0].files.len(), 1);
5187        for fragment in &fragments[1..] {
5188            assert!(
5189                matches!(fragment.row_id_meta, Some(RowIdMeta::Column)),
5190                "fragment {}'s row ids are over the budget, got {:?}",
5191                fragment.id,
5192                fragment.row_id_meta
5193            );
5194            assert_eq!(fragment.files.len(), 2);
5195            assert_eq!(fragment.files[1].fields.as_ref(), [ROW_ID_FIELD_ID]);
5196        }
5197        let mut placed_row_ids = Vec::with_capacity(1000);
5198        for fragment in &fragments {
5199            let row_ids = match &fragment.row_id_meta {
5200                Some(RowIdMeta::Inline(bytes)) => read_row_ids(bytes).unwrap(),
5201                _ => read_spilled_row_ids(&dataset, fragment).await.unwrap(),
5202            };
5203            placed_row_ids.extend(row_ids.iter());
5204        }
5205        assert_eq!(
5206            placed_row_ids,
5207            (0..500)
5208                .chain(scattered.iter().copied())
5209                .collect::<Vec<_>>()
5210        );
5211    }
5212
5213    /// A fragment compacts on its own when one of its files is kept only for
5214    /// the row lineage it carries next to user columns that are gone. Neither
5215    /// layout compaction writes may qualify, or every compaction would plan
5216    /// another. Field -2 is the tombstone and -3..=-5 are the lineage columns;
5217    /// the schema holds fields 0 and 1, and 5 and 6 were dropped.
5218    #[rstest]
5219    #[case::in_file_carrier(vec![vec![0, 1, -3, -4, -5]], false)]
5220    #[case::lineage_only_file(vec![vec![0, 1], vec![-3, -4, -5]], false)]
5221    #[case::tombstoned_carrier(vec![vec![-2, -2, -3, -4, -5], vec![0, 1]], true)]
5222    #[case::carrier_of_dropped_columns(vec![vec![5, 6, -3, -4, -5], vec![0, 1]], true)]
5223    #[case::dead_file_without_lineage(vec![vec![-2, 6], vec![0, 1]], false)]
5224    fn compaction_reclaims_lineage_carriers_of_dead_user_columns(
5225        #[case] files: Vec<Vec<i32>>,
5226        #[case] expected: bool,
5227    ) {
5228        let mut fragment = Fragment::new(0);
5229        for (index, fields) in files.into_iter().enumerate() {
5230            let column_indices = (0..fields.len() as i32).collect();
5231            fragment.add_file(
5232                format!("{index}.lance"),
5233                fields,
5234                column_indices,
5235                ConcreteFileVersion::V2_2,
5236                None,
5237            );
5238        }
5239        let schema_field_ids = HashSet::from([0, 1]);
5240        assert_eq!(
5241            pins_dead_user_columns_for_lineage(&fragment, &schema_field_ids),
5242            expected
5243        );
5244    }
5245
5246    /// The writer sizes a column's pages by the memory its arrays hold, so a
5247    /// lineage array sharing one buffer with the whole task would make it cut
5248    /// a tiny page for every batch.
5249    #[tokio::test]
5250    async fn append_row_lineage_columns_gives_each_batch_its_own_buffer() {
5251        let batch = arrow_array::record_batch!(("i", Int32, [0, 1, 2, 3])).unwrap();
5252        let schema = batch.schema();
5253        let batches = vec![batch; 3]
5254            .into_iter()
5255            .map(Ok::<_, datafusion::error::DataFusionError>);
5256        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
5257            schema,
5258            futures::stream::iter(batches),
5259        ));
5260        let spill = RowLineageSpill {
5261            row_ids: true,
5262            ..Default::default()
5263        };
5264        let fields = spill.schema_fields().unwrap();
5265
5266        let output = append_row_lineage_columns(input, &fields, vec![(0..12).collect()])
5267            .try_collect::<Vec<_>>()
5268            .await
5269            .unwrap();
5270
5271        assert_eq!(output.len(), 3);
5272        for (index, batch) in output.iter().enumerate() {
5273            let start = index as u64 * 4;
5274            let row_ids = batch[ROW_ID].as_primitive::<UInt64Type>();
5275            assert_eq!(
5276                row_ids.values().to_vec(),
5277                (start..start + 4).collect::<Vec<_>>()
5278            );
5279            assert!(
5280                row_ids.get_buffer_memory_size() <= batch.num_rows() * 8,
5281                "batch {index}'s row ids hold {} bytes for {} rows",
5282                row_ids.get_buffer_memory_size(),
5283                batch.num_rows()
5284            );
5285        }
5286    }
5287
5288    #[rstest]
5289    #[tokio::test]
5290    async fn test_compact_data_files(
5291        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
5292        data_storage_version: LanceFileVersion,
5293    ) {
5294        let test_dir = TempStrDir::default();
5295        let test_uri = &test_dir;
5296
5297        let data = sample_data();
5298
5299        // Create a table with 2 small fragments
5300        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
5301        let write_params = WriteParams {
5302            max_rows_per_file: 5_000,
5303            max_rows_per_group: 1_000,
5304            data_storage_version: Some(data_storage_version),
5305            ..Default::default()
5306        };
5307        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
5308            .await
5309            .unwrap();
5310
5311        // Add a column
5312        let schema = Schema::new(vec![
5313            Field::new("a", DataType::Int64, false),
5314            Field::new("x", DataType::Float32, false),
5315        ]);
5316
5317        let data = RecordBatch::try_new(
5318            Arc::new(schema),
5319            vec![
5320                Arc::new(Int64Array::from_iter_values(0..10_000)),
5321                Arc::new(Float32Array::from_iter_values(
5322                    (0..10_000).map(|x| x as f32 * std::f32::consts::PI),
5323                )),
5324            ],
5325        )
5326        .unwrap();
5327        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
5328
5329        dataset.merge(reader, "a", "a").await.unwrap();
5330
5331        let expected_remap = expect_remap(
5332            &[vec![
5333                // 3 small fragments are rewritten entirely
5334                (row_addrs(0, 0..5000), true),
5335                (row_addrs(1, 0..5000), true),
5336            ]],
5337            2,
5338        );
5339
5340        let plan = plan_compaction(
5341            &dataset,
5342            &CompactionOptions {
5343                ..Default::default()
5344            },
5345        )
5346        .await
5347        .unwrap();
5348        assert_eq!(plan.tasks().len(), 1);
5349        assert_eq!(plan.tasks()[0].fragments.len(), 2);
5350
5351        let metrics = compact_files(&mut dataset, plan.options, Some(Arc::new(expected_remap)))
5352            .await
5353            .unwrap();
5354
5355        assert_eq!(metrics.files_removed, 4); // 2 fragments with 2 data files
5356        assert_eq!(metrics.files_added, 1); // 1 fragment with 1 data file
5357        assert_eq!(metrics.fragments_removed, 2);
5358        assert_eq!(metrics.fragments_added, 1);
5359
5360        // Assert order unchanged and data is all there.
5361        let scanner = dataset.scan();
5362        let batches = scanner
5363            .try_into_stream()
5364            .await
5365            .unwrap()
5366            .try_collect::<Vec<_>>()
5367            .await
5368            .unwrap();
5369        let scanned_data = concat_batches(&batches[0].schema(), &batches).unwrap();
5370
5371        assert_eq!(scanned_data, data);
5372    }
5373
5374    #[rstest]
5375    #[tokio::test]
5376    async fn test_compact_with_io_buffer_size(
5377        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
5378        data_storage_version: LanceFileVersion,
5379    ) {
5380        // Compaction should succeed and produce correct results when an
5381        // explicit io_buffer_size is provided via CompactionOptions.
5382        let test_dir = TempStrDir::default();
5383        let test_uri = &test_dir;
5384
5385        let data = sample_data();
5386
5387        // Create a table with 2 small fragments so there is something to compact.
5388        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], data.schema());
5389        let write_params = WriteParams {
5390            max_rows_per_file: 5_000,
5391            max_rows_per_group: 1_000,
5392            data_storage_version: Some(data_storage_version),
5393            ..Default::default()
5394        };
5395        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
5396            .await
5397            .unwrap();
5398        assert_eq!(dataset.get_fragments().len(), 2);
5399
5400        let options = CompactionOptions {
5401            // A generous buffer so the read does not deadlock on large batches.
5402            io_buffer_size: Some(256 * 1024 * 1024),
5403            ..Default::default()
5404        };
5405        let plan = plan_compaction(&dataset, &options).await.unwrap();
5406        assert_eq!(plan.tasks().len(), 1);
5407
5408        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
5409        assert_eq!(metrics.fragments_removed, 2);
5410        assert_eq!(metrics.fragments_added, 1);
5411
5412        // All rows are preserved after compaction.
5413        let scanner = dataset.scan();
5414        let batches = scanner
5415            .try_into_stream()
5416            .await
5417            .unwrap()
5418            .try_collect::<Vec<_>>()
5419            .await
5420            .unwrap();
5421        let scanned_data = concat_batches(&batches[0].schema(), &batches).unwrap();
5422        assert_eq!(scanned_data.num_rows(), data.num_rows());
5423    }
5424
5425    #[rstest]
5426    #[tokio::test]
5427    async fn test_compact_deletions(
5428        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
5429        data_storage_version: LanceFileVersion,
5430    ) {
5431        // For files that have few rows, we don't want to compact just 1 since
5432        // that won't do anything. But if there are deletions to materialize,
5433        // we want to do groups of 1. This test checks that.
5434        let test_dir = TempStrDir::default();
5435        let test_uri = &test_dir;
5436
5437        let data = sample_data();
5438
5439        // Create a table with 1 fragment
5440        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 1000))], data.schema());
5441        let write_params = WriteParams {
5442            max_rows_per_file: 1000,
5443            data_storage_version: Some(data_storage_version),
5444            ..Default::default()
5445        };
5446        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
5447            .await
5448            .unwrap();
5449
5450        dataset.delete("a <= 500").await.unwrap();
5451
5452        // Threshold must be satisfied
5453        let mut options = CompactionOptions {
5454            materialize_deletions_threshold: 0.8,
5455            ..Default::default()
5456        };
5457        let plan = plan_compaction(&dataset, &options).await.unwrap();
5458        assert_eq!(plan.tasks().len(), 0);
5459
5460        // Ignore deletions if materialize_deletions is false
5461        options.materialize_deletions_threshold = 0.1;
5462        options.materialize_deletions = false;
5463        let plan = plan_compaction(&dataset, &options).await.unwrap();
5464        assert_eq!(plan.tasks().len(), 0);
5465
5466        // Materialize deletions if threshold is met
5467        options.materialize_deletions = true;
5468        let plan = plan_compaction(&dataset, &options).await.unwrap();
5469        assert_eq!(plan.tasks().len(), 1);
5470
5471        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
5472        assert_eq!(metrics.fragments_removed, 1);
5473        assert_eq!(metrics.files_removed, 2);
5474        assert_eq!(metrics.fragments_added, 1);
5475
5476        let fragments = dataset.get_fragments();
5477        assert_eq!(fragments.len(), 1);
5478        assert!(fragments[0].metadata.deletion_file.is_none());
5479    }
5480
5481    #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
5482    struct IgnoreRemap {}
5483
5484    #[async_trait]
5485    impl IndexRemapper for IgnoreRemap {
5486        async fn remap_indices(&self, _: RowAddrRemap, _: &[u64]) -> Result<Vec<RemappedIndex>> {
5487            Ok(Vec::new())
5488        }
5489    }
5490
5491    #[async_trait]
5492    impl IndexRemapperOptions for IgnoreRemap {
5493        async fn create_remapper(&self, _: &Dataset) -> Result<Option<Box<dyn IndexRemapper>>> {
5494            Ok(None)
5495        }
5496    }
5497
5498    #[rstest]
5499    #[case::without_index(false)]
5500    #[case::with_index(true)]
5501    #[tokio::test]
5502    async fn test_row_addrs_only_used_with_remappable_index(#[case] has_index: bool) {
5503        let data = sample_data();
5504        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 9_000))], data.schema());
5505        let mut dataset = Dataset::write(
5506            reader,
5507            "memory://",
5508            Some(WriteParams {
5509                max_rows_per_file: 3_000,
5510                data_storage_version: Some(LanceFileVersion::Legacy),
5511                ..Default::default()
5512            }),
5513        )
5514        .await
5515        .unwrap();
5516
5517        if has_index {
5518            create_scalar_index(&mut dataset, "a", false).await;
5519        }
5520
5521        let options = CompactionOptions {
5522            target_rows_per_fragment: 9_000,
5523            ..Default::default()
5524        };
5525        let plan = plan_compaction(&dataset, &options).await.unwrap();
5526        assert_eq!(plan.tasks().len(), 1);
5527
5528        let mut result = rewrite_files(Cow::Borrowed(&dataset), plan.tasks()[0].clone(), &options)
5529            .await
5530            .unwrap();
5531        assert_eq!(result.row_addrs.is_some(), has_index);
5532
5533        if has_index {
5534            let row_addrs_bytes = result
5535                .row_addrs
5536                .as_ref()
5537                .expect("indexed compaction should capture row addresses");
5538            let row_addrs =
5539                RoaringTreemap::deserialize_from(&mut Cursor::new(row_addrs_bytes)).unwrap();
5540            assert_eq!(row_addrs.len(), 9_000);
5541            // The captured addresses are contiguous per-fragment ranges, so the
5542            // persisted blob must be run-optimized: O(fragments) bytes, not
5543            // O(rows). Without run containers this serializes at ~2 bytes per
5544            // address (~18 KB here), so under one byte per address proves the
5545            // run form was written.
5546            assert!(
5547                row_addrs_bytes.len() < row_addrs.len() as usize,
5548                "serialized row addrs ({} bytes for {} addresses) should be \
5549                 run-optimized before persisting",
5550                row_addrs_bytes.len(),
5551                row_addrs.len()
5552            );
5553        } else {
5554            // Simulate a stale worker result that captured row addresses before the
5555            // dataset no longer needed a remapper. Invalid bytes ensure the commit
5556            // does not attempt to deserialize or materialize the unused map.
5557            result.row_addrs = Some(b"not a roaring treemap".to_vec());
5558            commit_compaction(
5559                &mut dataset,
5560                vec![result],
5561                Arc::new(DatasetIndexRemapperOptions::default()),
5562                &options,
5563            )
5564            .await
5565            .unwrap();
5566            assert_eq!(dataset.get_fragments().len(), 1);
5567        }
5568    }
5569
5570    #[rstest::rstest]
5571    #[tokio::test]
5572    async fn test_compact_distributed(
5573        #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)]
5574        data_storage_version: LanceFileVersion,
5575        #[values(false, true)] use_stable_row_id: bool,
5576    ) {
5577        // Can run the tasks independently
5578        // Can provide subset of tasks to commit_compaction
5579        // Once committed, can't commit remaining tasks
5580        let test_dir = TempStrDir::default();
5581        let test_uri = &test_dir;
5582
5583        let data = sample_data();
5584
5585        // Write dataset as 9 1k row fragments
5586        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 9000))], data.schema());
5587        let write_params = WriteParams {
5588            max_rows_per_file: 1000,
5589            data_storage_version: Some(data_storage_version),
5590            enable_stable_row_ids: use_stable_row_id,
5591            ..Default::default()
5592        };
5593        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
5594            .await
5595            .unwrap();
5596
5597        // Plan compaction with 3 tasks
5598        let options = CompactionOptions {
5599            target_rows_per_fragment: 3_000,
5600            ..Default::default()
5601        };
5602        let plan = plan_compaction(&dataset, &options).await.unwrap();
5603        assert_eq!(plan.tasks().len(), 3);
5604
5605        let dataset_ref = &dataset;
5606        let mut results = futures::stream::iter(plan.compaction_tasks())
5607            .then(|task| async move { task.execute(dataset_ref).await.unwrap() })
5608            .collect::<Vec<_>>()
5609            .await;
5610
5611        assert_eq!(results.len(), 3);
5612
5613        assert_eq!(
5614            results[0]
5615                .original_fragments
5616                .iter()
5617                .map(|f| f.id)
5618                .collect::<Vec<_>>(),
5619            vec![0, 1, 2]
5620        );
5621        assert_eq!(results[0].metrics.files_removed, 3);
5622        assert_eq!(results[0].metrics.files_added, 1);
5623
5624        // Just commit the last task
5625        commit_compaction(
5626            &mut dataset,
5627            vec![results.pop().unwrap()],
5628            Arc::new(IgnoreRemap::default()),
5629            &options,
5630        )
5631        .await
5632        .unwrap();
5633
5634        // 1 commit for reserve fragments and 1 for final commit, both
5635        // from the call to commit_compaction
5636        assert_eq!(dataset.manifest.version, 3);
5637
5638        // Can commit the remaining tasks
5639        commit_compaction(
5640            &mut dataset,
5641            results,
5642            Arc::new(IgnoreRemap::default()),
5643            &options,
5644        )
5645        .await
5646        .unwrap();
5647        // 1 commit for reserve fragments and 1 for final commit, both
5648        // from the call to commit_compaction
5649        assert_eq!(dataset.manifest.version, 5);
5650
5651        assert_eq!(dataset.manifest.uses_stable_row_ids(), use_stable_row_id,);
5652    }
5653
5654    #[tokio::test]
5655    async fn test_stable_row_indices() {
5656        // Validate behavior of indices after compaction with stable row ids.
5657        let mut data_gen = BatchGenerator::new()
5658            .col(Box::new(
5659                RandomVector::new().vec_width(16).named("vec".to_owned()),
5660            ))
5661            .col(Box::new(IncrementingInt32::new().named("i".to_owned())));
5662        let mut dataset = Dataset::write(
5663            data_gen.batch(500),
5664            "memory://test/table",
5665            Some(WriteParams {
5666                enable_stable_row_ids: true,
5667                max_rows_per_file: 100, // 5 files
5668                ..Default::default()
5669            }),
5670        )
5671        .await
5672        .unwrap();
5673
5674        // Delete first 110 rows so rowids != final rowaddrs
5675        // First 100 rows deletes first file. Next 10 deletes part of second
5676        // file, so we will trigger the with deletions code path.
5677        dataset.delete("i < 110").await.unwrap();
5678
5679        dataset
5680            .create_index(
5681                &["i"],
5682                IndexType::Scalar,
5683                Some("scalar".into()),
5684                &ScalarIndexParams::default(),
5685                false,
5686            )
5687            .await
5688            .unwrap();
5689        let params = VectorIndexParams::ivf_pq(1, 8, 1, MetricType::L2, 50);
5690        dataset
5691            .create_index(
5692                &["vec"],
5693                IndexType::Vector,
5694                Some("vector".into()),
5695                &params,
5696                false,
5697            )
5698            .await
5699            .unwrap();
5700
5701        async fn index_set(dataset: &Dataset) -> HashSet<Uuid> {
5702            dataset
5703                .load_indices()
5704                .await
5705                .unwrap()
5706                .iter()
5707                .map(|index| index.uuid)
5708                .collect()
5709        }
5710        let indices = index_set(&dataset).await;
5711
5712        async fn vector_query(dataset: &Dataset) -> RecordBatch {
5713            let mut scanner = dataset.scan();
5714
5715            let query = Float32Array::from(vec![0.0f32; 16]);
5716            scanner
5717                .nearest("vec", &query, 10)
5718                .unwrap()
5719                .project(&["i"])
5720                .unwrap();
5721
5722            scanner.try_into_batch().await.unwrap()
5723        }
5724
5725        async fn scalar_query(dataset: &Dataset) -> RecordBatch {
5726            let mut scanner = dataset.scan();
5727
5728            scanner.filter("i = 100").unwrap().project(&["i"]).unwrap();
5729
5730            scanner.try_into_batch().await.unwrap()
5731        }
5732
5733        let before_vec_result = vector_query(&dataset).await;
5734        let before_scalar_result = scalar_query(&dataset).await;
5735
5736        let options = CompactionOptions {
5737            target_rows_per_fragment: 180,
5738            ..Default::default()
5739        };
5740        let _metrics = compact_files(&mut dataset, options, None).await.unwrap();
5741
5742        // The indices should be unchanged after compaction, since we are using
5743        // stable row ids.
5744        let current_indices = index_set(&dataset).await;
5745        assert_eq!(indices, current_indices);
5746
5747        let after_vec_result = vector_query(&dataset).await;
5748        assert_eq!(before_vec_result, after_vec_result);
5749
5750        let after_scalar_result = scalar_query(&dataset).await;
5751        assert_eq!(before_scalar_result, after_scalar_result);
5752    }
5753
5754    /// Regression test for https://github.com/lance-format/lance/issues/8076
5755    ///
5756    /// Zone map, bloom filter, and FM indices report matches as physical row addresses, so
5757    /// compaction invalidates it even under stable row ids. Reusing it for the rewritten
5758    /// fragments made a filtered scan fail with an internal error (a fragment referenced
5759    /// by the index no longer existed) or, once translation tolerated that, silently drop
5760    /// every match.
5761    #[rstest]
5762    #[case::zone_map(BuiltinIndexType::ZoneMap, IndexType::ZoneMap, "i", "i > 0", 199)]
5763    #[case::bloom_filter(BuiltinIndexType::BloomFilter, IndexType::BloomFilter, "i", "i = 0", 1)]
5764    #[case::fm(
5765        BuiltinIndexType::Fm,
5766        IndexType::Fm,
5767        "text",
5768        "contains(text, 'needle')",
5769        100
5770    )]
5771    #[tokio::test]
5772    async fn test_addr_domain_index_after_compaction_with_stable_row_ids(
5773        #[case] builtin: BuiltinIndexType,
5774        #[case] index_type: IndexType,
5775        #[case] indexed_column: &str,
5776        #[case] query: &str,
5777        #[case] expected_rows: usize,
5778    ) {
5779        let mut dataset = lance_datagen::gen_batch()
5780            .col("i", lance_datagen::array::step::<Int32Type>())
5781            .col(
5782                "text",
5783                lance_datagen::array::cycle_utf8_literals(&["needle", "haystack"]),
5784            )
5785            .into_ram_dataset_with_params(
5786                FragmentCount::from(2),
5787                FragmentRowCount::from(100),
5788                Some(WriteParams {
5789                    enable_stable_row_ids: true,
5790                    max_rows_per_file: 100,
5791                    ..Default::default()
5792                }),
5793            )
5794            .await
5795            .unwrap();
5796
5797        dataset
5798            .create_index(
5799                &[indexed_column],
5800                index_type,
5801                Some("addr_idx".to_string()),
5802                &ScalarIndexParams::for_builtin(builtin),
5803                false,
5804            )
5805            .await
5806            .unwrap();
5807
5808        compact_files(&mut dataset, CompactionOptions::default(), None)
5809            .await
5810            .unwrap();
5811
5812        // The index only knows the pre-compaction fragments, so it must not claim to
5813        // cover the fragment they were rewritten into.
5814        let live_fragments: RoaringBitmap =
5815            dataset.fragments().iter().map(|f| f.id as u32).collect();
5816        let index = dataset
5817            .load_indices()
5818            .await
5819            .unwrap()
5820            .iter()
5821            .find(|index| index.name == "addr_idx")
5822            .expect("index must survive compaction")
5823            .clone();
5824        assert!(
5825            index
5826                .effective_fragment_bitmap(&live_fragments)
5827                .is_none_or(|covered| covered.is_empty()),
5828            "compaction must not point an address-domain index at the fragments it wrote"
5829        );
5830
5831        // Every fragment therefore falls back to a full scan, and the filter is answered
5832        // in full.
5833        let mut scanner = dataset.scan();
5834        scanner.filter(query).unwrap();
5835        let matched = scanner.try_into_batch().await.unwrap();
5836        assert_eq!(matched.num_rows(), expected_rows);
5837    }
5838
5839    // Regression test for https://github.com/lancedb/lance/issues/6161
5840    // When FragReuseIndexDetails exceeds 204800 bytes it is written to an external
5841    // file. Previously the file was silently dropped (temp file deleted) because
5842    // tokio::io::AsyncWriteExt::shutdown was called instead of
5843    // lance_io::traits::Writer::shutdown, which persists the temp file.
5844    #[tokio::test]
5845    async fn test_defer_index_remap_large_external_file() {
5846        let test_dir = TempStrDir::default();
5847        let test_uri = &test_dir;
5848
5849        // Create ~150 fragments × 1000 rows to produce a FragReuseIndexDetails
5850        // that exceeds the 204800-byte inline threshold (~302 KB serialized).
5851        let num_fragments = 150usize;
5852        let rows_per_fragment = 1000usize;
5853        let total_rows = num_fragments * rows_per_fragment;
5854
5855        let schema = Arc::new(Schema::new(vec![Field::new("i", DataType::Int32, false)]));
5856
5857        let mut dataset = Dataset::write(
5858            RecordBatchIterator::new(
5859                vec![Ok(RecordBatch::try_new(
5860                    schema.clone(),
5861                    vec![Arc::new(Int32Array::from_iter_values(0..total_rows as i32)) as ArrayRef],
5862                )
5863                .unwrap())],
5864                schema.clone(),
5865            ),
5866            test_uri,
5867            Some(WriteParams {
5868                max_rows_per_file: rows_per_fragment,
5869                ..Default::default()
5870            }),
5871        )
5872        .await
5873        .unwrap();
5874
5875        assert_eq!(dataset.get_fragments().len(), num_fragments);
5876
5877        // An FRI is only written for compactions that touch indexed data, so
5878        // index the column being compacted.
5879        create_scalar_index(&mut dataset, "i", false).await;
5880
5881        // Delete a few rows from each fragment so compaction has something to do.
5882        dataset.delete("i % 1000 = 0").await.unwrap();
5883
5884        compact_files(
5885            &mut dataset,
5886            CompactionOptions {
5887                defer_index_remap: true,
5888                ..Default::default()
5889            },
5890            None,
5891        )
5892        .await
5893        .unwrap();
5894
5895        // Loading the FragReuseIndex details must succeed even when the details
5896        // were written to an external file.
5897        let frag_reuse_meta = dataset
5898            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
5899            .await
5900            .unwrap()
5901            .expect("fragment reuse index must exist after compaction");
5902
5903        load_frag_reuse_index_details(&dataset, &frag_reuse_meta)
5904            .await
5905            .expect("loading large frag reuse index details must not fail");
5906    }
5907
5908    /// A shallow clone stamps its index metadata with a `base_id`, and the
5909    /// entry's external `details.binpb` stays in the SOURCE dataset's indices
5910    /// directory. Loading the details on the clone must resolve the file
5911    /// through the entry's base instead of the clone's own dataset root,
5912    /// which never contained the file.
5913    #[tokio::test]
5914    async fn test_shallow_clone_loads_external_frag_reuse_details() {
5915        // On disk: base-path resolution must reach the source dataset's
5916        // store, which memory:// fixtures cannot demonstrate.
5917        let source_dir = TempStrDir::default();
5918        let clone_dir = TempStrDir::default();
5919        let clone_uri = format!("{}/clone", clone_dir);
5920
5921        use crate::index::frag_reuse::build_frag_reuse_index_metadata;
5922        use lance_index::frag_reuse::{FragDigest, FragReuseIndexDetails, FragReuseVersion};
5923
5924        let schema = Arc::new(Schema::new(vec![Field::new("i", DataType::Int32, false)]));
5925        let mut dataset = Dataset::write(
5926            RecordBatchIterator::new(
5927                vec![Ok(RecordBatch::try_new(
5928                    schema.clone(),
5929                    vec![Arc::new(Int32Array::from_iter_values(0..8)) as ArrayRef],
5930                )
5931                .unwrap())],
5932                schema.clone(),
5933            ),
5934            &source_dir,
5935            Some(WriteParams {
5936                max_rows_per_file: 4,
5937                ..Default::default()
5938            }),
5939        )
5940        .await
5941        .unwrap();
5942
5943        // An FRI entry whose details spill to the external file: real
5944        // compactions produce run-compressed bitmaps far below the
5945        // 204800-byte inline threshold, so synthesize enough reuse versions
5946        // to cross it.
5947        let digest = |id: u64| FragDigest {
5948            id,
5949            physical_rows: 4,
5950            num_deleted_rows: 0,
5951        };
5952        let mut versions = Vec::new();
5953        for i in 0..3500u64 {
5954            let old_id = 1_000 + i;
5955            let mut addrs = RoaringTreemap::new();
5956            for offset in 0..4u64 {
5957                addrs.insert((old_id << 32) + offset);
5958            }
5959            let mut serialized = Vec::new();
5960            addrs.serialize_into(&mut serialized).unwrap();
5961            versions.push(FragReuseVersion {
5962                dataset_version: i + 1,
5963                groups: vec![FragReuseGroup {
5964                    changed_row_addrs: serialized,
5965                    old_frags: vec![digest(old_id)],
5966                    new_frags: vec![digest(100_000 + i)],
5967                }],
5968            });
5969        }
5970        let details = FragReuseIndexDetails { versions };
5971        let bitmap: RoaringBitmap = (0..3500u32).map(|i| 100_000 + i).collect();
5972        let entry = build_frag_reuse_index_metadata(&dataset, None, details, bitmap)
5973            .await
5974            .unwrap();
5975        dataset
5976            .apply_commit(
5977                Transaction::new(
5978                    dataset.manifest.version,
5979                    Operation::CreateIndex {
5980                        new_indices: vec![entry],
5981                        removed_indices: vec![],
5982                    },
5983                    None,
5984                ),
5985                &Default::default(),
5986                &Default::default(),
5987            )
5988            .await
5989            .unwrap();
5990
5991        let source_meta = dataset
5992            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
5993            .await
5994            .unwrap()
5995            .expect("fragment reuse index must exist");
5996        // Sanity: the details actually went external.
5997        let proto = source_meta
5998            .index_details
5999            .as_ref()
6000            .unwrap()
6001            .to_msg::<lance_table::format::pb::FragmentReuseIndexDetails>()
6002            .unwrap();
6003        assert!(matches!(
6004            proto.content,
6005            Some(lance_table::format::pb::fragment_reuse_index_details::Content::External(_))
6006        ));
6007        let source_details = load_frag_reuse_index_details(&dataset, &source_meta)
6008            .await
6009            .unwrap();
6010        assert_eq!(source_details.versions.len(), 3500);
6011
6012        let version = dataset.manifest.version;
6013        let clone = dataset
6014            .shallow_clone(clone_uri.as_str(), version, None)
6015            .await
6016            .unwrap();
6017        let clone_meta = clone
6018            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6019            .await
6020            .unwrap()
6021            .expect("the clone must carry the fragment reuse index");
6022        assert!(clone_meta.base_id.is_some());
6023
6024        // Before the fix this failed with a not-found error: the loader
6025        // looked for details.binpb under the clone's own indices dir.
6026        let clone_details = load_frag_reuse_index_details(&clone, &clone_meta)
6027            .await
6028            .expect("the clone must load external FRI details through its base");
6029        assert_eq!(clone_details, source_details);
6030
6031        // The dataset-level open path goes through the same loader.
6032        let index = clone
6033            .open_frag_reuse_index(&NoOpMetricsCollector)
6034            .await
6035            .unwrap()
6036            .expect("the clone must open the fragment reuse index");
6037        assert_eq!(index.details.versions, source_details.versions);
6038    }
6039
6040    #[tokio::test]
6041    async fn test_defer_index_remap_rejected_with_stable_row_ids() {
6042        let test_dir = TempStrDir::default();
6043        let test_uri = &test_dir;
6044
6045        let data = sample_data();
6046        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 9000))], data.schema());
6047        let mut dataset = Dataset::write(
6048            reader,
6049            test_uri,
6050            Some(WriteParams {
6051                max_rows_per_file: 1000, // 9 fragments
6052                enable_stable_row_ids: true,
6053                ..Default::default()
6054            }),
6055        )
6056        .await
6057        .unwrap();
6058        assert!(dataset.manifest.uses_stable_row_ids());
6059
6060        let options = CompactionOptions {
6061            target_rows_per_fragment: 3_000,
6062            defer_index_remap: true,
6063            ..Default::default()
6064        };
6065
6066        // Fails at planning time, before any fragment is rewritten.
6067        let plan_err = plan_compaction(&dataset, &options).await.unwrap_err();
6068        assert!(matches!(plan_err, Error::InvalidInput { .. }));
6069        let msg = plan_err.to_string();
6070        assert!(msg.contains("defer_index_remap"));
6071        assert!(msg.contains("stable row IDs"));
6072
6073        // The full compact_files entry point fails the same way and leaves the
6074        // dataset untouched (no new manifest version, no orphaned data files).
6075        let version_before = dataset.manifest.version;
6076        let compact_err = compact_files(&mut dataset, options, None)
6077            .await
6078            .unwrap_err();
6079        assert!(matches!(compact_err, Error::InvalidInput { .. }));
6080        assert_eq!(dataset.manifest.version, version_before);
6081    }
6082
6083    #[tokio::test]
6084    async fn test_defer_index_remap() {
6085        let mut data_gen = BatchGenerator::new()
6086            .col(Box::new(
6087                RandomVector::new().vec_width(128).named("vec".to_owned()),
6088            ))
6089            .col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6090
6091        let mut dataset = Dataset::write(
6092            data_gen.batch(6_000),
6093            "memory://test/table",
6094            Some(WriteParams {
6095                max_rows_per_file: 1_000, // 6 files
6096                ..Default::default()
6097            }),
6098        )
6099        .await
6100        .unwrap();
6101
6102        // Create another same dataset to mimic behavior without deferred index remap
6103        let mut data_gen2 = BatchGenerator::new()
6104            .col(Box::new(
6105                RandomVector::new().vec_width(128).named("vec".to_owned()),
6106            ))
6107            .col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6108
6109        let mut dataset2 = Dataset::write(
6110            data_gen2.batch(6_000),
6111            "memory://test/table",
6112            Some(WriteParams {
6113                max_rows_per_file: 1_000, // 6 files
6114                ..Default::default()
6115            }),
6116        )
6117        .await
6118        .unwrap();
6119
6120        // Delete some rows to create deletions
6121        dataset.delete("i < 500").await.unwrap();
6122        dataset2.delete("i < 500").await.unwrap();
6123
6124        // Create the same scalar index on both datasets so deferred and immediate
6125        // remapping are compared under the same conditions.
6126        create_scalar_index(&mut dataset, "i", false).await;
6127        create_scalar_index(&mut dataset2, "i", false).await;
6128
6129        // Verify the initial state - no fragment reuse index should exist
6130        let initial_indices = dataset.load_indices().await.unwrap();
6131        assert_eq!(initial_indices.len(), 1);
6132        assert_eq!(initial_indices[0].name, "scalar");
6133
6134        // Store the original scalar index UUID for comparison
6135        let original_scalar_uuid = initial_indices[0].uuid;
6136
6137        // Plan and execute compaction manually
6138        let options = CompactionOptions {
6139            target_rows_per_fragment: 2_000,
6140            defer_index_remap: true,
6141            ..Default::default()
6142        };
6143        let options2 = CompactionOptions {
6144            target_rows_per_fragment: 2_000,
6145            defer_index_remap: false,
6146            ..Default::default()
6147        };
6148
6149        let plan = plan_compaction(&dataset, &options).await.unwrap();
6150        let plan2 = plan_compaction(&dataset2, &options2).await.unwrap();
6151
6152        let mut expected_all_old_frag_ids = Vec::new();
6153        let mut expected_all_new_frag_ids = Vec::new();
6154        let mut expected_all_new_frag_bitmap = RoaringBitmap::new();
6155        let mut expected_all_row_id_map = HashMap::new();
6156        let mut deferred_results = Vec::new();
6157        let mut immediate_results = Vec::new();
6158
6159        for (task, task2) in plan.tasks().iter().zip(plan2.tasks()) {
6160            let deferred_result = rewrite_files(Cow::Borrowed(&dataset), task.clone(), &options)
6161                .await
6162                .unwrap();
6163            let immediate_result =
6164                rewrite_files(Cow::Borrowed(&dataset2), task2.clone(), &options2)
6165                    .await
6166                    .unwrap();
6167
6168            // Both should produce row_addrs (address-style row IDs)
6169            assert!(deferred_result.row_addrs.is_some());
6170            assert!(!deferred_result.row_addrs.as_ref().unwrap().is_empty());
6171            assert!(!deferred_result.row_addrs.as_ref().unwrap().is_empty());
6172            assert!(!deferred_result.original_fragments.is_empty());
6173            assert!(!deferred_result.new_fragments.is_empty());
6174
6175            assert!(immediate_result.row_addrs.is_some());
6176            assert!(!immediate_result.original_fragments.is_empty());
6177            assert!(!immediate_result.new_fragments.is_empty());
6178
6179            // Both should capture the same row addresses
6180            assert_eq!(deferred_result.row_addrs, immediate_result.row_addrs);
6181
6182            deferred_results.push(deferred_result);
6183            immediate_results.push(immediate_result);
6184        }
6185
6186        // Reserve fragment IDs for immediate results to build expected values
6187        {
6188            let frags: Vec<&mut Fragment> = immediate_results
6189                .iter_mut()
6190                .flat_map(|r| r.new_fragments.iter_mut())
6191                .collect();
6192            reserve_fragment_ids(&dataset2, frags.into_iter())
6193                .await
6194                .unwrap();
6195        }
6196
6197        // Build expected values by transposing using the immediate results
6198        for immediate_result in &immediate_results {
6199            let row_addrs_bytes = immediate_result.row_addrs.as_ref().unwrap();
6200            let row_addrs =
6201                RoaringTreemap::deserialize_from(&mut Cursor::new(row_addrs_bytes)).unwrap();
6202            let transposed = transpose_row_addrs(
6203                row_addrs,
6204                &immediate_result.original_fragments,
6205                &immediate_result.new_fragments,
6206            );
6207            expected_all_row_id_map.extend(transposed);
6208            immediate_result.new_fragments.iter().for_each(|frag| {
6209                expected_all_new_frag_bitmap.insert(frag.id as u32);
6210            });
6211            expected_all_new_frag_ids.extend(
6212                immediate_result
6213                    .new_fragments
6214                    .iter()
6215                    .map(|s| s.id)
6216                    .collect::<Vec<_>>(),
6217            );
6218            expected_all_old_frag_ids.extend(
6219                immediate_result
6220                    .original_fragments
6221                    .iter()
6222                    .map(|s| s.id)
6223                    .collect::<Vec<_>>(),
6224            );
6225        }
6226
6227        // Now commit the first compaction (using deferred results)
6228        let first_metrics = commit_compaction(
6229            &mut dataset,
6230            deferred_results.clone(),
6231            Arc::new(DatasetIndexRemapperOptions::default()),
6232            &options,
6233        )
6234        .await
6235        .unwrap();
6236
6237        // Verify compaction happened
6238        assert!(first_metrics.fragments_removed > 0);
6239        assert!(first_metrics.fragments_added > 0);
6240
6241        // Load and verify the fragment reuse index content
6242        let Some(frag_reuse_index_meta) = dataset
6243            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6244            .await
6245            .unwrap()
6246        else {
6247            panic!("Fragment reuse index must be available");
6248        };
6249
6250        assert_eq!(
6251            frag_reuse_index_meta.fragment_bitmap.clone().unwrap(),
6252            expected_all_new_frag_bitmap
6253        );
6254        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
6255            .await
6256            .unwrap();
6257        let frag_reuse_index =
6258            open_frag_reuse_index(frag_reuse_index_meta.uuid, frag_reuse_details.as_ref())
6259                .await
6260                .unwrap();
6261        let stats = CompactFragReuseIndexHandle(Arc::new(frag_reuse_index.clone()))
6262            .statistics()
6263            .unwrap();
6264        assert_eq!(
6265            serde_json::to_string(&stats).unwrap(),
6266            dataset
6267                .index_statistics(FRAG_REUSE_INDEX_NAME)
6268                .await
6269                .unwrap()
6270        );
6271
6272        // Verify the index has one version with the correct dataset version
6273        let compaction_version = &frag_reuse_index.details.versions[0];
6274        assert_eq!(frag_reuse_index.details.versions.len(), 1);
6275        assert_eq!(
6276            compaction_version.dataset_version,
6277            frag_reuse_index_meta.dataset_version
6278        );
6279
6280        // Verify the index compaction version information matches the RewriteResults
6281        let mut compacted_all_old_frag_digests = Vec::new();
6282        let mut compacted_all_new_frag_digests = Vec::new();
6283        let mut transposed_map = HashMap::new();
6284        for group in compaction_version.groups.iter() {
6285            let changed_row_addr_bytes = &group.changed_row_addrs;
6286            let mut cursor = Cursor::new(&changed_row_addr_bytes);
6287            let changed_row_addrs = RoaringTreemap::deserialize_from(&mut cursor).unwrap();
6288            compacted_all_old_frag_digests.extend(group.old_frags.clone());
6289            compacted_all_new_frag_digests.extend(group.new_frags.clone());
6290
6291            let group_transposed_map = transpose_row_ids_from_digest(
6292                changed_row_addrs,
6293                &group.old_frags,
6294                &group.new_frags,
6295            );
6296            transposed_map.extend(group_transposed_map);
6297        }
6298        assert_eq!(transposed_map, expected_all_row_id_map);
6299        assert_eq!(
6300            compacted_all_old_frag_digests
6301                .iter()
6302                .map(|f| f.id)
6303                .collect::<Vec<_>>(),
6304            expected_all_old_frag_ids
6305        );
6306        assert_eq!(
6307            compacted_all_new_frag_digests
6308                .iter()
6309                .map(|f| f.id)
6310                .collect::<Vec<_>>(),
6311            expected_all_new_frag_ids
6312        );
6313
6314        // Verify the scalar index UUID is unchanged (it should not be remapped yet)
6315        let Some(current_scalar_index) = dataset.load_index_by_name("scalar").await.unwrap() else {
6316            panic!("scalar index must be available");
6317        };
6318        assert_eq!(current_scalar_index.uuid, original_scalar_uuid);
6319    }
6320
6321    #[tokio::test]
6322    async fn test_defer_index_remap_skips_fri_when_no_indexed_data() {
6323        // A deferred compaction touching no indexed data must write no FRI --
6324        // such a version is un-drainable (remap no-ops, trim retains it forever).
6325        let mut data_gen =
6326            BatchGenerator::new().col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6327
6328        let mut dataset = Dataset::write(
6329            data_gen.batch(600),
6330            "memory://test/noindex",
6331            Some(WriteParams {
6332                max_rows_per_file: 100, // 6 small files -> compaction has work
6333                ..Default::default()
6334            }),
6335        )
6336        .await
6337        .unwrap();
6338
6339        // No index at all: nothing covers any fragment.
6340        assert!(dataset.load_indices().await.unwrap().is_empty());
6341        let fragments_before = dataset.get_fragments().len();
6342        assert!(fragments_before > 1, "need multiple fragments to compact");
6343
6344        let options = CompactionOptions {
6345            target_rows_per_fragment: 100_000,
6346            defer_index_remap: true,
6347            ..Default::default()
6348        };
6349        compact_files(&mut dataset, options, None).await.unwrap();
6350
6351        // Compaction actually ran...
6352        assert!(
6353            dataset.get_fragments().len() < fragments_before,
6354            "compaction should have merged fragments"
6355        );
6356        // ...but no fragment-reuse index was created.
6357        assert!(
6358            dataset
6359                .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6360                .await
6361                .unwrap()
6362                .is_none(),
6363            "deferred compaction with no indexed data must not create an FRI"
6364        );
6365    }
6366
6367    #[tokio::test]
6368    async fn test_defer_index_remap_multiple_compactions() {
6369        let mut data_gen = BatchGenerator::new()
6370            .col(Box::new(
6371                RandomVector::new().vec_width(128).named("vec".to_owned()),
6372            ))
6373            .col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6374
6375        let mut dataset = Dataset::write(
6376            data_gen.batch(6_000),
6377            "memory://test/table",
6378            Some(WriteParams {
6379                max_rows_per_file: 1_000, // 6 files
6380                ..Default::default()
6381            }),
6382        )
6383        .await
6384        .unwrap();
6385
6386        // FRI is written only for compactions touching indexed data; index "i" so
6387        // the successive deferred compactions build a chained fragment-reuse index.
6388        create_scalar_index(&mut dataset, "i", false).await;
6389
6390        let options = CompactionOptions {
6391            target_rows_per_fragment: 2_000,
6392            defer_index_remap: true,
6393            ..Default::default()
6394        };
6395
6396        let mut compact_read_versions = Vec::new();
6397        for i in 0..10 {
6398            dataset
6399                .delete(&format!("i < {}", 500 * (i + 1)))
6400                .await
6401                .unwrap();
6402            let read_version = dataset.manifest.version;
6403            compact_files(&mut dataset, options.clone(), None)
6404                .await
6405                .unwrap();
6406
6407            // Record the read version for verification if compaction has happened
6408            if dataset.manifest.version > read_version {
6409                compact_read_versions.push(read_version);
6410            }
6411
6412            // Load and verify the fragment reuse index content
6413            let Some(frag_reuse_index_meta) = dataset
6414                .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6415                .await
6416                .unwrap()
6417            else {
6418                panic!("Fragment reuse index must be available");
6419            };
6420            let frag_reuse_details =
6421                load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
6422                    .await
6423                    .unwrap();
6424            let frag_reuse_index =
6425                open_frag_reuse_index(frag_reuse_index_meta.uuid, frag_reuse_details.as_ref())
6426                    .await
6427                    .unwrap();
6428
6429            // Verify the index has one version with the correct dataset version
6430            assert_eq!(
6431                frag_reuse_index
6432                    .details
6433                    .versions
6434                    .iter()
6435                    .map(|v| v.dataset_version)
6436                    .collect::<Vec<_>>(),
6437                compact_read_versions
6438            );
6439        }
6440    }
6441
6442    #[tokio::test]
6443    async fn test_defer_index_remap_mixed_records_all_groups() {
6444        // All-or-nothing: a compaction touching any indexed data records the full
6445        // FRI, including the unindexed group (a per-group filter would drop it).
6446        let mut data_gen =
6447            BatchGenerator::new().col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6448        let mut dataset = Dataset::write(
6449            data_gen.batch(300),
6450            "memory://test/mixed",
6451            Some(WriteParams {
6452                max_rows_per_file: 100, // 3 fragments
6453                ..Default::default()
6454            }),
6455        )
6456        .await
6457        .unwrap();
6458
6459        // Index the initial fragments, then append more that stay unindexed.
6460        create_scalar_index(&mut dataset, "i", false).await;
6461        Dataset::write(
6462            data_gen.batch(300),
6463            WriteDestination::Dataset(Arc::new(dataset.clone())),
6464            Some(WriteParams {
6465                max_rows_per_file: 100, // 3 more, unindexed
6466                mode: WriteMode::Append,
6467                ..Default::default()
6468            }),
6469        )
6470        .await
6471        .unwrap();
6472        dataset.checkout_latest().await.unwrap();
6473
6474        // Fragments not covered by the scalar index are the "unindexed" ones.
6475        let indexed: HashSet<u32> = dataset
6476            .load_index_by_name("scalar")
6477            .await
6478            .unwrap()
6479            .unwrap()
6480            .fragment_bitmap
6481            .unwrap()
6482            .iter()
6483            .collect();
6484        let unindexed_frags: Vec<u64> = dataset
6485            .fragments()
6486            .iter()
6487            .map(|f| f.id)
6488            .filter(|id| !indexed.contains(&(*id as u32)))
6489            .collect();
6490        assert!(
6491            !unindexed_frags.is_empty(),
6492            "expected some unindexed fragments"
6493        );
6494
6495        compact_files(
6496            &mut dataset,
6497            CompactionOptions {
6498                target_rows_per_fragment: 100_000,
6499                defer_index_remap: true,
6500                ..Default::default()
6501            },
6502            None,
6503        )
6504        .await
6505        .unwrap();
6506
6507        // All-or-nothing: because indexed fragments were compacted, the FRI is
6508        // written AND records the unindexed group too (a per-group filter would
6509        // have dropped it).
6510        let fri_meta = dataset
6511            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6512            .await
6513            .unwrap()
6514            .expect("mixed compaction must write an FRI");
6515        let details = load_frag_reuse_index_details(&dataset, &fri_meta)
6516            .await
6517            .unwrap();
6518        let recorded_old: HashSet<u64> = details
6519            .versions
6520            .iter()
6521            .flat_map(|v| v.old_frag_ids())
6522            .collect();
6523        for f in &unindexed_frags {
6524            assert!(
6525                recorded_old.contains(f),
6526                "unindexed fragment {f} must be recorded in the FRI (all-or-nothing)"
6527            );
6528        }
6529    }
6530
6531    #[tokio::test]
6532    async fn test_deferred_compaction_not_split_by_frag_reuse_index() {
6533        // The fragment-reuse index is a system index and must be excluded from
6534        // compaction bin planning; otherwise its covered fragment is isolated and
6535        // the small fragments never coalesce back to one.
6536        let data = sample_data();
6537        let test_dir = TempStrDir::default();
6538        let test_uri = &test_dir;
6539        let options = CompactionOptions {
6540            defer_index_remap: true,
6541            ..Default::default()
6542        };
6543
6544        // Two small fragments -> deferred compaction folds them into one,
6545        // creating the fragment-reuse index.
6546        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 400))], data.schema());
6547        let mut dataset = Dataset::write(
6548            reader,
6549            test_uri,
6550            Some(WriteParams {
6551                max_rows_per_file: 200,
6552                ..Default::default()
6553            }),
6554        )
6555        .await
6556        .unwrap();
6557
6558        // Index "a" so the deferred compaction records an FRI (only written for
6559        // compactions touching indexed data). The FRI is a system index and must
6560        // still not split later compaction bins -- the property this test guards.
6561        create_scalar_index(&mut dataset, "a", false).await;
6562        compact_files(&mut dataset, options.clone(), None)
6563            .await
6564            .unwrap();
6565        assert_eq!(dataset.get_fragments().len(), 1);
6566        assert!(
6567            dataset
6568                .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6569                .await
6570                .unwrap()
6571                .is_some()
6572        );
6573
6574        // Append two more small fragments, then compact again.
6575        let reader = RecordBatchIterator::new(vec![Ok(data.slice(400, 400))], data.schema());
6576        let mut dataset = Dataset::write(
6577            reader,
6578            test_uri,
6579            Some(WriteParams {
6580                max_rows_per_file: 200,
6581                mode: WriteMode::Append,
6582                ..Default::default()
6583            }),
6584        )
6585        .await
6586        .unwrap();
6587        assert_eq!(dataset.get_fragments().len(), 3);
6588
6589        // Reindex so every fragment is data-indexed -- then the FRI (a system
6590        // index, correctly excluded from bin planning) is the only thing that
6591        // could split the bin.
6592        create_scalar_index(&mut dataset, "a", true).await;
6593
6594        compact_files(&mut dataset, options, None).await.unwrap();
6595        assert_eq!(
6596            dataset.get_fragments().len(),
6597            1,
6598            "FRI (a system index) must not split the compaction bin; all fragments coalesce"
6599        );
6600    }
6601
6602    #[tokio::test]
6603    async fn test_remap_index_after_compaction() {
6604        let mut data_gen = BatchGenerator::new()
6605            .col(Box::new(
6606                RandomVector::new().vec_width(128).named("vec".to_owned()),
6607            ))
6608            .col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6609
6610        let mut dataset = Dataset::write(
6611            data_gen.batch(6_000),
6612            "memory://test/table",
6613            Some(WriteParams {
6614                max_rows_per_file: 1_000, // 6 files
6615                ..Default::default()
6616            }),
6617        )
6618        .await
6619        .unwrap();
6620
6621        // Create a index to be remapped
6622        let index_name = Some("scalar".into());
6623        dataset
6624            .create_index(
6625                &["i"],
6626                IndexType::Scalar,
6627                index_name.clone(),
6628                &ScalarIndexParams::default(),
6629                false,
6630            )
6631            .await
6632            .unwrap();
6633
6634        let options = CompactionOptions {
6635            target_rows_per_fragment: 2_000,
6636            defer_index_remap: true,
6637            ..Default::default()
6638        };
6639
6640        // Remap without a frag reuse index should yield unsupported
6641        let Some(scalar_index) = dataset.load_index_by_name("scalar").await.unwrap() else {
6642            panic!("scalar index must be available");
6643        };
6644
6645        let result = remapping::remap_column_index(&mut dataset, &["i"], index_name.clone()).await;
6646        assert!(matches!(result, Err(Error::NotSupported { .. })));
6647
6648        let plan = plan_compaction(&dataset, &options).await.unwrap();
6649
6650        // Commit each rewrite task separately to simulate 3 compaction runs
6651        // being accumulated in the fragment reuse index
6652        for task in plan.tasks().iter() {
6653            let rewrite_result = rewrite_files(Cow::Borrowed(&dataset), task.clone(), &options)
6654                .await
6655                .unwrap();
6656
6657            commit_compaction(
6658                &mut dataset,
6659                Vec::from([rewrite_result]),
6660                Arc::new(DatasetIndexRemapperOptions::default()),
6661                &options,
6662            )
6663            .await
6664            .unwrap();
6665        }
6666
6667        // Load and verify the fragment reuse index content
6668        let Some(frag_reuse_index_meta) = dataset
6669            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6670            .await
6671            .unwrap()
6672        else {
6673            panic!("Fragment reuse index must be available");
6674        };
6675        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
6676            .await
6677            .unwrap();
6678        let frag_reuse_index =
6679            open_frag_reuse_index(frag_reuse_index_meta.uuid, frag_reuse_details.as_ref())
6680                .await
6681                .unwrap();
6682
6683        assert_eq!(frag_reuse_index.details.versions.len(), plan.tasks().len());
6684
6685        // Check auto-remap
6686        let mut all_fragment_bitmap = RoaringBitmap::new();
6687        dataset.fragments().iter().for_each(|f| {
6688            all_fragment_bitmap.insert(f.id as u32);
6689        });
6690        let Some(scalar_index_before_remap) = dataset.load_index_by_name("scalar").await.unwrap()
6691        else {
6692            panic!("scalar index must be available");
6693        };
6694        assert_eq!(
6695            scalar_index_before_remap.fragment_bitmap.unwrap(),
6696            all_fragment_bitmap
6697        );
6698
6699        // Trigger index remap
6700        remapping::remap_column_index(&mut dataset, &["i"], index_name.clone())
6701            .await
6702            .unwrap();
6703
6704        // Compare against original index
6705        let indices = read_manifest_indexes(
6706            &dataset.object_store,
6707            &dataset.manifest_location,
6708            &dataset.manifest,
6709        )
6710        .await
6711        .unwrap();
6712        let Some(remapped_scalar_index) = indices.into_iter().find(|idx| idx.name == "scalar")
6713        else {
6714            panic!("scalar index must be available");
6715        };
6716        assert_ne!(remapped_scalar_index.uuid, scalar_index.uuid);
6717        assert_eq!(
6718            remapped_scalar_index.fragment_bitmap.unwrap(),
6719            all_fragment_bitmap
6720        );
6721    }
6722
6723    #[tokio::test]
6724    async fn test_concurrent_compaction_reindex_compaction_commit_first() {
6725        let mut data_gen = BatchGenerator::new()
6726            .col(Box::new(
6727                RandomVector::new().vec_width(128).named("vec".to_owned()),
6728            ))
6729            .col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6730
6731        let mut dataset = Dataset::write(
6732            data_gen.batch(6_000),
6733            "memory://test/table",
6734            Some(WriteParams {
6735                max_rows_per_file: 1_000, // 6 files
6736                ..Default::default()
6737            }),
6738        )
6739        .await
6740        .unwrap();
6741
6742        // Create an index
6743        let index_name = Some("scalar".into());
6744        dataset
6745            .create_index(
6746                &["i"],
6747                IndexType::Scalar,
6748                index_name.clone(),
6749                &ScalarIndexParams::default(),
6750                false,
6751            )
6752            .await
6753            .unwrap();
6754
6755        // Write some more data for reindexing
6756        Dataset::write(
6757            data_gen.batch(6_000),
6758            WriteDestination::Dataset(Arc::new(dataset.clone())),
6759            Some(WriteParams {
6760                max_rows_per_file: 1_000, // 6 files
6761                mode: WriteMode::Append,
6762                ..Default::default()
6763            }),
6764        )
6765        .await
6766        .unwrap();
6767
6768        dataset.checkout_latest().await.unwrap();
6769        let mut dataset_clone = dataset.clone();
6770
6771        // First commit a compaction with deferred remap
6772        compact_files(
6773            &mut dataset,
6774            CompactionOptions {
6775                target_rows_per_fragment: 2_000,
6776                defer_index_remap: true,
6777                ..Default::default()
6778            },
6779            None,
6780        )
6781        .await
6782        .unwrap();
6783
6784        // Concurrent reindex should succeed
6785        dataset_clone
6786            .create_index(
6787                &["i"],
6788                IndexType::Scalar,
6789                index_name.clone(),
6790                &ScalarIndexParams::default(),
6791                true,
6792            )
6793            .await
6794            .unwrap();
6795
6796        // Check new index does not cover the compacted files
6797        dataset.checkout_latest().await.unwrap();
6798
6799        let Some(scalar_index) = dataset.load_index_by_name("scalar").await.unwrap() else {
6800            panic!("scalar index must be available");
6801        };
6802        let index_frags = scalar_index
6803            .fragment_bitmap
6804            .unwrap()
6805            .iter()
6806            .collect::<HashSet<_>>();
6807        assert_eq!(
6808            index_frags,
6809            dataset
6810                .fragments()
6811                .iter()
6812                .map(|f| f.id as u32)
6813                .collect::<HashSet<_>>()
6814        )
6815    }
6816
6817    #[tokio::test]
6818    async fn test_concurrent_compaction_reindex_reindex_commit_first() {
6819        let mut data_gen = BatchGenerator::new()
6820            .col(Box::new(
6821                RandomVector::new().vec_width(128).named("vec".to_owned()),
6822            ))
6823            .col(Box::new(IncrementingInt32::new().named("i".to_owned())));
6824
6825        let mut dataset = Dataset::write(
6826            data_gen.batch(6_000),
6827            "memory://test/table",
6828            Some(WriteParams {
6829                max_rows_per_file: 1_000, // 6 files
6830                ..Default::default()
6831            }),
6832        )
6833        .await
6834        .unwrap();
6835
6836        // Create an index
6837        let index_name = Some("scalar".into());
6838        dataset
6839            .create_index(
6840                &["i"],
6841                IndexType::Scalar,
6842                index_name.clone(),
6843                &ScalarIndexParams::default(),
6844                false,
6845            )
6846            .await
6847            .unwrap();
6848
6849        // Write some more data for reindexing
6850        Dataset::write(
6851            data_gen.batch(6_000),
6852            WriteDestination::Dataset(Arc::new(dataset.clone())),
6853            Some(WriteParams {
6854                max_rows_per_file: 1_000, // 6 files
6855                mode: WriteMode::Append,
6856                ..Default::default()
6857            }),
6858        )
6859        .await
6860        .unwrap();
6861
6862        dataset.checkout_latest().await.unwrap();
6863        let mut dataset_clone = dataset.clone();
6864
6865        // Concurrent reindex should succeed
6866        dataset
6867            .create_index(
6868                &["i"],
6869                IndexType::Scalar,
6870                index_name.clone(),
6871                &ScalarIndexParams::default(),
6872                true,
6873            )
6874            .await
6875            .unwrap();
6876
6877        // First commit a compaction with deferred remap
6878        compact_files(
6879            &mut dataset_clone,
6880            CompactionOptions {
6881                target_rows_per_fragment: 2_000,
6882                defer_index_remap: true,
6883                ..Default::default()
6884            },
6885            None,
6886        )
6887        .await
6888        .unwrap();
6889
6890        // Check new index is auto-remapped
6891        dataset.checkout_latest().await.unwrap();
6892        let Some(scalar_index) = dataset.load_index_by_name("scalar").await.unwrap() else {
6893            panic!("scalar index must be available");
6894        };
6895        let index_frags = scalar_index
6896            .fragment_bitmap
6897            .unwrap()
6898            .iter()
6899            .collect::<HashSet<_>>();
6900        assert_eq!(
6901            index_frags,
6902            dataset
6903                .fragments()
6904                .iter()
6905                .map(|f| f.id as u32)
6906                .collect::<HashSet<_>>()
6907        )
6908    }
6909
6910    #[tokio::test]
6911    async fn test_concurrent_cleanup_and_compaction_rebase_cleanup() {
6912        let mut dataset = lance_datagen::gen_batch()
6913            .col(
6914                "vec",
6915                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
6916            )
6917            .col("i", lance_datagen::array::step::<Int32Type>())
6918            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
6919            .await
6920            .unwrap();
6921
6922        // Index "i" so the deferred compaction touches indexed data and writes an FRI.
6923        create_scalar_index(&mut dataset, "i", false).await;
6924
6925        let options = CompactionOptions {
6926            target_rows_per_fragment: 2_000,
6927            defer_index_remap: true,
6928            ..Default::default()
6929        };
6930
6931        let plan = plan_compaction(&dataset, &options).await.unwrap();
6932        let tasks = plan.tasks();
6933
6934        // Only compact the first task, record the state of the dataset
6935        let rewrite_result = rewrite_files(Cow::Borrowed(&dataset), tasks[0].clone(), &options)
6936            .await
6937            .unwrap();
6938
6939        commit_compaction(
6940            &mut dataset,
6941            Vec::from([rewrite_result]),
6942            Arc::new(DatasetIndexRemapperOptions::default()),
6943            &options,
6944        )
6945        .await
6946        .unwrap();
6947
6948        let mut dataset_clone = dataset.clone();
6949
6950        // Load and verify the fragment reuse index content
6951        let Some(frag_reuse_index_meta) = dataset
6952            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6953            .await
6954            .unwrap()
6955        else {
6956            panic!("Fragment reuse index must be available");
6957        };
6958
6959        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
6960            .await
6961            .unwrap();
6962        assert_eq!(frag_reuse_details.versions.len(), 1);
6963
6964        // First commit the remaining 2 compaction tasks.
6965        let rewrite_result2 = rewrite_files(Cow::Borrowed(&dataset), tasks[1].clone(), &options)
6966            .await
6967            .unwrap();
6968        let rewritten_frags2 = rewrite_result2
6969            .original_fragments
6970            .iter()
6971            .map(|f| f.id)
6972            .collect::<Vec<_>>();
6973        commit_compaction(
6974            &mut dataset,
6975            Vec::from([rewrite_result2]),
6976            Arc::new(DatasetIndexRemapperOptions::default()),
6977            &options,
6978        )
6979        .await
6980        .unwrap();
6981
6982        // Get the new fragment IDs from the frag_reuse_index after commit
6983        let frag_reuse_index_meta2 = dataset
6984            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
6985            .await
6986            .unwrap()
6987            .unwrap();
6988        let frag_reuse_details2 = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta2)
6989            .await
6990            .unwrap();
6991        let new_frags2 = frag_reuse_details2.versions.last().unwrap().new_frag_ids();
6992
6993        let rewrite_result3 = rewrite_files(Cow::Borrowed(&dataset), tasks[2].clone(), &options)
6994            .await
6995            .unwrap();
6996        let rewritten_frags3 = rewrite_result3
6997            .original_fragments
6998            .iter()
6999            .map(|f| f.id)
7000            .collect::<Vec<_>>();
7001        commit_compaction(
7002            &mut dataset,
7003            Vec::from([rewrite_result3]),
7004            Arc::new(DatasetIndexRemapperOptions::default()),
7005            &options,
7006        )
7007        .await
7008        .unwrap();
7009
7010        // Get the new fragment IDs from the frag_reuse_index after commit
7011        let frag_reuse_index_meta3 = dataset
7012            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
7013            .await
7014            .unwrap()
7015            .unwrap();
7016        let frag_reuse_details3 = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta3)
7017            .await
7018            .unwrap();
7019        let new_frags3 = frag_reuse_details3.versions.last().unwrap().new_frag_ids();
7020
7021        // Concurrently commit a frag_reuse_index cleanup operation. dataset_clone
7022        // only knows the first reuse version; catch its index up so the cleanup
7023        // removes that version. After rebase onto the other compactions it should
7024        // contain the new compaction versions.
7025        remapping::remap_column_index(&mut dataset_clone, &["i"], Some("scalar".into()))
7026            .await
7027            .unwrap();
7028        cleanup_frag_reuse_index(&mut dataset_clone).await.unwrap();
7029
7030        // Load and verify the fragment reuse index content
7031        dataset.checkout_latest().await.unwrap();
7032        let Some(frag_reuse_index_meta) = dataset
7033            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
7034            .await
7035            .unwrap()
7036        else {
7037            panic!("Fragment reuse index must be available");
7038        };
7039        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
7040            .await
7041            .unwrap();
7042        assert_eq!(frag_reuse_details.versions.len(), 2);
7043        assert_eq!(
7044            frag_reuse_details.versions[0].old_frag_ids(),
7045            rewritten_frags2
7046        );
7047        assert_eq!(frag_reuse_details.versions[0].new_frag_ids(), new_frags2);
7048        assert_eq!(
7049            frag_reuse_details.versions[1].old_frag_ids(),
7050            rewritten_frags3
7051        );
7052        assert_eq!(frag_reuse_details.versions[1].new_frag_ids(), new_frags3);
7053    }
7054
7055    #[tokio::test]
7056    async fn test_concurrent_cleanup_and_compaction_rebase_compaction() {
7057        let mut dataset = lance_datagen::gen_batch()
7058            .col(
7059                "vec",
7060                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
7061            )
7062            .col("i", lance_datagen::array::step::<Int32Type>())
7063            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
7064            .await
7065            .unwrap();
7066
7067        // Index "i" so the deferred compaction touches indexed data and writes an FRI.
7068        create_scalar_index(&mut dataset, "i", false).await;
7069
7070        let options = CompactionOptions {
7071            target_rows_per_fragment: 2_000,
7072            defer_index_remap: true,
7073            ..Default::default()
7074        };
7075
7076        let plan = plan_compaction(&dataset, &options).await.unwrap();
7077        let tasks = plan.tasks();
7078
7079        // Only compact the first task, record the state of the dataset
7080        let rewrite_result = rewrite_files(Cow::Borrowed(&dataset), tasks[0].clone(), &options)
7081            .await
7082            .unwrap();
7083
7084        commit_compaction(
7085            &mut dataset,
7086            Vec::from([rewrite_result]),
7087            Arc::new(DatasetIndexRemapperOptions::default()),
7088            &options,
7089        )
7090        .await
7091        .unwrap();
7092
7093        let mut dataset_clone = dataset.clone();
7094
7095        // Load and verify the fragment reuse index content
7096        let Some(frag_reuse_index_meta) = dataset
7097            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
7098            .await
7099            .unwrap()
7100        else {
7101            panic!("Fragment reuse index must be available");
7102        };
7103        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
7104            .await
7105            .unwrap();
7106        assert_eq!(frag_reuse_details.versions.len(), 1);
7107
7108        // Catch the index up to the compaction (on `dataset` only; `dataset_clone`
7109        // keeps the un-caught-up index for the concurrent rewrite below), then
7110        // clean up: with the index caught up the trim removes the first version.
7111        remapping::remap_column_index(&mut dataset, &["i"], Some("scalar".into()))
7112            .await
7113            .unwrap();
7114        cleanup_frag_reuse_index(&mut dataset).await.unwrap();
7115
7116        // Load and verify the fragment reuse index content
7117        dataset.checkout_latest().await.unwrap();
7118        let Some(frag_reuse_index_meta) = dataset
7119            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
7120            .await
7121            .unwrap()
7122        else {
7123            panic!("Fragment reuse index must be available");
7124        };
7125        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
7126            .await
7127            .unwrap();
7128        assert_eq!(frag_reuse_details.versions.len(), 0);
7129
7130        // Concurrently commit a rewrite
7131        // After rebase it should only contain the latest reuse version
7132        let rewrite_result2 =
7133            rewrite_files(Cow::Borrowed(&dataset_clone), tasks[1].clone(), &options)
7134                .await
7135                .unwrap();
7136        let rewritten_frags2 = rewrite_result2
7137            .original_fragments
7138            .iter()
7139            .map(|f| f.id)
7140            .collect::<Vec<_>>();
7141        commit_compaction(
7142            &mut dataset_clone,
7143            Vec::from([rewrite_result2]),
7144            Arc::new(DatasetIndexRemapperOptions::default()),
7145            &options,
7146        )
7147        .await
7148        .unwrap();
7149
7150        // Load and verify the fragment reuse index content
7151        dataset.checkout_latest().await.unwrap();
7152        let Some(frag_reuse_index_meta) = dataset
7153            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
7154            .await
7155            .unwrap()
7156        else {
7157            panic!("Fragment reuse index must be available");
7158        };
7159        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
7160            .await
7161            .unwrap();
7162        assert_eq!(frag_reuse_details.versions.len(), 1);
7163        assert_eq!(
7164            frag_reuse_details.versions[0].old_frag_ids(),
7165            rewritten_frags2
7166        );
7167        // Verify new fragment IDs are non-zero (allocated by commit_compaction)
7168        let new_frags2 = frag_reuse_details.versions[0].new_frag_ids();
7169        assert!(new_frags2.iter().all(|id| *id != 0));
7170    }
7171
7172    #[tokio::test]
7173    async fn test_concurrent_compactions_with_defer_index_remap() {
7174        let mut dataset = lance_datagen::gen_batch()
7175            .col(
7176                "vec",
7177                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
7178            )
7179            .col("i", lance_datagen::array::step::<Int32Type>())
7180            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
7181            .await
7182            .unwrap();
7183
7184        // Index "i" so the deferred compaction touches indexed data and writes an FRI.
7185        create_scalar_index(&mut dataset, "i", false).await;
7186
7187        let options = CompactionOptions {
7188            target_rows_per_fragment: 2_000,
7189            defer_index_remap: true,
7190            ..Default::default()
7191        };
7192
7193        let plan = plan_compaction(&dataset, &options).await.unwrap();
7194        let tasks = plan.tasks();
7195
7196        let mut dataset_clone = dataset.clone();
7197
7198        // Only compact the first task, record the state of the dataset
7199        let rewrite_result = rewrite_files(Cow::Borrowed(&dataset), tasks[0].clone(), &options)
7200            .await
7201            .unwrap();
7202
7203        commit_compaction(
7204            &mut dataset,
7205            Vec::from([rewrite_result]),
7206            Arc::new(DatasetIndexRemapperOptions::default()),
7207            &options,
7208        )
7209        .await
7210        .unwrap();
7211
7212        // Load and verify the fragment reuse index content
7213        let Some(frag_reuse_index_meta) = dataset
7214            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
7215            .await
7216            .unwrap()
7217        else {
7218            panic!("Fragment reuse index must be available");
7219        };
7220        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
7221            .await
7222            .unwrap();
7223        assert_eq!(frag_reuse_details.versions.len(), 1);
7224
7225        // Concurrently commit a rewrite should fail
7226        let rewrite_result2 =
7227            rewrite_files(Cow::Borrowed(&dataset_clone), tasks[1].clone(), &options)
7228                .await
7229                .unwrap();
7230        let result = commit_compaction(
7231            &mut dataset_clone,
7232            Vec::from([rewrite_result2]),
7233            Arc::new(DatasetIndexRemapperOptions::default()),
7234            &options,
7235        )
7236        .await;
7237        assert!(matches!(result, Err(Error::RetryableCommitConflict { .. })));
7238    }
7239
7240    #[tokio::test]
7241    async fn test_read_bitmap_index_with_defer_index_remap() {
7242        // Create a dataset with categorical values
7243        let mut dataset = lance_datagen::gen_batch()
7244            .col(
7245                "vec",
7246                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
7247            )
7248            .col(
7249                "category",
7250                lance_datagen::array::cycle::<Int32Type>(vec![1, 2, 3]),
7251            )
7252            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
7253            .await
7254            .unwrap();
7255
7256        // Get initial counts for each category
7257        let count1 = dataset
7258            .count_rows(Some("category = 1".to_owned()))
7259            .await
7260            .unwrap();
7261        let count2 = dataset
7262            .count_rows(Some("category = 2".to_owned()))
7263            .await
7264            .unwrap();
7265        let count3 = dataset
7266            .count_rows(Some("category = 3".to_owned()))
7267            .await
7268            .unwrap();
7269
7270        // Create a bitmap index on the category column
7271        let index_name = Some("category_idx".into());
7272        dataset
7273            .create_index(
7274                &["category"],
7275                IndexType::Bitmap,
7276                index_name.clone(),
7277                &ScalarIndexParams::default(),
7278                false,
7279            )
7280            .await
7281            .unwrap();
7282        let indices = dataset.load_indices().await.unwrap();
7283        let original_index = indices
7284            .iter()
7285            .find(|idx| idx.name == "category_idx")
7286            .unwrap();
7287
7288        // Run compaction with deferred index remapping
7289        let options = CompactionOptions {
7290            target_rows_per_fragment: 2_000,
7291            defer_index_remap: true,
7292            ..Default::default()
7293        };
7294
7295        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
7296        assert!(metrics.fragments_removed > 0);
7297        assert!(metrics.fragments_added > 0);
7298
7299        // Verify the index UUID is unchanged (it should not be remapped yet)
7300        let Some(current_index) = dataset.load_index_by_name("category_idx").await.unwrap() else {
7301            panic!("category index must be available");
7302        };
7303        assert_eq!(current_index.uuid, original_index.uuid);
7304
7305        // Verify that scans still work correctly and return the same counts
7306        assert_eq!(
7307            dataset
7308                .count_rows(Some("category = 1".to_owned()))
7309                .await
7310                .unwrap(),
7311            count1
7312        );
7313        assert_eq!(
7314            dataset
7315                .count_rows(Some("category = 2".to_owned()))
7316                .await
7317                .unwrap(),
7318            count2
7319        );
7320        assert_eq!(
7321            dataset
7322                .count_rows(Some("category = 3".to_owned()))
7323                .await
7324                .unwrap(),
7325            count3
7326        );
7327
7328        // Verify that after index creation and compaction, scan uses bitmap index scan
7329        let mut scanner = dataset.scan();
7330        scanner.filter("category = 1").unwrap();
7331        scanner.project::<String>(&[]).unwrap().with_row_id();
7332        let plan = scanner.explain_plan(false).await.unwrap();
7333        assert!(
7334            plan.contains("ScalarIndexQuery: query=[category = 1]@category_idx(Bitmap)"),
7335            "Expected index query in plan: {}",
7336            plan
7337        );
7338    }
7339
7340    #[tokio::test]
7341    async fn test_read_bloom_filter_index_with_defer_index_remap() {
7342        let mut dataset = lance_datagen::gen_batch()
7343            .col("id", lance_datagen::array::step::<Int32Type>())
7344            .into_ram_dataset(FragmentCount::from(3), FragmentRowCount::from(3))
7345            .await
7346            .unwrap();
7347
7348        dataset
7349            .create_index(
7350                &["id"],
7351                IndexType::BloomFilter,
7352                Some("id_idx".into()),
7353                &ScalarIndexParams::for_builtin(BuiltinIndexType::BloomFilter),
7354                false,
7355            )
7356            .await
7357            .unwrap();
7358
7359        let metrics = compact_files(
7360            &mut dataset,
7361            CompactionOptions {
7362                target_rows_per_fragment: 512,
7363                defer_index_remap: true,
7364                ..Default::default()
7365            },
7366            None,
7367        )
7368        .await
7369        .unwrap();
7370        assert!(metrics.fragments_removed > 0);
7371        assert!(metrics.fragments_added > 0);
7372
7373        assert_eq!(
7374            dataset.count_rows(Some("id = 2".to_owned())).await.unwrap(),
7375            1
7376        );
7377
7378        let mut scanner = dataset.scan();
7379        scanner.filter("id = 2").unwrap();
7380        let plan = scanner.explain_plan(false).await.unwrap();
7381        assert!(
7382            plan.contains("ScalarIndexQuery: query=[id = 2]@id_idx(BloomFilter)"),
7383            "Expected BloomFilter index query in plan: {plan}"
7384        );
7385    }
7386
7387    #[tokio::test]
7388    async fn test_read_zonemap_index_with_defer_index_remap() {
7389        let batch = arrow_array::record_batch!(
7390            ("id", Int32, (0..12).collect::<Vec<_>>()),
7391            (
7392                "value",
7393                Int64,
7394                [
7395                    Some(0),
7396                    None,
7397                    Some(20),
7398                    Some(30),
7399                    Some(40),
7400                    None,
7401                    Some(60),
7402                    Some(70),
7403                    Some(80),
7404                    None,
7405                    Some(100),
7406                    Some(110)
7407                ]
7408            )
7409        )
7410        .unwrap();
7411        let reader = RecordBatchIterator::new([Ok(batch.clone())], batch.schema());
7412        let mut dataset = Dataset::write(
7413            reader,
7414            "memory://",
7415            Some(WriteParams {
7416                max_rows_per_file: 4,
7417                max_rows_per_group: 4,
7418                enable_stable_row_ids: false,
7419                ..Default::default()
7420            }),
7421        )
7422        .await
7423        .unwrap();
7424        assert_eq!(dataset.get_fragments().len(), 3);
7425
7426        dataset
7427            .create_index(
7428                &["value"],
7429                IndexType::ZoneMap,
7430                Some("value_idx".into()),
7431                &ScalarIndexParams::for_builtin(BuiltinIndexType::ZoneMap),
7432                false,
7433            )
7434            .await
7435            .unwrap();
7436
7437        let metrics = compact_files(
7438            &mut dataset,
7439            CompactionOptions {
7440                target_rows_per_fragment: 512,
7441                defer_index_remap: true,
7442                ..Default::default()
7443            },
7444            None,
7445        )
7446        .await
7447        .unwrap();
7448        assert_eq!(metrics.fragments_removed, 3);
7449        assert_eq!(metrics.fragments_added, 1);
7450
7451        async fn scan_ids(dataset: &Dataset, filter: &str, use_scalar_index: bool) -> Vec<i32> {
7452            let mut scanner = dataset.scan();
7453            scanner.filter(filter).unwrap();
7454            scanner.project(&["id"]).unwrap();
7455            scanner.use_scalar_index(use_scalar_index);
7456            scanner.try_into_batch().await.unwrap()["id"]
7457                .as_primitive::<Int32Type>()
7458                .values()
7459                .to_vec()
7460        }
7461
7462        for (filter, expected) in [
7463            ("value IS NULL", vec![1, 5, 9]),
7464            ("value = 20", vec![2]),
7465            ("value > 90", vec![10, 11]),
7466        ] {
7467            assert_eq!(scan_ids(&dataset, filter, false).await, expected);
7468            assert_eq!(scan_ids(&dataset, filter, true).await, expected);
7469        }
7470
7471        let merged = dataset
7472            .merge_existing_index_segments(dataset.load_indices_by_name("value_idx").await.unwrap())
7473            .await
7474            .unwrap();
7475        dataset
7476            .commit_existing_index_segments("value_idx", "value", vec![merged])
7477            .await
7478            .unwrap();
7479
7480        for (filter, expected) in [
7481            ("value IS NULL", vec![1, 5, 9]),
7482            ("value = 20", vec![2]),
7483            ("value > 90", vec![10, 11]),
7484        ] {
7485            assert_eq!(scan_ids(&dataset, filter, false).await, expected);
7486            assert_eq!(scan_ids(&dataset, filter, true).await, expected);
7487        }
7488
7489        let mut scanner = dataset.scan();
7490        scanner.filter("value IS NULL").unwrap();
7491        let plan = scanner.explain_plan(false).await.unwrap();
7492        assert!(
7493            plan.contains("ScalarIndexQuery: query=[value IS NULL]@value_idx(ZoneMap)"),
7494            "Expected ZoneMap index query in plan: {plan}"
7495        );
7496    }
7497
7498    #[tokio::test]
7499    async fn test_read_btree_index_with_defer_index_remap() {
7500        // Create a dataset with an incremental ID column
7501        let mut dataset = lance_datagen::gen_batch()
7502            .col(
7503                "vec",
7504                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
7505            )
7506            .col("id", lance_datagen::array::step::<Int32Type>())
7507            .into_ram_dataset(FragmentCount::from(110), FragmentRowCount::from(1000))
7508            .await
7509            .unwrap();
7510
7511        // Get initial counts for some ID ranges
7512        let count_low = dataset
7513            .count_rows(Some("id < 1000".to_owned()))
7514            .await
7515            .unwrap();
7516        let count_mid = dataset
7517            .count_rows(Some("id >= 2000 and id < 3000".to_owned()))
7518            .await
7519            .unwrap();
7520        let count_high = dataset
7521            .count_rows(Some("id >= 5000".to_owned()))
7522            .await
7523            .unwrap();
7524
7525        // Create a btree index on the id column
7526        let index_name = Some("id_idx".into());
7527        dataset
7528            .create_index(
7529                &["id"],
7530                IndexType::BTree,
7531                index_name.clone(),
7532                &ScalarIndexParams::default(),
7533                false,
7534            )
7535            .await
7536            .unwrap();
7537        let indices = dataset.load_indices().await.unwrap();
7538        let original_index = indices.iter().find(|idx| idx.name == "id_idx").unwrap();
7539
7540        // Run compaction with deferred index remapping
7541        let options = CompactionOptions {
7542            target_rows_per_fragment: 50_000,
7543            defer_index_remap: true,
7544            ..Default::default()
7545        };
7546
7547        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
7548        assert!(metrics.fragments_removed > 0);
7549        assert!(metrics.fragments_added > 0);
7550
7551        // Verify the index UUID is unchanged (it should not be remapped yet)
7552        let Some(current_index) = dataset.load_index_by_name("id_idx").await.unwrap() else {
7553            panic!("id index must be available");
7554        };
7555        assert_eq!(current_index.uuid, original_index.uuid);
7556
7557        // Verify that scans still work correctly and return the same counts
7558        assert_eq!(
7559            dataset
7560                .count_rows(Some("id < 1000".to_owned()))
7561                .await
7562                .unwrap(),
7563            count_low
7564        );
7565        assert_eq!(
7566            dataset
7567                .count_rows(Some("id >= 2000 and id < 3000".to_owned()))
7568                .await
7569                .unwrap(),
7570            count_mid
7571        );
7572        assert_eq!(
7573            dataset
7574                .count_rows(Some("id >= 5000".to_owned()))
7575                .await
7576                .unwrap(),
7577            count_high
7578        );
7579
7580        // Verify that after index creation and compaction, scan uses btree index scan
7581        let mut scanner = dataset.scan();
7582        scanner.filter("id >= 2000 and id < 3000").unwrap();
7583        scanner.project::<String>(&[]).unwrap().with_row_id();
7584        let plan = scanner.explain_plan(false).await.unwrap();
7585        assert!(
7586            plan.contains("ScalarIndexQuery: query=[id >= 2000 && id < 3000]@id_idx(BTree)"),
7587            "Expected scalar index query in plan: {}",
7588            plan
7589        );
7590    }
7591
7592    #[rstest]
7593    #[case(IndexRemapMode::Compact)]
7594    #[case(IndexRemapMode::Direct)]
7595    #[tokio::test]
7596    async fn test_btree_index_remap_after_compaction(#[case] index_remap_mode: IndexRemapMode) {
7597        let mut dataset = lance_datagen::gen_batch()
7598            .col(
7599                "vec",
7600                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(32)),
7601            )
7602            .col("id", lance_datagen::array::step::<Int32Type>())
7603            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
7604            .await
7605            .unwrap();
7606
7607        // Delete rows scattered across fragments so the remap must drop some old
7608        // addresses and shift the survivors.
7609        dataset.delete("id % 10 == 0").await.unwrap();
7610
7611        dataset
7612            .create_index(
7613                &["id"],
7614                IndexType::BTree,
7615                Some("id_idx".into()),
7616                &ScalarIndexParams::default(),
7617                false,
7618            )
7619            .await
7620            .unwrap();
7621
7622        let count_low = dataset
7623            .count_rows(Some("id < 1000".to_owned()))
7624            .await
7625            .unwrap();
7626        let count_mid = dataset
7627            .count_rows(Some("id >= 2000 and id < 3000".to_owned()))
7628            .await
7629            .unwrap();
7630        let count_high = dataset
7631            .count_rows(Some("id >= 5000".to_owned()))
7632            .await
7633            .unwrap();
7634
7635        let options = CompactionOptions {
7636            target_rows_per_fragment: 50_000,
7637            index_remap_mode,
7638            ..Default::default()
7639        };
7640        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
7641        assert!(metrics.fragments_removed > 0);
7642        assert!(metrics.fragments_added > 0);
7643
7644        // The index was remapped inline and must still drive scans.
7645        let mut scanner = dataset.scan();
7646        scanner.filter("id >= 2000 and id < 3000").unwrap();
7647        scanner.project::<String>(&[]).unwrap().with_row_id();
7648        let plan = scanner.explain_plan(false).await.unwrap();
7649        assert!(
7650            plan.contains("ScalarIndexQuery: query=[id >= 2000 && id < 3000]@id_idx(BTree)"),
7651            "Expected scalar index query in plan: {}",
7652            plan
7653        );
7654
7655        // Counts resolved through the remapped index match the pre-compaction
7656        // values in both remap modes.
7657        assert_eq!(
7658            dataset
7659                .count_rows(Some("id < 1000".to_owned()))
7660                .await
7661                .unwrap(),
7662            count_low
7663        );
7664        assert_eq!(
7665            dataset
7666                .count_rows(Some("id >= 2000 and id < 3000".to_owned()))
7667                .await
7668                .unwrap(),
7669            count_mid
7670        );
7671        assert_eq!(
7672            dataset
7673                .count_rows(Some("id >= 5000".to_owned()))
7674                .await
7675                .unwrap(),
7676            count_high
7677        );
7678    }
7679
7680    #[rstest]
7681    #[case(IndexRemapMode::Compact)]
7682    #[case(IndexRemapMode::Direct)]
7683    #[tokio::test]
7684    async fn test_ivf_pq_index_remap_after_compaction(#[case] index_remap_mode: IndexRemapMode) {
7685        use arrow_array::cast::AsArray;
7686        use lance_index::vector::pq::PQBuildParams;
7687
7688        const DIM: u32 = 32;
7689        let mut dataset = lance_datagen::gen_batch()
7690            .col("id", lance_datagen::array::step::<Int32Type>())
7691            .col(
7692                "vec",
7693                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(DIM)),
7694            )
7695            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
7696            .await
7697            .unwrap();
7698
7699        let params = VectorIndexParams::with_ivf_pq_params(
7700            DistanceType::L2,
7701            small_ivf(),
7702            PQBuildParams {
7703                max_iters: 2,
7704                num_sub_vectors: 2,
7705                ..Default::default()
7706            },
7707        );
7708        dataset
7709            .create_index(
7710                &["vec"],
7711                IndexType::Vector,
7712                Some("vec_idx".into()),
7713                &params,
7714                false,
7715            )
7716            .await
7717            .unwrap();
7718        let original_uuid = dataset
7719            .load_index_by_name("vec_idx")
7720            .await
7721            .unwrap()
7722            .unwrap()
7723            .uuid;
7724
7725        // Delete rows scattered across fragments so the remap must drop some old
7726        // addresses and shift the survivors.
7727        dataset.delete("id % 10 == 0").await.unwrap();
7728
7729        // Sample queries from surviving vectors and capture the pre-compaction
7730        // KNN answer and the surviving id set.
7731        let mut survivors: Vec<(i32, Vec<f32>)> = Vec::new();
7732        {
7733            let mut scanner = dataset.scan();
7734            scanner.project(&["id", "vec"]).unwrap();
7735            let batches = scanner
7736                .try_into_stream()
7737                .await
7738                .unwrap()
7739                .try_collect::<Vec<_>>()
7740                .await
7741                .unwrap();
7742            for batch in &batches {
7743                let ids = batch["id"].as_primitive::<Int32Type>();
7744                let vecs = batch["vec"].as_fixed_size_list();
7745                for i in 0..batch.num_rows() {
7746                    let v = vecs.value(i);
7747                    survivors.push((
7748                        ids.value(i),
7749                        v.as_primitive::<Float32Type>().values().to_vec(),
7750                    ));
7751                }
7752            }
7753        }
7754        let surviving_ids: std::collections::HashSet<i32> =
7755            survivors.iter().map(|(id, _)| *id).collect();
7756        let step = (survivors.len() / 16).max(1);
7757        let queries: Vec<Vec<f32>> = survivors
7758            .iter()
7759            .step_by(step)
7760            .map(|(_, v)| v.clone())
7761            .collect();
7762        let k = 10;
7763        let mut baseline: Vec<Vec<i32>> = Vec::new();
7764        for q in &queries {
7765            baseline.push(vector_knn_ids(&dataset, q, k).await);
7766        }
7767
7768        // Inline remap (defer_index_remap = false): compaction physically
7769        // rebuilds the vector index through the configured remap mode.
7770        let metrics = compact_files(
7771            &mut dataset,
7772            CompactionOptions {
7773                target_rows_per_fragment: 50_000,
7774                index_remap_mode,
7775                ..Default::default()
7776            },
7777            None,
7778        )
7779        .await
7780        .unwrap();
7781        assert!(metrics.fragments_removed > 0);
7782        assert!(metrics.fragments_added > 0);
7783
7784        // The index was physically remapped inline, so its uuid must change.
7785        assert_ne!(
7786            dataset
7787                .load_index_by_name("vec_idx")
7788                .await
7789                .unwrap()
7790                .unwrap()
7791                .uuid,
7792            original_uuid,
7793            "vector index must be physically remapped inline"
7794        );
7795
7796        // The remap only relabels row addresses; it must not resurrect deleted
7797        // rows, and KNN must stay close to the pre-compaction answer in both
7798        // remap modes.
7799        for (i, q) in queries.iter().enumerate() {
7800            let after = vector_knn_ids(&dataset, q, k).await;
7801            for id in &after {
7802                assert!(
7803                    surviving_ids.contains(id),
7804                    "KNN returned id {id} that is not a surviving row (query #{i}, mode {index_remap_mode:?})"
7805                );
7806            }
7807            let overlap = after.iter().filter(|id| baseline[i].contains(id)).count();
7808            assert!(
7809                overlap >= 8,
7810                "KNN top-{k} diverged after compaction: overlap {overlap} < 8 (query #{i}, mode {index_remap_mode:?})"
7811            );
7812        }
7813    }
7814
7815    #[rstest]
7816    #[case(IndexRemapMode::Compact)]
7817    #[case(IndexRemapMode::Direct)]
7818    #[tokio::test]
7819    async fn test_inverted_index_remap_after_compaction(#[case] index_remap_mode: IndexRemapMode) {
7820        use arrow_array::cast::AsArray;
7821
7822        let mut dataset = lance_datagen::gen_batch()
7823            .col("id", lance_datagen::array::step::<Int32Type>())
7824            .col("doc", lance_datagen::array::random_sentence(1, 100, false))
7825            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
7826            .await
7827            .unwrap();
7828
7829        dataset
7830            .create_index(
7831                &["doc"],
7832                IndexType::Inverted,
7833                Some("doc_idx".into()),
7834                &InvertedIndexParams::default(),
7835                false,
7836            )
7837            .await
7838            .unwrap();
7839        let original_uuid = dataset
7840            .load_index_by_name("doc_idx")
7841            .await
7842            .unwrap()
7843            .unwrap()
7844            .uuid;
7845
7846        // Sample a few words from a real document to drive full-text searches.
7847        let words: Vec<String> = {
7848            let mut scanner = dataset.scan();
7849            scanner
7850                .project(&["doc"])
7851                .unwrap()
7852                .limit(Some(1), None)
7853                .unwrap();
7854            let batches = scanner
7855                .try_into_stream()
7856                .await
7857                .unwrap()
7858                .try_collect::<Vec<_>>()
7859                .await
7860                .unwrap();
7861            let mut words: Vec<String> = batches[0]["doc"]
7862                .as_string::<i32>()
7863                .value(0)
7864                .split_whitespace()
7865                .map(|s| s.to_string())
7866                .collect();
7867            words.sort();
7868            words.dedup();
7869            words.truncate(3);
7870            words
7871        };
7872        assert!(!words.is_empty(), "sampled document must contain words");
7873
7874        // Delete rows scattered across fragments so the remap must drop some old
7875        // addresses and shift the survivors.
7876        dataset.delete("id % 10 == 0").await.unwrap();
7877
7878        // Capture the post-deletion full-text-search counts (resolved through the
7879        // index + deletion vectors) before compaction physically remaps.
7880        let mut before = Vec::new();
7881        for word in &words {
7882            let mut scanner = dataset.scan();
7883            scanner
7884                .full_text_search(FullTextSearchQuery::new(word.clone()))
7885                .unwrap();
7886            scanner.project::<String>(&[]).unwrap().with_row_id();
7887            before.push(scanner.count_rows().await.unwrap());
7888        }
7889
7890        // Inline remap (defer_index_remap = false): compaction physically rebuilds
7891        // the inverted index through the configured remap mode.
7892        let options = CompactionOptions {
7893            target_rows_per_fragment: 50_000,
7894            index_remap_mode,
7895            ..Default::default()
7896        };
7897        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
7898        assert!(metrics.fragments_removed > 0);
7899        assert!(metrics.fragments_added > 0);
7900
7901        // The index was physically remapped inline, so its uuid must change.
7902        assert_ne!(
7903            dataset
7904                .load_index_by_name("doc_idx")
7905                .await
7906                .unwrap()
7907                .unwrap()
7908                .uuid,
7909            original_uuid,
7910            "inverted index must be physically remapped inline (mode {index_remap_mode:?})"
7911        );
7912
7913        // The remapped index must still drive full-text search.
7914        let mut scanner = dataset.scan();
7915        scanner
7916            .full_text_search(FullTextSearchQuery::new(words[0].clone()))
7917            .unwrap();
7918        scanner.project::<String>(&[]).unwrap().with_row_id();
7919        let plan = scanner.explain_plan(true).await.unwrap();
7920        assert!(
7921            plan.contains("MatchQuery"),
7922            "Expected inverted index scan in plan: {}",
7923            plan
7924        );
7925
7926        // Counts resolved through the remapped index match the pre-compaction
7927        // values in both remap modes.
7928        for (word, expected) in words.iter().zip(before) {
7929            let mut scanner = dataset.scan();
7930            scanner
7931                .full_text_search(FullTextSearchQuery::new(word.clone()))
7932                .unwrap();
7933            scanner.project::<String>(&[]).unwrap().with_row_id();
7934            assert_eq!(
7935                scanner.count_rows().await.unwrap(),
7936                expected,
7937                "full-text count for {word:?} changed after compaction (mode {index_remap_mode:?})"
7938            );
7939        }
7940    }
7941
7942    #[tokio::test]
7943    async fn test_read_inverted_index_with_defer_index_remap() {
7944        // Generate random words using lance-datagen
7945        let mut words_gen = lance_datagen::array::random_sentence(1, 100, true);
7946        let doc_col = words_gen
7947            .generate_default(lance_datagen::RowCount::from(6000))
7948            .unwrap();
7949
7950        let batch = RecordBatch::try_new(
7951            Schema::new(vec![Field::new("doc", DataType::LargeUtf8, false)]).into(),
7952            vec![doc_col.clone()],
7953        )
7954        .unwrap();
7955        let schema_ref = batch.schema();
7956        let stream = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema_ref);
7957        let mut dataset = Dataset::write(
7958            stream,
7959            "memory://test/table",
7960            Some(WriteParams {
7961                max_rows_per_file: 1_000, // 6 files
7962                ..Default::default()
7963            }),
7964        )
7965        .await
7966        .unwrap();
7967
7968        // Get initial counts for some word searches
7969        // Extract some test words from the generated documents
7970        let large_string_array = doc_col.as_any().downcast_ref::<LargeStringArray>().unwrap();
7971        let sample_words: Vec<String> = large_string_array
7972            .value(0)
7973            .split_whitespace()
7974            .take(10)
7975            .map(|s| s.to_string())
7976            .collect();
7977        let test_word1 = &sample_words[0];
7978        let test_word2 = &sample_words[1];
7979        let test_word3 = &sample_words[2];
7980
7981        // Create an inverted index on the doc column
7982        let index_name = Some("doc_idx".into());
7983        dataset
7984            .create_index(
7985                &["doc"],
7986                IndexType::Inverted,
7987                index_name.clone(),
7988                &InvertedIndexParams::default(),
7989                false,
7990            )
7991            .await
7992            .unwrap();
7993        let indices = dataset.load_indices().await.unwrap();
7994        let original_index = indices.iter().find(|idx| idx.name == "doc_idx").unwrap();
7995
7996        // Run compaction with deferred index remapping
7997        let options = CompactionOptions {
7998            target_rows_per_fragment: 2_000,
7999            defer_index_remap: true,
8000            ..Default::default()
8001        };
8002
8003        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
8004        assert!(metrics.fragments_removed > 0);
8005        assert!(metrics.fragments_added > 0);
8006
8007        // Verify the index UUID is unchanged (it should not be remapped yet)
8008        let Some(current_index) = dataset.load_index_by_name("doc_idx").await.unwrap() else {
8009            panic!("doc index must be available");
8010        };
8011        assert_eq!(current_index.uuid, original_index.uuid);
8012
8013        // Initial scan
8014        let mut scanner = dataset.scan();
8015        scanner
8016            .full_text_search(FullTextSearchQuery::new(test_word1.clone()))
8017            .unwrap();
8018        scanner.project::<String>(&[]).unwrap().with_row_id();
8019        let count1 = scanner.count_rows().await.unwrap();
8020        scanner = dataset.scan();
8021        scanner
8022            .full_text_search(FullTextSearchQuery::new(test_word2.clone()))
8023            .unwrap();
8024        scanner.project::<String>(&[]).unwrap().with_row_id();
8025        let count2 = scanner.count_rows().await.unwrap();
8026        scanner = dataset.scan();
8027        scanner
8028            .full_text_search(FullTextSearchQuery::new(test_word3.clone()))
8029            .unwrap();
8030        scanner.project::<String>(&[]).unwrap().with_row_id();
8031        let count3 = scanner.count_rows().await.unwrap();
8032
8033        // Verify that after index creation and compaction, scan uses inverted index scan
8034        let mut scanner = dataset.scan();
8035        scanner
8036            .full_text_search(FullTextSearchQuery::new(test_word1.clone()))
8037            .unwrap();
8038        scanner.project::<String>(&[]).unwrap().with_row_id();
8039        let plan = scanner.explain_plan(true).await.unwrap();
8040        assert!(
8041            plan.contains("MatchQuery"),
8042            "Expected inverted index scan in plan: {}",
8043            plan
8044        );
8045        assert!(
8046            !plan.contains("LanceScan"),
8047            "Expected no fragment scan in plan: {}",
8048            plan
8049        );
8050
8051        // Reindex to the latest
8052        dataset
8053            .create_index(
8054                &["doc"],
8055                IndexType::Inverted,
8056                index_name.clone(),
8057                &InvertedIndexParams::default(),
8058                true,
8059            )
8060            .await
8061            .unwrap();
8062
8063        // Verify that scans still work correctly and return the same counts
8064        let mut scanner = dataset.scan();
8065        scanner
8066            .full_text_search(FullTextSearchQuery::new(test_word1.clone()))
8067            .unwrap();
8068        scanner.project::<String>(&[]).unwrap().with_row_id();
8069        assert_eq!(scanner.count_rows().await.unwrap(), count1);
8070        scanner = dataset.scan();
8071        scanner
8072            .full_text_search(FullTextSearchQuery::new(test_word2.clone()))
8073            .unwrap();
8074        scanner.project::<String>(&[]).unwrap().with_row_id();
8075        assert_eq!(scanner.count_rows().await.unwrap(), count2);
8076        scanner = dataset.scan();
8077        scanner
8078            .full_text_search(FullTextSearchQuery::new(test_word3.clone()))
8079            .unwrap();
8080        scanner.project::<String>(&[]).unwrap().with_row_id();
8081        assert_eq!(scanner.count_rows().await.unwrap(), count3);
8082    }
8083
8084    /// Deferred compaction that materializes deletions must not corrupt an
8085    /// inverted (FTS) index read through the fragment-reuse index. The index's
8086    /// posting lists reference doc_ids positionally; if the load-time remap
8087    /// dropped the deleted rows it would renumber the doc_ids and desync the
8088    /// posting lists (out-of-bounds `num_tokens`, wrong/stale row ids). The
8089    /// tombstone-preserve-positions load path must keep results correct in the
8090    /// FRI window and after the physical remap + trim.
8091    #[tokio::test]
8092    async fn test_read_inverted_index_with_defer_index_remap_and_deletions() {
8093        // Enough surviving docs for several compressed posting-list blocks
8094        // (BLOCK_SIZE = 128), split across several fragments so compaction has
8095        // real work — but no larger.
8096        const ROWS: i32 = 1200;
8097        const DELETED: i32 = 400;
8098
8099        // Every row contains "lance", so the term matches all live rows; `id`
8100        // tells us exactly which rows survive.
8101        let ids = Int32Array::from_iter_values(0..ROWS);
8102        let docs = LargeStringArray::from_iter_values((0..ROWS).map(|_| "lance apple orange"));
8103        let batch = RecordBatch::try_new(
8104            Schema::new(vec![
8105                Field::new("id", DataType::Int32, false),
8106                Field::new("doc", DataType::LargeUtf8, false),
8107            ])
8108            .into(),
8109            vec![Arc::new(ids) as ArrayRef, Arc::new(docs) as ArrayRef],
8110        )
8111        .unwrap();
8112        let schema_ref = batch.schema();
8113        let stream = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema_ref);
8114        let mut dataset = Dataset::write(
8115            stream,
8116            "memory://test/table",
8117            Some(WriteParams {
8118                max_rows_per_file: 200, // 6 fragments
8119                ..Default::default()
8120            }),
8121        )
8122        .await
8123        .unwrap();
8124
8125        dataset
8126            .create_index(
8127                &["doc"],
8128                IndexType::Inverted,
8129                Some("doc_idx".into()),
8130                &InvertedIndexParams::default(),
8131                false,
8132            )
8133            .await
8134            .unwrap();
8135
8136        // Delete a prefix, then deferred-compact so the deletions are
8137        // materialized into the fragment-reuse index the index is read through.
8138        dataset.delete(&format!("id < {DELETED}")).await.unwrap();
8139        compact_files(
8140            &mut dataset,
8141            CompactionOptions {
8142                target_rows_per_fragment: 2_000,
8143                defer_index_remap: true,
8144                ..Default::default()
8145            },
8146            None,
8147        )
8148        .await
8149        .unwrap();
8150        assert!(
8151            dataset
8152                .load_index_by_name(FRAG_REUSE_INDEX_NAME)
8153                .await
8154                .unwrap()
8155                .is_some(),
8156            "deferred compaction must leave a fragment-reuse index"
8157        );
8158
8159        // FTS "lance" → sorted surviving ids. Projecting `id` forces a take, so
8160        // a stale row address would error or return a wrong/dead row.
8161        async fn search_ids(dataset: &Dataset) -> Vec<i32> {
8162            let mut scanner = dataset.scan();
8163            scanner
8164                .full_text_search(FullTextSearchQuery::new("lance".to_owned()))
8165                .unwrap();
8166            scanner.project::<&str>(&["id"]).unwrap();
8167            let batches = scanner
8168                .try_into_stream()
8169                .await
8170                .unwrap()
8171                .try_collect::<Vec<_>>()
8172                .await
8173                .unwrap();
8174            let mut ids: Vec<i32> = batches
8175                .iter()
8176                .flat_map(|b| {
8177                    b.column_by_name("id")
8178                        .unwrap()
8179                        .as_any()
8180                        .downcast_ref::<Int32Array>()
8181                        .unwrap()
8182                        .values()
8183                        .to_vec()
8184                })
8185                .collect();
8186            ids.sort_unstable();
8187            ids
8188        }
8189
8190        let expected = (DELETED..ROWS).collect::<Vec<_>>();
8191
8192        // FRI window: index read through the reuse index.
8193        let during = search_ids(&dataset).await;
8194        assert_eq!(
8195            during, expected,
8196            "FRI-window FTS must return exactly the surviving rows (no resurrection, no loss, no stale rows)"
8197        );
8198
8199        // Physical remap + trim: must still be correct.
8200        remapping::remap_column_index(&mut dataset, &["doc"], Some("doc_idx".into()))
8201            .await
8202            .unwrap();
8203        cleanup_frag_reuse_index(&mut dataset).await.unwrap();
8204        let after = search_ids(&dataset).await;
8205        assert_eq!(
8206            after, expected,
8207            "FTS must stay correct after physical remap + fragment-reuse trim"
8208        );
8209    }
8210
8211    #[tokio::test]
8212    async fn test_read_ngram_index_with_defer_index_remap() {
8213        // Generate random words using lance-datagen
8214        let mut words_gen = lance_datagen::array::random_sentence(1, 100, true);
8215        let doc_col = words_gen
8216            .generate_default(lance_datagen::RowCount::from(6000))
8217            .unwrap();
8218
8219        let batch = RecordBatch::try_new(
8220            Schema::new(vec![Field::new("doc", DataType::LargeUtf8, false)]).into(),
8221            vec![doc_col.clone()],
8222        )
8223        .unwrap();
8224        let schema_ref = batch.schema();
8225        let stream = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema_ref);
8226        let mut dataset = Dataset::write(
8227            stream,
8228            "memory://test/table",
8229            Some(WriteParams {
8230                max_rows_per_file: 1_000, // 6 files
8231                ..Default::default()
8232            }),
8233        )
8234        .await
8235        .unwrap();
8236
8237        // Get initial counts for some word searches
8238        // Extract some test words from the generated documents
8239        let large_string_array = doc_col.as_any().downcast_ref::<LargeStringArray>().unwrap();
8240        let sample_words: Vec<String> = large_string_array
8241            .value(0)
8242            .split_whitespace()
8243            .take(10)
8244            .map(|s| s.to_string())
8245            .collect();
8246        let test_word1 = &sample_words[0];
8247        let test_word2 = &sample_words[1];
8248        let test_word3 = &sample_words[2];
8249
8250        // Create an inverted index on the doc column
8251        let index_name = Some("doc_idx".into());
8252        dataset
8253            .create_index(
8254                &["doc"],
8255                IndexType::NGram,
8256                index_name.clone(),
8257                &ScalarIndexParams::default(),
8258                false,
8259            )
8260            .await
8261            .unwrap();
8262        let indices = dataset.load_indices().await.unwrap();
8263        let original_index = indices.iter().find(|idx| idx.name == "doc_idx").unwrap();
8264
8265        // Initial scan
8266        let count1 = dataset
8267            .count_rows(Some(format!("contains(doc, '{}')", test_word1)))
8268            .await
8269            .unwrap();
8270        let count2 = dataset
8271            .count_rows(Some(format!("contains(doc, '{}')", test_word2)))
8272            .await
8273            .unwrap();
8274        let count3 = dataset
8275            .count_rows(Some(format!("contains(doc, '{}')", test_word3)))
8276            .await
8277            .unwrap();
8278
8279        // Run compaction with deferred index remapping
8280        let options = CompactionOptions {
8281            target_rows_per_fragment: 2_000,
8282            defer_index_remap: true,
8283            ..Default::default()
8284        };
8285
8286        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
8287        assert!(metrics.fragments_removed > 0);
8288        assert!(metrics.fragments_added > 0);
8289
8290        // Verify the index UUID is unchanged (it should not be remapped yet)
8291        let Some(current_index) = dataset.load_index_by_name("doc_idx").await.unwrap() else {
8292            panic!("doc index must be available");
8293        };
8294        assert_eq!(current_index.uuid, original_index.uuid);
8295
8296        // Verify that scans still work correctly and return the same counts
8297        assert_eq!(
8298            dataset
8299                .count_rows(Some(format!("contains(doc, '{}')", test_word1)))
8300                .await
8301                .unwrap(),
8302            count1
8303        );
8304        assert_eq!(
8305            dataset
8306                .count_rows(Some(format!("contains(doc, '{}')", test_word2)))
8307                .await
8308                .unwrap(),
8309            count2
8310        );
8311        assert_eq!(
8312            dataset
8313                .count_rows(Some(format!("contains(doc, '{}')", test_word3)))
8314                .await
8315                .unwrap(),
8316            count3
8317        );
8318
8319        // Verify that after index creation and compaction, scan uses inverted index scan
8320        let mut scanner = dataset.scan();
8321        scanner
8322            .filter(&format!("contains(doc, '{}')", test_word1))
8323            .unwrap();
8324        scanner.project::<String>(&[]).unwrap().with_row_id();
8325        let plan = scanner.explain_plan(false).await.unwrap();
8326        assert!(
8327            plan.contains("ScalarIndexQuery: query=[contains(doc, Utf8"),
8328            "Expected scalar index query in plan: {}",
8329            plan
8330        );
8331    }
8332
8333    #[tokio::test]
8334    async fn test_read_label_list_index_with_defer_index_remap() {
8335        // Create a dataset with list data for labels
8336        let mut dataset = lance_datagen::gen_batch()
8337            .col(
8338                "vec",
8339                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
8340            )
8341            .col(
8342                "labels",
8343                lance_datagen::array::rand_list_any(
8344                    lance_datagen::array::cycle::<Int64Type>(vec![1, 2, 3, 4, 5]),
8345                    false,
8346                ),
8347            )
8348            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
8349            .await
8350            .unwrap();
8351
8352        // Get initial counts for different label values
8353        let count1 = dataset
8354            .count_rows(Some("array_has_any(labels, [1])".to_owned()))
8355            .await
8356            .unwrap();
8357        let count2 = dataset
8358            .count_rows(Some("array_has_any(labels, [5])".to_owned()))
8359            .await
8360            .unwrap();
8361        let count3 = dataset
8362            .count_rows(Some("array_has_any(labels, [10])".to_owned()))
8363            .await
8364            .unwrap();
8365
8366        // Create a label list index on the labels column
8367        let index_name = Some("labels_idx".into());
8368        dataset
8369            .create_index(
8370                &["labels"],
8371                IndexType::LabelList,
8372                index_name.clone(),
8373                &ScalarIndexParams::default(),
8374                false,
8375            )
8376            .await
8377            .unwrap();
8378        let indices = dataset.load_indices().await.unwrap();
8379        let original_index = indices.iter().find(|idx| idx.name == "labels_idx").unwrap();
8380
8381        // Run compaction with deferred index remapping
8382        let options = CompactionOptions {
8383            target_rows_per_fragment: 2000,
8384            defer_index_remap: true,
8385            ..Default::default()
8386        };
8387        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
8388        assert!(metrics.fragments_removed > 0);
8389        assert!(metrics.fragments_added > 0);
8390
8391        // Verify that the index UUID remains unchanged
8392        let indices = dataset.load_indices().await.unwrap();
8393        let current_index = indices.iter().find(|idx| idx.name == "labels_idx").unwrap();
8394        assert_eq!(current_index.uuid, original_index.uuid);
8395
8396        // Verify that scans still work correctly and return the same counts
8397        assert_eq!(
8398            dataset
8399                .count_rows(Some("array_has_any(labels, [1])".to_owned()))
8400                .await
8401                .unwrap(),
8402            count1
8403        );
8404        assert_eq!(
8405            dataset
8406                .count_rows(Some("array_has_any(labels, [5])".to_owned()))
8407                .await
8408                .unwrap(),
8409            count2
8410        );
8411        assert_eq!(
8412            dataset
8413                .count_rows(Some("array_has_any(labels, [10])".to_owned()))
8414                .await
8415                .unwrap(),
8416            count3
8417        );
8418
8419        // Verify that after index creation and compaction, scan uses label list index scan
8420        let mut scanner = dataset.scan();
8421        scanner.filter("array_has_any(labels, [1])").unwrap();
8422        scanner.project::<String>(&[]).unwrap().with_row_id();
8423        let plan = scanner.explain_plan(false).await.unwrap();
8424        assert!(
8425            plan.contains(
8426                "ScalarIndexQuery: query=[array_has_any(labels, List([1]))]@labels_idx(LabelList)",
8427            ),
8428            "Expected scalar index query in plan: {}",
8429            plan
8430        );
8431    }
8432
8433    #[tokio::test]
8434    async fn test_read_ivf_pq_index_v3_with_defer_index_remap() {
8435        // Create a dataset with vector data
8436        let mut dataset = lance_datagen::gen_batch()
8437            .col(
8438                "vec",
8439                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
8440            )
8441            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
8442            .await
8443            .unwrap();
8444
8445        // Get some query vectors for KNN search
8446        let query_vec1: PrimitiveArray<Float32Type> =
8447            PrimitiveArray::from_iter_values(std::iter::repeat_n(0.0, 128));
8448        let query_vec2: PrimitiveArray<Float32Type> =
8449            PrimitiveArray::from_iter_values(std::iter::repeat_n(1.1, 128));
8450        let query_vec3: PrimitiveArray<Float32Type> =
8451            PrimitiveArray::from_iter_values(std::iter::repeat_n(2.2, 128));
8452
8453        // Get initial KNN search results
8454        let mut scanner = dataset.scan();
8455        scanner.nearest("vec", &query_vec1, 10).unwrap();
8456        scanner.project::<String>(&[]).unwrap().with_row_id();
8457        let results1 = scanner
8458            .try_into_stream()
8459            .await
8460            .unwrap()
8461            .try_collect::<Vec<_>>()
8462            .await
8463            .unwrap();
8464        let count1 = results1.len();
8465
8466        scanner = dataset.scan();
8467        scanner.nearest("vec", &query_vec2, 10).unwrap();
8468        scanner.project::<String>(&[]).unwrap().with_row_id();
8469        let results2 = scanner
8470            .try_into_stream()
8471            .await
8472            .unwrap()
8473            .try_collect::<Vec<_>>()
8474            .await
8475            .unwrap();
8476        let count2 = results2.len();
8477
8478        scanner = dataset.scan();
8479        scanner.nearest("vec", &query_vec3, 10).unwrap();
8480        scanner.project::<String>(&[]).unwrap().with_row_id();
8481        let results3 = scanner
8482            .try_into_stream()
8483            .await
8484            .unwrap()
8485            .try_collect::<Vec<_>>()
8486            .await
8487            .unwrap();
8488        let count3 = results3.len();
8489
8490        // Create an IVF-PQ index on the vec column
8491        let index_name = Some("vec_idx".into());
8492        dataset
8493            .create_index(
8494                &["vec"],
8495                IndexType::Vector,
8496                index_name.clone(),
8497                &VectorIndexParams {
8498                    metric_type: DistanceType::L2,
8499                    stages: vec![
8500                        StageParams::Ivf(IvfBuildParams {
8501                            max_iters: 2,
8502                            num_partitions: Some(2),
8503                            sample_rate: 2,
8504                            ..Default::default()
8505                        }),
8506                        StageParams::PQ(PQBuildParams {
8507                            max_iters: 2,
8508                            num_sub_vectors: 2,
8509                            ..Default::default()
8510                        }),
8511                    ],
8512                    version: crate::index::vector::IndexFileVersion::V3,
8513                    skip_transpose: false,
8514                    runtime_hints: Default::default(),
8515                },
8516                false,
8517            )
8518            .await
8519            .unwrap();
8520        let indices = dataset.load_indices().await.unwrap();
8521        let original_index = indices.iter().find(|idx| idx.name == "vec_idx").unwrap();
8522
8523        // Run compaction with deferred index remapping
8524        let options = CompactionOptions {
8525            target_rows_per_fragment: 2_000,
8526            defer_index_remap: true,
8527            ..Default::default()
8528        };
8529
8530        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
8531        assert!(metrics.fragments_removed > 0);
8532        assert!(metrics.fragments_added > 0);
8533
8534        // Verify the index UUID is unchanged (it should not be remapped yet)
8535        let Some(current_index) = dataset.load_index_by_name("vec_idx").await.unwrap() else {
8536            panic!("vec index must be available");
8537        };
8538        assert_eq!(current_index.uuid, original_index.uuid);
8539
8540        // Verify that KNN searches still work correctly and return the same counts
8541        let mut scanner = dataset.scan();
8542        scanner.nearest("vec", &query_vec1, 10).unwrap();
8543        scanner.project::<String>(&[]).unwrap().with_row_id();
8544        let new_results1 = scanner
8545            .try_into_stream()
8546            .await
8547            .unwrap()
8548            .try_collect::<Vec<_>>()
8549            .await
8550            .unwrap();
8551        assert_eq!(new_results1.len(), count1);
8552
8553        scanner = dataset.scan();
8554        scanner.nearest("vec", &query_vec2, 10).unwrap();
8555        scanner.project::<String>(&[]).unwrap().with_row_id();
8556        let new_results2 = scanner
8557            .try_into_stream()
8558            .await
8559            .unwrap()
8560            .try_collect::<Vec<_>>()
8561            .await
8562            .unwrap();
8563        assert_eq!(new_results2.len(), count2);
8564
8565        scanner = dataset.scan();
8566        scanner.nearest("vec", &query_vec3, 10).unwrap();
8567        scanner.project::<String>(&[]).unwrap().with_row_id();
8568        let new_results3 = scanner
8569            .try_into_stream()
8570            .await
8571            .unwrap()
8572            .try_collect::<Vec<_>>()
8573            .await
8574            .unwrap();
8575        assert_eq!(new_results3.len(), count3);
8576
8577        // Verify that after index creation and compaction, scan uses vector index scan
8578        let mut scanner = dataset.scan();
8579        scanner.nearest("vec", &query_vec1, 10).unwrap();
8580        scanner.project::<String>(&[]).unwrap().with_row_id();
8581        let plan = scanner.explain_plan(false).await.unwrap();
8582        assert!(
8583            plan.contains("ANNSubIndex"),
8584            "Expected vector index scan in plan: {}",
8585            plan
8586        );
8587        assert!(
8588            !plan.contains("LanceScan"),
8589            "Expected no fragment scan in plan: {}",
8590            plan
8591        );
8592    }
8593
8594    #[tokio::test]
8595    async fn test_read_ivf_rq_index_v3_with_defer_index_remap() {
8596        use arrow_array::cast::AsArray;
8597        use lance_index::vector::bq::RQBuildParams;
8598
8599        let mut dataset = lance_datagen::gen_batch()
8600            .col(
8601                "vec",
8602                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
8603            )
8604            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
8605            .await
8606            .unwrap();
8607
8608        let stored: Vec<Vec<f32>> = {
8609            let mut scanner = dataset.scan();
8610            scanner.project(&["vec"]).unwrap();
8611            let batches = scanner
8612                .try_into_stream()
8613                .await
8614                .unwrap()
8615                .try_collect::<Vec<_>>()
8616                .await
8617                .unwrap();
8618            let mut out = Vec::new();
8619            for batch in &batches {
8620                let vecs = batch["vec"].as_fixed_size_list();
8621                for i in 0..batch.num_rows() {
8622                    let values = vecs.value(i);
8623                    let values = values.as_primitive::<Float32Type>();
8624                    out.push(values.values().to_vec());
8625                }
8626            }
8627            out
8628        };
8629
8630        let index_name = Some("vec_idx".into());
8631        dataset
8632            .create_index(
8633                &["vec"],
8634                IndexType::Vector,
8635                index_name.clone(),
8636                &VectorIndexParams {
8637                    metric_type: DistanceType::L2,
8638                    stages: vec![
8639                        StageParams::Ivf(IvfBuildParams {
8640                            max_iters: 2,
8641                            num_partitions: Some(2),
8642                            sample_rate: 2,
8643                            ..Default::default()
8644                        }),
8645                        StageParams::RQ(RQBuildParams::new(1)),
8646                    ],
8647                    version: crate::index::vector::IndexFileVersion::V3,
8648                    skip_transpose: false,
8649                    runtime_hints: Default::default(),
8650                },
8651                false,
8652            )
8653            .await
8654            .unwrap();
8655        let indices = dataset.load_indices().await.unwrap();
8656        let original_index = indices.iter().find(|idx| idx.name == "vec_idx").unwrap();
8657
8658        let options = CompactionOptions {
8659            target_rows_per_fragment: 2_000,
8660            defer_index_remap: true,
8661            ..Default::default()
8662        };
8663        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
8664        assert!(metrics.fragments_removed > 0);
8665        assert!(metrics.fragments_added > 0);
8666
8667        let Some(current_index) = dataset.load_index_by_name("vec_idx").await.unwrap() else {
8668            panic!("vec index must be available");
8669        };
8670        assert_eq!(current_index.uuid, original_index.uuid);
8671
8672        let frag_reuse_present = dataset
8673            .load_indices()
8674            .await
8675            .unwrap()
8676            .iter()
8677            .any(|idx| idx.name == FRAG_REUSE_INDEX_NAME);
8678        assert!(
8679            frag_reuse_present,
8680            "defer_index_remap must record a {} index",
8681            FRAG_REUSE_INDEX_NAME
8682        );
8683
8684        let sample_step = (stored.len() / 8).max(1);
8685        let mut checked = 0;
8686        for query in stored.iter().step_by(sample_step) {
8687            let query_vec = PrimitiveArray::<Float32Type>::from_iter_values(query.iter().copied());
8688            let mut scanner = dataset.scan();
8689            scanner.nearest("vec", &query_vec, 5).unwrap();
8690            scanner.project(&["vec"]).unwrap().with_row_id();
8691            let batches = scanner
8692                .try_into_stream()
8693                .await
8694                .unwrap()
8695                .try_collect::<Vec<_>>()
8696                .await
8697                .unwrap();
8698            assert!(!batches.is_empty(), "query returned no batches");
8699            let top = &batches[0];
8700            assert!(top.num_rows() > 0, "query returned empty top batch");
8701            let top_vec = top["vec"].as_fixed_size_list().value(0);
8702            let top_vec = top_vec.as_primitive::<Float32Type>();
8703            assert_eq!(
8704                top_vec.values(),
8705                query.as_slice(),
8706                "top-1 self-recall returned a different vector than the query"
8707            );
8708            checked += 1;
8709        }
8710        assert!(checked > 0, "expected to check at least one stored vector");
8711    }
8712
8713    /// Build an `id` + `vec` dataset, create the given IVF vector index,
8714    /// optionally delete rows, then run deferred compaction (which materializes
8715    /// the deletions into the fragment-reuse index) and assert that KNN over
8716    /// surviving vectors during the FRI window (a) never returns a deleted row
8717    /// and (b) stays consistent with the pre-compaction answer.
8718    ///
8719    /// The deletion path is the interesting one: materialized deletions drop
8720    /// rows from the quantization storage at load time, which shifts storage
8721    /// positions. Flat storage (FLAT/PQ/SQ/RQ) is scanned linearly so this is
8722    /// fine, but the HNSW graph addresses storage positionally and is not
8723    /// frag-reuse aware, so a desync would surface here as recall collapse or a
8724    /// resurrected/again-deleted row.
8725    /// Top-k `id`s for a KNN query against the `vec` column.
8726    async fn vector_knn_ids(dataset: &Dataset, query: &[f32], k: usize) -> Vec<i32> {
8727        use arrow_array::cast::AsArray;
8728        use arrow_array::types::{Float32Type, Int32Type};
8729        let qa = PrimitiveArray::<Float32Type>::from_iter_values(query.iter().copied());
8730        let mut scanner = dataset.scan();
8731        scanner.nearest("vec", &qa, k).unwrap();
8732        scanner.project(&["id"]).unwrap();
8733        let batches = scanner
8734            .try_into_stream()
8735            .await
8736            .unwrap()
8737            .try_collect::<Vec<_>>()
8738            .await
8739            .unwrap();
8740        let mut ids = Vec::new();
8741        for b in &batches {
8742            ids.extend(b["id"].as_primitive::<Int32Type>().values().iter().copied());
8743        }
8744        ids
8745    }
8746
8747    async fn check_vector_defer_compaction(
8748        params: VectorIndexParams,
8749        delete_predicate: Option<&str>,
8750        k: usize,
8751        min_overlap: usize,
8752    ) {
8753        use arrow_array::cast::AsArray;
8754        use arrow_array::types::{Float32Type, Int32Type};
8755        use lance_datagen::Dimension;
8756
8757        const DIM: u32 = 32;
8758        let mut dataset = lance_datagen::gen_batch()
8759            .col("id", lance_datagen::array::step::<Int32Type>())
8760            .col(
8761                "vec",
8762                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(DIM)),
8763            )
8764            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
8765            .await
8766            .unwrap();
8767
8768        dataset
8769            .create_index(
8770                &["vec"],
8771                IndexType::Vector,
8772                Some("vec_idx".into()),
8773                &params,
8774                false,
8775            )
8776            .await
8777            .unwrap();
8778        let original_uuid = dataset
8779            .load_index_by_name("vec_idx")
8780            .await
8781            .unwrap()
8782            .unwrap()
8783            .uuid;
8784
8785        if let Some(pred) = delete_predicate {
8786            dataset.delete(pred).await.unwrap();
8787        }
8788
8789        // Collect surviving (id, vec) pairs and the set of surviving ids.
8790        let mut survivors: Vec<(i32, Vec<f32>)> = Vec::new();
8791        {
8792            let mut scanner = dataset.scan();
8793            scanner.project(&["id", "vec"]).unwrap();
8794            let batches = scanner
8795                .try_into_stream()
8796                .await
8797                .unwrap()
8798                .try_collect::<Vec<_>>()
8799                .await
8800                .unwrap();
8801            for batch in &batches {
8802                let ids = batch["id"].as_primitive::<Int32Type>();
8803                let vecs = batch["vec"].as_fixed_size_list();
8804                for i in 0..batch.num_rows() {
8805                    let v = vecs.value(i);
8806                    let v = v.as_primitive::<Float32Type>().values().to_vec();
8807                    survivors.push((ids.value(i), v));
8808                }
8809            }
8810        }
8811        assert!(!survivors.is_empty());
8812        let surviving_ids: std::collections::HashSet<i32> =
8813            survivors.iter().map(|(id, _)| *id).collect();
8814
8815        // Sample queries from survivors and capture the pre-compaction answer.
8816        let step = (survivors.len() / 16).max(1);
8817        let queries: Vec<(i32, Vec<f32>)> = survivors.iter().step_by(step).cloned().collect();
8818        let mut baseline: Vec<Vec<i32>> = Vec::new();
8819        for (_, q) in &queries {
8820            baseline.push(vector_knn_ids(&dataset, q, k).await);
8821        }
8822
8823        // Deferred compaction materializes the deletions into the frag-reuse index.
8824        let metrics = compact_files(
8825            &mut dataset,
8826            CompactionOptions {
8827                target_rows_per_fragment: 2_000,
8828                defer_index_remap: true,
8829                ..Default::default()
8830            },
8831            None,
8832        )
8833        .await
8834        .unwrap();
8835        assert!(metrics.fragments_removed > 0);
8836        assert!(
8837            dataset
8838                .load_indices()
8839                .await
8840                .unwrap()
8841                .iter()
8842                .any(|idx| idx.name == FRAG_REUSE_INDEX_NAME),
8843            "deferred compaction must record a frag-reuse index"
8844        );
8845        assert_eq!(
8846            dataset
8847                .load_index_by_name("vec_idx")
8848                .await
8849                .unwrap()
8850                .unwrap()
8851                .uuid,
8852            original_uuid,
8853            "index must not be physically remapped yet (FRI window)"
8854        );
8855
8856        // During the FRI window: no deleted rows, and stable vs the baseline.
8857        for (i, (_, q)) in queries.iter().enumerate() {
8858            let after = vector_knn_ids(&dataset, q, k).await;
8859            for id in &after {
8860                assert!(
8861                    surviving_ids.contains(id),
8862                    "KNN returned id {id} that is not a surviving row (query #{i})"
8863                );
8864            }
8865            let overlap = after.iter().filter(|id| baseline[i].contains(id)).count();
8866            assert!(
8867                overlap >= min_overlap,
8868                "KNN top-{k} diverged after deferred compaction: overlap {overlap} < {min_overlap} (query #{i})"
8869            );
8870        }
8871    }
8872
8873    fn small_ivf() -> lance_index::vector::ivf::IvfBuildParams {
8874        lance_index::vector::ivf::IvfBuildParams {
8875            max_iters: 2,
8876            num_partitions: Some(2),
8877            sample_rate: 2,
8878            ..Default::default()
8879        }
8880    }
8881
8882    #[tokio::test]
8883    async fn test_ivf_flat_defer_compaction_with_deletions() {
8884        let params = VectorIndexParams::with_ivf_flat_params(DistanceType::L2, small_ivf());
8885        // Flat storage is scanned linearly; dropping deleted rows is exact.
8886        // Boxed for CI clippy `large_futures`: the compaction future grew past 16 KiB.
8887        Box::pin(check_vector_defer_compaction(
8888            params,
8889            Some("id < 1500"),
8890            10,
8891            10,
8892        ))
8893        .await;
8894    }
8895
8896    #[tokio::test]
8897    async fn test_ivf_hnsw_sq_defer_compaction_merge_only() {
8898        use lance_index::vector::{hnsw::builder::HnswBuildParams, sq::builder::SQBuildParams};
8899        let params = VectorIndexParams::with_ivf_hnsw_sq_params(
8900            DistanceType::L2,
8901            small_ivf(),
8902            HnswBuildParams::default(),
8903            SQBuildParams::default(),
8904        );
8905        // No deletions: storage positions are stable, so the graph stays aligned.
8906        // Boxed for CI clippy `large_futures`: the compaction future grew past 16 KiB.
8907        Box::pin(check_vector_defer_compaction(params, None, 10, 9)).await;
8908    }
8909
8910    // NOTE: IVF_HNSW_* under materialized deletions is a known gap (lance#3993,
8911    // HNSW auto-remap not implemented) — the HNSW graph isn't realigned after the
8912    // frag-reuse drop. Deferred remap is gated off for HNSW tables, so there is
8913    // no lance-level reproducer here; the gate is tested in the data plane.
8914    // Merge-only HNSW is covered (see the *_remap_and_trim tests).
8915
8916    #[tokio::test]
8917    async fn test_ivf_pq_defer_compaction_with_deletions() {
8918        use lance_index::vector::pq::PQBuildParams;
8919        let params = VectorIndexParams::with_ivf_pq_params(
8920            DistanceType::L2,
8921            small_ivf(),
8922            PQBuildParams {
8923                max_iters: 2,
8924                num_sub_vectors: 2,
8925                ..Default::default()
8926            },
8927        );
8928        // Boxed for CI clippy `large_futures`: the compaction future grew past 16 KiB.
8929        Box::pin(check_vector_defer_compaction(
8930            params,
8931            Some("id < 1500"),
8932            10,
8933            8,
8934        ))
8935        .await;
8936    }
8937
8938    #[tokio::test]
8939    async fn test_ivf_sq_defer_compaction_with_deletions() {
8940        use lance_index::vector::sq::builder::SQBuildParams;
8941        let params = VectorIndexParams::with_ivf_sq_params(
8942            DistanceType::L2,
8943            small_ivf(),
8944            SQBuildParams::default(),
8945        );
8946        // Boxed for CI clippy `large_futures`: the compaction future grew past 16 KiB.
8947        Box::pin(check_vector_defer_compaction(
8948            params,
8949            Some("id < 1500"),
8950            10,
8951            8,
8952        ))
8953        .await;
8954    }
8955
8956    #[tokio::test]
8957    async fn test_ivf_rq_defer_compaction_with_deletions() {
8958        use lance_index::vector::bq::RQBuildParams;
8959        let params = VectorIndexParams::with_ivf_rq_params(
8960            DistanceType::L2,
8961            small_ivf(),
8962            RQBuildParams::new(1),
8963        );
8964        // Boxed for CI clippy `large_futures`: the compaction future grew past 16 KiB.
8965        Box::pin(check_vector_defer_compaction(
8966            params,
8967            Some("id < 1500"),
8968            10,
8969            8,
8970        ))
8971        .await;
8972    }
8973
8974    /// Merge-only deferred compaction, then a PHYSICAL remap + FRI trim. Asserts
8975    /// the index is rebuilt, the fragment-reuse index trims to zero versions,
8976    /// and KNN stays consistent with the pre-compaction answer through both the
8977    /// FRI window and the physical remap. (HNSW rebuilds its graph on physical
8978    /// remap, so the overlap is recall-tolerant.)
8979    async fn check_vector_remap_and_trim(
8980        params: VectorIndexParams,
8981        k: usize,
8982        window_overlap: usize,
8983        post_remap_overlap: Option<usize>,
8984    ) {
8985        use arrow_array::cast::AsArray;
8986        use arrow_array::types::{Float32Type, Int32Type};
8987        use lance_datagen::Dimension;
8988
8989        const DIM: u32 = 8;
8990        let mut dataset = lance_datagen::gen_batch()
8991            .col("id", lance_datagen::array::step::<Int32Type>())
8992            .col(
8993                "vec",
8994                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(DIM)),
8995            )
8996            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(64))
8997            .await
8998            .unwrap();
8999        dataset
9000            .create_index(
9001                &["vec"],
9002                IndexType::Vector,
9003                Some("vec_idx".into()),
9004                &params,
9005                false,
9006            )
9007            .await
9008            .unwrap();
9009        let original_uuid = dataset
9010            .load_index_by_name("vec_idx")
9011            .await
9012            .unwrap()
9013            .unwrap()
9014            .uuid;
9015
9016        // Sample queries from stored vectors + capture the pre-compaction answer.
9017        let mut rows: Vec<Vec<f32>> = Vec::new();
9018        {
9019            let mut scanner = dataset.scan();
9020            scanner.project(&["vec"]).unwrap();
9021            let batches = scanner
9022                .try_into_stream()
9023                .await
9024                .unwrap()
9025                .try_collect::<Vec<_>>()
9026                .await
9027                .unwrap();
9028            for batch in &batches {
9029                let vecs = batch["vec"].as_fixed_size_list();
9030                for i in 0..batch.num_rows() {
9031                    let v = vecs.value(i);
9032                    rows.push(v.as_primitive::<Float32Type>().values().to_vec());
9033                }
9034            }
9035        }
9036        let step = (rows.len() / 4).max(1);
9037        let queries: Vec<Vec<f32>> = rows.iter().step_by(step).cloned().collect();
9038        let mut baseline: Vec<Vec<i32>> = Vec::new();
9039        for q in &queries {
9040            baseline.push(vector_knn_ids(&dataset, q, k).await);
9041        }
9042
9043        // Merge-only deferred compaction.
9044        let metrics = compact_files(
9045            &mut dataset,
9046            CompactionOptions {
9047                target_rows_per_fragment: 128,
9048                defer_index_remap: true,
9049                ..Default::default()
9050            },
9051            None,
9052        )
9053        .await
9054        .unwrap();
9055        assert!(metrics.fragments_removed > 0);
9056        assert_eq!(
9057            dataset
9058                .load_index_by_name("vec_idx")
9059                .await
9060                .unwrap()
9061                .unwrap()
9062                .uuid,
9063            original_uuid,
9064            "index must not be physically remapped yet (FRI window)"
9065        );
9066        for (i, q) in queries.iter().enumerate() {
9067            let window = vector_knn_ids(&dataset, q, k).await;
9068            let overlap = window.iter().filter(|id| baseline[i].contains(id)).count();
9069            assert!(
9070                overlap >= window_overlap,
9071                "FRI-window KNN diverged: overlap {overlap} < {window_overlap} (query #{i})"
9072            );
9073        }
9074
9075        // Physical remap + trim the fragment-reuse index.
9076        remapping::remap_column_index(&mut dataset, &["vec"], Some("vec_idx".into()))
9077            .await
9078            .unwrap();
9079        cleanup_frag_reuse_index(&mut dataset).await.unwrap();
9080
9081        let remapped_uuid = dataset
9082            .load_index_by_name("vec_idx")
9083            .await
9084            .unwrap()
9085            .unwrap()
9086            .uuid;
9087        assert_ne!(
9088            remapped_uuid, original_uuid,
9089            "index should have been physically remapped"
9090        );
9091        if let Some(meta) = dataset
9092            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
9093            .await
9094            .unwrap()
9095        {
9096            let versions = load_frag_reuse_index_details(&dataset, &meta)
9097                .await
9098                .unwrap()
9099                .versions
9100                .len();
9101            assert_eq!(versions, 0, "frag-reuse index must trim to zero versions");
9102        }
9103
9104        for (i, q) in queries.iter().enumerate() {
9105            let after = vector_knn_ids(&dataset, q, k).await;
9106            // No stale/desynced addresses (a bad address fails the take above).
9107            assert!(
9108                !after.is_empty(),
9109                "post-remap KNN returned no rows (query #{i})"
9110            );
9111            // Physical remap rebuilds the HNSW graph, so recall is only compared
9112            // for the exact (non-HNSW) types.
9113            if let Some(min_overlap) = post_remap_overlap {
9114                let overlap = after.iter().filter(|id| baseline[i].contains(id)).count();
9115                assert!(
9116                    overlap >= min_overlap,
9117                    "post-remap KNN diverged: overlap {overlap} < {min_overlap} (query #{i})"
9118                );
9119            }
9120        }
9121    }
9122
9123    #[tokio::test]
9124    async fn test_ivf_flat_remap_and_trim() {
9125        let params = VectorIndexParams::with_ivf_flat_params(DistanceType::L2, small_ivf());
9126        // Boxed for CI clippy `large_futures`: the remap future grew past 16 KiB.
9127        Box::pin(check_vector_remap_and_trim(params, 10, 8, Some(8))).await;
9128    }
9129
9130    // Regression: PQ storage used to remap its codes through the frag-reuse
9131    // index but keep the pre-remap `row_ids` field, so search returned stale
9132    // (compacted-away) addresses and the take failed with "fragment ... does
9133    // not exist" — even merge-only, and only observable when the query fetches
9134    // row content (the existing `test_read_ivf_pq_index_v3_with_defer_index_remap`
9135    // projects no columns, so it never takes and missed this).
9136    #[tokio::test]
9137    async fn test_ivf_pq_remap_and_trim() {
9138        use lance_index::vector::pq::PQBuildParams;
9139        let params = VectorIndexParams::with_ivf_pq_params(
9140            DistanceType::L2,
9141            small_ivf(),
9142            PQBuildParams {
9143                max_iters: 2,
9144                num_sub_vectors: 2,
9145                ..Default::default()
9146            },
9147        );
9148        // Boxed for CI clippy `large_futures`: the remap future grew past 16 KiB.
9149        Box::pin(check_vector_remap_and_trim(params, 10, 8, Some(8))).await;
9150    }
9151
9152    #[tokio::test]
9153    async fn test_ivf_sq_remap_and_trim() {
9154        use lance_index::vector::sq::builder::SQBuildParams;
9155        let params = VectorIndexParams::with_ivf_sq_params(
9156            DistanceType::L2,
9157            small_ivf(),
9158            SQBuildParams::default(),
9159        );
9160        // Boxed for CI clippy `large_futures`: the remap future grew past 16 KiB.
9161        Box::pin(check_vector_remap_and_trim(params, 10, 8, Some(8))).await;
9162    }
9163
9164    #[tokio::test]
9165    async fn test_ivf_rq_remap_and_trim() {
9166        use lance_index::vector::bq::RQBuildParams;
9167        let params = VectorIndexParams::with_ivf_rq_params(
9168            DistanceType::L2,
9169            small_ivf(),
9170            RQBuildParams::new(1),
9171        );
9172        // Boxed for CI clippy `large_futures`: the remap future grew past 16 KiB.
9173        Box::pin(check_vector_remap_and_trim(params, 10, 8, Some(8))).await;
9174    }
9175
9176    #[tokio::test]
9177    async fn test_ivf_hnsw_sq_remap_and_trim() {
9178        use lance_index::vector::{hnsw::builder::HnswBuildParams, sq::builder::SQBuildParams};
9179        let params = VectorIndexParams::with_ivf_hnsw_sq_params(
9180            DistanceType::L2,
9181            small_ivf(),
9182            HnswBuildParams::default(),
9183            SQBuildParams::default(),
9184        );
9185        // Physical remap rebuilds the HNSW graph, so use a recall-tolerant overlap.
9186        // Boxed for CI clippy `large_futures`: the remap future grew past 16 KiB.
9187        Box::pin(check_vector_remap_and_trim(params, 10, 7, None)).await;
9188    }
9189
9190    #[tokio::test]
9191    async fn test_ivf_hnsw_pq_remap_and_trim() {
9192        use lance_index::vector::{hnsw::builder::HnswBuildParams, pq::PQBuildParams};
9193        let params = VectorIndexParams::with_ivf_hnsw_pq_params(
9194            DistanceType::L2,
9195            small_ivf(),
9196            HnswBuildParams::default()
9197                .max_level(2)
9198                .num_edges(4)
9199                .ef_construction(16),
9200            PQBuildParams {
9201                max_iters: 2,
9202                num_sub_vectors: 2,
9203                num_bits: 4,
9204                sample_rate: 2,
9205                ..Default::default()
9206            },
9207        );
9208        // Boxed for CI clippy `large_futures`: the remap future grew past 16 KiB.
9209        Box::pin(check_vector_remap_and_trim(params, 10, 7, None)).await;
9210    }
9211
9212    // Scalar index correctness across deferred compaction WITH materialized
9213    // deletions. The existing test_read_*_index_with_defer_index_remap tests are
9214    // merge-only and project no columns (count-only), so they never take and
9215    // never exercise the deletion drop path. These add an `id` column, delete a
9216    // prefix, defer-compact, then run the indexed query *projecting id* (a take)
9217    // and assert no deleted row is returned. Bitmap/BTree have no positional
9218    // internal structure so the drop path is exact; the Inverted (FTS) index
9219    // does (see its test below), and currently desyncs under deletions.
9220
9221    #[tokio::test]
9222    async fn test_bitmap_index_defer_compaction_with_deletions() {
9223        use arrow_array::cast::AsArray;
9224        use arrow_array::types::Int32Type;
9225        let mut dataset = lance_datagen::gen_batch()
9226            .col("id", lance_datagen::array::step::<Int32Type>())
9227            .col(
9228                "category",
9229                lance_datagen::array::cycle::<Int32Type>(vec![1, 2, 3]),
9230            )
9231            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
9232            .await
9233            .unwrap();
9234        dataset
9235            .create_index(
9236                &["category"],
9237                IndexType::Bitmap,
9238                Some("category_idx".into()),
9239                &ScalarIndexParams::default(),
9240                false,
9241            )
9242            .await
9243            .unwrap();
9244        dataset.delete("id < 1500").await.unwrap();
9245        let metrics = compact_files(
9246            &mut dataset,
9247            CompactionOptions {
9248                target_rows_per_fragment: 2_000,
9249                defer_index_remap: true,
9250                ..Default::default()
9251            },
9252            None,
9253        )
9254        .await
9255        .unwrap();
9256        assert!(metrics.fragments_removed > 0);
9257        assert!(
9258            dataset
9259                .load_indices()
9260                .await
9261                .unwrap()
9262                .iter()
9263                .any(|idx| idx.name == FRAG_REUSE_INDEX_NAME),
9264            "deferred compaction must record a frag-reuse index"
9265        );
9266
9267        let mut scanner = dataset.scan();
9268        scanner.filter("category = 3").unwrap();
9269        scanner.project(&["id"]).unwrap();
9270        let batches = scanner
9271            .try_into_stream()
9272            .await
9273            .unwrap()
9274            .try_collect::<Vec<_>>()
9275            .await
9276            .unwrap();
9277        let mut returned = 0;
9278        for b in &batches {
9279            for id in b["id"].as_primitive::<Int32Type>().values() {
9280                assert!(
9281                    *id >= 1500,
9282                    "bitmap returned deleted id {id} in the FRI window"
9283                );
9284                returned += 1;
9285            }
9286        }
9287        assert!(returned > 0, "expected surviving category=3 rows");
9288    }
9289
9290    // NOTE: Inverted/FTS under materialized deletions is broken (BM25 scores
9291    // via positional num_tokens[doc_id]; the frag-reuse drop shifts doc_id
9292    // positions -> out-of-bounds). It is gated off defer in the data plane
9293    // until fixed, so there is no lance-level reproducer here. Merge-only FTS
9294    // is covered by test_read_inverted_index_with_defer_index_remap.
9295
9296    #[tokio::test]
9297    async fn test_default_compaction_planner() {
9298        let test_dir = TempStrDir::default();
9299        let test_uri = &test_dir;
9300
9301        let data = sample_data();
9302        let schema = data.schema();
9303
9304        // Create dataset with multiple small fragments
9305        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], schema.clone());
9306        let write_params = WriteParams {
9307            max_rows_per_file: 2000,
9308            ..Default::default()
9309        };
9310        let dataset = Dataset::write(reader, test_uri, Some(write_params))
9311            .await
9312            .unwrap();
9313
9314        assert_eq!(dataset.get_fragments().len(), 5);
9315
9316        // Test default planner
9317        let options = CompactionOptions {
9318            target_rows_per_fragment: 5000,
9319            materialize_deletions_threshold: 2.0,
9320            ..Default::default()
9321        };
9322
9323        let planner = DefaultCompactionPlanner::new(options).unwrap();
9324        let plan = planner.plan(&dataset).await.unwrap();
9325
9326        // Should create tasks to compact small fragments
9327        assert!(!plan.tasks.is_empty());
9328        assert_eq!(plan.read_version, dataset.manifest.version);
9329        // make sure options.validate() worked
9330        assert!(!plan.options.materialize_deletions);
9331    }
9332
9333    #[test]
9334    fn test_from_dataset_config() {
9335        let config = HashMap::from([
9336            (
9337                "lance.compaction.target_rows_per_fragment".to_string(),
9338                "500000".to_string(),
9339            ),
9340            (
9341                "lance.compaction.max_rows_per_group".to_string(),
9342                "2048".to_string(),
9343            ),
9344            (
9345                "lance.compaction.max_bytes_per_file".to_string(),
9346                "1000000".to_string(),
9347            ),
9348            (
9349                "lance.compaction.materialize_deletions".to_string(),
9350                "false".to_string(),
9351            ),
9352            (
9353                "lance.compaction.materialize_deletions_threshold".to_string(),
9354                "0.25".to_string(),
9355            ),
9356            (
9357                "lance.compaction.defer_index_remap".to_string(),
9358                "true".to_string(),
9359            ),
9360            (
9361                "lance.compaction.batch_size".to_string(),
9362                "4096".to_string(),
9363            ),
9364            (
9365                "lance.compaction.io_buffer_size".to_string(),
9366                "1073741824".to_string(),
9367            ),
9368            (
9369                "lance.compaction.compaction_mode".to_string(),
9370                "try_binary_copy".to_string(),
9371            ),
9372            (
9373                "lance.compaction.binary_copy_read_batch_bytes".to_string(),
9374                "8388608".to_string(),
9375            ),
9376            (
9377                "lance.compaction.index_remap_mode".to_string(),
9378                "compact".to_string(),
9379            ),
9380            (
9381                "lance.compaction.max_source_fragments".to_string(),
9382                "20".to_string(),
9383            ),
9384            (
9385                "lance.compaction.max_source_rows".to_string(),
9386                "1000000".to_string(),
9387            ),
9388            (
9389                "lance.compaction.max_source_bytes".to_string(),
9390                "1073741824".to_string(),
9391            ),
9392        ]);
9393
9394        let opts = CompactionOptions::from_dataset_config(&config).unwrap();
9395        assert_eq!(opts.target_rows_per_fragment, 500_000);
9396        assert_eq!(opts.max_rows_per_group, 2048);
9397        assert_eq!(opts.max_bytes_per_file, Some(1_000_000));
9398        assert!(!opts.materialize_deletions);
9399        assert!((opts.materialize_deletions_threshold - 0.25).abs() < f32::EPSILON);
9400        assert!(opts.defer_index_remap);
9401        assert_eq!(opts.batch_size, Some(4096));
9402        assert_eq!(opts.io_buffer_size, Some(1_073_741_824));
9403        assert_eq!(opts.compaction_mode, Some(CompactionMode::TryBinaryCopy));
9404        assert_eq!(opts.binary_copy_read_batch_bytes, Some(8_388_608));
9405        // A non-default value proves the config string was actually parsed.
9406        assert_eq!(opts.index_remap_mode, IndexRemapMode::Compact);
9407        assert_eq!(opts.max_source_fragments, Some(20));
9408        assert_eq!(opts.max_source_rows, Some(1_000_000));
9409        assert_eq!(opts.max_source_bytes, Some(1_073_741_824));
9410    }
9411
9412    #[test]
9413    fn test_from_dataset_config_empty() {
9414        let config = HashMap::new();
9415        let opts = CompactionOptions::from_dataset_config(&config).unwrap();
9416        let defaults = CompactionOptions::default();
9417        assert_eq!(
9418            opts.target_rows_per_fragment,
9419            defaults.target_rows_per_fragment
9420        );
9421        assert_eq!(opts.max_rows_per_group, defaults.max_rows_per_group);
9422        assert_eq!(opts.max_bytes_per_file, defaults.max_bytes_per_file);
9423        assert_eq!(opts.materialize_deletions, defaults.materialize_deletions);
9424        assert_eq!(
9425            opts.materialize_deletions_threshold,
9426            defaults.materialize_deletions_threshold
9427        );
9428        assert_eq!(opts.defer_index_remap, defaults.defer_index_remap);
9429        assert_eq!(opts.index_remap_mode, defaults.index_remap_mode);
9430        assert_eq!(opts.index_remap_mode, IndexRemapMode::Direct);
9431        assert_eq!(opts.batch_size, defaults.batch_size);
9432        assert_eq!(opts.compaction_mode, defaults.compaction_mode);
9433        assert_eq!(
9434            opts.binary_copy_read_batch_bytes,
9435            defaults.binary_copy_read_batch_bytes
9436        );
9437    }
9438
9439    #[test]
9440    fn test_from_dataset_config_partial() {
9441        let config = HashMap::from([(
9442            "lance.compaction.target_rows_per_fragment".to_string(),
9443            "500000".to_string(),
9444        )]);
9445
9446        let opts = CompactionOptions::from_dataset_config(&config).unwrap();
9447        assert_eq!(opts.target_rows_per_fragment, 500_000);
9448        // Other fields should remain at defaults
9449        let defaults = CompactionOptions::default();
9450        assert_eq!(opts.max_rows_per_group, defaults.max_rows_per_group);
9451        assert_eq!(opts.max_bytes_per_file, defaults.max_bytes_per_file);
9452        assert_eq!(opts.materialize_deletions, defaults.materialize_deletions);
9453        assert_eq!(opts.defer_index_remap, defaults.defer_index_remap);
9454        assert_eq!(opts.batch_size, defaults.batch_size);
9455        assert_eq!(opts.compaction_mode, defaults.compaction_mode);
9456        assert_eq!(
9457            opts.binary_copy_read_batch_bytes,
9458            defaults.binary_copy_read_batch_bytes
9459        );
9460    }
9461
9462    #[test]
9463    fn test_from_dataset_config_ignores_other_keys() {
9464        let config = HashMap::from([
9465            (
9466                "lance.compaction.target_rows_per_fragment".to_string(),
9467                "500000".to_string(),
9468            ),
9469            (
9470                "lance.auto_cleanup.interval".to_string(),
9471                "3600".to_string(),
9472            ),
9473            ("some.other.key".to_string(), "value".to_string()),
9474        ]);
9475
9476        let opts = CompactionOptions::from_dataset_config(&config).unwrap();
9477        assert_eq!(opts.target_rows_per_fragment, 500_000);
9478    }
9479
9480    #[test]
9481    fn test_from_dataset_config_invalid_value() {
9482        let config = HashMap::from([(
9483            "lance.compaction.target_rows_per_fragment".to_string(),
9484            "not_a_number".to_string(),
9485        )]);
9486
9487        let result = CompactionOptions::from_dataset_config(&config);
9488        let err_msg = result.unwrap_err().to_string();
9489        assert!(err_msg.contains("target_rows_per_fragment"));
9490        assert!(err_msg.contains("not_a_number"));
9491    }
9492
9493    #[test]
9494    fn test_from_dataset_config_invalid_bool() {
9495        let config = HashMap::from([(
9496            "lance.compaction.materialize_deletions".to_string(),
9497            "yes".to_string(),
9498        )]);
9499
9500        let result = CompactionOptions::from_dataset_config(&config);
9501        let err_msg = result.unwrap_err().to_string();
9502        assert!(err_msg.contains("materialize_deletions"));
9503        assert!(err_msg.contains("yes"));
9504    }
9505
9506    #[test]
9507    fn test_from_dataset_config_unknown_compaction_key() {
9508        // Unknown keys should be ignored (with a warning) for forwards compatibility
9509        let config = HashMap::from([(
9510            "lance.compaction.unknown_key".to_string(),
9511            "value".to_string(),
9512        )]);
9513
9514        let opts = CompactionOptions::from_dataset_config(&config).unwrap();
9515        // Should return defaults since the unknown key is skipped
9516        let defaults = CompactionOptions::default();
9517        assert_eq!(
9518            opts.target_rows_per_fragment,
9519            defaults.target_rows_per_fragment
9520        );
9521    }
9522
9523    #[test]
9524    fn test_from_dataset_config_invalid_compaction_mode() {
9525        let config = HashMap::from([(
9526            "lance.compaction.compaction_mode".to_string(),
9527            "invalid_mode".to_string(),
9528        )]);
9529
9530        let result = CompactionOptions::from_dataset_config(&config);
9531        let err_msg = result.unwrap_err().to_string();
9532        assert!(err_msg.contains("invalid_mode"));
9533    }
9534
9535    #[test]
9536    fn test_from_dataset_config_max_overlays_per_fragment() {
9537        let key = "lance.compaction.max_overlays_per_fragment".to_string();
9538
9539        // An integer sets the threshold.
9540        let config = HashMap::from([(key.clone(), "3".to_string())]);
9541        let opts = CompactionOptions::from_dataset_config(&config).unwrap();
9542        assert_eq!(opts.max_overlays_per_fragment, Some(3));
9543
9544        // "none" (case-insensitive) disables the trigger, overriding the Some(10) default.
9545        let config = HashMap::from([(key.clone(), "None".to_string())]);
9546        let opts = CompactionOptions::from_dataset_config(&config).unwrap();
9547        assert_eq!(opts.max_overlays_per_fragment, None);
9548
9549        // Anything else is rejected.
9550        let config = HashMap::from([(key, "not_a_number".to_string())]);
9551        let err_msg = CompactionOptions::from_dataset_config(&config)
9552            .unwrap_err()
9553            .to_string();
9554        assert!(err_msg.contains("max_overlays_per_fragment"));
9555        assert!(err_msg.contains("not_a_number"));
9556    }
9557
9558    #[test]
9559    fn test_apply_dataset_config_overrides() {
9560        let config = HashMap::from([(
9561            "lance.compaction.target_rows_per_fragment".to_string(),
9562            "500000".to_string(),
9563        )]);
9564
9565        let mut opts = CompactionOptions {
9566            max_rows_per_group: 4096,
9567            ..Default::default()
9568        };
9569        opts.apply_dataset_config(&config).unwrap();
9570
9571        // Config value should be applied
9572        assert_eq!(opts.target_rows_per_fragment, 500_000);
9573        // Explicitly set value should be preserved (config didn't have this key)
9574        assert_eq!(opts.max_rows_per_group, 4096);
9575    }
9576
9577    #[test]
9578    fn test_apply_dataset_config_overwrites_matching_field() {
9579        let config = HashMap::from([(
9580            "lance.compaction.max_rows_per_group".to_string(),
9581            "2048".to_string(),
9582        )]);
9583
9584        let mut opts = CompactionOptions {
9585            max_rows_per_group: 4096,
9586            ..Default::default()
9587        };
9588        opts.apply_dataset_config(&config).unwrap();
9589
9590        // Config value should overwrite the pre-set value
9591        assert_eq!(opts.max_rows_per_group, 2048);
9592    }
9593
9594    #[tokio::test]
9595    async fn test_max_source_fragments() {
9596        let test_dir = TempStrDir::default();
9597        let dataset = dataset_with_ten_small_fragments(&test_dir).await;
9598
9599        // Plan without limit - all 10 fragments should be candidates.
9600        // Use a target that splits the 10 fragments into multiple tasks.
9601        let opts_no_limit = CompactionOptions {
9602            target_rows_per_fragment: 250,
9603            ..Default::default()
9604        };
9605        let plan_all = plan_compaction(&dataset, &opts_no_limit).await.unwrap();
9606        let total_source_frags: usize = plan_all.tasks().iter().map(|t| t.fragments.len()).sum();
9607        assert_eq!(total_source_frags, 10);
9608        assert!(
9609            plan_all.num_tasks() > 2,
9610            "need multiple tasks to test bounding, got {}",
9611            plan_all.num_tasks()
9612        );
9613
9614        // Plan with max_source_fragments=4 should include tasks covering <= 4
9615        // source fragments
9616        let opts_bounded = CompactionOptions {
9617            target_rows_per_fragment: 250,
9618            max_source_fragments: Some(4),
9619            ..Default::default()
9620        };
9621        let plan_bounded = plan_compaction(&dataset, &opts_bounded).await.unwrap();
9622        let bounded_source_frags: usize =
9623            plan_bounded.tasks().iter().map(|t| t.fragments.len()).sum();
9624        assert!(
9625            bounded_source_frags <= 4,
9626            "expected at most 4 source fragments, got {bounded_source_frags}"
9627        );
9628        assert!(
9629            bounded_source_frags > 0,
9630            "expected at least 1 source fragment in bounded plan"
9631        );
9632        assert!(
9633            plan_bounded.num_tasks() < plan_all.num_tasks(),
9634            "bounded plan ({}) should have fewer tasks than unbounded ({})",
9635            plan_bounded.num_tasks(),
9636            plan_all.num_tasks()
9637        );
9638
9639        // Execute bounded compaction incrementally
9640        let mut dataset = dataset;
9641        compact_files(&mut dataset, opts_bounded, None)
9642            .await
9643            .unwrap();
9644        let after_first = dataset.get_fragments().len();
9645        assert!(
9646            after_first < 10,
9647            "expected fewer than 10 fragments after first compaction, got {after_first}"
9648        );
9649        assert!(
9650            after_first > 1,
9651            "expected partial compaction (not fully compacted), got {after_first}"
9652        );
9653
9654        // Run again to make more progress
9655        let opts_bounded = CompactionOptions {
9656            target_rows_per_fragment: 250,
9657            max_source_fragments: Some(4),
9658            ..Default::default()
9659        };
9660        compact_files(&mut dataset, opts_bounded, None)
9661            .await
9662            .unwrap();
9663        let after_second = dataset.get_fragments().len();
9664        assert!(
9665            after_second <= after_first,
9666            "expected progress: {after_second} should be <= {after_first}"
9667        );
9668    }
9669
9670    /// Writes `sample_data` as 10 fragments of 100 rows each, deletes half of
9671    /// fragment 0's rows, and puts a one-cell data overlay on it, so every
9672    /// `max_source_*` planning budget is exercised against a dataset that
9673    /// carries deleted rows and overlay files.
9674    async fn dataset_with_ten_small_fragments(test_uri: &str) -> Dataset {
9675        let data = sample_data();
9676        let schema = data.schema();
9677        let write_params = WriteParams {
9678            max_rows_per_file: 100,
9679            ..Default::default()
9680        };
9681        Dataset::write(
9682            RecordBatchIterator::new(vec![Ok(data.slice(0, 100))], schema.clone()),
9683            test_uri,
9684            Some(write_params.clone()),
9685        )
9686        .await
9687        .unwrap();
9688        for i in 1..10 {
9689            let mut append_params = write_params.clone();
9690            append_params.mode = WriteMode::Append;
9691            Dataset::write(
9692                RecordBatchIterator::new(vec![Ok(data.slice(i * 100, 100))], schema.clone()),
9693                test_uri,
9694                Some(append_params),
9695            )
9696            .await
9697            .unwrap();
9698        }
9699        let mut dataset = Dataset::open(test_uri).await.unwrap();
9700        assert_eq!(dataset.get_fragments().len(), 10);
9701        // Fragment 0 keeps 100 physical rows but only 50 live rows.
9702        dataset.delete("a < 50").await.unwrap();
9703        // The overlay shadows one surviving base value (offset 60, kept clear
9704        // of the delete predicate) without adding any rows.
9705        let dataset = commit_overlay(
9706            dataset,
9707            0,
9708            &[0],
9709            OverlayCoverage::dense(bitmap([60])),
9710            vec![Arc::new(Int64Array::from(vec![60_000_i64]))],
9711        )
9712        .await;
9713        assert_eq!(
9714            dataset.get_fragment(0).unwrap().metadata().overlays.len(),
9715            1
9716        );
9717        dataset
9718    }
9719
9720    #[tokio::test]
9721    async fn test_excluded_fragments_are_planning_boundaries() {
9722        let test_dir = TempStrDir::default();
9723        let mut dataset = dataset_with_ten_small_fragments(&test_dir).await;
9724        let excluded_fragment_id = 4;
9725        let options = CompactionOptions {
9726            target_rows_per_fragment: 250,
9727            excluded_fragment_ids: vec![excluded_fragment_id, excluded_fragment_id, u32::MAX],
9728            ..Default::default()
9729        };
9730
9731        let plan = plan_compaction(&dataset, &options).await.unwrap();
9732        let planned_fragment_ids = plan
9733            .tasks()
9734            .iter()
9735            .map(|task| {
9736                task.fragments
9737                    .iter()
9738                    .map(|fragment| fragment.id as u32)
9739                    .collect::<Vec<_>>()
9740            })
9741            .collect::<Vec<_>>();
9742
9743        assert_eq!(
9744            planned_fragment_ids,
9745            vec![vec![0, 1, 2, 3], vec![5, 6, 7, 8, 9]]
9746        );
9747        assert!(
9748            planned_fragment_ids
9749                .iter()
9750                .flatten()
9751                .all(|fragment_id| *fragment_id != excluded_fragment_id)
9752        );
9753
9754        let metrics = compact_files(&mut dataset, options, None).await.unwrap();
9755        assert_eq!(metrics.fragments_removed, 9);
9756        let remaining_fragment_ids = dataset
9757            .get_fragments()
9758            .iter()
9759            .map(|fragment| fragment.id() as u32)
9760            .collect::<Vec<_>>();
9761        assert!(remaining_fragment_ids.contains(&excluded_fragment_id));
9762    }
9763
9764    #[tokio::test]
9765    async fn test_max_source_rows() {
9766        let test_dir = TempStrDir::default();
9767        let dataset = dataset_with_ten_small_fragments(&test_dir).await;
9768
9769        // The first task covers fragments 0..=2: 250 live rows (fragment 0
9770        // keeps 50 after the deletes, and its overlay adds none) but 300
9771        // physical rows. A budget between the two admits it and only it,
9772        // proving the budget is enforced against live rows.
9773        let opts = CompactionOptions {
9774            target_rows_per_fragment: 250,
9775            max_source_rows: Some(270),
9776            ..Default::default()
9777        };
9778        let plan = plan_compaction(&dataset, &opts).await.unwrap();
9779        assert_eq!(plan.num_tasks(), 1);
9780        let physical_rows: usize = plan
9781            .tasks()
9782            .iter()
9783            .flat_map(|t| &t.fragments)
9784            .map(|f| f.physical_rows.unwrap())
9785            .sum();
9786        assert_eq!(physical_rows, 300);
9787    }
9788
9789    #[tokio::test]
9790    async fn test_max_source_bytes() {
9791        let test_dir = TempStrDir::default();
9792        let dataset = dataset_with_ten_small_fragments(&test_dir).await;
9793
9794        let first_task_base_bytes: u64 = dataset.get_fragments()[..3]
9795            .iter()
9796            .flat_map(|f| f.metadata.files.iter())
9797            .map(|df| df.file_size_bytes.get().unwrap().get())
9798            .sum();
9799        let overlay_bytes = dataset.get_fragment(0).unwrap().metadata().overlays[0]
9800            .data_file
9801            .file_size_bytes
9802            .get()
9803            .unwrap()
9804            .get();
9805        assert!(first_task_base_bytes > 0 && overlay_bytes > 0);
9806
9807        // The first task covers fragments 0..=2 (target 250 rows). A budget of
9808        // exactly their base bytes is exceeded once fragment 0's overlay file
9809        // is counted, so the plan is empty: the budget is a hard upper bound,
9810        // overlay bytes are part of a task's source, and deletion files are
9811        // never counted.
9812        let opts = CompactionOptions {
9813            target_rows_per_fragment: 250,
9814            max_source_bytes: Some(first_task_base_bytes),
9815            ..Default::default()
9816        };
9817        let plan = plan_compaction(&dataset, &opts).await.unwrap();
9818        assert_eq!(plan.num_tasks(), 0);
9819
9820        // Widening the budget by the overlay's bytes admits exactly the first
9821        // task and nothing more.
9822        let budget = first_task_base_bytes + overlay_bytes;
9823        let opts = CompactionOptions {
9824            target_rows_per_fragment: 250,
9825            max_source_bytes: Some(budget),
9826            ..Default::default()
9827        };
9828        let plan = plan_compaction(&dataset, &opts).await.unwrap();
9829        assert_eq!(plan.num_tasks(), 1);
9830        let source_bytes: u64 = plan
9831            .tasks()
9832            .iter()
9833            .flat_map(|t| &t.fragments)
9834            .flat_map(|f| {
9835                f.files
9836                    .iter()
9837                    .chain(f.overlays.iter().map(|o| &o.data_file))
9838            })
9839            .map(|df| df.file_size_bytes.get().unwrap().get())
9840            .sum();
9841        assert_eq!(source_bytes, budget);
9842    }
9843
9844    #[tokio::test]
9845    async fn test_compaction_uses_manifest_config() {
9846        let test_dir = TempStrDir::default();
9847        let test_uri = &test_dir;
9848
9849        let data = sample_data();
9850        let schema = data.schema();
9851
9852        // Create dataset with small fragments
9853        let reader = RecordBatchIterator::new(vec![Ok(data.clone())], schema.clone());
9854        let write_params = WriteParams {
9855            max_rows_per_file: 2000,
9856            ..Default::default()
9857        };
9858        let mut dataset = Dataset::write(reader, test_uri, Some(write_params))
9859            .await
9860            .unwrap();
9861
9862        assert_eq!(dataset.get_fragments().len(), 5);
9863
9864        // Set compaction config in manifest
9865        dataset
9866            .update_config([
9867                ("lance.compaction.target_rows_per_fragment", "5000"),
9868                ("lance.compaction.materialize_deletions_threshold", "2.0"),
9869            ])
9870            .await
9871            .unwrap();
9872
9873        // Build options from the dataset config (as the bindings do)
9874        let opts = CompactionOptions::from_dataset_config(&dataset.manifest.config).unwrap();
9875        assert_eq!(opts.target_rows_per_fragment, 5000);
9876        assert!((opts.materialize_deletions_threshold - 2.0).abs() < f32::EPSILON);
9877
9878        // Verify the config flows through plan_compaction
9879        let plan = plan_compaction(&dataset, &opts).await.unwrap();
9880        assert!(!plan.tasks.is_empty());
9881        assert_eq!(plan.options.target_rows_per_fragment, 5000);
9882        // validate() should have turned off materialize_deletions since threshold >= 1.0
9883        assert!(!plan.options.materialize_deletions);
9884    }
9885
9886    // check_rewrite_txn takes the (None, Some(_)) branch when a Rewrite with
9887    // defer_index_remap=true is committed against a previously committed
9888    // CreateIndex, declaring COMPATIBLE without verifying that the Rewrite's
9889    // FRI groups don't straddle the CreateIndex's fragment bitmap. When a
9890    // group mixes indexed and unindexed fragments, commit succeeds and later
9891    // queries fail at load_indices with "split of indexed and non-indexed
9892    // data".
9893    #[tokio::test]
9894    async fn test_rewrite_fri_vs_create_index_conflict() {
9895        use crate::index::DatasetIndexExt;
9896        use crate::index::vector::VectorIndexParams;
9897        use futures::TryStreamExt;
9898        use lance_datagen::{BatchCount, Dimension, RowCount, array, gen_batch};
9899        use lance_index::IndexType;
9900        use lance_linalg::distance::MetricType;
9901
9902        async fn append_fragment(uri: &str, rows: u64) -> Dataset {
9903            let reader = gen_batch()
9904                .col("vec", array::rand_vec::<Float32Type>(Dimension::from(16)))
9905                .into_reader_rows(RowCount::from(rows), BatchCount::from(1));
9906            let params = WriteParams {
9907                max_rows_per_file: rows as usize,
9908                mode: WriteMode::Append,
9909                ..Default::default()
9910            };
9911            Dataset::write(reader, uri, Some(params)).await.unwrap()
9912        }
9913
9914        let tmpdir = TempStrDir::default();
9915        let uri = format!("file://{}", tmpdir.as_str());
9916
9917        // frag0 (256 rows) with a base IVF index.
9918        let reader = gen_batch()
9919            .col("vec", array::rand_vec::<Float32Type>(Dimension::from(16)))
9920            .into_reader_rows(RowCount::from(256), BatchCount::from(1));
9921        let mut dataset = Dataset::write(
9922            reader,
9923            &uri,
9924            Some(WriteParams {
9925                max_rows_per_file: 256,
9926                mode: WriteMode::Overwrite,
9927                ..Default::default()
9928            }),
9929        )
9930        .await
9931        .unwrap();
9932        let index_params = VectorIndexParams::ivf_pq(2, 8, 2, MetricType::L2, 50);
9933        dataset
9934            .create_index(&["vec"], IndexType::Vector, None, &index_params, true)
9935            .await
9936            .unwrap();
9937
9938        // Append frag1 (unindexed), snapshot a stale handle pointing here,
9939        // then append frag2 (also unindexed).
9940        dataset = append_fragment(&uri, 64).await;
9941        let mut stale = dataset.clone();
9942        dataset = append_fragment(&uri, 64).await;
9943
9944        // Plan + execute compaction of frag1+frag2 with deferred remap.
9945        let options = CompactionOptions {
9946            defer_index_remap: true,
9947            ..Default::default()
9948        };
9949        let plan = plan_compaction(&dataset, &options).await.unwrap();
9950        assert!(!plan.tasks.is_empty());
9951        let snapshot = dataset.clone();
9952        let completed: Vec<RewriteResult> = futures::stream::iter(plan.tasks.into_iter())
9953            .map(|task| rewrite_files(Cow::Borrowed(&snapshot), task, &options))
9954            .buffer_unordered(1)
9955            .try_collect()
9956            .await
9957            .unwrap();
9958
9959        // optimize_indices on the stale handle indexes frag1 only (frag2
9960        // didn't exist at that version), commits as CreateIndex. `dataset`
9961        // stays at its pre-optimize version so the Rewrite commit has to
9962        // conflict-check against this CreateIndex.
9963        stale
9964            .optimize_indices(&lance_index::optimize::OptimizeOptions::append())
9965            .await
9966            .unwrap();
9967
9968        // Commit the pre-executed Rewrite. The FRI group [frag1, frag2]
9969        // straddles the new CreateIndex bitmap (frag1 indexed, frag2 not), so
9970        // check_rewrite_txn must reject this as a retryable conflict rather
9971        // than letting it commit into a broken state that fails queries.
9972        let err = commit_compaction(
9973            &mut dataset,
9974            completed,
9975            Arc::new(DatasetIndexRemapperOptions::default()),
9976            &options,
9977        )
9978        .await
9979        .expect_err("commit should fail with retryable conflict");
9980        assert!(
9981            matches!(err, Error::RetryableCommitConflict { .. }),
9982            "unexpected error: {err}"
9983        );
9984    }
9985
9986    /// Reproduce the distributed-compaction concurrent-delete data-resurrection bug.
9987    ///
9988    /// In the distributed (Spark) path the caller opens **two separate** `Dataset` handles:
9989    ///
9990    /// 1. dataset_plan  — used for `plan_compaction` (version = V)
9991    /// 2. dataset_commit — opened **fresh** for `commit_compaction` (version = V+N)
9992    ///
9993    /// Because `commit_compaction` builds the `Rewrite` transaction with
9994    /// `dataset.manifest.version` (= V+N), `load_and_sort_new_transactions` only
9995    /// scans versions after V+N and finds nothing.  Any concurrent DELETE that
9996    /// happened between V and V+N is silently ignored, causing the deleted rows to
9997    /// reappear in the compacted fragment.
9998    ///
9999    /// After the fix, `commit_compaction` uses `min(tasks.read_version)` (= V) as
10000    /// the transaction `read_version`, so the conflict checker correctly loads and
10001    /// rejects the DELETE, returning a retryable conflict error instead of silently
10002    /// resurrecting data.
10003    #[tokio::test]
10004    async fn test_distributed_compact_concurrent_delete_no_resurrection() {
10005        let test_dir = TempStrDir::default();
10006        let test_uri = &test_dir;
10007
10008        // Write 4 fragments × 1 000 rows each (a=0..4000).
10009        let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false)]));
10010        let data = RecordBatch::try_new(
10011            schema.clone(),
10012            vec![Arc::new(Int64Array::from_iter_values(0..4_000))],
10013        )
10014        .unwrap();
10015        let mut dataset_plan = Dataset::write(
10016            RecordBatchIterator::new(vec![Ok(data)], schema.clone()),
10017            test_uri,
10018            Some(WriteParams {
10019                max_rows_per_file: 1_000,
10020                ..Default::default()
10021            }),
10022        )
10023        .await
10024        .unwrap();
10025
10026        assert_eq!(dataset_plan.manifest.version, 1);
10027        assert_eq!(dataset_plan.get_fragments().len(), 4);
10028
10029        // ── Step 1: plan compaction at version V=1 ───────────────────────────────
10030        let options = CompactionOptions {
10031            target_rows_per_fragment: 10_000,
10032            ..Default::default()
10033        };
10034        let plan = plan_compaction(&dataset_plan, &options).await.unwrap();
10035        assert_eq!(plan.tasks().len(), 1, "expected one compaction task");
10036
10037        // ── Step 2: execute tasks (simulating distributed executors at V=1) ──────
10038        // Clone dataset_plan so the closure can own its copy while the original
10039        // remains available for the concurrent DELETE in Step 3.
10040        let dataset_for_tasks = dataset_plan.clone();
10041        let results: Vec<RewriteResult> = futures::stream::iter(plan.compaction_tasks())
10042            .then(|task| {
10043                let ds = dataset_for_tasks.clone();
10044                async move {
10045                    // Executors open the dataset at the planned read_version
10046                    task.execute(&ds).await.unwrap()
10047                }
10048            })
10049            .collect()
10050            .await;
10051        assert_eq!(results.len(), 1);
10052        assert_eq!(
10053            results[0].read_version, 1,
10054            "tasks must carry read_version=1"
10055        );
10056
10057        // ── Step 3: concurrent DELETE commits at V=2 ─────────────────────────────
10058        // Delete rows where a < 1000 (the first 1 000 rows in fragment 0).
10059        dataset_plan.delete("a < 1000").await.unwrap();
10060        assert_eq!(dataset_plan.manifest.version, 2);
10061
10062        // ── Step 4: the Spark driver opens a *fresh* dataset (latest = V=2) ──────
10063        // This is exactly what OptimizeExec.scala does for commitCompaction.
10064        let mut dataset_commit = Dataset::open(test_uri).await.unwrap();
10065        assert_eq!(
10066            dataset_commit.manifest.version, 2,
10067            "fresh dataset must be at the post-delete version"
10068        );
10069
10070        // ── Step 5: commit_compaction with the stale results ─────────────────────
10071        let commit_result = commit_compaction(
10072            &mut dataset_commit,
10073            results,
10074            Arc::new(IgnoreRemap::default()),
10075            &options,
10076        )
10077        .await;
10078
10079        // ── Step 6: assert correct behaviour ─────────────────────────────────────
10080        // BEFORE fix: commit_result is Ok(…) and the deleted rows are resurrected.
10081        // AFTER  fix: commit_result is Err(retryable conflict), protecting data integrity.
10082        assert!(
10083            commit_result.is_err(),
10084            "commit_compaction must fail with a conflict error when a concurrent \
10085             DELETE touched the same fragments; got Ok instead — deleted rows were \
10086             silently resurrected"
10087        );
10088        let err_msg = commit_result.unwrap_err().to_string();
10089        assert!(
10090            err_msg.contains("retryable")
10091                || err_msg.contains("conflict")
10092                || err_msg.contains("preempted"),
10093            "expected a retryable conflict error, got: {err_msg}"
10094        );
10095
10096        // The on-disk table must still reflect the DELETE (a < 1000 remains absent).
10097        let latest = Dataset::open(test_uri).await.unwrap();
10098        let row_count = latest
10099            .count_rows(Some("a < 1000".to_string()))
10100            .await
10101            .unwrap();
10102        assert_eq!(
10103            row_count, 0,
10104            "rows deleted before compaction must not be resurrected; found {row_count}"
10105        );
10106    }
10107
10108    fn count_all_files_in(dir: &std::path::Path) -> std::io::Result<usize> {
10109        if !dir.exists() {
10110            return Ok(0);
10111        }
10112        let mut count = 0;
10113        for entry in std::fs::read_dir(dir)? {
10114            let path = entry?.path();
10115            if path.is_dir() {
10116                count += count_all_files_in(&path)?;
10117            } else if path.is_file() {
10118                // Ignore macOS system files if any
10119                if path
10120                    .file_name()
10121                    .and_then(|name| name.to_str())
10122                    .is_some_and(|file_name| !file_name.starts_with('.'))
10123                {
10124                    count += 1;
10125                }
10126            }
10127        }
10128        Ok(count)
10129    }
10130
10131    fn count_data_files_in(base_dir: &str) -> usize {
10132        let data_dir = std::path::Path::new(base_dir).join("data");
10133        count_all_files_in(&data_dir).unwrap_or(0)
10134    }
10135
10136    /// Once `commit_compaction` reaches the Rewrite commit, its input may be a
10137    /// replay of a result whose earlier commit landed ambiguously. A failure on
10138    /// this call must therefore leave data files for GC instead of deleting
10139    /// files that an existing version may reference.
10140    #[tokio::test]
10141    async fn test_commit_compaction_leaves_data_for_gc_on_commit_failure() {
10142        use crate::dataset::builder::DatasetBuilder;
10143        use crate::utils::test::FailingProxyStore;
10144        use lance_io::object_store::ObjectStoreParams;
10145
10146        let test_dir = TempStrDir::default();
10147        let test_uri = test_dir.as_str();
10148        // Prefix `/` so Windows drive letters (e.g. `C:`) don't get parsed as
10149        // the URL authority.
10150        let path_prefix = if test_uri.starts_with('/') { "" } else { "/" };
10151        let routed_uri = format!("file-object-store://{path_prefix}{test_uri}");
10152
10153        let data = sample_data();
10154        let reader = RecordBatchIterator::new(vec![Ok(data.slice(0, 200))], data.schema());
10155        Dataset::write(
10156            reader,
10157            &routed_uri,
10158            Some(WriteParams {
10159                max_rows_per_file: 100,
10160                enable_stable_row_ids: true,
10161                ..Default::default()
10162            }),
10163        )
10164        .await
10165        .unwrap();
10166
10167        let baseline_files = count_data_files_in(test_uri);
10168
10169        let failing = Arc::new(FailingProxyStore::new());
10170        // `commit_compaction` first calls `reserve_fragment_ids` (which writes a
10171        // ReserveFragments transaction) and then calls `apply_commit` for the
10172        // rewrite itself. Skip the first transaction write so the reserve
10173        // succeeds, and fail the second so `apply_commit` errors out — that's
10174        // the branch we want to exercise cleanup for.
10175        failing.fail_after_n("put", "_transactions", 1, "injected commit failure");
10176        failing.fail_after_n(
10177            "put_multipart",
10178            "_transactions",
10179            1,
10180            "injected commit failure",
10181        );
10182
10183        let mut dataset = DatasetBuilder::from_uri(&routed_uri)
10184            .with_read_params(crate::dataset::ReadParams {
10185                store_options: Some(ObjectStoreParams {
10186                    object_store_wrapper: Some(failing.clone()),
10187                    ..Default::default()
10188                }),
10189                ..Default::default()
10190            })
10191            .load()
10192            .await
10193            .unwrap();
10194
10195        let options = CompactionOptions {
10196            target_rows_per_fragment: 1000,
10197            ..Default::default()
10198        };
10199        let result = compact_files(&mut dataset, options, None).await;
10200        assert!(
10201            result.is_err(),
10202            "Compaction should fail when transaction commit fails"
10203        );
10204
10205        assert!(
10206            count_data_files_in(test_uri) > baseline_files,
10207            "Compaction data files should be retained for GC after the Rewrite commit fails"
10208        );
10209    }
10210
10211    #[tokio::test]
10212    async fn test_commit_compaction_leaves_blob_v2_sidecars_for_gc_on_commit_failure() {
10213        use crate::BlobArrayBuilder;
10214        use crate::dataset::builder::DatasetBuilder;
10215        use crate::utils::test::FailingProxyStore;
10216        use lance_io::object_store::ObjectStoreParams;
10217
10218        let test_dir = TempStrDir::default();
10219        let test_uri = test_dir.as_str();
10220        let path_prefix = if test_uri.starts_with('/') { "" } else { "/" };
10221        let routed_uri = format!("file-object-store://{path_prefix}{test_uri}");
10222
10223        let id_array = Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef;
10224        // Use one packed blob and one dedicated blob to verify both sidecar layouts.
10225        let packed_data = vec![0u8; 100 * 1024];
10226        let dedicated_data = vec![1u8; 5 * 1024 * 1024];
10227        let mut blob_builder = BlobArrayBuilder::new(2);
10228        blob_builder.push_bytes(&packed_data).unwrap();
10229        blob_builder.push_bytes(&dedicated_data).unwrap();
10230        let blob_array: ArrayRef = blob_builder.finish().unwrap();
10231
10232        let schema = Arc::new(Schema::new(vec![
10233            Field::new("id", DataType::Int32, false),
10234            crate::blob_field("blob", true),
10235        ]));
10236        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
10237        let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone());
10238
10239        Dataset::write(
10240            reader,
10241            &routed_uri,
10242            Some(WriteParams {
10243                max_rows_per_file: 1, // Create 2 fragments
10244                enable_stable_row_ids: true,
10245                data_storage_version: Some(lance_file::version::LanceFileVersion::V2_2),
10246                ..Default::default()
10247            }),
10248        )
10249        .await
10250        .unwrap();
10251
10252        let baseline_files = count_data_files_in(test_uri);
10253
10254        let failing = Arc::new(FailingProxyStore::new());
10255        failing.fail_after_n("put", "_transactions", 1, "injected commit failure");
10256        failing.fail_after_n(
10257            "put_multipart",
10258            "_transactions",
10259            1,
10260            "injected commit failure",
10261        );
10262
10263        let mut dataset = DatasetBuilder::from_uri(&routed_uri)
10264            .with_read_params(crate::dataset::ReadParams {
10265                store_options: Some(ObjectStoreParams {
10266                    object_store_wrapper: Some(failing.clone()),
10267                    ..Default::default()
10268                }),
10269                ..Default::default()
10270            })
10271            .load()
10272            .await
10273            .unwrap();
10274
10275        let options = CompactionOptions {
10276            target_rows_per_fragment: 1000,
10277            ..Default::default()
10278        };
10279        let result = compact_files(&mut dataset, options, None).await;
10280        assert!(
10281            result.is_err(),
10282            "Compaction should fail when transaction commit fails"
10283        );
10284
10285        assert!(
10286            count_data_files_in(test_uri) > baseline_files,
10287            "Blob v2 sidecars should be retained for GC after the Rewrite commit fails"
10288        );
10289    }
10290
10291    async fn read_blob_bytes_by_index(
10292        dataset: &Arc<Dataset>,
10293        column: &str,
10294    ) -> Vec<(i32, Option<Vec<u8>>)> {
10295        let mut scanner = dataset.scan();
10296        scanner.with_row_id();
10297        let batch = scanner
10298            .project(&["id", column])
10299            .unwrap()
10300            .try_into_batch()
10301            .await
10302            .unwrap();
10303        let ids = batch
10304            .column_by_name("id")
10305            .unwrap()
10306            .as_primitive::<Int32Type>();
10307        let row_ids = batch
10308            .column_by_name(ROW_ID)
10309            .unwrap()
10310            .as_primitive::<UInt64Type>();
10311
10312        let mut result = Vec::with_capacity(batch.num_rows());
10313        for i in 0..batch.num_rows() {
10314            let row_id = row_ids.value(i);
10315            let id = ids.value(i);
10316            let blobs = dataset.take_blobs(&[row_id], column).await.unwrap();
10317            match blobs.into_iter().next().flatten() {
10318                Some(blob) => {
10319                    let data = blob.read().await.unwrap();
10320                    result.push((id, Some(data.to_vec())));
10321                }
10322                None => result.push((id, None)),
10323            }
10324        }
10325        result
10326    }
10327
10328    fn mixed_blob_values() -> Vec<(i32, Option<Vec<u8>>)> {
10329        vec![
10330            (0, Some(vec![b'0'; 80])),
10331            (1, None),
10332            (2, Some(Vec::new())),
10333            (3, Some(vec![b'3'; 80])),
10334            (4, Some(vec![b'4'; 80])),
10335            (5, Some(vec![b'5'; 80])),
10336        ]
10337    }
10338
10339    async fn assert_compaction_preserves_blob_values(
10340        mut dataset: Dataset,
10341        expected: &[(i32, Option<Vec<u8>>)],
10342    ) {
10343        assert_eq!(dataset.get_fragments().len(), 3);
10344
10345        let mut before = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
10346        before.sort_by_key(|(id, _)| *id);
10347        assert_eq!(before, expected);
10348
10349        compact_files(
10350            &mut dataset,
10351            CompactionOptions {
10352                target_rows_per_fragment: 1024 * 1024,
10353                ..Default::default()
10354            },
10355            None,
10356        )
10357        .await
10358        .unwrap();
10359
10360        assert_eq!(dataset.get_fragments().len(), 1);
10361
10362        let mut after = read_blob_bytes_by_index(&Arc::new(dataset), "blob").await;
10363        after.sort_by_key(|(id, _)| *id);
10364        assert_eq!(after, expected);
10365    }
10366
10367    #[test]
10368    fn test_blob_v2_rewrite_plan_skips_non_blob_list_subtree() {
10369        let items_field = Field::new(
10370            "items",
10371            DataType::List(Arc::new(Field::new("item", DataType::Int32, true))),
10372            true,
10373        );
10374        let info_field = Field::new(
10375            "info",
10376            DataType::Struct(
10377                vec![
10378                    Arc::new(items_field),
10379                    Arc::new(crate::blob_field("blob", true)),
10380                ]
10381                .into(),
10382            ),
10383            true,
10384        );
10385        let logical_arrow_schema = Schema::new(vec![info_field]);
10386        let logical_schema =
10387            lance_core::datatypes::Schema::try_from(&logical_arrow_schema).unwrap();
10388        let mut input_schema = logical_schema.clone();
10389        input_schema.fields[0].unload_blobs_recursive();
10390        let input_field = Field::from(&input_schema.fields[0]);
10391
10392        let plan =
10393            BlobV2FieldRewritePlan::try_new(&logical_schema.fields[0], &input_field).unwrap();
10394        let BlobV2FieldRewritePlan::Struct { children, .. } = &plan else {
10395            panic!("nested blob field should produce a struct rewrite plan");
10396        };
10397        assert!(matches!(
10398            children[0],
10399            BlobV2FieldRewritePlan::Passthrough { .. }
10400        ));
10401        assert!(matches!(children[1], BlobV2FieldRewritePlan::Blob { .. }));
10402
10403        let DataType::Struct(output_children) = plan.output_field().data_type() else {
10404            panic!("nested blob rewrite plan should declare a struct output");
10405        };
10406        let DataType::Struct(input_children) = input_field.data_type() else {
10407            panic!("nested blob input should be a struct");
10408        };
10409        assert_eq!(output_children[0], input_children[0]);
10410        let DataType::Struct(blob_children) = output_children[1].data_type() else {
10411            panic!("blob rewrite plan should declare a struct blob output");
10412        };
10413        assert_eq!(
10414            BlobV2Layout::classify(blob_children),
10415            Some(BlobV2Layout::Logical)
10416        );
10417    }
10418
10419    #[tokio::test]
10420    async fn test_compact_blob_v1_preserves_null_empty_and_payload_order() {
10421        let test_dir = TempStrDir::default();
10422        let expected = mixed_blob_values();
10423        let schema = Arc::new(Schema::new(vec![
10424            Field::new("id", DataType::Int32, false),
10425            Field::new("blob", DataType::LargeBinary, true)
10426                .with_metadata([(BLOB_META_KEY.to_string(), "true".to_string())].into()),
10427        ]));
10428        let batch = RecordBatch::try_new(
10429            schema.clone(),
10430            vec![
10431                Arc::new(Int32Array::from_iter_values(0..expected.len() as i32)),
10432                Arc::new(LargeBinaryArray::from_iter(
10433                    expected.iter().map(|(_, value)| value.as_deref()),
10434                )),
10435            ],
10436        )
10437        .unwrap();
10438        let dataset = Dataset::write(
10439            RecordBatchIterator::new(vec![Ok(batch)], schema),
10440            &test_dir,
10441            Some(WriteParams {
10442                data_storage_version: Some(LanceFileVersion::V2_0),
10443                max_rows_per_file: 2,
10444                ..Default::default()
10445            }),
10446        )
10447        .await
10448        .unwrap();
10449
10450        assert_compaction_preserves_blob_values(dataset, &expected).await;
10451    }
10452
10453    #[tokio::test]
10454    async fn test_compact_blob_v2_preserves_null_empty_and_payload_order() {
10455        use crate::BlobArrayBuilder;
10456
10457        let test_dir = TempStrDir::default();
10458        let expected = mixed_blob_values();
10459        let mut blob_builder = BlobArrayBuilder::new(expected.len());
10460        for (_, value) in &expected {
10461            match value {
10462                Some(value) => blob_builder.push_bytes(value).unwrap(),
10463                None => blob_builder.push_null().unwrap(),
10464            }
10465        }
10466        let schema = Arc::new(Schema::new(vec![
10467            Field::new("id", DataType::Int32, false),
10468            crate::blob_field("blob", true),
10469        ]));
10470        let batch = RecordBatch::try_new(
10471            schema.clone(),
10472            vec![
10473                Arc::new(Int32Array::from_iter_values(0..expected.len() as i32)),
10474                blob_builder.finish().unwrap(),
10475            ],
10476        )
10477        .unwrap();
10478        let dataset = Dataset::write(
10479            RecordBatchIterator::new(vec![Ok(batch)], schema),
10480            &test_dir,
10481            Some(WriteParams {
10482                data_storage_version: Some(LanceFileVersion::V2_2),
10483                max_rows_per_file: 2,
10484                ..Default::default()
10485            }),
10486        )
10487        .await
10488        .unwrap();
10489
10490        assert_compaction_preserves_blob_values(dataset, &expected).await;
10491    }
10492
10493    #[tokio::test]
10494    async fn test_compact_blob_v2_preserves_external_references() {
10495        use crate::BlobArrayBuilder;
10496        use lance_core::utils::tempfile::TempDir;
10497        use lance_table::format::BasePath;
10498
10499        let test_dir = TempDir::default();
10500        let external_dir = TempDir::default();
10501        let external_path = external_dir.std_path().join("external.bin");
10502        std::fs::write(&external_path, b"external-data").unwrap();
10503        let external_uri = format!("file://{}", external_path.display());
10504        let base_uri = format!("file://{}", external_dir.std_path().display());
10505
10506        let mut blob_builder = BlobArrayBuilder::new(2);
10507        blob_builder.push_uri(external_uri.clone()).unwrap();
10508        blob_builder.push_bytes(b"inline-data").unwrap();
10509        let blob_array: ArrayRef = blob_builder.finish().unwrap();
10510
10511        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1]));
10512        let schema = Arc::new(Schema::new(vec![
10513            Field::new("id", DataType::Int32, false),
10514            crate::blob_field("blob", true),
10515        ]));
10516
10517        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
10518        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
10519
10520        let mut dataset = Dataset::write(
10521            reader,
10522            &test_dir.path_str(),
10523            Some(WriteParams {
10524                data_storage_version: Some(LanceFileVersion::V2_2),
10525                max_rows_per_file: 1,
10526                initial_bases: Some(vec![BasePath {
10527                    id: 1,
10528                    name: Some("external".to_string()),
10529                    path: base_uri,
10530                    is_dataset_root: false,
10531                }]),
10532                ..Default::default()
10533            }),
10534        )
10535        .await
10536        .unwrap();
10537
10538        assert_eq!(dataset.get_fragments().len(), 2);
10539
10540        for frag in dataset.get_fragments() {
10541            let rows = frag.physical_rows().await.unwrap();
10542            assert!(rows > 0, "fragment {} should have rows", frag.id());
10543        }
10544
10545        let options = CompactionOptions {
10546            target_rows_per_fragment: 1024 * 1024,
10547            ..Default::default()
10548        };
10549        let plan = plan_compaction(&dataset, &options).await.unwrap();
10550        assert!(
10551            !plan.tasks().is_empty(),
10552            "compaction plan should have tasks, got {} tasks",
10553            plan.tasks().len()
10554        );
10555
10556        compact_files(&mut dataset, options, None).await.unwrap();
10557
10558        assert_eq!(dataset.get_fragments().len(), 1);
10559
10560        let scan_result = dataset
10561            .scan()
10562            .project(&["id", "blob"])
10563            .unwrap()
10564            .try_into_batch()
10565            .await
10566            .unwrap();
10567        assert_eq!(scan_result.num_rows(), 2);
10568
10569        let ids = scan_result
10570            .column_by_name("id")
10571            .unwrap()
10572            .as_primitive::<Int32Type>();
10573        let mut id_values: Vec<i32> = ids.iter().map(|v| v.unwrap()).collect();
10574        id_values.sort();
10575        assert_eq!(id_values, vec![0, 1]);
10576
10577        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
10578        blob_values.sort_by_key(|(id, _)| *id);
10579        assert_eq!(
10580            blob_values,
10581            vec![
10582                (0, Some(b"external-data".to_vec())),
10583                (1, Some(b"inline-data".to_vec()))
10584            ]
10585        );
10586    }
10587
10588    #[tokio::test]
10589    async fn test_compact_blob_v2_packed_and_dedicated() {
10590        use crate::BlobArrayBuilder;
10591        use lance_arrow::BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY;
10592        use lance_core::utils::tempfile::TempDir;
10593
10594        let test_dir = TempDir::default();
10595
10596        let inline_data = b"small-inline-blob".as_slice();
10597        let packed_data: Vec<u8> = (0..64 * 1024 + 1024).map(|i| (i % 256) as u8).collect();
10598        let dedicated_data: Vec<u8> = (0..4 * 1024 * 1024 + 512)
10599            .map(|i| ((i + 97) % 256) as u8)
10600            .collect();
10601
10602        let mut blob_builder = BlobArrayBuilder::new(3);
10603        blob_builder.push_bytes(inline_data).unwrap();
10604        blob_builder.push_bytes(&packed_data).unwrap();
10605        blob_builder.push_bytes(&dedicated_data).unwrap();
10606        let blob_array: ArrayRef = blob_builder.finish().unwrap();
10607
10608        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2]));
10609        let mut blob_field = crate::blob_field("blob", true);
10610        {
10611            let metadata = blob_field.metadata().clone();
10612            let mut new_metadata = metadata;
10613            new_metadata.insert(
10614                BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY.to_string(),
10615                (4 * 1024 * 1024).to_string(),
10616            );
10617            blob_field = blob_field.with_metadata(new_metadata);
10618        }
10619        let schema = Arc::new(Schema::new(vec![
10620            Field::new("id", DataType::Int32, false),
10621            blob_field,
10622        ]));
10623
10624        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
10625        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
10626
10627        let mut dataset = Dataset::write(
10628            reader,
10629            &test_dir.path_str(),
10630            Some(WriteParams {
10631                data_storage_version: Some(LanceFileVersion::V2_2),
10632                max_rows_per_file: 1,
10633                ..Default::default()
10634            }),
10635        )
10636        .await
10637        .unwrap();
10638
10639        assert_eq!(dataset.get_fragments().len(), 3);
10640
10641        compact_files(
10642            &mut dataset,
10643            CompactionOptions {
10644                target_rows_per_fragment: 1024 * 1024,
10645                ..Default::default()
10646            },
10647            None,
10648        )
10649        .await
10650        .unwrap();
10651
10652        assert_eq!(dataset.get_fragments().len(), 1);
10653
10654        let scan_result = dataset
10655            .scan()
10656            .project(&["id", "blob"])
10657            .unwrap()
10658            .try_into_batch()
10659            .await
10660            .unwrap();
10661        assert_eq!(scan_result.num_rows(), 3);
10662
10663        let ids = scan_result
10664            .column_by_name("id")
10665            .unwrap()
10666            .as_primitive::<Int32Type>();
10667        let id_values: Vec<i32> = ids.iter().map(|v| v.unwrap()).collect();
10668        assert_eq!(id_values, vec![0, 1, 2]);
10669
10670        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
10671        blob_values.sort_by_key(|(id, _)| *id);
10672        assert_eq!(
10673            blob_values,
10674            vec![
10675                (0, Some(inline_data.to_vec())),
10676                (1, Some(packed_data)),
10677                (2, Some(dedicated_data))
10678            ]
10679        );
10680    }
10681
10682    #[tokio::test]
10683    async fn test_compact_blob_v2_with_null_rows() {
10684        use crate::BlobArrayBuilder;
10685        use lance_core::utils::tempfile::TempDir;
10686
10687        let test_dir = TempDir::default();
10688
10689        let mut blob_builder = BlobArrayBuilder::new(4);
10690        blob_builder.push_bytes(b"inline-0").unwrap();
10691        blob_builder.push_null().unwrap();
10692        blob_builder.push_bytes(b"inline-2").unwrap();
10693        blob_builder.push_null().unwrap();
10694        let blob_array: ArrayRef = blob_builder.finish().unwrap();
10695
10696        let id_array: ArrayRef =
10697            Arc::new(Int32Array::from(vec![Some(0), Some(1), Some(2), Some(3)]));
10698        let schema = Arc::new(Schema::new(vec![
10699            Field::new("id", DataType::Int32, false),
10700            crate::blob_field("blob", true),
10701        ]));
10702
10703        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
10704        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
10705
10706        let mut dataset = Dataset::write(
10707            reader,
10708            &test_dir.path_str(),
10709            Some(WriteParams {
10710                data_storage_version: Some(LanceFileVersion::V2_2),
10711                max_rows_per_file: 2,
10712                ..Default::default()
10713            }),
10714        )
10715        .await
10716        .unwrap();
10717
10718        assert_eq!(dataset.get_fragments().len(), 2);
10719
10720        compact_files(
10721            &mut dataset,
10722            CompactionOptions {
10723                target_rows_per_fragment: 1024 * 1024,
10724                ..Default::default()
10725            },
10726            None,
10727        )
10728        .await
10729        .unwrap();
10730
10731        assert_eq!(dataset.get_fragments().len(), 1);
10732
10733        let scan_result = dataset
10734            .scan()
10735            .project(&["id", "blob"])
10736            .unwrap()
10737            .try_into_batch()
10738            .await
10739            .unwrap();
10740        assert_eq!(scan_result.num_rows(), 4);
10741
10742        let ids = scan_result
10743            .column_by_name("id")
10744            .unwrap()
10745            .as_primitive::<Int32Type>();
10746        let id_values: Vec<i32> = ids.iter().map(|v| v.unwrap()).collect();
10747        assert_eq!(id_values, vec![0, 1, 2, 3]);
10748
10749        let blob_col = scan_result.column_by_name("blob").unwrap();
10750        assert!(
10751            matches!(blob_col.data_type(), DataType::Struct(_)),
10752            "blob column should be a struct after compaction"
10753        );
10754
10755        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
10756        blob_values.sort_by_key(|(id, _)| *id);
10757        assert_eq!(
10758            blob_values,
10759            vec![
10760                (0, Some(b"inline-0".to_vec())),
10761                (1, None),
10762                (2, Some(b"inline-2".to_vec())),
10763                (3, None)
10764            ]
10765        );
10766    }
10767
10768    #[tokio::test]
10769    async fn test_compact_blob_v2_deleted_rows_not_resurrected() {
10770        use crate::BlobArrayBuilder;
10771        use lance_core::utils::tempfile::TempDir;
10772
10773        let test_dir = TempDir::default();
10774
10775        let mut blob_builder = BlobArrayBuilder::new(4);
10776        blob_builder.push_bytes(b"blob-0").unwrap();
10777        blob_builder.push_bytes(b"blob-1").unwrap();
10778        blob_builder.push_bytes(b"blob-2").unwrap();
10779        blob_builder.push_bytes(b"blob-3").unwrap();
10780        let blob_array: ArrayRef = blob_builder.finish().unwrap();
10781
10782        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2, 3]));
10783        let schema = Arc::new(Schema::new(vec![
10784            Field::new("id", DataType::Int32, false),
10785            crate::blob_field("blob", true),
10786        ]));
10787
10788        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
10789        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
10790
10791        let mut dataset = Dataset::write(
10792            reader,
10793            &test_dir.path_str(),
10794            Some(WriteParams {
10795                data_storage_version: Some(LanceFileVersion::V2_2),
10796                max_rows_per_file: 2,
10797                ..Default::default()
10798            }),
10799        )
10800        .await
10801        .unwrap();
10802
10803        assert_eq!(dataset.get_fragments().len(), 2);
10804
10805        dataset.delete("id = 1").await.unwrap();
10806        dataset.delete("id = 2").await.unwrap();
10807
10808        compact_files(
10809            &mut dataset,
10810            CompactionOptions {
10811                target_rows_per_fragment: 1024 * 1024,
10812                materialize_deletions_threshold: 0.0,
10813                ..Default::default()
10814            },
10815            None,
10816        )
10817        .await
10818        .unwrap();
10819
10820        let scan_result = dataset
10821            .scan()
10822            .project(&["id", "blob"])
10823            .unwrap()
10824            .try_into_batch()
10825            .await
10826            .unwrap();
10827        assert_eq!(scan_result.num_rows(), 2);
10828
10829        let ids = scan_result
10830            .column_by_name("id")
10831            .unwrap()
10832            .as_primitive::<Int32Type>();
10833        let mut id_values: Vec<i32> = ids.iter().map(|v| v.unwrap()).collect();
10834        id_values.sort();
10835        assert_eq!(id_values, vec![0, 3]);
10836
10837        let blob_col = scan_result.column_by_name("blob").unwrap();
10838        let struct_arr = blob_col.as_any().downcast_ref::<StructArray>().unwrap();
10839        let kind_col = struct_arr
10840            .column_by_name("kind")
10841            .unwrap()
10842            .as_primitive::<UInt8Type>();
10843
10844        for i in 0..kind_col.len() {
10845            assert!(
10846                !kind_col.is_null(i),
10847                "row {} should have a non-null kind after compaction of deleted rows",
10848                i
10849            );
10850        }
10851
10852        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
10853        blob_values.sort_by_key(|(id, _)| *id);
10854        assert_eq!(
10855            blob_values,
10856            vec![(0, Some(b"blob-0".to_vec())), (3, Some(b"blob-3".to_vec()))]
10857        );
10858    }
10859
10860    #[tokio::test]
10861    async fn test_compact_blob_v2_external_and_data_blob_mixed() {
10862        use crate::BlobArrayBuilder;
10863        use lance_arrow::BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY;
10864        use lance_core::utils::tempfile::TempDir;
10865        use lance_table::format::BasePath;
10866
10867        let test_dir = TempDir::default();
10868        let external_dir = TempDir::default();
10869        let external_path = external_dir.std_path().join("external.bin");
10870        std::fs::write(&external_path, b"external-payload").unwrap();
10871        let external_uri = format!("file://{}", external_path.display());
10872        let base_uri = format!("file://{}", external_dir.std_path().display());
10873
10874        let packed_data: Vec<u8> = (0..64 * 1024 + 512).map(|i| (i % 256) as u8).collect();
10875
10876        let mut blob_builder = BlobArrayBuilder::new(4);
10877        blob_builder.push_uri(external_uri.clone()).unwrap();
10878        blob_builder.push_bytes(&packed_data).unwrap();
10879        blob_builder.push_bytes(b"inline-small").unwrap();
10880        blob_builder.push_uri(external_uri.clone()).unwrap();
10881        let blob_array: ArrayRef = blob_builder.finish().unwrap();
10882
10883        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2, 3]));
10884        let mut blob_field = crate::blob_field("blob", true);
10885        {
10886            let mut new_metadata = blob_field.metadata().clone();
10887            new_metadata.insert(
10888                BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY.to_string(),
10889                (4 * 1024 * 1024).to_string(),
10890            );
10891            blob_field = blob_field.with_metadata(new_metadata);
10892        }
10893        let schema = Arc::new(Schema::new(vec![
10894            Field::new("id", DataType::Int32, false),
10895            blob_field,
10896        ]));
10897
10898        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
10899        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
10900
10901        let mut dataset = Dataset::write(
10902            reader,
10903            &test_dir.path_str(),
10904            Some(WriteParams {
10905                data_storage_version: Some(LanceFileVersion::V2_2),
10906                max_rows_per_file: 2,
10907                initial_bases: Some(vec![BasePath {
10908                    id: 1,
10909                    name: Some("external".to_string()),
10910                    path: base_uri,
10911                    is_dataset_root: false,
10912                }]),
10913                ..Default::default()
10914            }),
10915        )
10916        .await
10917        .unwrap();
10918
10919        assert_eq!(dataset.get_fragments().len(), 2);
10920
10921        compact_files(
10922            &mut dataset,
10923            CompactionOptions {
10924                target_rows_per_fragment: 1024 * 1024,
10925                ..Default::default()
10926            },
10927            None,
10928        )
10929        .await
10930        .unwrap();
10931
10932        assert_eq!(dataset.get_fragments().len(), 1);
10933
10934        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
10935        blob_values.sort_by_key(|(id, _)| *id);
10936        assert_eq!(
10937            blob_values,
10938            vec![
10939                (0, Some(b"external-payload".to_vec())),
10940                (1, Some(packed_data)),
10941                (2, Some(b"inline-small".to_vec())),
10942                (3, Some(b"external-payload".to_vec()))
10943            ]
10944        );
10945    }
10946
10947    #[tokio::test]
10948    async fn test_compact_blob_v2_multiple_blob_columns() {
10949        use crate::BlobArrayBuilder;
10950        use lance_core::utils::tempfile::TempDir;
10951
10952        let test_dir = TempDir::default();
10953
10954        let mut image_builder = BlobArrayBuilder::new(3);
10955        image_builder.push_bytes(b"image-0").unwrap();
10956        image_builder.push_bytes(b"image-1").unwrap();
10957        image_builder.push_bytes(b"image-2").unwrap();
10958        let image_array: ArrayRef = image_builder.finish().unwrap();
10959
10960        let mut thumb_builder = BlobArrayBuilder::new(3);
10961        thumb_builder.push_bytes(b"thumb-0").unwrap();
10962        thumb_builder.push_null().unwrap();
10963        thumb_builder.push_bytes(b"thumb-2").unwrap();
10964        let thumb_array: ArrayRef = thumb_builder.finish().unwrap();
10965
10966        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2]));
10967        let schema = Arc::new(Schema::new(vec![
10968            Field::new("id", DataType::Int32, false),
10969            crate::blob_field("image", true),
10970            crate::blob_field("thumbnail", true),
10971        ]));
10972
10973        let batch =
10974            RecordBatch::try_new(schema.clone(), vec![id_array, image_array, thumb_array]).unwrap();
10975        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
10976
10977        let mut dataset = Dataset::write(
10978            reader,
10979            &test_dir.path_str(),
10980            Some(WriteParams {
10981                data_storage_version: Some(LanceFileVersion::V2_2),
10982                max_rows_per_file: 1,
10983                ..Default::default()
10984            }),
10985        )
10986        .await
10987        .unwrap();
10988
10989        assert_eq!(dataset.get_fragments().len(), 3);
10990
10991        compact_files(
10992            &mut dataset,
10993            CompactionOptions {
10994                target_rows_per_fragment: 1024 * 1024,
10995                ..Default::default()
10996            },
10997            None,
10998        )
10999        .await
11000        .unwrap();
11001
11002        assert_eq!(dataset.get_fragments().len(), 1);
11003
11004        let mut image_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "image").await;
11005        image_values.sort_by_key(|(id, _)| *id);
11006        assert_eq!(
11007            image_values,
11008            vec![
11009                (0, Some(b"image-0".to_vec())),
11010                (1, Some(b"image-1".to_vec())),
11011                (2, Some(b"image-2".to_vec()))
11012            ]
11013        );
11014
11015        let mut thumb_values =
11016            read_blob_bytes_by_index(&Arc::new(dataset.clone()), "thumbnail").await;
11017        thumb_values.sort_by_key(|(id, _)| *id);
11018        assert_eq!(
11019            thumb_values,
11020            vec![
11021                (0, Some(b"thumb-0".to_vec())),
11022                (1, None),
11023                (2, Some(b"thumb-2".to_vec()))
11024            ]
11025        );
11026    }
11027
11028    #[tokio::test]
11029    async fn test_compact_blob_v2_external_and_null_mixed() {
11030        use crate::BlobArrayBuilder;
11031        use lance_core::utils::tempfile::TempDir;
11032        use lance_table::format::BasePath;
11033
11034        let test_dir = TempDir::default();
11035        let external_dir = TempDir::default();
11036        let external_path = external_dir.std_path().join("mixed-external.bin");
11037        std::fs::write(&external_path, b"external-mixed-data").unwrap();
11038        let external_uri = format!("file://{}", external_path.display());
11039        let base_uri = format!("file://{}", external_dir.std_path().display());
11040
11041        let mut blob_builder = BlobArrayBuilder::new(4);
11042        blob_builder.push_uri(external_uri.clone()).unwrap();
11043        blob_builder.push_null().unwrap();
11044        blob_builder.push_uri(external_uri.clone()).unwrap();
11045        blob_builder.push_null().unwrap();
11046        let blob_array: ArrayRef = blob_builder.finish().unwrap();
11047
11048        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2, 3]));
11049        let schema = Arc::new(Schema::new(vec![
11050            Field::new("id", DataType::Int32, false),
11051            crate::blob_field("blob", true),
11052        ]));
11053
11054        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
11055        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
11056
11057        let mut dataset = Dataset::write(
11058            reader,
11059            &test_dir.path_str(),
11060            Some(WriteParams {
11061                data_storage_version: Some(LanceFileVersion::V2_2),
11062                max_rows_per_file: 2,
11063                initial_bases: Some(vec![BasePath {
11064                    id: 1,
11065                    name: Some("external".to_string()),
11066                    path: base_uri,
11067                    is_dataset_root: false,
11068                }]),
11069                ..Default::default()
11070            }),
11071        )
11072        .await
11073        .unwrap();
11074
11075        assert_eq!(dataset.get_fragments().len(), 2);
11076
11077        compact_files(
11078            &mut dataset,
11079            CompactionOptions {
11080                target_rows_per_fragment: 1024 * 1024,
11081                ..Default::default()
11082            },
11083            None,
11084        )
11085        .await
11086        .unwrap();
11087
11088        assert_eq!(dataset.get_fragments().len(), 1);
11089
11090        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
11091        blob_values.sort_by_key(|(id, _)| *id);
11092        assert_eq!(
11093            blob_values,
11094            vec![
11095                (0, Some(b"external-mixed-data".to_vec())),
11096                (1, None),
11097                (2, Some(b"external-mixed-data".to_vec())),
11098                (3, None)
11099            ]
11100        );
11101    }
11102
11103    #[tokio::test]
11104    async fn test_compact_blob_v2_all_null_and_all_external_fragments() {
11105        use crate::BlobArrayBuilder;
11106        use lance_core::utils::tempfile::TempDir;
11107        use lance_table::format::BasePath;
11108
11109        let test_dir = TempDir::default();
11110        let external_dir = TempDir::default();
11111        let external_path = external_dir.std_path().join("all-ext.bin");
11112        std::fs::write(&external_path, b"all-external-data").unwrap();
11113        let external_uri = format!("file://{}", external_path.display());
11114        let base_uri = format!("file://{}", external_dir.std_path().display());
11115
11116        let mut null_builder = BlobArrayBuilder::new(2);
11117        null_builder.push_null().unwrap();
11118        null_builder.push_null().unwrap();
11119        let null_array: ArrayRef = null_builder.finish().unwrap();
11120
11121        let mut ext_builder = BlobArrayBuilder::new(2);
11122        ext_builder.push_uri(external_uri.clone()).unwrap();
11123        ext_builder.push_uri(external_uri.clone()).unwrap();
11124        let ext_array: ArrayRef = ext_builder.finish().unwrap();
11125
11126        let id_null_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1]));
11127        let null_schema = Arc::new(Schema::new(vec![
11128            Field::new("id", DataType::Int32, false),
11129            crate::blob_field("blob", true),
11130        ]));
11131        let null_batch =
11132            RecordBatch::try_new(null_schema.clone(), vec![id_null_array, null_array]).unwrap();
11133
11134        let id_ext_array: ArrayRef = Arc::new(Int32Array::from(vec![2, 3]));
11135        let ext_schema = Arc::new(Schema::new(vec![
11136            Field::new("id", DataType::Int32, false),
11137            crate::blob_field("blob", true),
11138        ]));
11139        let ext_batch =
11140            RecordBatch::try_new(ext_schema.clone(), vec![id_ext_array, ext_array]).unwrap();
11141
11142        let mut dataset = Dataset::write(
11143            RecordBatchIterator::new(
11144                vec![null_batch, ext_batch].into_iter().map(Ok),
11145                null_schema.clone(),
11146            ),
11147            &test_dir.path_str(),
11148            Some(WriteParams {
11149                data_storage_version: Some(LanceFileVersion::V2_2),
11150                max_rows_per_file: 2,
11151                initial_bases: Some(vec![BasePath {
11152                    id: 1,
11153                    name: Some("external".to_string()),
11154                    path: base_uri,
11155                    is_dataset_root: false,
11156                }]),
11157                ..Default::default()
11158            }),
11159        )
11160        .await
11161        .unwrap();
11162
11163        assert_eq!(dataset.get_fragments().len(), 2);
11164
11165        compact_files(
11166            &mut dataset,
11167            CompactionOptions {
11168                target_rows_per_fragment: 1024 * 1024,
11169                ..Default::default()
11170            },
11171            None,
11172        )
11173        .await
11174        .unwrap();
11175
11176        assert_eq!(dataset.get_fragments().len(), 1);
11177
11178        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
11179        blob_values.sort_by_key(|(id, _)| *id);
11180        assert_eq!(
11181            blob_values,
11182            vec![
11183                (0, None),
11184                (1, None),
11185                (2, Some(b"all-external-data".to_vec())),
11186                (3, Some(b"all-external-data".to_vec()))
11187            ]
11188        );
11189    }
11190
11191    #[tokio::test]
11192    async fn test_compact_blob_v2_external_with_multiple_base_ids() {
11193        use crate::BlobArrayBuilder;
11194        use lance_core::utils::tempfile::TempDir;
11195        use lance_table::format::BasePath;
11196
11197        let test_dir = TempDir::default();
11198        let base_a_dir = TempDir::default();
11199        let base_b_dir = TempDir::default();
11200
11201        let path_a = base_a_dir.std_path().join("data-a.bin");
11202        std::fs::write(&path_a, b"from-base-a").unwrap();
11203        let uri_a = format!("file://{}", path_a.display());
11204        let base_uri_a = format!("file://{}", base_a_dir.std_path().display());
11205
11206        let path_b = base_b_dir.std_path().join("data-b.bin");
11207        std::fs::write(&path_b, b"from-base-b").unwrap();
11208        let uri_b = format!("file://{}", path_b.display());
11209        let base_uri_b = format!("file://{}", base_b_dir.std_path().display());
11210
11211        let mut blob_builder = BlobArrayBuilder::new(4);
11212        blob_builder.push_uri(uri_a.clone()).unwrap();
11213        blob_builder.push_uri(uri_b).unwrap();
11214        blob_builder.push_bytes(b"inline-data").unwrap();
11215        blob_builder.push_uri(uri_a).unwrap();
11216        let blob_array: ArrayRef = blob_builder.finish().unwrap();
11217
11218        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2, 3]));
11219        let schema = Arc::new(Schema::new(vec![
11220            Field::new("id", DataType::Int32, false),
11221            crate::blob_field("blob", true),
11222        ]));
11223
11224        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
11225        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
11226
11227        let mut dataset = Dataset::write(
11228            reader,
11229            &test_dir.path_str(),
11230            Some(WriteParams {
11231                data_storage_version: Some(LanceFileVersion::V2_2),
11232                max_rows_per_file: 2,
11233                initial_bases: Some(vec![
11234                    BasePath {
11235                        id: 1,
11236                        name: Some("base_a".to_string()),
11237                        path: base_uri_a,
11238                        is_dataset_root: false,
11239                    },
11240                    BasePath {
11241                        id: 2,
11242                        name: Some("base_b".to_string()),
11243                        path: base_uri_b,
11244                        is_dataset_root: false,
11245                    },
11246                ]),
11247                ..Default::default()
11248            }),
11249        )
11250        .await
11251        .unwrap();
11252
11253        assert_eq!(dataset.get_fragments().len(), 2);
11254
11255        compact_files(
11256            &mut dataset,
11257            CompactionOptions {
11258                target_rows_per_fragment: 1024 * 1024,
11259                ..Default::default()
11260            },
11261            None,
11262        )
11263        .await
11264        .unwrap();
11265
11266        assert_eq!(dataset.get_fragments().len(), 1);
11267
11268        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
11269        blob_values.sort_by_key(|(id, _)| *id);
11270        assert_eq!(
11271            blob_values,
11272            vec![
11273                (0, Some(b"from-base-a".to_vec())),
11274                (1, Some(b"from-base-b".to_vec())),
11275                (2, Some(b"inline-data".to_vec())),
11276                (3, Some(b"from-base-a".to_vec()))
11277            ]
11278        );
11279    }
11280
11281    #[tokio::test]
11282    async fn test_compact_blob_v2_large_blobs() {
11283        use crate::BlobArrayBuilder;
11284        use lance_core::utils::tempfile::TempDir;
11285
11286        let test_dir = TempDir::default();
11287
11288        let large_blob_a: Vec<u8> = (0..512 * 1024).map(|i| (i % 256) as u8).collect();
11289        let large_blob_b: Vec<u8> = (0..256 * 1024).map(|i| ((i + 42) % 256) as u8).collect();
11290
11291        let mut blob_builder = BlobArrayBuilder::new(3);
11292        blob_builder.push_bytes(&large_blob_a).unwrap();
11293        blob_builder.push_bytes(&large_blob_b).unwrap();
11294        blob_builder.push_bytes(b"small-blob").unwrap();
11295        let blob_array: ArrayRef = blob_builder.finish().unwrap();
11296
11297        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2]));
11298        let schema = Arc::new(Schema::new(vec![
11299            Field::new("id", DataType::Int32, false),
11300            crate::blob_field("blob", true),
11301        ]));
11302
11303        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
11304        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
11305
11306        let mut dataset = Dataset::write(
11307            reader,
11308            &test_dir.path_str(),
11309            Some(WriteParams {
11310                data_storage_version: Some(LanceFileVersion::V2_2),
11311                max_rows_per_file: 1,
11312                ..Default::default()
11313            }),
11314        )
11315        .await
11316        .unwrap();
11317
11318        assert_eq!(dataset.get_fragments().len(), 3);
11319
11320        compact_files(
11321            &mut dataset,
11322            CompactionOptions {
11323                target_rows_per_fragment: 1024 * 1024,
11324                ..Default::default()
11325            },
11326            None,
11327        )
11328        .await
11329        .unwrap();
11330
11331        assert_eq!(dataset.get_fragments().len(), 1);
11332
11333        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
11334        blob_values.sort_by_key(|(id, _)| *id);
11335        assert_eq!(
11336            blob_values,
11337            vec![
11338                (0, Some(large_blob_a)),
11339                (1, Some(large_blob_b)),
11340                (2, Some(b"small-blob".to_vec()))
11341            ]
11342        );
11343    }
11344
11345    #[tokio::test]
11346    async fn test_compact_blob_v2_blob_kind_reclassification() {
11347        use crate::BlobArrayBuilder;
11348        use lance_arrow::BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY;
11349        use lance_core::utils::tempfile::TempDir;
11350
11351        let test_dir = TempDir::default();
11352
11353        let medium_data: Vec<u8> = (0..32 * 1024).map(|i| (i % 256) as u8).collect();
11354
11355        let mut blob_builder = BlobArrayBuilder::new(2);
11356        blob_builder.push_bytes(&medium_data).unwrap();
11357        blob_builder.push_bytes(&medium_data).unwrap();
11358        let blob_array: ArrayRef = blob_builder.finish().unwrap();
11359
11360        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1]));
11361        let mut blob_field = crate::blob_field("blob", true);
11362        {
11363            let mut new_metadata = blob_field.metadata().clone();
11364            new_metadata.insert(
11365                BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY.to_string(),
11366                (16 * 1024).to_string(),
11367            );
11368            blob_field = blob_field.with_metadata(new_metadata);
11369        }
11370        let schema = Arc::new(Schema::new(vec![
11371            Field::new("id", DataType::Int32, false),
11372            blob_field,
11373        ]));
11374
11375        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
11376        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
11377
11378        let mut dataset = Dataset::write(
11379            reader,
11380            &test_dir.path_str(),
11381            Some(WriteParams {
11382                data_storage_version: Some(LanceFileVersion::V2_2),
11383                max_rows_per_file: 1,
11384                ..Default::default()
11385            }),
11386        )
11387        .await
11388        .unwrap();
11389
11390        assert_eq!(dataset.get_fragments().len(), 2);
11391
11392        compact_files(
11393            &mut dataset,
11394            CompactionOptions {
11395                target_rows_per_fragment: 1024 * 1024,
11396                ..Default::default()
11397            },
11398            None,
11399        )
11400        .await
11401        .unwrap();
11402
11403        assert_eq!(dataset.get_fragments().len(), 1);
11404
11405        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
11406        blob_values.sort_by_key(|(id, _)| *id);
11407        assert_eq!(
11408            blob_values,
11409            vec![
11410                (0, Some(medium_data.clone())),
11411                (1, Some(medium_data.clone()))
11412            ]
11413        );
11414    }
11415
11416    #[tokio::test]
11417    async fn test_compact_blob_v2_multi_batch() {
11418        use crate::BlobArrayBuilder;
11419        use lance_core::utils::tempfile::TempDir;
11420
11421        let test_dir = TempDir::default();
11422
11423        let mut blob_builder = BlobArrayBuilder::new(6);
11424        blob_builder.push_bytes(b"batch-0-row-0").unwrap();
11425        blob_builder.push_bytes(b"batch-0-row-1").unwrap();
11426        blob_builder.push_bytes(b"batch-1-row-0").unwrap();
11427        blob_builder.push_null().unwrap();
11428        blob_builder.push_bytes(b"batch-1-row-2").unwrap();
11429        blob_builder.push_bytes(b"batch-1-row-3").unwrap();
11430        let blob_array: ArrayRef = blob_builder.finish().unwrap();
11431
11432        let id_array: ArrayRef = Arc::new(Int32Array::from(vec![0, 1, 2, 3, 4, 5]));
11433        let schema = Arc::new(Schema::new(vec![
11434            Field::new("id", DataType::Int32, false),
11435            crate::blob_field("blob", true),
11436        ]));
11437
11438        let batch = RecordBatch::try_new(schema.clone(), vec![id_array, blob_array]).unwrap();
11439        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone());
11440
11441        let mut dataset = Dataset::write(
11442            reader,
11443            &test_dir.path_str(),
11444            Some(WriteParams {
11445                data_storage_version: Some(LanceFileVersion::V2_2),
11446                max_rows_per_file: 2,
11447                ..Default::default()
11448            }),
11449        )
11450        .await
11451        .unwrap();
11452
11453        assert_eq!(dataset.get_fragments().len(), 3);
11454
11455        compact_files(
11456            &mut dataset,
11457            CompactionOptions {
11458                target_rows_per_fragment: 1024 * 1024,
11459                batch_size: Some(2),
11460                ..Default::default()
11461            },
11462            None,
11463        )
11464        .await
11465        .unwrap();
11466
11467        assert_eq!(dataset.get_fragments().len(), 1);
11468
11469        let mut blob_values = read_blob_bytes_by_index(&Arc::new(dataset.clone()), "blob").await;
11470        blob_values.sort_by_key(|(id, _)| *id);
11471        assert_eq!(
11472            blob_values,
11473            vec![
11474                (0, Some(b"batch-0-row-0".to_vec())),
11475                (1, Some(b"batch-0-row-1".to_vec())),
11476                (2, Some(b"batch-1-row-0".to_vec())),
11477                (3, None),
11478                (4, Some(b"batch-1-row-2".to_vec())),
11479                (5, Some(b"batch-1-row-3".to_vec()))
11480            ]
11481        );
11482    }
11483    // ---- `max_overlays_per_fragment` compaction trigger ----
11484    //
11485    // Tests for the trigger that fully compacts a fragment carrying too many data
11486    // overlay files into a fresh fragment with the overlays (and deletions)
11487    // materialized into the base data.
11488    use arrow_array::record_batch;
11489    use lance_file::writer::FileWriterOptions;
11490    use lance_io::utils::CachedFileSize;
11491    use lance_table::format::DataFile;
11492    use lance_table::format::overlay::{DataOverlayFile, OverlayCoverage};
11493    use std::collections::BTreeMap;
11494
11495    use crate::dataset::DATA_DIR;
11496    use crate::dataset::transaction::DataOverlayGroup;
11497
11498    /// Two-fragment Int32 dataset: `id` (field 0) = 0..12 and `val` (field 1) =
11499    /// id * 10, six rows per fragment (fragments 0 and 1).
11500    async fn create_base_dataset(uri: &str) -> Dataset {
11501        let batch = record_batch!(
11502            ("id", Int32, (0..12).collect::<Vec<_>>()),
11503            ("val", Int32, (0..12).map(|v| v * 10).collect::<Vec<_>>())
11504        )
11505        .unwrap();
11506        let schema = batch.schema();
11507        let write_params = WriteParams {
11508            max_rows_per_file: 6,
11509            max_rows_per_group: 6,
11510            data_storage_version: Some(LanceFileVersion::Stable),
11511            ..Default::default()
11512        };
11513        let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone());
11514        Dataset::write(reader, uri, Some(write_params))
11515            .await
11516            .unwrap()
11517    }
11518
11519    fn i32_array(values: impl IntoIterator<Item = Option<i32>>) -> ArrayRef {
11520        Arc::new(Int32Array::from_iter(values))
11521    }
11522
11523    fn bitmap(offsets: impl IntoIterator<Item = u32>) -> RoaringBitmap {
11524        RoaringBitmap::from_iter(offsets)
11525    }
11526
11527    /// Write a dense overlay covering `fields` of `fragment_id` with `columns`
11528    /// as the per-field value columns, then commit it as a `DataOverlay`.
11529    async fn commit_overlay(
11530        dataset: Dataset,
11531        fragment_id: u64,
11532        fields: &[i32],
11533        coverage: OverlayCoverage,
11534        columns: Vec<ArrayRef>,
11535    ) -> Dataset {
11536        let read_version = dataset.version().version;
11537        let overlay_schema = dataset.schema().project_by_ids(fields, true);
11538        let filename = format!("{}.lance", Uuid::new_v4());
11539        let path = dataset.base.clone().join(DATA_DIR).join(filename.as_str());
11540        let obj_writer = dataset.object_store.create(&path).await.unwrap();
11541        let file_version = LanceFileVersion::Stable.resolve();
11542        let mut writer = lance_file::versions::create_writer(
11543            file_version,
11544            obj_writer,
11545            overlay_schema,
11546            FileWriterOptions::default(),
11547        )
11548        .unwrap();
11549        for (column_index, array) in columns.into_iter().enumerate() {
11550            writer.write_column(column_index, array).await.unwrap();
11551        }
11552        let summary = writer.finish().await.unwrap();
11553
11554        let mut data_file = DataFile::new_unstarted(filename, file_version);
11555        data_file.fields = writer
11556            .field_id_to_column_indices()
11557            .iter()
11558            .map(|(f, _)| *f as i32)
11559            .collect::<Vec<_>>()
11560            .into();
11561        data_file.column_indices = writer
11562            .field_id_to_column_indices()
11563            .iter()
11564            .map(|(_, c)| *c as i32)
11565            .collect::<Vec<_>>()
11566            .into();
11567        data_file.file_size_bytes = CachedFileSize::new(summary.size_bytes);
11568
11569        Dataset::commit(
11570            WriteDestination::Dataset(Arc::new(dataset)),
11571            Operation::DataOverlay {
11572                groups: vec![DataOverlayGroup {
11573                    fragment_id,
11574                    overlays: vec![DataOverlayFile {
11575                        data_file,
11576                        coverage,
11577                        committed_version: 0,
11578                    }],
11579                }],
11580            },
11581            Some(read_version),
11582            None,
11583            None,
11584            Arc::new(Default::default()),
11585            false,
11586        )
11587        .await
11588        .unwrap()
11589    }
11590
11591    /// Commit `n` distinct single-cell overlays to fragment 0 (offset `i`, val
11592    /// column set to `1000 + i`), so the fragment ends up with `n` overlays. The
11593    /// `1000 +` offset keeps overlaid values clear of the base `id * 10` values.
11594    async fn commit_n_overlays(mut dataset: Dataset, n: u32) -> Dataset {
11595        for i in 0..n {
11596            dataset = commit_overlay(
11597                dataset,
11598                0,
11599                &[1],
11600                OverlayCoverage::dense(bitmap([i])),
11601                vec![i32_array([Some(1000 + i as i32)])],
11602            )
11603            .await;
11604        }
11605        dataset
11606    }
11607
11608    /// Options whose only compaction trigger is the overlay limit: base
11609    /// fragments here are far below the default 1M-row target, which would
11610    /// otherwise make them size-based compaction candidates on their own.
11611    fn overlay_only_options(max_overlays_per_fragment: usize) -> CompactionOptions {
11612        CompactionOptions {
11613            max_overlays_per_fragment: Some(max_overlays_per_fragment),
11614            target_rows_per_fragment: 6,
11615            ..Default::default()
11616        }
11617    }
11618
11619    /// Scan `id` and `val` and return an `id -> val` map (order-independent).
11620    async fn id_val_map(dataset: &Dataset) -> BTreeMap<i32, Option<i32>> {
11621        let mut scanner = dataset.scan();
11622        scanner.project(&["id", "val"]).unwrap();
11623        let batch = scanner.try_into_batch().await.unwrap();
11624        let mut out = BTreeMap::new();
11625        let ids = batch
11626            .column(0)
11627            .as_any()
11628            .downcast_ref::<Int32Array>()
11629            .unwrap();
11630        let vals = batch
11631            .column(1)
11632            .as_any()
11633            .downcast_ref::<Int32Array>()
11634            .unwrap();
11635        for i in 0..batch.num_rows() {
11636            let v = if vals.is_null(i) {
11637                None
11638            } else {
11639                Some(vals.value(i))
11640            };
11641            out.insert(ids.value(i), v);
11642        }
11643        out
11644    }
11645
11646    #[rstest]
11647    #[case::default_target(LanceFileVersion::V2_2, None)]
11648    #[case::explicit_target(LanceFileVersion::V2_2, Some(LanceFileVersion::V2_2))]
11649    #[case::non_default_target(LanceFileVersion::V2_0, Some(LanceFileVersion::V2_2))]
11650    #[tokio::test]
11651    async fn test_compaction_target_preserves_overlays(
11652        #[case] default_version: LanceFileVersion,
11653        #[case] target: Option<LanceFileVersion>,
11654        #[values(
11655            CompactionMode::Reencode,
11656            CompactionMode::TryBinaryCopy,
11657            CompactionMode::ForceBinaryCopy
11658        )]
11659        mode: CompactionMode,
11660    ) {
11661        let batch = record_batch!(
11662            ("id", Int32, [0, 1, 2, 3]),
11663            ("val", Int32, [10, 20, 30, 40])
11664        )
11665        .unwrap();
11666        let mut dataset = Dataset::write(
11667            RecordBatchIterator::new([Ok(RecordBatch::new_empty(batch.schema()))], batch.schema()),
11668            "memory://",
11669            Some(WriteParams {
11670                data_storage_version: Some(default_version),
11671                ..Default::default()
11672            }),
11673        )
11674        .await
11675        .unwrap();
11676        dataset
11677            .append(
11678                RecordBatchIterator::new([Ok(batch.clone())], batch.schema()),
11679                Some(WriteParams {
11680                    data_storage_version: Some(LanceFileVersion::V2_2),
11681                    max_rows_per_file: 2,
11682                    ..Default::default()
11683                }),
11684            )
11685            .await
11686            .unwrap();
11687        // The first fragment is clean; eligibility must inspect every fragment.
11688        let mut dataset = commit_overlay(
11689            dataset,
11690            1,
11691            &[1],
11692            OverlayCoverage::dense(bitmap([0, 1])),
11693            vec![i32_array([Some(999), None])],
11694        )
11695        .await;
11696        let expected = BTreeMap::from([(0, Some(10)), (1, Some(20)), (2, Some(999)), (3, None)]);
11697        assert_eq!(id_val_map(&dataset).await, expected);
11698        let manifest = dataset.manifest.clone();
11699        let options = CompactionOptions {
11700            target_rows_per_fragment: 4,
11701            data_storage_version: target,
11702            compaction_mode: Some(mode),
11703            ..Default::default()
11704        };
11705        let result = compact_files(&mut dataset, options, None).await;
11706        if mode == CompactionMode::ForceBinaryCopy {
11707            let error = result.unwrap_err();
11708            assert!(matches!(error, Error::NotSupported { .. }));
11709            assert!(error.to_string().contains("binary copy is not supported"));
11710            assert_eq!(dataset.manifest, manifest);
11711        } else {
11712            let metrics = result.unwrap();
11713            assert_eq!(metrics.fragments_removed, 2);
11714            assert_eq!(metrics.fragments_added, 1);
11715            assert!(dataset.manifest.fragments[0].overlays.is_empty());
11716            assert_eq!(
11717                dataset.manifest.fragments[0].files[0]
11718                    .file_version()
11719                    .unwrap(),
11720                ConcreteFileVersion::V2_2
11721            );
11722        }
11723        assert_eq!(id_val_map(&dataset).await, expected);
11724        assert_eq!(
11725            dataset.manifest.data_storage_format.lance_file_format(),
11726            default_version.resolve()
11727        );
11728        dataset.validate().await.unwrap();
11729    }
11730
11731    #[tokio::test]
11732    async fn test_max_overlays_triggers_full_compaction() {
11733        // Fragment 0 gets 3 overlays; fragment 1 stays clean.
11734        let dataset = create_base_dataset("memory://").await;
11735        let mut dataset = commit_n_overlays(dataset, 3).await;
11736        assert_eq!(
11737            dataset.get_fragment(0).unwrap().metadata().overlays.len(),
11738            3
11739        );
11740
11741        // Threshold 2: only fragment 0 (3 > 2) is compacted.
11742        let metrics = compact_files(&mut dataset, overlay_only_options(2), None)
11743            .await
11744            .unwrap();
11745        assert_eq!(metrics.fragments_removed, 1);
11746        assert_eq!(metrics.fragments_added, 1);
11747
11748        let fragments = dataset.get_fragments();
11749        assert_eq!(fragments.len(), 2);
11750        // The compacted fragment is a fresh single-data-file fragment with no
11751        // overlays; fragment 1 is untouched.
11752        let compacted = fragments
11753            .iter()
11754            .find(|f| f.id() != 1)
11755            .expect("a new fragment id was assigned");
11756        assert!(compacted.metadata().overlays.is_empty());
11757        assert_eq!(compacted.metadata().files.len(), 1);
11758
11759        // The overlaid values were materialized: id i in 0..3 -> 1000 + i.
11760        let values = id_val_map(&dataset).await;
11761        let expected: BTreeMap<i32, Option<i32>> = (0..12)
11762            .map(|id| {
11763                let v = if id < 3 { 1000 + id } else { id * 10 };
11764                (id, Some(v))
11765            })
11766            .collect();
11767        assert_eq!(values, expected);
11768    }
11769
11770    #[tokio::test]
11771    async fn test_below_threshold_is_a_noop() {
11772        let dataset = create_base_dataset("memory://").await;
11773        let mut dataset = commit_n_overlays(dataset, 2).await;
11774
11775        // 2 overlays, threshold 2: `overlays > max` is false, so no compaction.
11776        let metrics = compact_files(&mut dataset, overlay_only_options(2), None)
11777            .await
11778            .unwrap();
11779        assert_eq!(metrics.fragments_removed, 0);
11780        assert_eq!(metrics.fragments_added, 0);
11781        assert_eq!(
11782            dataset.get_fragment(0).unwrap().metadata().overlays.len(),
11783            2
11784        );
11785    }
11786
11787    #[tokio::test]
11788    async fn test_overlay_compaction_materializes_deletions() {
11789        let dataset = create_base_dataset("memory://").await;
11790        let mut dataset = commit_n_overlays(dataset, 3).await;
11791        // Delete a row from the overlaid fragment (id 2 is at offset 2).
11792        dataset.delete("id = 2").await.unwrap();
11793        assert!(
11794            dataset
11795                .get_fragment(0)
11796                .unwrap()
11797                .metadata()
11798                .deletion_file
11799                .is_some()
11800        );
11801
11802        compact_files(&mut dataset, overlay_only_options(2), None)
11803            .await
11804            .unwrap();
11805
11806        // The deletion was materialized: no deletion file remains and id 2 is gone.
11807        for fragment in dataset.get_fragments() {
11808            assert!(fragment.metadata().deletion_file.is_none());
11809            assert!(fragment.metadata().overlays.is_empty());
11810        }
11811        let values = id_val_map(&dataset).await;
11812        assert!(!values.contains_key(&2));
11813        // Surviving overlaid cells still carry their materialized values.
11814        assert_eq!(values.get(&0), Some(&Some(1000)));
11815        assert_eq!(values.get(&1), Some(&Some(1001)));
11816    }
11817
11818    #[tokio::test]
11819    async fn test_overlay_compaction_reconciles_stale_index() {
11820        let mut dataset = create_base_dataset("memory://").await;
11821        // Index `val` before any overlay -> the index is stale once val is overlaid.
11822        dataset
11823            .create_index(
11824                &["val"],
11825                IndexType::Scalar,
11826                None,
11827                &ScalarIndexParams::default(),
11828                true,
11829            )
11830            .await
11831            .unwrap();
11832
11833        // Overlay val[0] 0 -> 100 (committed after the index) and push fragment 0
11834        // over the overlay limit.
11835        let mut dataset = commit_n_overlays(dataset, 3).await;
11836
11837        let val_index_before = dataset
11838            .load_indices()
11839            .await
11840            .unwrap()
11841            .iter()
11842            .find(|i| i.fields == vec![1])
11843            .expect("val index present")
11844            .clone();
11845        assert!(
11846            val_index_before
11847                .fragment_bitmap
11848                .as_ref()
11849                .unwrap()
11850                .contains(0)
11851        );
11852
11853        compact_files(&mut dataset, overlay_only_options(2), None)
11854            .await
11855            .unwrap();
11856
11857        // The stale val index no longer covers the compacted fragment, so its
11858        // rows fall back to a flat scan instead of serving stale values.
11859        let indices = dataset.load_indices().await.unwrap();
11860        let val_index = indices
11861            .iter()
11862            .find(|i| i.fields == vec![1])
11863            .expect("val index present");
11864        let compacted_id = dataset
11865            .get_fragments()
11866            .iter()
11867            .map(|f| f.id() as u32)
11868            .find(|id| *id != 1)
11869            .unwrap();
11870        assert!(
11871            !val_index
11872                .fragment_bitmap
11873                .as_ref()
11874                .unwrap()
11875                .contains(compacted_id),
11876            "stale index must drop the compacted fragment from its coverage"
11877        );
11878
11879        // The indexed query is correct: the materialized value is found and the
11880        // stale pre-overlay value is gone.
11881        let mut scanner = dataset.scan();
11882        scanner
11883            .filter("val = 1000")
11884            .unwrap()
11885            .project(&["id"])
11886            .unwrap();
11887        let batch = scanner.try_into_batch().await.unwrap();
11888        let ids = batch
11889            .column(0)
11890            .as_any()
11891            .downcast_ref::<Int32Array>()
11892            .unwrap();
11893        assert_eq!(ids.len(), 1);
11894        assert_eq!(ids.value(0), 0);
11895
11896        let mut scanner = dataset.scan();
11897        scanner.filter("val = 0").unwrap().project(&["id"]).unwrap();
11898        let batch = scanner.try_into_batch().await.unwrap();
11899        assert_eq!(batch.num_rows(), 0, "stale value 0 must no longer match");
11900    }
11901
11902    /// Round 6: unlike `migrate_fragments`, the reuse-record normalization
11903    /// must keep a fully-deleted fragment: the rewrite group still removes
11904    /// it from the manifest and the transition still consumes it (zero live
11905    /// rows), so a silently shortened source list would desynchronize the
11906    /// digests, the group and the manifest.
11907    #[tokio::test]
11908    async fn test_normalize_source_fragments_keeps_fully_deleted() {
11909        use lance_table::format::{DeletionFile, DeletionFileType};
11910        let dataset = lance_datagen::gen_batch()
11911            .col("i", lance_datagen::array::step::<Int32Type>())
11912            .into_ram_dataset(FragmentCount::from(1), FragmentRowCount::from(4))
11913            .await
11914            .unwrap();
11915        let mut fragment = Fragment::new(7);
11916        fragment.physical_rows = Some(4);
11917        fragment.deletion_file = Some(DeletionFile {
11918            read_version: 1,
11919            id: 1,
11920            file_type: DeletionFileType::Array,
11921            num_deleted_rows: Some(4),
11922            base_id: None,
11923        });
11924        let normalized = normalize_source_fragments(&dataset, &[fragment])
11925            .await
11926            .unwrap();
11927        assert_eq!(normalized.len(), 1);
11928        assert_eq!(normalized[0].id, 7);
11929        assert_eq!(normalized[0].physical_rows, Some(4));
11930        assert_eq!(
11931            normalized[0]
11932                .deletion_file
11933                .as_ref()
11934                .unwrap()
11935                .num_deleted_rows,
11936            Some(4)
11937        );
11938    }
11939}