Skip to main content

lance/dataset/index/
frag_reuse.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::sync::Arc;
5
6use crate::Dataset;
7use crate::dataset::transaction::{Operation, Transaction};
8use crate::index::DatasetIndexInternalExt;
9use crate::index::frag_reuse::{build_frag_reuse_index_metadata, load_frag_reuse_index_details};
10use lance_core::{Error, Result};
11use lance_index::frag_reuse::{
12    CompactFragReuseIndex, FRAG_REUSE_INDEX_NAME, FragReuseIndexDetails, FragReuseVersion,
13};
14use lance_index::is_system_index;
15use lance_index::metrics::NoOpMetricsCollector;
16use lance_table::format::IndexMetadata;
17use lance_table::io::manifest::read_manifest_indexes;
18use log::warn;
19use roaring::RoaringBitmap;
20
21impl Dataset {
22    /// Opens the fragment reuse index (FRI) recorded in the dataset version this
23    /// handle has loaded, or `None` if that version has none. The index is served
24    /// from the session index cache, so repeated calls do not re-read it.
25    ///
26    /// The FRI records how physical row addresses moved in compactions run with
27    /// [`CompactionOptions::defer_index_remap`]: one reuse version per compaction,
28    /// applied oldest to newest. [`CompactFragReuseIndex::row_addr_remap`] gives
29    /// the raw per-address result:
30    ///
31    /// * `None`: not mapped by any retained version. Helpers such as
32    ///   [`CompactFragReuseIndex::remap_row_id`] return these unchanged.
33    /// * `Some(None)`: deleted by a recorded compaction.
34    /// * `Some(Some(addr))`: the last address reached through the retained
35    ///   mappings, not a validated current location.
36    ///
37    /// # Limitations
38    ///
39    /// * Covers only the loaded version; check out a newer version to observe
40    ///   later compactions.
41    /// * Versions are trimmed by [`cleanup_frag_reuse_index`] once indices catch
42    ///   up, so an unmapped address may still have moved.
43    /// * Mapped destinations are not checked against the manifest and can be
44    ///   stale, for example after every row of the destination fragment is deleted.
45    /// * Not every compaction records an FRI: it requires `defer_index_remap`,
46    ///   fresh index-free tables do not receive one automatically, and datasets
47    ///   with stable row ids reject the option.
48    /// * Says nothing about deletion files, source-value changes, or whether an
49    ///   address belongs to this table or branch.
50    ///
51    /// Prefer the remap methods over [`CompactFragReuseIndex::details`], which
52    /// mirrors the persisted format and is not a long-term client contract.
53    ///
54    /// # Example
55    ///
56    /// ```no_run
57    /// # async fn example(dataset: &lance::Dataset) -> lance::Result<()> {
58    /// let old_row_addr: u64 = 42;
59    /// if let Some(frag_reuse_index) = dataset.frag_reuse_index().await? {
60    ///     match frag_reuse_index.row_addr_remap().get(old_row_addr) {
61    ///         None => println!("no recorded movement for {old_row_addr}"),
62    ///         Some(None) => println!("row {old_row_addr} was deleted by compaction"),
63    ///         Some(Some(new_row_addr)) => println!("row moved to {new_row_addr}"),
64    ///     }
65    /// }
66    /// # Ok(())
67    /// # }
68    /// ```
69    ///
70    /// [`CompactionOptions::defer_index_remap`]: crate::dataset::optimize::CompactionOptions::defer_index_remap
71    pub async fn frag_reuse_index(&self) -> Result<Option<Arc<CompactFragReuseIndex>>> {
72        self.open_frag_reuse_index(&NoOpMetricsCollector).await
73    }
74}
75
76/// Cleanup a fragment reuse index based on the current condition of the indices.
77/// If all the indices currently available are already caught up to as a specific reuse version,
78/// all older reuse versions (inclusive) can be cleaned up.
79///
80/// An index is considered caught up against a specific reuse version if either:
81/// 1. its coverage is disjoint from the fragments the reuse chain touches, so it
82///    holds nothing the FRI would remap (the common multi-index case: a
83///    compaction rewrote a sibling index's fragments, not this one); or
84/// 2. it is at or past the reuse version's dataset version and no old fragment
85///    in the version is still in its bitmap. A missing bitmap counts as caught
86///    up, else the version could never be cleaned up.
87///
88/// Note that there could be a race condition that an index is being added during the cleanup,
89/// This will make that specific index not efficient until the next reindex,
90/// but it will not cause any correctness problem.
91///
92/// Typically run after [`compact_files`] with deferred remap and per-index
93/// [`remap_column_index`] have caught the indexes up.
94///
95/// # Example
96///
97/// ```no_run
98/// # use lance::dataset::index::frag_reuse::cleanup_frag_reuse_index;
99/// # async fn example(dataset: &mut lance::Dataset) -> lance::Result<()> {
100/// // Trim the fragment-reuse index to the versions still needed by some index.
101/// cleanup_frag_reuse_index(dataset).await?;
102/// # Ok(())
103/// # }
104/// ```
105///
106/// [`compact_files`]: crate::dataset::optimize::compact_files
107/// [`remap_column_index`]: crate::dataset::optimize::remapping::remap_column_index
108pub async fn cleanup_frag_reuse_index(dataset: &mut Dataset) -> lance_core::Result<()> {
109    // check against index metadata before auto-remap
110    let indices = read_manifest_indexes(
111        &dataset.object_store,
112        &dataset.manifest_location,
113        &dataset.manifest,
114    )
115    .await?;
116    let Some(frag_reuse_index_meta) = indices.iter().find(|idx| idx.name == FRAG_REUSE_INDEX_NAME)
117    else {
118        return Ok(());
119    };
120
121    let frag_reuse_details = load_frag_reuse_index_details(dataset, frag_reuse_index_meta).await?;
122
123    let chain_frag_bitmap = reuse_chain_frag_bitmap(&frag_reuse_details.versions);
124
125    let mut retained_versions = Vec::new();
126    let mut fragment_bitmaps = RoaringBitmap::new();
127    for version in frag_reuse_details.versions.iter() {
128        let check_results = indices
129            .iter()
130            .map(|idx| is_index_remap_caught_up(version, idx, &chain_frag_bitmap))
131            .collect::<Vec<_>>();
132
133        if check_results
134            .iter()
135            .any(|r| matches!(r, Err(Error::InvalidInput { .. })))
136        {
137            // If the check fails, the reuse version is likely corrupted, do not retain it.
138            continue;
139        }
140
141        if !check_results.into_iter().all(|r| r.unwrap()) {
142            fragment_bitmaps.extend(version.new_frag_bitmap());
143            retained_versions.push(version.clone());
144        }
145    }
146
147    // Return early if there is nothing to cleanup
148    if retained_versions.len() == frag_reuse_details.versions.len() {
149        return Ok(());
150    }
151
152    let frag_reuse_index_details = FragReuseIndexDetails {
153        versions: retained_versions,
154    };
155
156    let new_index_meta = build_frag_reuse_index_metadata(
157        dataset,
158        Some(frag_reuse_index_meta),
159        frag_reuse_index_details,
160        fragment_bitmaps,
161    )
162    .await?;
163
164    let transaction = Transaction::new(
165        dataset.manifest.version,
166        Operation::CreateIndex {
167            new_indices: vec![new_index_meta],
168            removed_indices: vec![frag_reuse_index_meta.clone()],
169        },
170        None,
171    );
172
173    dataset
174        .apply_commit(transaction, &Default::default(), &Default::default())
175        .await?;
176
177    Ok(())
178}
179
180/// Every fragment the reuse chain touches (old + new) across all versions. An
181/// index disjoint from this set holds no row address the FRI remaps, so trimming
182/// can never strand it (fragment ids are never reused).
183fn reuse_chain_frag_bitmap(versions: &[FragReuseVersion]) -> RoaringBitmap {
184    let mut bitmap = RoaringBitmap::new();
185    for version in versions {
186        bitmap.extend(version.old_frag_ids().iter().map(|&id| id as u32));
187        bitmap.extend(version.new_frag_ids().iter().map(|&id| id as u32));
188    }
189    bitmap
190}
191
192fn is_index_remap_caught_up(
193    frag_reuse_version: &FragReuseVersion,
194    index_meta: &IndexMetadata,
195    chain_frag_bitmap: &RoaringBitmap,
196) -> lance_core::Result<bool> {
197    if is_system_index(index_meta) {
198        return Ok(true);
199    }
200
201    // Disjoint coverage => caught up regardless of dataset_version, bypassing the
202    // stale-version gate below (see fn docs). The chain includes NEW fragments
203    // deliberately: a deferred-remap commit advances a covering index's bitmap
204    // onto them before its data is remapped, so an old-frag-only check would
205    // clear a still-stale index and trim a version it needs.
206    if let Some(index_frag_bitmap) = &index_meta.fragment_bitmap
207        && index_frag_bitmap.is_disjoint(chain_frag_bitmap)
208    {
209        return Ok(true);
210    }
211
212    if index_meta.dataset_version < frag_reuse_version.dataset_version {
213        return Ok(false);
214    }
215
216    match index_meta.fragment_bitmap.clone() {
217        Some(index_frag_bitmap) => {
218            for group in frag_reuse_version.groups.iter() {
219                let mut old_frag_in_index = 0;
220                for old_frag in group.old_frags.iter() {
221                    if index_frag_bitmap.contains(old_frag.id as u32) {
222                        old_frag_in_index += 1;
223                    }
224                }
225
226                if old_frag_in_index > 0 {
227                    if old_frag_in_index != group.old_frags.len() {
228                        // This should never happen because we always commit a full rewrite group
229                        // and we always reindex either the entire group or nothing.
230                        // We use invalid input to be consistent with
231                        // dataset::transaction::recalculate_fragment_bitmap
232                        return Err(Error::invalid_input(format!(
233                            "The compaction plan included a rewrite group that was a split of indexed and non-indexed data: {:?}",
234                            group.old_frags
235                        )));
236                    }
237                    return Ok(false);
238                }
239            }
240            Ok(true)
241        }
242        None => {
243            warn!(
244                "Index {} ({}) missing fragment bitmap, cannot determine if it is caught up with the fragment reuse version, consider retraining the index",
245                index_meta.name, index_meta.uuid
246            );
247            Ok(true)
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::dataset::optimize::{CompactionOptions, compact_files, remapping};
256    use crate::index::DatasetIndexExt;
257    use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount};
258    use all_asserts::{assert_false, assert_true};
259    use arrow_array::cast::AsArray;
260    use arrow_array::types::{Float32Type, Int32Type};
261    use lance_core::ROW_ADDR;
262    use lance_core::utils::address::RowAddress;
263    use lance_datagen::Dimension;
264    use lance_index::IndexType;
265    use lance_index::scalar::ScalarIndexParams;
266    use std::collections::HashMap;
267
268    fn frag_digest(id: u64) -> lance_index::frag_reuse::FragDigest {
269        lance_index::frag_reuse::FragDigest {
270            id,
271            physical_rows: 100,
272            num_deleted_rows: 0,
273        }
274    }
275
276    fn reuse_version(dataset_version: u64, old: &[u64], new: &[u64]) -> FragReuseVersion {
277        FragReuseVersion {
278            dataset_version,
279            groups: vec![lance_index::frag_reuse::FragReuseGroup {
280                changed_row_addrs: Vec::new(),
281                old_frags: old.iter().copied().map(frag_digest).collect(),
282                new_frags: new.iter().copied().map(frag_digest).collect(),
283            }],
284        }
285    }
286
287    fn index_covering(dataset_version: u64, covered: &[u32]) -> IndexMetadata {
288        IndexMetadata {
289            uuid: uuid::Uuid::new_v4(),
290            fields: vec![0],
291            covering_fields: vec![],
292            name: "test_idx".into(),
293            dataset_version,
294            fragment_bitmap: Some(RoaringBitmap::from_iter(covered.iter().copied())),
295            index_details: None,
296            index_version: 0,
297            created_at: None,
298            base_id: None,
299            files: None,
300        }
301    }
302
303    /// The catch-up determination must not pin the FRI on an index that is
304    /// simply unrelated to the compaction, while still retaining versions that a
305    /// covering-but-not-yet-remapped index needs.
306    #[test]
307    fn test_caught_up_uses_fragment_coverage_not_only_version() {
308        // A reuse version at dataset_version 10 rewrote fragments [4, 5] -> [6].
309        let version = reuse_version(10, &[4, 5], &[6]);
310        let chain = reuse_chain_frag_bitmap(std::slice::from_ref(&version));
311
312        // Non-covering, stale version: touches none of the rewritten frags, so
313        // caught up despite version 5 < 10 (the case the old gate got wrong).
314        assert_true!(
315            is_index_remap_caught_up(&version, &index_covering(5, &[1, 2, 3]), &chain).unwrap()
316        );
317
318        // Still holds an old fragment: not caught up.
319        assert_false!(
320            is_index_remap_caught_up(&version, &index_covering(5, &[1, 4, 5]), &chain).unwrap()
321        );
322
323        // Bitmap advanced onto the new fragment but data not yet remapped: not
324        // caught up (why the chain must include new frags).
325        assert_false!(
326            is_index_remap_caught_up(&version, &index_covering(5, &[1, 6]), &chain).unwrap()
327        );
328
329        // Once remapped (version advanced): caught up.
330        assert_true!(
331            is_index_remap_caught_up(&version, &index_covering(11, &[1, 6]), &chain).unwrap()
332        );
333    }
334
335    /// The chain spans every reuse version, not just the one being checked: a
336    /// stale index touching only a *later* version's fragment must still fall to
337    /// the version gate (a per-version chain would wrongly clear it).
338    #[test]
339    fn test_caught_up_uses_whole_reuse_chain() {
340        let v1 = reuse_version(10, &[4, 5], &[6]); // 4,5 -> 6
341        let v2 = reuse_version(11, &[6], &[7]); // 6 -> 7
342        let chain = reuse_chain_frag_bitmap(&[v1.clone(), v2]);
343
344        // Stale index (version 5) covering only v2's new fragment [7]: not
345        // disjoint from the chain, so not caught up on v1.
346        assert_false!(is_index_remap_caught_up(&v1, &index_covering(5, &[1, 7]), &chain).unwrap());
347    }
348
349    /// Whole-fragment removal (every row deleted, no replacement): an index
350    /// emptied by the deletion has an empty bitmap and must count as caught up --
351    /// it holds only dead rows -- else its stale version pins the removed-fragment
352    /// version forever (remap hits the drop-everything path, never advancing it).
353    #[test]
354    fn test_caught_up_handles_fragment_removal() {
355        // Reuse version 20 removed fragment [7] outright (no replacement).
356        let version = reuse_version(20, &[7], &[]);
357        let chain = reuse_chain_frag_bitmap(std::slice::from_ref(&version));
358
359        // Index emptied by the deletion (empty bitmap): caught up.
360        assert_true!(is_index_remap_caught_up(&version, &index_covering(5, &[]), &chain).unwrap());
361
362        // Bitmap still lists the removed fragment (not yet updated): retained.
363        assert_false!(
364            is_index_remap_caught_up(&version, &index_covering(5, &[7]), &chain).unwrap()
365        );
366    }
367
368    #[tokio::test]
369    async fn test_cleanup_frag_reuse_index() {
370        let mut dataset = lance_datagen::gen_batch()
371            .col(
372                "vec",
373                lance_datagen::array::rand_vec::<Float32Type>(Dimension::from(128)),
374            )
375            .col("i", lance_datagen::array::step::<Int32Type>())
376            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
377            .await
378            .unwrap();
379
380        // Create an index to be remapped
381        let index_name = Some("scalar".into());
382        dataset
383            .create_index(
384                &["i"],
385                IndexType::Scalar,
386                index_name.clone(),
387                &ScalarIndexParams::default(),
388                false,
389            )
390            .await
391            .unwrap();
392
393        // Compact and check index not caught up
394        compact_files(
395            &mut dataset,
396            CompactionOptions {
397                target_rows_per_fragment: 2_000,
398                defer_index_remap: true,
399                ..Default::default()
400            },
401            None,
402        )
403        .await
404        .unwrap();
405        let Some(frag_reuse_index_meta) = dataset
406            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
407            .await
408            .unwrap()
409        else {
410            panic!("Fragment reuse index must be available");
411        };
412        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
413            .await
414            .unwrap();
415        assert_eq!(frag_reuse_details.versions.len(), 1);
416        let indices = dataset.load_indices().await.unwrap();
417        let scalar_index = indices.iter().find(|idx| idx.name == "scalar").unwrap();
418        // Should not be considered caught up because index was created at an old dataset version
419        assert_false!(
420            is_index_remap_caught_up(
421                &frag_reuse_details.versions[0],
422                scalar_index,
423                &reuse_chain_frag_bitmap(&frag_reuse_details.versions),
424            )
425            .unwrap()
426        );
427
428        // Remap and check index is caught up
429        remapping::remap_column_index(&mut dataset, &["i"], index_name.clone())
430            .await
431            .unwrap();
432        let indices = dataset.load_indices().await.unwrap();
433        let scalar_index = indices.iter().find(|idx| idx.name == "scalar").unwrap();
434        assert_true!(
435            is_index_remap_caught_up(
436                &frag_reuse_details.versions[0],
437                scalar_index,
438                &reuse_chain_frag_bitmap(&frag_reuse_details.versions),
439            )
440            .unwrap()
441        );
442
443        // Cleanup frag reuse index and check there is no reuse version
444        let mut dataset_clone = dataset.clone();
445        cleanup_frag_reuse_index(&mut dataset).await.unwrap();
446        let Some(frag_reuse_index_meta) = dataset
447            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
448            .await
449            .unwrap()
450        else {
451            panic!("Fragment reuse index must be available");
452        };
453        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
454            .await
455            .unwrap();
456        assert_eq!(frag_reuse_details.versions.len(), 0);
457
458        // Try doing a concurrent cleanup should fail with conflict
459        assert!(matches!(
460            cleanup_frag_reuse_index(&mut dataset_clone).await,
461            Err(Error::RetryableCommitConflict { .. })
462        ));
463    }
464
465    /// With more than one index on the table, remapping every index must catch
466    /// all of them up so the reuse index can be trimmed.
467    ///
468    /// Regression: `remap_column_index` used to decide whether to remap an
469    /// index's data from the presence of the old fragments in its fragment
470    /// bitmap. But `load_indices` coverage-remaps the bitmap onto the new
471    /// fragments in memory, and remapping the *first* index commits a manifest
472    /// that persists that cleaned bitmap for the others — so remapping the
473    /// remaining indexes became a silent no-op (their data was never remapped
474    /// and their `dataset_version` never advanced), and the reuse index could
475    /// never be trimmed.
476    #[tokio::test]
477    async fn test_cleanup_frag_reuse_index_multiple_indices() {
478        let mut dataset = lance_datagen::gen_batch()
479            .col("i", lance_datagen::array::step::<Int32Type>())
480            .col("j", lance_datagen::array::step::<Int32Type>())
481            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
482            .await
483            .unwrap();
484
485        for col in ["i", "j"] {
486            dataset
487                .create_index(
488                    &[col],
489                    IndexType::Scalar,
490                    Some(format!("{col}_idx")),
491                    &ScalarIndexParams::default(),
492                    false,
493                )
494                .await
495                .unwrap();
496        }
497
498        compact_files(
499            &mut dataset,
500            CompactionOptions {
501                target_rows_per_fragment: 2_000,
502                defer_index_remap: true,
503                ..Default::default()
504            },
505            None,
506        )
507        .await
508        .unwrap();
509
510        let frag_reuse_index_meta = dataset
511            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
512            .await
513            .unwrap()
514            .expect("Fragment reuse index must be available");
515        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
516            .await
517            .unwrap();
518        assert_eq!(frag_reuse_details.versions.len(), 1);
519
520        for col in ["i", "j"] {
521            remapping::remap_column_index(&mut dataset, &[col], Some(format!("{col}_idx")))
522                .await
523                .unwrap();
524        }
525
526        // Every index must now be caught up (data remapped, version advanced).
527        let indices = dataset.load_indices().await.unwrap();
528        for col in ["i", "j"] {
529            let index = indices
530                .iter()
531                .find(|idx| idx.name == format!("{col}_idx"))
532                .unwrap();
533            assert!(
534                is_index_remap_caught_up(
535                    &frag_reuse_details.versions[0],
536                    index,
537                    &reuse_chain_frag_bitmap(&frag_reuse_details.versions),
538                )
539                .unwrap(),
540                "index {col}_idx was not caught up after remap"
541            );
542        }
543
544        // ... so the reuse index trims down to zero versions.
545        cleanup_frag_reuse_index(&mut dataset).await.unwrap();
546        let frag_reuse_index_meta = dataset
547            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
548            .await
549            .unwrap()
550            .expect("Fragment reuse index must be available");
551        let frag_reuse_details = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
552            .await
553            .unwrap();
554        assert_eq!(frag_reuse_details.versions.len(), 0);
555
556        // Data correctness, not just version bookkeeping: with the reuse index
557        // trimmed there is no auto-remap safety net, so each index must resolve
558        // to LIVE rows. An index whose data was not actually remapped (e.g. one
559        // whose bitmap was coverage-remapped by a sibling's commit before its
560        // own data remap) points at compacted-away fragments and errors on take.
561        use futures::TryStreamExt;
562        for col in ["i", "j"] {
563            let rows: usize = dataset
564                .scan()
565                .filter(&format!("{col} >= 2000 AND {col} < 3000"))
566                .unwrap()
567                .try_into_stream()
568                .await
569                .unwrap()
570                .try_collect::<Vec<_>>()
571                .await
572                .unwrap()
573                .iter()
574                .map(|b| b.num_rows())
575                .sum();
576            assert_eq!(
577                rows, 1000,
578                "index {col}_idx must resolve to live rows after remap+trim"
579            );
580        }
581    }
582
583    /// When the reuse index has accumulated several versions, a single remap
584    /// must compose them and rebuild + commit the index exactly ONCE, not once
585    /// per version.
586    #[tokio::test]
587    async fn test_remap_index_batches_multiple_reuse_versions() {
588        let mut dataset = lance_datagen::gen_batch()
589            .col("i", lance_datagen::array::step::<Int32Type>())
590            .into_ram_dataset(FragmentCount::from(8), FragmentRowCount::from(1000))
591            .await
592            .unwrap();
593        dataset
594            .create_index(
595                &["i"],
596                IndexType::Scalar,
597                Some("i_idx".into()),
598                &ScalarIndexParams::default(),
599                false,
600            )
601            .await
602            .unwrap();
603
604        // Accumulate multiple reuse versions: each round deletes a prefix, which
605        // shrinks fragments below target and forces another deferred compaction.
606        let options = CompactionOptions {
607            target_rows_per_fragment: 4_000,
608            defer_index_remap: true,
609            ..Default::default()
610        };
611        for round in 0..4 {
612            dataset
613                .delete(&format!("i < {}", 1_000 * (round + 1)))
614                .await
615                .unwrap();
616            compact_files(&mut dataset, options.clone(), None)
617                .await
618                .unwrap();
619        }
620
621        let frag_reuse_index_meta = dataset
622            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
623            .await
624            .unwrap()
625            .expect("Fragment reuse index must be available");
626        let num_versions = load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
627            .await
628            .unwrap()
629            .versions
630            .len();
631        assert!(
632            num_versions >= 2,
633            "test needs multiple reuse versions to exercise batching, got {num_versions}"
634        );
635
636        // A single remap must commit exactly once, regardless of version count.
637        let version_before = dataset.manifest.version;
638        remapping::remap_column_index(&mut dataset, &["i"], Some("i_idx".into()))
639            .await
640            .unwrap();
641        let commits = dataset.manifest.version - version_before;
642        assert_eq!(
643            commits, 1,
644            "batched remap must commit once, not once per reuse version ({num_versions})"
645        );
646
647        // ... and the reuse index then trims to zero.
648        cleanup_frag_reuse_index(&mut dataset).await.unwrap();
649        let frag_reuse_index_meta = dataset
650            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
651            .await
652            .unwrap()
653            .expect("Fragment reuse index must be available");
654        assert_eq!(
655            load_frag_reuse_index_details(&dataset, &frag_reuse_index_meta)
656                .await
657                .unwrap()
658                .versions
659                .len(),
660            0
661        );
662    }
663
664    async fn row_addrs_by_i(dataset: &Dataset) -> HashMap<i32, u64> {
665        let batch = dataset
666            .scan()
667            .project(&["i"])
668            .unwrap()
669            .with_row_address()
670            .try_into_batch()
671            .await
672            .unwrap();
673        let ids = batch["i"].as_primitive::<Int32Type>();
674        let addrs = batch[ROW_ADDR].as_primitive::<arrow_array::types::UInt64Type>();
675        ids.values()
676            .iter()
677            .copied()
678            .zip(addrs.values().iter().copied())
679            .collect()
680    }
681
682    #[tokio::test]
683    async fn test_frag_reuse_index_accessor() {
684        let mut dataset = lance_datagen::gen_batch()
685            .col("i", lance_datagen::array::step::<Int32Type>())
686            .into_ram_dataset(FragmentCount::from(6), FragmentRowCount::from(1000))
687            .await
688            .unwrap();
689
690        assert!(dataset.frag_reuse_index().await.unwrap().is_none());
691
692        // Non-deferred compaction of fragment 0 (deletions above the threshold) records no FRI.
693        dataset.delete("i < 200").await.unwrap();
694        compact_files(
695            &mut dataset,
696            CompactionOptions {
697                target_rows_per_fragment: 1_000,
698                ..Default::default()
699            },
700            None,
701        )
702        .await
703        .unwrap();
704        assert!(dataset.frag_reuse_index().await.unwrap().is_none());
705        let num_fragments = dataset.fragments().len();
706        assert_eq!(num_fragments, 6);
707
708        dataset
709            .create_index(
710                &["i"],
711                IndexType::Scalar,
712                Some("i_idx".into()),
713                &ScalarIndexParams::default(),
714                false,
715            )
716            .await
717            .unwrap();
718
719        // Deletions above the threshold make exactly these two fragments candidates.
720        dataset.delete("i >= 1000 AND i < 1250").await.unwrap();
721        dataset.delete("i >= 2000 AND i < 2250").await.unwrap();
722        let before = row_addrs_by_i(&dataset).await;
723        let pre_compaction_version = dataset.version().version;
724        let rewritten_frags = [
725            RowAddress::from(before[&1250]).fragment_id(),
726            RowAddress::from(before[&2250]).fragment_id(),
727        ];
728        let untouched_addr = before[&5000];
729        // Offset 0 of the first rewritten fragment is i=1000, deleted above.
730        let deleted_addr = u64::from(RowAddress::new_from_parts(rewritten_frags[0], 0));
731
732        compact_files(
733            &mut dataset,
734            CompactionOptions {
735                target_rows_per_fragment: 1_000,
736                defer_index_remap: true,
737                ..Default::default()
738            },
739            None,
740        )
741        .await
742        .unwrap();
743        let after = row_addrs_by_i(&dataset).await;
744        for frag in rewritten_frags {
745            assert!(
746                dataset.fragments().iter().all(|f| f.id != u64::from(frag)),
747                "fragment {frag} should have been rewritten"
748            );
749        }
750
751        let frag_reuse_index = dataset
752            .frag_reuse_index()
753            .await
754            .unwrap()
755            .expect("deferred compaction must record an FRI");
756        let frag_reuse_index_meta = dataset
757            .load_index_by_name(FRAG_REUSE_INDEX_NAME)
758            .await
759            .unwrap()
760            .expect("FRI must be in the manifest");
761        assert_eq!(frag_reuse_index.uuid, frag_reuse_index_meta.uuid);
762        assert_eq!(frag_reuse_index.details.versions.len(), 1);
763        assert_false!(frag_reuse_index.is_empty());
764
765        // Cache reuse, not an API guarantee.
766        let again = dataset.frag_reuse_index().await.unwrap().unwrap();
767        assert!(Arc::ptr_eq(&frag_reuse_index, &again));
768
769        let remap = frag_reuse_index.row_addr_remap();
770        for i in [1250, 1999, 2250, 2999] {
771            assert_eq!(
772                remap.get(before[&i]),
773                Some(Some(after[&i])),
774                "row i={i} should have moved"
775            );
776            assert_eq!(frag_reuse_index.remap_row_id(before[&i]), Some(after[&i]));
777        }
778        assert_eq!(remap.get(deleted_addr), Some(None));
779        assert_eq!(frag_reuse_index.remap_row_id(deleted_addr), None);
780        assert_eq!(remap.get(untouched_addr), None);
781        assert_eq!(after[&5000], untouched_addr);
782        assert_eq!(
783            frag_reuse_index.remap_row_id(untouched_addr),
784            Some(untouched_addr)
785        );
786
787        let pre_compaction = dataset
788            .checkout_version(pre_compaction_version)
789            .await
790            .unwrap();
791        assert!(pre_compaction.frag_reuse_index().await.unwrap().is_none());
792
793        remapping::remap_column_index(&mut dataset, &["i"], Some("i_idx".into()))
794            .await
795            .unwrap();
796        cleanup_frag_reuse_index(&mut dataset).await.unwrap();
797        let trimmed = dataset
798            .frag_reuse_index()
799            .await
800            .unwrap()
801            .expect("trimmed FRI keeps an (empty) manifest entry");
802        assert_true!(trimmed.is_empty());
803        assert_eq!(trimmed.details.versions.len(), 0);
804        assert_ne!(trimmed.uuid, frag_reuse_index.uuid);
805        assert_eq!(trimmed.row_addr_remap().get(before[&1250]), None);
806        assert_eq!(
807            trimmed.remap_row_id(before[&1250]),
808            Some(before[&1250]),
809            "trimmed history passes a moved address through unchanged"
810        );
811    }
812
813    /// Deleting every row of a destination fragment removes it from the manifest,
814    /// but the FRI still maps into it.
815    #[tokio::test]
816    async fn test_frag_reuse_index_mapped_destination_can_be_removed() {
817        let mut dataset = lance_datagen::gen_batch()
818            .col("i", lance_datagen::array::step::<Int32Type>())
819            .into_ram_dataset(FragmentCount::from(4), FragmentRowCount::from(1000))
820            .await
821            .unwrap();
822        dataset
823            .create_index(
824                &["i"],
825                IndexType::Scalar,
826                Some("i_idx".into()),
827                &ScalarIndexParams::default(),
828                false,
829            )
830            .await
831            .unwrap();
832
833        // Target equals fragment size, so only the two deletion-heavy fragments are candidates.
834        dataset.delete("i < 250").await.unwrap();
835        dataset.delete("i >= 1000 AND i < 1250").await.unwrap();
836        let before = row_addrs_by_i(&dataset).await;
837        compact_files(
838            &mut dataset,
839            CompactionOptions {
840                target_rows_per_fragment: 1_000,
841                defer_index_remap: true,
842                ..Default::default()
843            },
844            None,
845        )
846        .await
847        .unwrap();
848        let after = row_addrs_by_i(&dataset).await;
849        let moved_rows = [250, 999, 1250, 1999];
850        let destination_frags: Vec<u32> = moved_rows
851            .iter()
852            .map(|i| RowAddress::from(after[i]).fragment_id())
853            .collect();
854        for i in moved_rows {
855            assert_ne!(before[&i], after[&i], "row i={i} should have moved");
856        }
857
858        dataset.delete("i < 2000").await.unwrap();
859        let remaining_frag_ids: Vec<u64> = dataset.fragments().iter().map(|f| f.id).collect();
860        for frag in &destination_frags {
861            assert!(
862                !remaining_frag_ids.contains(&u64::from(*frag)),
863                "destination fragment {frag} should be removed; remaining: {remaining_frag_ids:?}"
864            );
865        }
866
867        let frag_reuse_index = dataset.frag_reuse_index().await.unwrap().unwrap();
868        assert_eq!(frag_reuse_index.details.versions.len(), 1);
869        for i in moved_rows {
870            assert_eq!(
871                frag_reuse_index.row_addr_remap().get(before[&i]),
872                Some(Some(after[&i])),
873                "row i={i} still maps into a removed fragment"
874            );
875            assert_eq!(frag_reuse_index.remap_row_id(before[&i]), Some(after[&i]));
876        }
877    }
878}