lance-format / lance-format/lance

Merging index segments after deferred-remap compaction silently drops live entries

Open
#9,270 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Rust
Stars
7.1k
Forks
852
Avg merge
3d 18h
Merged PRs (30d)
272

Description

Description

Dataset::merge_existing_index_segments can produce an empty index when merging uncommitted segments built before compaction with defer_index_remap: true. A sequential build → compact → merge reproduces the failure.

Every index returns all four matching rows before compaction. After compaction:

Index family Rows returned
BTree 0 of 4
Bitmap 4 of 4
ZoneMap 0 of 4
BloomFilter 0 of 4
NGram 4 of 4
FM-index 0 of 4
Inverted 4 of 4
LabelList 0 of 4

Queries read the merged index directly, without scan fallback. For approximate indices, these counts refer to candidate rows. RTree and vector indices were not tested.

Steps to reproduce

rust/lance/src/index/create.rs

#[cfg(test)]
#[rstest::rstest]
#[case::bitmap(lance_index::IndexType::Bitmap)]
#[case::btree(lance_index::IndexType::BTree)]
#[case::zonemap(lance_index::IndexType::ZoneMap)]
#[case::bloomfilter(lance_index::IndexType::BloomFilter)]
#[case::ngram(lance_index::IndexType::NGram)]
#[case::fm(lance_index::IndexType::Fm)]
#[case::inverted(lance_index::IndexType::Inverted)]
#[case::label_list(lance_index::IndexType::LabelList)]
#[tokio::test]
async fn test_merge_uncommitted_segments_across_deferred_compaction(
    #[case] index_type: lance_index::IndexType,
) {
    use std::{ops::Bound, sync::Arc};

    use arrow_array::{
        ListArray, RecordBatch, RecordBatchIterator, RecordBatchReader, types::Int32Type,
    };
    use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema};
    use datafusion::common::ScalarValue;
    use lance_datagen::gen_batch;
    use lance_index::{
        IndexType,
        metrics::NoOpMetricsCollector,
        scalar::{
            AnyQuery, BloomFilterQuery, LabelListQuery, SargableQuery, ScalarIndexParams,
            SearchResult, TextQuery, TokenQuery,
        },
    };

    use crate::{
        Dataset,
        dataset::WriteParams,
        index::{DatasetIndexExt, create::CreateIndexBuilder},
    };

    let column = match index_type {
        IndexType::NGram | IndexType::Fm | IndexType::Inverted => "text",
        IndexType::LabelList => "labels",
        _ => "id",
    };
    let query: Box<dyn AnyQuery> = match index_type {
        IndexType::NGram | IndexType::Fm => {
            Box::new(TextQuery::StringContains("document".to_string()))
        }
        IndexType::Inverted => Box::new(TokenQuery::TokensContains("document".to_string())),
        IndexType::LabelList => {
            Box::new(LabelListQuery::HasAnyLabel(vec![ScalarValue::Int32(Some(
                1,
            ))]))
        }
        IndexType::BloomFilter => Box::new(BloomFilterQuery::IsIn(
            (0..4).map(|id| ScalarValue::Int32(Some(id))).collect(),
        )),
        _ => Box::new(SargableQuery::Range(
            Bound::Included(ScalarValue::Int32(Some(0))),
            Bound::Excluded(ScalarValue::Int32(Some(4))),
        )),
    };
    let reader = gen_batch()
        .col("id", lance_datagen::array::step::<Int32Type>())
        .col(
            "text",
            lance_datagen::array::fill_utf8("document".to_string()),
        )
        .into_reader_rows(
            lance_datagen::RowCount::from(2),
            lance_datagen::BatchCount::from(2),
        );
    let mut fields = reader.schema().fields().to_vec();
    fields.push(Arc::new(ArrowField::new(
        "labels",
        DataType::List(Arc::new(ArrowField::new("item", DataType::Int32, true))),
        false,
    )));
    let schema = Arc::new(ArrowSchema::new(fields));
    let batch_schema = schema.clone();
    let batches = reader.map(move |batch| {
        let batch = batch.unwrap();
        let labels = ListArray::from_iter_primitive::<Int32Type, _, _>(
            (0..batch.num_rows()).map(|_| Some(vec![Some(1)])),
        );
        let mut columns = batch.columns().to_vec();
        columns.push(Arc::new(labels));
        RecordBatch::try_new(batch_schema.clone(), columns)
    });
    let reader = RecordBatchIterator::new(batches, schema);
    let mut dataset = Dataset::write(
        reader,
        "memory://",
        Some(WriteParams {
            max_rows_per_file: 2,
            enable_stable_row_ids: false,
            ..Default::default()
        }),
    )
    .await
    .unwrap();
    assert_eq!(dataset.get_fragments().len(), 2);

    let params = ScalarIndexParams::for_builtin(index_type.try_into().unwrap());
    let mut segments = Vec::with_capacity(2);
    for fragment in dataset.get_fragments() {
        segments.push(
            CreateIndexBuilder::new(&mut dataset, &[column], index_type, &params)
                .name("in_flight".to_string())
                .fragments(vec![fragment.id() as u32])
                .execute_uncommitted()
                .await
                .unwrap(),
        );
    }

    // Compaction needs an indexed group to write fragment-reuse metadata.
    // One committed segment keeps both fragments in the same compaction bin.
    dataset
        .create_index(
            &[column],
            index_type,
            Some("committed".to_string()),
            &params,
            false,
        )
        .await
        .unwrap();

    for compact in [false, true] {
        if compact {
            crate::dataset::optimize::compact_files(
                &mut dataset,
                crate::dataset::optimize::CompactionOptions {
                    target_rows_per_fragment: 4,
                    defer_index_remap: true,
                    ..Default::default()
                },
                None,
            )
            .await
            .unwrap();
            assert_eq!(dataset.get_fragments().len(), 1);
            assert!(dataset.get_fragments()[0].id() > 1);
            assert_eq!(dataset.count_rows(None).await.unwrap(), 4);
        }

        let merged = dataset
            .merge_existing_index_segments(segments.clone())
            .await
            .unwrap();
        // Query the output directly so a scan cannot use the committed
        // scaffolding index or fall back to reading unindexed fragments.
        let index = crate::index::scalar::open_scalar_index(
            &dataset,
            column,
            &merged,
            &NoOpMetricsCollector,
        )
        .await
        .unwrap();
        let result = index
            .search(query.as_ref(), &NoOpMetricsCollector)
            .await
            .unwrap();
        let rows = match result {
            SearchResult::Exact(rows) => rows,
            // These queries return candidates; every fixture row is a true match.
            SearchResult::AtMost(rows)
                if matches!(
                    index_type,
                    IndexType::ZoneMap
                        | IndexType::BloomFilter
                        | IndexType::NGram
                        | IndexType::Inverted
                ) =>
            {
                rows
            }
            other => panic!("unexpected {index_type:?} search result: {other:?}"),
        };
        assert_eq!(
            rows.true_rows().row_addrs().unwrap().count(),
            4,
            "{index_type:?} merge lost rows (compacted: {compact})"
        );
    }
}

Run from the repository root:

cargo nextest run -p lance --lib test_merge_uncommitted_segments_across_deferred_compaction --no-fail-fast

Lance version

ac1d994a2ebacd9ebea70c01af740876582c326c

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the reproducer in rust/lance/src/index/create.rs and run cargo nextest run -p lance --lib test_merge_uncommitted_segments_across_deferred_compaction --no-fail-fast from the repository root. Trace merge_existing_index_segments together with compact_files when defer_index_remap is true; done means the test passes and all eight tested index families return four rows after compaction.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.