Skip to content

fix(index): replace same-name index segments across types - #8166

Open
lance-gatekeeper[bot] wants to merge 5 commits into
mainfrom
gatekeeper/fix-7842-1
Open

fix(index): replace same-name index segments across types#8166
lance-gatekeeper[bot] wants to merge 5 commits into
mainfrom
gatekeeper/fix-7842-1

Conversation

@lance-gatekeeper

@lance-gatekeeper lance-gatekeeper Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • make an index type change replace every same-name segment only when the incoming segments cover all current fragments
  • reject partial cross-type commits before publishing metadata, while preserving same-type partial segment updates
  • treat legacy same-name segments without index details as an unconditional structural mismatch that requires full replacement
  • add regression coverage for full, partial, legacy-metadata, and unknown-type-URL collision cases

Root cause

commit_existing_index_segments originally excluded different-type metadata from removal. Removing that filter fixed full replacements, but the normal coverage logic still retained disjoint old-type segments during a partial type switch. The later type-change guard first skipped legacy segments whose index details were absent, then represented that absence with a string sentinel that could collide with an accepted incoming type URL. Both paths could publish mixed logical index segments.

Validation

  • cargo fmt --all -- --check
  • cargo clippy --all --tests --benches -- -D warnings
  • cargo test -p lance --lib commit_existing_index_segments (14 passed)
  • cargo test -p lance --lib test_partial_type_change_with_legacy_missing_details_is_rejected -- --nocapture (1 passed, including the unknown-type-URL collision)

Fixes #7842

@github-actions github-actions Bot added the bug Something isn't working label Aug 3, 2026
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.62385% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/index.rs 98.62% 0 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@lance-gatekeeper lance-gatekeeper Bot left a comment

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.

Gate recommendation: request changes. The current coverage-based removal still allows a partial type switch to commit a heterogeneous logical index, so normal scalar loading fails after a successful metadata commit. Treat a type change as a whole-name replacement—requiring full current-fragment coverage and removing every same-name segment—or reject the partial cross-type commit before writing metadata.

