Skip to content

fix(reader): rewrite NOT out of the read predicate before pruning - #3416

Merged
laskoviymishka merged 4 commits into
apache:mainfrom
anoopj:fix-not-predicate-pruning
Oct 14, 2026
Merged

laskoviymishka merged 4 commits into
apache:mainfrom
anoopj:fix-not-predicate-pruning

Conversation

@anoopj

@anoopj anoopj commented Oct 12, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

What changes are included in this PR?

The reader fans a FileScanTask's predicate to the row-group metrics evaluator and the page-index evaluator, both conservative (inclusive) filters that cannot soundly negate a NOT node. The row-group evaluator's naive !inner flips a MIGHT_MATCH bound to CANT_MATCH. For NOT(x > 5) over a row group spanning [0, 10] that prunes a group which does contain matching rows (silent data loss). The page-index evaluator rejects NOT outright, aborting the read. A FileScanTask built or deserialized outside TableScan carries an un-normalized predicate, so a raw NOT reaches both.

Rewrite NOT out of the predicate once, where it is first bound in the read pipeline, so every consumer sees a NOT-free predicate.

Are these changes tested?

Added tests.

The reader fans a FileScanTask's predicate to the row-group metrics
evaluator and the page-index evaluator, both conservative (inclusive)
filters that cannot soundly negate a NOT node. The row-group evaluator's
naive `!inner` flips a MIGHT_MATCH bound to CANT_MATCH. For NOT(x > 5) over
a row group spanning [0, 10] that prunes a group which does contain matching
rows (silent data loss). The page-index evaluator rejects NOT outright,
aborting the read. A FileScanTask built or deserialized outside TableScan
carries an un-normalized predicate, so a raw NOT reaches both.

Rewrite NOT out of the predicate once, where it is first bound in the read
pipeline, so every consumer sees a NOT-free predicate.
@laskoviymishka
laskoviymishka self-requested a review October 12, 2026 21:02

@laskoviymishka laskoviymishka left a comment

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.

The fix itself looks right to me — normalizing NOT out of the predicate before the row-group and page-index evaluators run matches what Java's RewriteNot and PyIceberg do, and turning the row-group evaluator's not() into a hard Err instead of Ok(!inner) is the right backstop against the silent mispruning.

My one ask before merge is on the tests. The read test's doc says it exercises both the row-group and page-index evaluators, but with a single row group and default writer properties the row-group stage prunes first and the page-index path almost certainly never runs — so of the two failures this fixes, only the row-group half is actually demonstrated, and a regression in the page-index path would stay green. I'd like a test that genuinely forces the page-index path (and ideally a bit more NOT-shape coverage) before this goes in. Details inline. Once that's covered, I'll take another pass.

/// A `FileScanTask` built or deserialized outside `TableScan` carries an
/// un-normalized predicate, so a raw `Not(...)` can reach the reader. Driving
/// it through a full read with row-group filtering and row selection enabled
/// exercises both the row-group and page-index evaluators, which negate NOT

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.

The doc here says the test exercises both the row-group and page-index evaluators, but with a single row group and default writer props the row-group stage prunes first and the page-index path almost certainly never runs — there may be no column/offset index written at all. So of the two failures this fixes, only the row-group mispruning is actually under test.

Before merge I'd want a second read test that forces the page-index path — row_group_filtering_enabled(false), row_selection_enabled(true), a small data_page_row_count_limit so there are real data pages, and an assertion the file carries a column index. Run against pre-fix code it should fail with the NOT-unsupported error.

While you're in here, a couple more NOT shapes beyond NOT(x > 5) would be worth it — the ones where the negation isn't a simple flip: NOT(a AND b)/NOT(a OR b), NOT(x IS NULL), NOT(StartsWith), and a NOT arriving via the equality-delete combination.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added test_not_predicate_read_through_page_index, to test this.

▎ but with a single row group and default writer props the row-group stage prunes first and the page-index path almost certainly never runs

The default Arrow writer does emit a column index, so the page-index path runs whenever a row group survives (the new test exercises exactly that). Agreed it wasn't isolated in the original test, which is why the new one turns row-group filtering off.

▎ NOT(a AND b)/NOT(a OR b), NOT(x IS NULL), NOT(StartsWith), and a NOT arriving via the equality-delete combination

This PR only adds the single rewrite_not() call at the pipeline boundary; it doesn't change rewrite_not. The per-shape behavior lives in rewrite_not and is already unit-tested, so testing each shape end to end here would be at the wrong layer and duplicate it. The equality-delete combination is the one that actually touches reader code (the predicate is AND-ed with the delete predicate before the rewrite), so I can add that one if you'd like.

iceberg_schema_ref.as_ref(),
);

assert!(result.is_err());

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.

is_err() on its own can pass for the wrong reason — a field-id lookup or bind failure would satisfy it just as well, so this doesn't actually prove the NOT path is what got rejected. I'd pin it:

let err = result.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Unexpected);
assert!(err.message().contains("NOT"));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

@anoopj
anoopj requested a review from laskoviymishka October 12, 2026 23:49

@laskoviymishka laskoviymishka left a comment

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.

🌬️ ⛵

@laskoviymishka
laskoviymishka added this pull request to the merge queue Oct 13, 2026
@laskoviymishka

Copy link
Copy Markdown
Contributor

need to rebase once #3425 merged

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Oct 13, 2026
@laskoviymishka
laskoviymishka added this pull request to the merge queue Oct 14, 2026
Merged via the queue into apache:main with commit a269dad Oct 14, 2026
23 of 24 checks passed
@anoopj
anoopj deleted the fix-not-predicate-pruning branch October 14, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reader drops rows or errors on a NOT predicate

2 participants