Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions rust/lance/src/dataset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use lance_core::deepsize::DeepSizeOf;

use crate::dataset::metadata::UpdateFieldMetadataBuilder;
use crate::dataset::transaction::translate_schema_metadata_updates;
use crate::index::DatasetIndexExt;
use crate::session::caches::{DSMetadataCache, ManifestKey, TransactionKey};
use crate::session::index_caches::DSIndexCache;
use itertools::Itertools;
Expand Down Expand Up @@ -130,7 +129,6 @@ use crate::dataset::cleanup::{CleanupOperation, CleanupPolicy, CleanupPolicyBuil
use crate::dataset::refs::{BranchContents, BranchIdentifier, Branches, Tags};
use crate::dataset::sql::SqlQueryBuilder;
use crate::datatypes::Schema;
use crate::index::retain_supported_indices;
use crate::io::commit::{
DEFAULT_COMMIT_RETRY_TIMEOUT, commit_detached_transaction, commit_new_dataset,
commit_transaction, detect_overlapping_fragments,
Expand Down Expand Up @@ -804,12 +802,16 @@ impl Dataset {
LittleEndian::read_u32(&last_block[offset_in_block..offset_in_block + 4]) as usize;
let message_data = &last_block[offset_in_block + 4..offset_in_block + 4 + message_len];
let section = lance_table::format::pb::IndexSection::decode(message_data)?;
let mut indices: Vec<IndexMetadata> = section
// Cached unfiltered: this is the same cache the commit path reads
// from, and an index this build cannot decode still has to survive
// into the next manifest. Version filtering happens on the way out,
// in `DatasetIndexExt::load_indices`.
let indices: Vec<IndexMetadata> = section
.indices
.into_iter()
.map(IndexMetadata::try_from)
.collect::<Result<Vec<_>>>()?;
retain_supported_indices(&mut indices);
crate::index::warn_about_unsupported_indices(&indices);
let ds_index_cache = session.index_cache.for_dataset(uri);
let metadata_key = crate::session::index_caches::IndexMetadataKey {
version: manifest_location.version,
Expand Down Expand Up @@ -3086,8 +3088,12 @@ impl Dataset {

rowids::validate_stable_row_ids(self).await?;

// Validate indices
let indices = self.load_indices().await?;
// Validate indices. Over the complete list: these checks are about what
// the manifest says, not about what this build can use, and duplicate
// uuids or overlapping coverage are no less corrupt for involving an
// index this build has no reader for. `migrate_indices` already runs the
// same overlap check over the complete list on every commit.
let indices = crate::index::load_all_indices(self).await?;
self.validate_indices(&indices)?;

Ok(())
Expand Down
164 changes: 147 additions & 17 deletions rust/lance/src/dataset/optimize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ use super::{WriteMode, WriteParams, cleanup_data_fragments, write_fragments_inte
use crate::Dataset;
use crate::Result;
use crate::dataset::utils::CapturedRowIds;
use crate::index::DatasetIndexExt;
use crate::index::{
DatasetIndexExt, DatasetIndexInternalExt, load_all_indices, unsupported_index_version,
};
use crate::io::commit::{DEFAULT_COMMIT_RETRY_TIMEOUT, commit_transaction, migrate_fragments};
use arrow::array::AsArray;
use arrow::datatypes::{UInt8Type, UInt32Type, UInt64Type};
Expand All @@ -125,7 +127,8 @@ use lance_core::utils::tokio::get_num_compute_intensive_cpus;
use lance_core::utils::tracing::{DATASET_COMPACTING_EVENT, TRACE_DATASET_EVENTS};
use lance_index::frag_reuse::{FRAG_REUSE_INDEX_NAME, FragReuseGroup};
use lance_index::is_system_index;
use lance_table::format::{Fragment, RowIdMeta};
use lance_index::metrics::NoOpMetricsCollector;
use lance_table::format::{Fragment, IndexMetadata, RowIdMeta};
use roaring::{RoaringBitmap, RoaringTreemap};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
Expand Down Expand Up @@ -752,10 +755,38 @@ impl CompactionPlanner for DefaultCompactionPlanner {
fragments.windows(2).all(|w| w[0].id() < w[1].id()),
"fragments in manifest are not sorted"
);
// Without stable row ids a rewrite moves every row address, so the
// indices over the rewritten fragments have to be remapped in the same
// commit. An index this build cannot open cannot be remapped, so its
// fragments join the caller's own exclusions and are left uncompacted -
// taking the same path, which terminates the current bin rather than
// letting the candidates on either side of the gap be planned together.
let mut excluded_fragment_ids = self.excluded_fragment_ids.clone();
if !dataset.manifest.uses_stable_row_ids() && !self.options.defer_index_remap {
let unremappable = unremappable_index_coverage(dataset)
.await?
.into_iter()
.fold(RoaringBitmap::new(), |mut covered, (_, fragments)| {
covered |= fragments;
covered
});
if !unremappable.is_empty() {
// Otherwise a compaction that plans nothing looks like a
// compaction that found nothing to do.
log::info!(
"holding {} fragment(s) back from compaction: they are covered by an index \
this build cannot read, and so cannot be remapped here",
unremappable.len(),
);
}
excluded_fragment_ids |= unremappable;
}
let excluded_fragment_ids = &excluded_fragment_ids;

let mut fragment_metrics = futures::stream::iter(fragments)
.map(|fragment| async {
.map(|fragment| async move {
if u32::try_from(fragment.id())
.is_ok_and(|fragment_id| self.excluded_fragment_ids.contains(fragment_id))
.is_ok_and(|fragment_id| excluded_fragment_ids.contains(fragment_id))
{
Ok(None)
} else {
Expand Down Expand Up @@ -2064,29 +2095,120 @@ impl CandidateBin {
}

async fn load_index_fragmaps(dataset: &Dataset) -> Result<Vec<RoaringBitmap>> {
let indices = dataset.load_indices().await?;
// Coverage, not usability: these bitmaps decide the rewrite groups. Under
// stable row ids `Transaction::recalculate_fragment_bitmap` then rejects any
// group that splits an index's coverage, and it walks every index the new
// manifest carries - including the ones this build cannot read. Binning from
// the filtered view fails that check outright on a dataset holding an index
// written by a newer Lance. The same bitmaps also decide, through
// `any_group_indexed`, whether a deferred compaction writes the
// fragment-reuse index that a build which can read that index needs to
// repair its coverage.
let indices = load_all_indices(dataset).await?;
let mut index_fragmaps = Vec::with_capacity(indices.len());
// System indices (fragment-reuse, mem-wal) don't define data coverage and
// aren't remapped per rewrite group, so they must not constrain compaction
// bins -- otherwise deferred compaction's fragment-reuse index repeatedly
// splits the small-fragment run and they never coalesce.
for index in indices.iter().filter(|idx| !is_system_index(idx)) {
if let Some(fragment_bitmap) = index.fragment_bitmap.as_ref() {
index_fragmaps.push(fragment_bitmap.clone());
} else {
let dataset_at_index = dataset.checkout_version(index.dataset_version).await?;
// max_fragment_id is inclusive (the highest id); +1 for an exclusive
// upper bound so the last fragment is covered (None => empty range).
let frags = 0..dataset_at_index
.manifest
.max_fragment_id
.map_or(0, |m| m + 1);
index_fragmaps.push(RoaringBitmap::from_sorted_iter(frags).unwrap());
}
index_fragmaps.push(index_fragment_coverage(dataset, index).await?);
}
Ok(index_fragmaps)
}

/// The fragments an index segment covers, reconstructing the coverage of a
/// legacy segment that predates the bitmap from the dataset it was written
/// against.
async fn index_fragment_coverage(
dataset: &Dataset,
index: &IndexMetadata,
) -> Result<RoaringBitmap> {
if let Some(fragment_bitmap) = index.fragment_bitmap.as_ref() {
return Ok(fragment_bitmap.clone());
}
let dataset_at_index = dataset.checkout_version(index.dataset_version).await?;
// max_fragment_id is inclusive (the highest id); +1 for an exclusive
// upper bound so the last fragment is covered (None => empty range).
let frags = 0..dataset_at_index
.manifest
.max_fragment_id
.map_or(0, |m| m + 1);
let mut coverage = RoaringBitmap::from_sorted_iter(frags).unwrap();
// Reconstructed in the id space of the version the index was written
// against, which a later compaction has already moved on from.
// `load_all_indices` puts a stored bitmap into the current space by running
// it through the fragment-reuse index and leaves a `None` one alone, so a
// reconstruction has to take that step itself. Skipping it names the
// fragments a deferred compaction moved these rows out of, which is a set
// no rewrite can intersect - the guards below then wave through the rewrite
// of the fragment the rows actually live in.
if let Some(frag_reuse_index) = dataset.open_frag_reuse_index(&NoOpMetricsCollector).await? {
frag_reuse_index.remap_fragment_bitmap(&mut coverage)?;
}
Ok(coverage)
}

/// Each index this build has no reader for, by name, and the fragments it covers.
///
/// A rewrite moves every row address in the fragments it touches, and putting an
/// index back in step means opening it. A build that cannot open one cannot
/// remap it, so compacting the fragments it covers would leave it addressing
/// rows that are gone -- worse than the erase this whole path exists to prevent.
/// They are held out of the plan instead, and the rest of the table still
/// compacts.
///
/// Only the eager remap path needs this. Stable row ids keep the addresses
/// across a rewrite, and `defer_index_remap` hands the repair to a build that
/// can read the index, through the fragment-reuse index it writes.
async fn unremappable_index_coverage(dataset: &Dataset) -> Result<Vec<(String, RoaringBitmap)>> {
let mut coverage = Vec::new();
for index in load_all_indices(dataset).await?.iter() {
if is_system_index(index) || unsupported_index_version(index).is_none() {
continue;
}
coverage.push((
index.name.clone(),
index_fragment_coverage(dataset, index).await?,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy no-bitmap coverage is returned here in its original fragment-ID space. load_all_indices applies the fragment-reuse index only to stored Some(bitmap) values, so this reconstructed set never follows a prior deferred compaction. After deferred compaction maps fragments 0/1 to 2, the guard compares 0/1 against an eager rewrite of 2 and permits 2 to become 3; the future reader's reuse chain still ends at deleted fragment 2. Remap reconstructed coverage through the current fragment-reuse index before returning it, so both the planner and commit guard use current IDs.

This is the current-head successor to the earlier discussion.

Reproducer

I added and ran this regression on the current head:

#[tokio::test]
async fn gate_repro_legacy_unsupported_coverage_follows_the_reuse_chain() {
    use crate::dataset::index::DatasetIndexRemapperOptions;
    use crate::dataset::optimize::{CompactionPlan, TaskData, commit_compaction};

    let test_dir = tempfile::tempdir().unwrap();
    let test_uri = test_dir.path().to_str().unwrap();
    let write_params = WriteParams {
        enable_stable_row_ids: false,
        max_rows_per_file: 5,
        ..Default::default()
    };
    let mut dataset =
        Dataset::write(two_column_reader(), test_uri, Some(write_params))
            .await
            .unwrap();
    let params = ScalarIndexParams::for_builtin(BuiltinIndexType::BTree);
    dataset
        .create_index_builder(&["id"], IndexType::BTree, &params)
        .name("id_idx".to_string())
        .train(false)
        .await
        .unwrap();
    hide_index_from_this_build(&mut dataset, "id_idx").await;

    let hidden = manifest_index(&dataset, "id_idx").await;
    let mut legacy = hidden.clone();
    legacy.fragment_bitmap = None;
    dataset
        .apply_commit(
            Transaction::new(
                dataset.manifest.version,
                Operation::CreateIndex {
                    new_indices: vec![legacy],
                    removed_indices: vec![hidden],
                },
                None,
            ),
            &Default::default(),
            &Default::default(),
        )
        .await
        .unwrap();
    let original_coverage = dataset.fragment_bitmap.as_ref().clone();

    compact_files(
        &mut dataset,
        CompactionOptions {
            defer_index_remap: true,
            ..Default::default()
        },
        None,
    )
    .await
    .unwrap();

    let plan = CompactionPlan {
        tasks: vec![TaskData {
            fragments: dataset.fragments().as_ref().clone(),
        }],
        read_version: dataset.version().version,
        options: CompactionOptions::default(),
    };
    let mut rewrites = Vec::new();
    for task in plan.compaction_tasks() {
        rewrites.push(task.execute(&dataset).await.unwrap());
    }
    commit_compaction(
        &mut dataset,
        rewrites,
        Arc::new(DatasetIndexRemapperOptions::default()),
        &plan.options,
    )
    .await
    .unwrap();

    let mut mapped = original_coverage;
    dataset
        .open_frag_reuse_index(&NoOpMetricsCollector)
        .await
        .unwrap()
        .unwrap()
        .remap_fragment_bitmap(&mut mapped)
        .unwrap();
    let live = dataset.fragment_bitmap.as_ref();
    assert!(
        mapped.is_subset(live),
        "the reuse chain maps the legacy index to removed fragments: mapped={mapped:?}, live={live:?}"
    );
}

CARGO_TARGET_DIR=/home/agent/cache/cargo-target cargo test -p lance --lib gate_repro_legacy_unsupported_coverage_follows_the_reuse_chain -- --nocapture

Observed: the assertion failed with mapped=RoaringBitmap<[2]> and live=RoaringBitmap<[3]>.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 9e8e8fc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9e8e8fc: reconstructed legacy coverage now passes through the current fragment-reuse index before planner exclusion and commit validation. The focused regression passes and confirms that, after deferred compaction maps fragments 0/1 to 2, the commit boundary rejects rewriting fragment 2 and the default planner keeps it while compacting uncovered fragments.

));
}
Ok(coverage)
}

/// Refuse a plan that rewrites fragments an index this build cannot read covers.
///
/// [`DefaultCompactionPlanner`] keeps those fragments out of the plan, but
/// nothing forces a caller through it: `compact_files_with_planner` takes any
/// planner, [`CompactionPlan`] is public and serializable, and a distributed
/// driver hands [`commit_compaction`] results planned elsewhere. Committing such
/// a plan strands the index on fragment ids the rewrite deleted, so the commit
/// boundary refuses it rather than the planner alone.
async fn reject_unremappable_rewrite(
dataset: &Dataset,
completed_tasks: &[RewriteResult],
) -> Result<()> {
let rewritten = completed_tasks
.iter()
.flat_map(|task| task.original_fragments.iter())
.filter_map(|fragment| u32::try_from(fragment.id).ok())
.collect::<RoaringBitmap>();

for (name, covered) in unremappable_index_coverage(dataset).await? {
let blocked = covered & &rewritten;
if !blocked.is_empty() {
return Err(Error::invalid_input(format!(
"compaction would rewrite fragment(s) {:?}, which index {:?} covers. This build \
has no reader for that index, so it cannot be remapped onto the rewritten \
fragments and the commit would leave it addressing rows that no longer exist. \
Plan with DefaultCompactionPlanner, which holds those fragments back, set \
defer_index_remap, or compact from a build that can read the index.",
blocked.iter().collect::<Vec<_>>(),
name,
)));
}
}
Ok(())
}

pub async fn plan_compaction(
dataset: &Dataset,
options: &CompactionOptions,
Expand Down Expand Up @@ -2574,6 +2696,14 @@ pub async fn commit_compaction(
return Ok(CompactionMetrics::default());
}

// Before anything is written or committed. The condition is the planner's,
// not `has_address_style`: a dataset whose only index is one this build
// cannot read captures no row addresses at all, which is exactly the plan
// that has to be refused here.
if !dataset.manifest.uses_stable_row_ids() && !options.defer_index_remap {
reject_unremappable_rewrite(dataset, &completed_tasks).await?;
}

let has_address_style = completed_tasks.iter().any(|t| t.row_addrs.is_some());
// Address-style results require immediate index remapping unless it is deferred.
let needs_remapping =
Expand Down
11 changes: 8 additions & 3 deletions rust/lance/src/dataset/schema_evolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::{
transaction::{Operation, Transaction},
write::cleanup_data_fragments,
};
use crate::index::DatasetIndexExt;
use crate::index::load_all_indices;
use crate::{Error, Result, io::exec::Planner};
use arrow::compute::CastOptions;
use arrow::compute::can_cast_types;
Expand Down Expand Up @@ -798,9 +798,12 @@ pub(super) async fn alter_columns(
// affected column(s). The current behavior is to drop such indices without
// warning, which has caused production incidents where vector search silently
// regressed to brute-force scan. We require users to explicitly drop the
// index before altering the column type, so the action is never silent.
// index before altering the column type, so the action is never silent. That
// includes an index this build has no reader for: the cast reassigns the
// field id, so carrying it forward is impossible and staying quiet about it
// is the silent drop this guard exists to abolish.
if !cast_fields.is_empty() {
let indices = dataset.load_indices().await?;
let indices = load_all_indices(dataset).await?;
let affected: Vec<&lance_table::format::IndexMetadata> = indices
.iter()
.filter(|idx| {
Expand Down Expand Up @@ -1016,6 +1019,8 @@ fn exclude(source: &Schema, other: &Schema, version: &ConcreteFileVersion) -> Re
mod test {
use std::{collections::HashMap, fs, num::NonZero, path::Path as StdPath, sync::Mutex};

use crate::index::DatasetIndexExt;

#[test]
fn test_merge_introduces_required_field() {
let schema = |fields: Vec<ArrowField>| Schema::try_from(&ArrowSchema::new(fields)).unwrap();
Expand Down
Loading
Loading