Comment thread rust/lance/src/index.rs
.zip(incoming_type_url.as_deref())
.is_none_or(|(details, expected)| details.type_url == expected)
})
.map(|idx| -> Result<Option<IndexMetadata>> {

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.

Disjoint same-name segments now bypass removal at the is_disjoint branch below even when their index-details type differs from the incoming type, so a successful partial replacement can persist a logical index that readers reject. Require a type switch to cover the whole current dataset and remove every same-name segment, or reject the cross-type commit before the transaction.

Reproducer

I ran this added regression against 2c70daaefbfe25abf255329a9d8259d2ab2afeea:

#[tokio::test]
async fn test_partial_cross_type_replacement_keeps_logical_index_loadable() {
    use lance_datagen::{BatchCount, RowCount, array};

    let test_dir = tempfile::tempdir().unwrap();
    let reader = lance_datagen::gen_batch()
        .col("id", array::step::<arrow_array::types::Int32Type>())
        .into_reader_rows(RowCount::from(20), BatchCount::from(2));
    let mut dataset = Dataset::write(
        reader,
        test_dir.path().to_str().unwrap(),
        Some(WriteParams {
            max_rows_per_file: 20,
            max_rows_per_group: 20,
            ..Default::default()
        }),
    )
    .await
    .unwrap();

    let fragments = dataset.get_fragments();
    assert_eq!(fragments.len(), 2);
    let btree = ScalarIndexParams::for_builtin(BuiltinIndexType::BTree);
    let mut originals = Vec::new();
    for fragment in &fragments {
        originals.push(
            dataset
                .create_index_builder(&["id"], IndexType::BTree, &btree)
                .fragments(vec![fragment.id() as u32])
                .execute_uncommitted()
                .await
                .unwrap(),
        );
    }
    dataset
        .commit_existing_index_segments("shared_name", "id", originals)
        .await
        .unwrap();

    let bitmap = ScalarIndexParams::for_builtin(BuiltinIndexType::Bitmap);
    let replacement = dataset
        .create_index_builder(&["id"], IndexType::Bitmap, &bitmap)
        .fragments(vec![fragments[0].id() as u32])
        .execute_uncommitted()
        .await
        .unwrap();
    dataset
        .commit_existing_index_segments("shared_name", "id", vec![replacement])
        .await
        .unwrap();

    crate::index::scalar_logical::load_named_scalar_segments(
        &dataset,
        "id",
        "shared_name",
    )
    .await
    .expect("a successful commit must leave a loadable logical index");
}

Command: cargo test -p lance --lib test_partial_cross_type_replacement_keeps_logical_index_loadable -- --nocapture

Observed: the commit returned Ok, then the expectation failed with Scalar index shared_name on column id mixes incompatible segment types.

@lance-gatekeeper lance-gatekeeper Bot left a comment

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.

Gate recommendation: request changes.

The new head fixes modern metadata but still lets a partial type switch commit a heterogeneous logical index when an existing scalar segment has legacy missing index_details. Infer the legacy type before comparison, or conservatively require full replacement whenever any existing same-name type is unknown, so a successful commit cannot leave readers to discover mixed segment types.

Comment thread rust/lance/src/index.rs Outdated
)));
}
let existing_different_type_url = existing_named_indices.iter().find_map(|idx| {
let existing_type_url = idx.index_details.as_ref()?.type_url.as_str();

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.

find_map silently skips an existing segment when index_details is None, because this ? returns None from the closure. Stable legacy scalar metadata can have that shape. If it covers fragment 0, a new Bitmap segment for an appended fragment 1 is considered disjoint and commits alongside it; subsequent scalar loading rejects the logical index as mixing incompatible segment types. Infer the missing scalar details before comparing, or treat any unknown same-name segment as a possible type change and require full current-fragment coverage.

Reproducer

I added this regression test to the existing test module on 2e424cf9327ba9010919a7d5efac1c84690264a9:

#[tokio::test]
async fn test_partial_type_change_with_legacy_missing_details_is_rejected() {
    use lance_datagen::{BatchCount, RowCount, array};

    let test_dir = tempfile::tempdir().unwrap();
    let test_uri = test_dir.path().to_str().unwrap();
    let reader = lance_datagen::gen_batch()
        .col("id", array::step::<arrow_array::types::Int32Type>())
        .into_reader_rows(RowCount::from(10), BatchCount::from(1));
    let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap();

    let btree_params = ScalarIndexParams::for_builtin(BuiltinIndexType::BTree);
    let original = dataset
        .create_index_builder(&["id"], IndexType::BTree, &btree_params)
        .execute_uncommitted()
        .await
        .unwrap();
    dataset
        .commit_existing_index_segments("shared_name", "id", vec![original])
        .await
        .unwrap();

    let current = dataset.load_indices_by_name("shared_name").await.unwrap();
    let mut legacy = current.clone();
    legacy[0].index_details = None;
    legacy[0].index_version = 0;
    let transaction = Transaction::new(
        dataset.manifest.version,
        Operation::CreateIndex {
            new_indices: legacy,
            removed_indices: current,
        },
        None,
    );
    dataset
        .apply_commit(transaction, &Default::default(), &Default::default())
        .await
        .unwrap();

    let append_reader = lance_datagen::gen_batch()
        .col("id", array::step::<arrow_array::types::Int32Type>())
        .into_reader_rows(RowCount::from(10), BatchCount::from(1));
    let mut dataset = Dataset::write(
        append_reader,
        test_uri,
        Some(WriteParams {
            mode: WriteMode::Append,
            ..Default::default()
        }),
    )
    .await
    .unwrap();
    let fragments = dataset.get_fragments();
    assert_eq!(fragments.len(), 2);
    let legacy = dataset.load_indices_by_name("shared_name").await.unwrap();
    assert_eq!(legacy.len(), 1);
    assert!(legacy[0].index_details.is_none());

    let bitmap_params = ScalarIndexParams::for_builtin(BuiltinIndexType::Bitmap);
    let replacement = dataset
        .create_index_builder(&["id"], IndexType::Bitmap, &bitmap_params)
        .fragments(vec![fragments[1].id() as u32])
        .execute_uncommitted()
        .await
        .unwrap();
    let version_before = dataset.manifest.version;
    let result = dataset
        .commit_existing_index_segments("shared_name", "id", vec![replacement])
        .await;
    assert!(result.is_err(), "partial type change unexpectedly committed");
    assert_eq!(dataset.manifest.version, version_before);
}

Executed:

cargo test -p lance test_partial_type_change_with_legacy_missing_details_is_rejected -- --nocapture

Observed: the test failed at assert!(result.is_err()) because the partial type change committed. A companion test that allowed the commit confirmed two mixed segments were persisted and load_named_scalar_segments then returned mixes incompatible segment types.

@lance-gatekeeper lance-gatekeeper Bot left a comment

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.

Gate recommendation: request changes.

The legacy case is now covered for normal type URLs, but representing the unknown state with an accepted type-URL string leaves a sentinel collision that can still commit an unreadable mixed-type logical index. Keep unknownness structural—branch on missing details before comparing strings—or reject malformed incoming type URLs before writing metadata.

Comment thread rust/lance/src/index.rs Outdated
let existing_type_url = idx
.index_details
.as_ref()
.map_or("<unknown>", |details| details.type_url.as_str());

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.

Using "<unknown>" both as the missing-details sentinel and as a possible caller-provided Any.type_url lets this guard be bypassed. Public IndexSegment::new accepts the value, and validate_segment_index_details checks only presence and equality; with an existing legacy BTree lacking details, a disjoint incoming segment whose type URL is exactly "<unknown>" compares equal, commits, and leaves scalar loading to reject the mixed types. Although that URL is malformed under the protobuf Any contract, the public boundary currently accepts it, so it must return an input error rather than persist unreadable metadata. Branch on index_details.is_none() so it unconditionally requires full replacement, using the sentinel only for display, or validate type URLs before this comparison.

Reproducer

I added this regression to the existing test module on 64d161d3c05a28f4ac003e5a660e2ebff8c135cb:

#[tokio::test]
async fn test_unknown_type_url_cannot_bypass_legacy_partial_replacement_guard() {
    use lance_datagen::{BatchCount, RowCount, array};

    let test_dir = tempfile::tempdir().unwrap();
    let test_uri = test_dir.path().to_str().unwrap();
    let reader = lance_datagen::gen_batch()
        .col("id", array::step::<arrow_array::types::Int32Type>())
        .into_reader_rows(RowCount::from(10), BatchCount::from(1));
    let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap();

    let index_name = "sentinel_collision";
    let btree_params = ScalarIndexParams::for_builtin(BuiltinIndexType::BTree);
    let original = dataset
        .create_index_builder(&["id"], IndexType::BTree, &btree_params)
        .execute_uncommitted()
        .await
        .unwrap();
    dataset
        .commit_existing_index_segments(index_name, "id", vec![original])
        .await
        .unwrap();

    let current = dataset.load_indices_by_name(index_name).await.unwrap();
    let mut legacy = current.clone();
    legacy[0].index_details = None;
    legacy[0].index_version = 0;
    let transaction = Transaction::new(
        dataset.manifest.version,
        Operation::CreateIndex {
            new_indices: legacy,
            removed_indices: current,
        },
        None,
    );
    dataset
        .apply_commit(transaction, &Default::default(), &Default::default())
        .await
        .unwrap();

    let append_reader = lance_datagen::gen_batch()
        .col("id", array::step::<arrow_array::types::Int32Type>())
        .into_reader_rows(RowCount::from(10), BatchCount::from(1));
    let mut dataset = Dataset::write(
        append_reader,
        test_uri,
        Some(WriteParams {
            mode: WriteMode::Append,
            ..Default::default()
        }),
    )
    .await
    .unwrap();
    let fragments = dataset.get_fragments();
    assert_eq!(fragments.len(), 2);
    let field_id = dataset.schema().field("id").unwrap().id;
    let sentinel_segment = IndexSegment::new(
        Uuid::new_v4(),
        [fragments[1].id() as u32],
        [field_id],
        Arc::new(prost_types::Any {
            type_url: "<unknown>".to_string(),
            value: Vec::new(),
        }),
        0,
        dataset.manifest.version,
    );
    let version_before = dataset.manifest.version;
    let result = dataset
        .commit_existing_index_segments(index_name, "id", vec![sentinel_segment])
        .await;
    let loaded =
        crate::index::scalar_logical::load_named_scalar_segments(&dataset, "id", index_name)
            .await;

    assert!(
        result.is_err(),
        "sentinel type URL bypassed the partial replacement guard; loader result: {loaded:?}"
    );
    assert!(matches!(result.unwrap_err(), Error::InvalidInput { .. }));
    assert_eq!(dataset.manifest.version, version_before);
    assert_eq!(loaded.unwrap().len(), 1);
}

Executed:

cargo test -p lance test_unknown_type_url_cannot_bypass_legacy_partial_replacement_guard -- --nocapture

Observed: the test failed because the commit returned Ok; the manifest advanced and loaded was Err(InvalidInput("Scalar index 'sentinel_collision' on column 'id' mixes incompatible segment types")).

@lance-gatekeeper lance-gatekeeper Bot left a comment

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.

Gate recommendation: approve.

The unknown legacy state is now handled structurally before type-string comparison, closing the sentinel collision while preserving partial-replacement rejection and full-replacement cleanup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: commit_existing_index_segments retains a same-name index of a different type

0 participants