feat(java): scan fragment physical row slices - #8951
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b8afdc1af7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for row_id in sequence.slice(offset, count).iter() { | ||
| selected_row_ids.insert(row_id); |
There was a problem hiding this comment.
Preserve compressed stable row-id ranges
When a slice covers a large fragment with stable row IDs, this loop expands the compressed RowIdSequence and performs one tree insertion per physical row, making scanner creation O(rowCount) even when the sequence is represented by a single range; overlapping slices repeat the work. Whole-fragment slices over large ML datasets can therefore spend substantial time synchronously enumerating millions or billions of IDs before scanning. Convert each sliced sequence through its range-aware From<&RowIdSequence> implementation and union the resulting maps (ideally after merging overlapping physical ranges) so compressed ranges remain compressed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
Fragment slicing needs to remain in physical (fragment, offset) space until it is intersected with deletion state and any fragment scope. Converting tombstoned slots to stable logical IDs lets a slice follow an updated row into another fragment. Keeping a physical selection through scan planning is the cleanest fix and also provides a path to address the separate compressed-range planning cost in the existing discussion.
| )) | ||
| })?; | ||
| for row_id in sequence.slice(offset, count).iter() { | ||
| selected_row_ids.insert(row_id); |
There was a problem hiding this comment.
The stable-ID conversion loses the physical fragment/offset boundary here. A stable row ID is preserved when an update tombstones the old physical slot and writes the live row into another fragment, so adding the deleted slot's ID to this dataset-wide mask can select the replacement row outside the requested slice. This violates the documented fragmentSlices and fragmentIds intersection.
Keep the restriction in physical-address space until it has been intersected with fragment scope and deletion state; a logical-ID-only mask cannot distinguish a deleted incarnation from its live replacement when includeDeletedRows is involved.
Reproducer run against this head
I added this regression to ScannerTest:
@Test
void testFragmentSliceDoesNotFollowUpdatedStableRowId(@TempDir Path tempDir) throws Exception {
String path = tempDir.resolve("fragment_slice_stable_row_id_update").toString();
WriteParams params =
new WriteParams.Builder()
.withDataStorageVersion(LanceConstants.FILE_FORMAT_VERSION_STABLE)
.withEnableStableRowIds(true)
.build();
try (BufferAllocator allocator = new RootAllocator()) {
TestUtils.SimpleTestDataset fixture =
new TestUtils.SimpleTestDataset(allocator, path);
fixture.createDatasetWithWriteParams(params).close();
List<FragmentMetadata> metadata = fixture.createNewFragment(8, params);
try (Dataset original =
Dataset.commit(
allocator, path, new FragmentOperation.Append(metadata), Optional.of(1L))) {
int oldFragment = original.getFragments().get(0).getId();
org.lance.update.UpdateResult result =
original.update(
new org.lance.update.UpdateParams(
Collections.singletonMap("name", "'updated'"))
.withWhere("id = 2"));
try (Dataset updated = result.getDataset()) {
int replacementFragment =
updated.getFragments().stream()
.map(Fragment::getId)
.filter(id -> id != oldFragment)
.findFirst()
.orElseThrow();
ScanOptions options =
new ScanOptions.Builder()
.fragmentSlices(
Collections.singletonList(new FragmentSlice(oldFragment, 2, 1)))
.fragmentIds(Collections.singletonList(replacementFragment))
.columns(Collections.singletonList("id"))
.build();
try (LanceScanner scanner = updated.newScan(options)) {
assertEquals(Collections.emptyList(), readIds(scanner));
}
}
}
}
}Run with:
cd java && ./mvnw -Djava.io.tmpdir=/home/agent/tmp -Dtest=ScannerTest#testFragmentSliceDoesNotFollowUpdatedStableRowId test
Expected []; observed [2].
What
Add a Java/JNI scanner API for selecting physical row slices within Lance fragments:
A slice covers
[rowOffset, rowOffset + rowCount)in the fragment's physical row domain. Deleted rows do not compact offsets and are omitted by normal scans.Semantics
fragmentIdsandfragmentSlicesmay be combined; both restrictions apply.rowCount == 0is allowed and represents an empty slice.NotSupportederror.How
FragmentSliceandScanOptions.Builder.fragmentSlices(...).RowAddrTreeMap.FilteredReadExecpath.with_physical_row_addr_prefilterAPI; no second public Rust Scanner API or new file-reading path is added.Tests
Coverage includes:
COUNT(*)readersfragmentIdsintersectionFragment.newScan(options)Verified with:
cargo fmt --manifest-path ./java/lance-jni/Cargo.toml --all --checkcargo clippy --manifest-path ./java/lance-jni/Cargo.toml --all-targets./mvnw spotless:check./mvnw test -Dtest=ScannerTest,AsyncScannerTest(58 Java tests + 18 JNI Rust tests)./mvnw test -Dtest='!RestNamespaceTest,!DynamicContextProviderTest'(465 Java tests + 18 JNI Rust tests)The unfiltered Maven suite ran 485 Java tests; 13 existing REST namespace tests failed because their external endpoint returned HTTP 403. All other tests passed.