Skip to content

Core: Fix incorrect delete manifest pruning in entries metadata tables - #17440

Open
yangshangqing95 wants to merge 1 commit into
apache:mainfrom
yangshangqing95:fix/base-entries-content-pruning
Open

Core: Fix incorrect delete manifest pruning in entries metadata tables#17440
yangshangqing95 wants to merge 1 commit into
apache:mainfrom
yangshangqing95:fix/base-entries-content-pruning

Conversation

@yangshangqing95

Copy link
Copy Markdown
Contributor

This PR fixes incorrect pruning for negative data_file.content predicates in the entries and all_entries metadata tables.

A delete manifest may contain either position delete files or equality delete files. The existing evaluator treats both as ManifestContent.DELETES, which can incorrectly prune delete manifests for predicates such as:

data_file.content != 2
data_file.content NOT IN (2)

The fix uses:

may-contain semantics for = and IN
contains-only semantics for != and NOT IN

This prevents false-negative results while preserving safe manifest pruning.

Fixes #17439

@github-actions github-actions Bot added the core label Jul 30, 2026
Comment on lines +269 to +271
private boolean containsOnlyFileContent(int fileContentId) {
return ManifestContent.DATA == manifestContent && FileContent.DATA.id() == fileContentId;
}

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.

can add a comment to highlight why notEq only evaluate the data manifest: a delete manifest may hold both position and equality deletes, so it never has a single value that notEq could rule out.

return ManifestContent.DATA == manifestContent && FileContent.DATA.id() == fileContentId;
}

private <T> boolean containsOnlyFileContents(Set<T> fileContentIds) {

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.

nit: containsAllPossibleFileContents

if (fileContent(ref)) {
Literal<Integer> intLit = lit.to(Types.IntegerType.get());
if (!contentMatch(intLit.value())) {
if (!mayContainFileContent(intLit.value())) {

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.

for in and eq predicate, can we also cover the case where literal is outside the valid value range to ensure coverage? like Expressions.in("data_file.content", 3, 4) shall not scan for any manifests

}

private void assertManifestPlanned(Table metadataTable, Expression filter, String manifestPath) {

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.

nit, extra newline in LINE 207 and 218

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

Traced the pruning logic end-to-end and it's sound: for a DATA manifest content is always 0, so != 0 and NOT IN {…0…} are correctly unsatisfiable; for a DELETES manifest content ranges over {1, 2}, so != x is never unsatisfiable and NOT IN S is unsatisfiable only when S ⊇ {1, 2}. I also confirmed the premise: MergingSnapshotProducer groups new delete files only by spec id, so position and equality deletes really do land in the same manifest.

mayContainFileContent is behaviorally identical to the old contentMatch for every reachable input, so eq/in are a pure rename and the entire behavior change is confined to notEq/notIn. I hand-checked all four existing testEntriesTableDataFileContent* tests in TestMetadataTableScans, including the out-of-range literal cases, and none of them change result.

My comments are all on the test side — the notIn fix currently ships without coverage, and neither test builds the mixed delete manifest that motivates the change.

String deleteManifestPath =
Iterables.getOnlyElement(table.currentSnapshot().deleteManifests(table.io())).path();

Expression filter = Expressions.notIn("data_file.content", FileContent.EQUALITY_DELETES.id());

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.

This test doesn't exercise notIn. A single-literal NOT_IN is collapsed into a NOT_EQ bound predicate by UnboundPredicate.bindInOperation, so this routes through notEq/containsOnlyFileContent and is an exact duplicate of testNotEqualRetainsPositionDeleteManifest.

That leaves the new containsOnlyFileContents(Set) with no coverage for the behavior it changes — the two multi-literal cases in TestMetadataTableScans (notIn(1, 2) and notIn(0, 1, 2)) give identical results under the old and new code, so they pass either way. A discriminating case needs a set containing exactly one of the two delete content ids, e.g. notIn("data_file.content", 2, 3) against a delete manifest: the old anyMatch prunes it, the new code retains it.

}

@TestTemplate
public void testNotEqualRetainsPositionDeleteManifest() {

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.

Both retention tests create a manifest holding only a position delete (or a DV on v3) and assert at the manifest-planning level, so the mixed position+equality manifest that motivates the fix is never actually built, and the user-visible symptom is never shown.

TestMetadataTableScans.preparePartitionedTable() already produces exactly that manifest — one newRowDelta adding fileADeletes()/fileBDeletes() alongside FILE_C2_DELETES/FILE_D2_DELETES. Against that setup, notEqual("data_file.content", 1) silently drops two real equality-delete rows from the entries output before this fix and returns them after. That's the strongest regression test available here and it costs one method.

assertManifestNotPlanned(new ManifestEntriesTable(table), filter, dataManifestPath);
}

private void assertManifestPlanned(Table metadataTable, Expression filter, String manifestPath) {

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.

These two helpers reimplement scannedPaths(TableScan), which TestMetadataTableScans already inherits from MetadataTableScanTestBase (they also do a redundant double copy: ImmutableList.copyOf(...).stream().collect(toImmutableList())).

Since TestMetadataTableScans already owns testEntriesTableDataFileContentEq/NotEq/In/NotIn, extending those with the equality-delete cases would reuse the helper, get the mixed delete manifest for free, and keep all data_file.content coverage in one place.


Expression filter = Expressions.notEqual("data_file.content", FileContent.DATA.id());

assertManifestNotPlanned(new ManifestEntriesTable(table), filter, dataManifestPath);

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.

Minor inconsistency: this one checks only ManifestEntriesTable, while the other two tests assert against both ManifestEntriesTable and AllEntriesTable.

|| FileContent.EQUALITY_DELETES.id() == fileContentId;
}

// Be conservative for an unknown manifest content.

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.

This branch is unreachable — ManifestContent is a closed enum of DATA and DELETES, and ManifestFile.content() never returns null. A switch on the enum with the safe value in default would keep the same conservatism while letting the compiler flag a newly added constant; as written, all three helpers need a hand-audited fallback. Low stakes either way, since true for may-contain and false for contains-only are both the safe directions.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect delete manifest pruning for negative data_file.content predicates in entries metadata tables

3 participants