Skip to content

fix(merge_insert): deduplicate indexed candidates - #8176

Merged
Xuanwo merged 6 commits into
mainfrom
gatekeeper/fix-8057-1
Aug 5, 2026
Merged

Xuanwo merged 6 commits into
mainfrom
gatekeeper/fix-8057-1

Conversation

@lance-gatekeeper

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

Copy link
Copy Markdown
Contributor

Summary

  • de-duplicate scalar-index candidate row addresses across source batches before indexed takes
  • account retained candidate growth through the DataFusion task memory pool
  • add Rust and Python end-to-end regressions for cross-batch over-matching, empty candidates, and finite-pool behavior

Root cause

MapIndexExec produces a set of matching row addresses for each source batch, but its output stream did not de-duplicate addresses between batches. Composite-key IsIn probes can over-match because their per-column values are not tuple-correlated, so two batches could emit the same target address. Reading that target row twice made the exact downstream join appear ambiguous.

The fix retains previously emitted row addresses in a compressed RowAddrTreeMap for the lifetime of the index-mapping stream. Before mutating the retained set or allocating its output for a batch, MapIndexExecCandidates reserves a conservative per-candidate allowance through the DataFusion task pool, then shrinks the retained-state reservation to the approximate DeepSizeOf measurement. This is cooperative task-pool accounting, not an allocator-level peak-memory cap: DeepSizeOf is approximate and the returned output allocation lives beyond the retained-map measurement. A rejected batch preserves earlier candidates and reservations, and the reservation is released with the stream.

Validation

  • cargo test -p lance test_map_index_candidates_ -- --test-threads=1
  • cargo test -p lance test_indexed_merge_insert_deduplicates_cross_batch_candidates -- --test-threads=1
  • cargo test -p lance test_indexed_merge_insert_composite_key -- --test-threads=1
  • cargo test -p lance io::exec::scalar_index::tests --lib -- --test-threads=1
  • make build (from python/)
  • uv run pytest python/tests/test_dataset.py::test_indexed_merge_insert_deduplicates_cross_batch_candidates
  • uv run make lint (from python/)
  • cargo fmt --all
  • cargo clippy --all --tests --benches -- -D warnings
  • git diff --check

Fixes #8057

@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 99.00000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/io/exec/scalar_index.rs 98.00% 0 Missing and 1 partial ⚠️

📢 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: approve with a non-blocking risk.

The change restores stream-wide set semantics for indexed candidates and fixes the batch-dependent false ambiguity without weakening genuine duplicate-source handling. The remaining concern is the lifetime of the retained candidate state on large indexed merges.

Comment thread rust/lance/src/io/exec/scalar_index.rs Outdated
Self::map_batch(lookups, dataset, deletion_mask, batch, metrics).await
}
});
let mut emitted_row_addrs = RoaringTreemap::new();

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.

This treemap retains every unique candidate address until the stream ends, but the allocation is not registered with the DataFusion memory pool. A broad or many-batch indexed merge can therefore add O(unique target candidates) untracked memory on top of the existing per-batch masks and arrays. Please consider memory-accounting this state with a bounded failure under pressure, or use a spillable distinct if large candidate streams are expected.

@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 with a non-blocking risk.

This revision resolves the prior lifetime-accounting concern and preserves the verified cross-batch correctness fix. The retained set is now tracked at batch boundaries; if the pool is intended to be a hard peak-memory bound, pre-reservation remains the safer contract.

Comment thread rust/lance/src/io/exec/scalar_index.rs Outdated
.copied()
.filter(|row_addr| self.emitted.insert(*row_addr))
.collect();
if let Err(error) = self.reservation.try_resize(self.emitted.deep_size_of()) {

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.

The reservation is checked only after the complete candidate batch has been inserted and the unseen array allocated, so a broad batch can exceed the pool before returning ResourcesExhausted. Rollback restores logical membership, but the accounting is not a strict peak-memory bound. If hard boundedness is required, reserve conservatively before mutation and shrink to the measured size, or account insertion in bounded chunks. A finite-pool regression covering successful growth, later rejection, preserved prior candidates, and release on drop would also make the intended contract explicit.

@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.

Empty index results are valid and must remain successful empty candidate batches. Account the retained map baseline before comparing measured growth so the new memory bound does not reject no-match indexed merges.

}

let measured_size = self.emitted.deep_size_of();
if measured_size > provisional_size {

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.

An initial empty candidate batch always enters this error branch: both reservation.size() and unaccounted_candidates are zero, but RowAddrTreeMap::deep_size_of() includes size_of_val(self). A normal no-match probe therefore returns ResourcesExhausted even when the pool has capacity. Include the empty-map baseline in the provisional reservation (or return an empty array before this comparison) and keep a first-empty regression.

Reproducer
#[test]
fn test_map_index_candidates_accept_empty_first_batch() {
    let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(1024));
    let reservation =
        MemoryConsumer::new(MAP_INDEX_CANDIDATES_MEMORY_CONSUMER).register(&pool);
    let mut candidates = DistinctRowAddrs::new(reservation);

    let empty = candidates
        .retain_unseen(&UInt64Array::from(Vec::<u64>::new()))
        .unwrap();
    assert!(empty.is_empty());
}

cargo test -p lance test_map_index_candidates_accept_empty_first_batch -- --test-threads=1 failed on d945a74bca1b7185731d375df830e7da156a0e4f with ResourcesExhausted("MapIndexExecCandidates batch exceeded its 256-byte per-candidate reservation").

@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 with a non-blocking risk.

This revision fixes the empty-candidate regression while preserving cross-batch deduplication and finite-pool rollback. The retained-state reservation is suitable as cooperative pool accounting; avoid relying on it as an allocator-level peak cap without representation-threshold evidence.

/// Conservative upper bound on the [`DeepSizeOf`] growth from inserting one
/// address: a new B-tree entry, Roaring container metadata, and its payload.
/// The reservation is shrunk to the measured size after each input batch.
const ROW_ADDR_INSERT_RESERVATION_BYTES: usize = 256;

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.

This constant conservatively bounds the repository’s approximate DeepSizeOf metric, not demonstrated allocator-level peak usage: RowAddrSelection measures a Roaring bitmap with serialized_size(), and the provisional reservation is shrunk while the returned output array remains alive. The current mechanism is reasonable as cooperative pool accounting, but it should not be treated as a hard physical-memory cap unless representation-threshold and output-lifetime measurements establish that contract.

@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 change now establishes stream-wide candidate set semantics, covers empty and finite-pool transitions, and states the retained-map accounting contract accurately. No acceptance-changing risk remains.

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please also add an end-to-end python test. I'm not sure if I fully understand the bug you want to fix.

pub index_name: String,
}

const MAP_INDEX_CANDIDATES_MEMORY_CONSUMER: &str = "MapIndexExecCandidates";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's this for? Can we avoid this?

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.

Addressed in b268921. Kept the reservation because cross-batch deduplication retains every unique candidate until the stream ends; removing it would leave O(unique candidates) outside the task pool. Added an inline comment distinguishing this stream-lifetime state from per-batch reservations.

@github-actions github-actions Bot added the A-python Python bindings label Aug 4, 2026

@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 requested Python end-to-end regression now preserves two source batches, exercises both scalar indexes, and verifies the correct three updates without duplicated target rows. Production behavior and the previously verified accounting contract are unchanged.

@lance-gatekeeper

Copy link
Copy Markdown
Contributor Author

Addressed in b268921. Added a Python end-to-end regression that passes a two-batch RecordBatchReader through the indexed composite-key merge and verifies the three intended updates complete without a false ambiguity error.

@Xuanwo
Xuanwo merged commit 87b1ab1 into main Aug 5, 2026
43 checks passed
@Xuanwo
Xuanwo deleted the gatekeeper/fix-8057-1 branch August 5, 2026 09:11
@Xuanwo Xuanwo added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-python Python bindings bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: merge_insert indexed path rejects valid merges as ambiguous when one source batch's index probe over-matches another's

1 participant