Repository navigation
Conversation
… parquet RowFilter Upstream-candidate version, based on apache/main. Does not include the fixes from apache#2873 and apache#2630; see local/eq-deletes.md.
There was a problem hiding this comment.
Thanks for looking at this @brgr-s. I inlined some comments from my first pass, and I have a number of test gaps to suggest:
Scan predicate combined with an equality delete, end to end. Every new eqd_* test in row_filter.rs uses the eqd_read helper (row_filter.rs:1445), which never calls .with_predicate(...); the only with_predicate usage in this file is in a pre-existing, unrelated test (row_filter.rs:388). This is exactly the code path this PR rewired: final_predicate and eq_delete_sets used to be ANDed into one bound Predicate before binding; now they're two separately-pushed ArrowPredicates in row_filter_predicates (pipeline.rs:388-451). Nothing exercises both branches being taken in the same scan and checking the intersection is correct. Same gap one level up: no test combines a positional delete, an equality delete, and a scan predicate in one real read and checks output rows; the existing "both delete types together" test (caching_delete_file_loader.rs, formerly test_build_equality_delete_predicate_case_sensitive-adjacent) only asserts build_equality_delete_sets(...).is_ok(), not actual filtered output.
Two equality-delete files, same equality_ids, different on-disk parquet types, through the real load path. This is the happy-path counterpart to test_union_rejects_mismatched_layout: that test only proves union() rejects a mismatch when two EqDeleteSets are hand-built with different types. Nothing proves the realistic case, that evolve_schema/RecordBatchTransformer normalizes two physically-different-typed delete files (e.g. one written as int32, another as int64, same equality_ids) to the same recorded type before they ever reach union(), so the grouping-by-id-only key in build_equality_delete_sets (delete_filter.rs:213) stays safe. Right now that guarantee is established only by reading the code, not by a test; if evolve_schema's casting behavior ever regresses, nothing would catch it before union() starts erroring in production.
Equality-delete key column missing from the data file, with a non-null initial_default on that field. Ties to the row_filter.rs:127-130 finding above: add a test with a field added via schema evolution with .with_initial_default(...), an older data file written before that field existed, and an equality-delete file keyed on it, and assert on the intended behavior (spec.md:898). Worth having regardless of which way the finding gets resolved, since right now nothing pins the behavior down at all.
Decimal precision promotion through the probe. test_eq_delete_promotes_data_type_before_probe only covers Int32 -> Int64. Datum::to has an explicit branch that errors on decimal scale mismatches (datum.rs:1168-1182) and a separate branch for same-scale precision promotion; decimal columns are a realistic equality-delete key (e.g. monetary amounts) and Iceberg's supported decimal evolution (precision widening) goes through a different code path than the int case already tested.
Float/double equality-delete columns. The spec explicitly disallows these ("Float and double columns cannot be used as delete columns in equality delete files", spec.md:853), but nothing in this PR checks for or tests that. EqDeleteKey derives Hash/Eq on Datum, so a float delete column would hit IEEE-754 float equality/hashing (NaN, -0.0 vs 0.0) that the spec restriction exists specifically to avoid. Worth a test asserting a clear, explicit rejection rather than relying on callers never producing one.
Empty EqDeleteSet skip path. if set.is_empty() { continue; } (row_filter.rs:90-92) is untested directly: an equality-delete file that parses to zero keys (e.g. legitimately empty file) should produce no ArrowPredicate for that set and not affect row-group/row-selection filtering. Same code path also needs to handle every key column of a set being simultaneously absent from the data file (column_indices ends up empty, ProjectionMask::leaves(parquet_schema, [])); worth confirming RowFilter still reports the correct row count with a zero-column projected batch.
Lower confidence, likely pre-existing behavior this PR doesn't touch, but the new test suite had a natural opportunity to cover it: an equality-delete file whose key column was later dropped from the table. Spec.md:898, first sentence, requires the dropped column still be used for matching. evolve_schema's target schema is task.schema (the current schema), which by definition no longer declares a dropped field; whether RecordBatchTransformer still carries a dropped-but-still-referenced equality_ids column through is worth a test even though the mechanism predates this PR.
| let Some(pos) = batch_positions[i] else { | ||
| columns.push(vec![None; num_rows]); | ||
| continue; | ||
| }; |
There was a problem hiding this comment.
When an equality-delete key column is absent from a data file's parquet schema (schema evolution: the column was added after this file was written), the probe treats it as null for every row: columns.push(vec![None; num_rows]); continue;. Per spec.md:898, "the column value is read for older data files using normal projection rules" for exactly this case, and iceberg-rust already has a mechanism for "normal projection rules" for a missing column: RecordBatchTransformer resolves initial_default (see record_batch_transformer.rs, "Rule #3"), used for the data file's own output columns at pipeline.rs:494. The eq-delete probe runs earlier, inside the RowFilter, on raw decoded batches, before RecordBatchTransformer ever sees them, so it never consults initial_default and always substitutes null instead. Java's DeleteFilter avoids this by construction: it compares against record, which is built by the regular row reader against requiredSchema (DeleteFilter.java:99-101, 202-209), i.e. defaults are already resolved before the delete-set lookup runs at all. Concretely: a required column added later with a non-null initial_default, later used as an equality-delete key, will never delete matching rows in data files written before that column existed, because every row in those files probes as null instead of the default value. Low likelihood in practice, but it's a silent, spec-observable divergence, not a crash, so worth a test either way.
There was a problem hiding this comment.
I followed your trace and you are correct, this is a spec divergence.
Digging deeper: the old code was also spec-divergent, in two different ways:
- required missing columns lead to all rows beeing dropped for that data file
- optional missing columns on the other hand "do nothing"
This PR keeps all rows in both cases, but scoped to that specific layout group. All rows survive, but if other delete files apply (eq deletes with different layouts, pos deletes), they "can" delete rows... I'd argue that this a slight improvement (maybe ressurect some rows vs. hard dropping all), but the fact you pointed out remains: it is spec divergent, it needs to be made obvious, and it needs to be fixed (but maybe not on this PR). I think this would be worth filing an issue, even if this PR does not get accepted.
There was a problem hiding this comment.
it is spec divergent, it needs to be made obvious, and it needs to be fixed (but maybe not on this PR). I think this would be worth filing an issue, even if this PR does not get accepted.
The comment on test_eq_delete_on_column_absent_from_data_file makes the divergence visible. Could you file that issue and put its number in the test comment, so whoever fixes it finds the test?
There was a problem hiding this comment.
it is spec divergent, it needs to be made obvious, and it needs to be fixed (but maybe not on this PR).
#3302 fixes case B of #3369 on the predicate path in main. Before the row filter is built, it evaluates each leaf on a column missing from the data file against that column's identity partition value or initial-default. The keep predicate built from equality deletes goes through the same step. assert_absent_b_reads_as_7 checks that an equality delete on b = 7 removes every row of a file that doesn't store b, when b reads as 7 through initial-default or an identity partition value.
The set probe here doesn't go through that predicate, and a missing key column becomes None. If #3302 merges first, this PR would bring case B back as a regression against main.
What do you think about resolving a missing key column with the normal projection rules in the probe: the identity partition value, then initial-default, then null? That value is the same for every row of the file, so the probe can use it as a constant column. When every key column of a set is missing, the set reduces to one lookup per file that keeps or drops all rows. residual_for_missing_fields in #3302 resolves the same values from constants_map and initial_default, so the two paths could share that lookup.
There was a problem hiding this comment.
@mbutrovich I just pushed a change that resolves the missing key column with standard projection rules, after merging main in, so this branch should be up-to-date and not re-introduce the problem #3302 fixes.
|
@mbutrovich Thank you for your thourough review, really appreciate it. I will address your points shortly :) |
|
@mbutrovich I worked through your comments and will follow up with commits adressing where we agree. Specifically, because it is not inlined: the test gap is real, so more tests! I just have three comments on the test gaps: float/double equality-delete columns:
equality-delete key column later dropped from the table: this is pre-existing, but nothing would reach the probe: the scan would fail outright on I checked |
|
Did an independent verification pass on 06edf3c from a clean checkout: cargo test -p iceberg --lib → 1576 passed / 0 failed, arrow:: subset 169 passed, and clippy/fmt --check both clean. So CI green reproduces locally. I also reviewed the hash-probe for silent mis-deletion risks and it holds up:
The test suite now covers essentially the whole gap list from the first review (scan-pred × eq-delete intersection, pos+eq+pred, differing physical types through the real load path, decimal promotion, float/double canonicalization, empty set, absent key column). The absent-required-column → initial_default divergence is clearly documented and test-pinned; agree it's a good follow-up rather than a blocker here. LGTM from my (non-committer) review; the remaining threads read as agreed follow-ups. Would be great to get a committer's eyes on it to move it forward. |
|
This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that’s incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the [email protected] list. Thank you for your contributions. |
mbutrovich
left a comment
There was a problem hiding this comment.
Thanks for working through the first round, @brgr-s, and for the new tests. The branch now conflicts with main in caching_delete_file_loader.rs, delete_filter.rs, pipeline.rs, and row_filter.rs. Most of that comes from reader changes that landed since August, including the deletion vector read path in #3035. Could you merge or rebase onto main and resolve those? I'll do another pass once it's green.
The PR description says you'd rather rebase after #2873 and #2630 merge. #2873 was closed and continues as #3268, and both are still open. Would you be OK rebasing onto current main now instead of waiting for them?
equality-delete key column later dropped from the table: this is pre-existing, but nothing would reach the probe: the scan would fail outright on
generate_batch_transform
I think this is worth an issue rather than checking on an error string here. WDYT?
An issue works for me, since this PR doesn't change that path. Could you file it and link it here? The spec says a dropped delete column "must still be used when applying the equality deletes". The initial_default case from the other thread comes from the same spec paragraph, so one issue could cover both.
|
I removed the |
|
@mbutrovich Sorry for the late reply. I'm still interested in getting this merged, so I will work through your new comments and report back :) |
|
@mbutrovich #3268 merged in the meantime, so one thing less to do :) I'll file two issues
@JeonDaehong I'll file your note as issue as well, linking the Java-side issue and PR. |
|
Thanks for filing those follow-ups, @brgr-s! Looking forward to the issue for my note. Happy to help with it once it's up! |
|
FYI #3450: equality deletes on nested fields fail to load today. Since this PR reworks the same path, would you prefer I fix it on top of this PR after it lands, or separately now? |
|
@JeonDaehong If this was directed at me: either is fine for me. Currently this PR is not moving forward very fast, and I feel the bug your are describing might be a bit more urgent, so probably no point in waiting? |
|
Thanks @brgr-s! I'll fix it separately then, and keep the change small so it's easy to rebase this PR on top. I'll tag you on the PR. |
|
I generally don't like to increase scope of PRs for a few reasons: 1) they get squashed to the single commit on merge, so we lose the commit history, 2) that one commit is what goes in the changelog, which makes it hard to reason about the changes, 3) it makes it harder to revert if something breaks. I know we're review-limited, but that's always true so I still prefer smaller scoped PRs. |
Rebase of apache#2961 onto the fork base: - FileScanTaskDeleteFile requires file_format and its builder, like FileScanTask's, returns a Result since apache#3145, so the test helpers set the format and unwrap. - Literal equality follows Java's Double.compare since apache#3327: NaN matches NaN but -0.0 and 0.0 are distinct, so a -0.0 equality delete no longer removes the 0.0 row. The signed-zero test now expects that. Conflict resolutions made while replaying the PR's commits: the deletion-vector loader, the lost-wakeup fix in DeleteFilter (notified_owned) and the NOT normalization in the read pipeline are kept from upstream; the equality-delete set plumbing comes from the PR; both the upstream bloom-filter tests and the PR's equality-delete tests stay in row_filter.rs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
The snapshot now also carries apache/iceberg-rust#2961, equality deletes applied through a hashed key set inside the parquet RowFilter. No source changes needed; Cargo.lock follows the pin. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Which issue does this PR close?
What changes are included in this PR?
Equality deletes are currently applied by turning every delete row into a predicate
expression and OR-ing them into a single tree, which is then evaluated against every row
of every data file the delete file applies to. The cost is
O(data_rows * delete_keys).This replaces the predicate tree with a hash probe:
EqDeleteSet(arrow/caching_delete_file_loader.rs) holds the delete keys as aHashSet<EqDeleteKey>, whereEqDeleteKey(Vec<Option<Datum>>)is one delete row's equality-field values. The set also carries the layout it was built for asVec<(name, field_id, Type)>.EqDeleteSet::union, which returnsErrwhen the layouts differ rather than trusting the caller.build_equality_delete_predicates(arrow/reader/row_filter.rs) turns each set into oneArrowPredicatepushed into parquet'sRowFilter, with aProjectionMaskrestricted to that set's equality columns. Only those columns are decoded to evaluate the filter, and rows are dropped during decode rather than after materialisation.Cost becomes
O(data_rows + applicable_delete_refs).We hit this compacting merge-on-read tables written by Flink upsert (
write.upsert.enabled, format-version 2), where each commit contributes an equality-delete file and the reader ends up applying a large fraction of them to every data file. Two test tables, timing the read+rewrite phase:This is the same core idea as #2343 by @t3hw — replace the per-delete-row predicate tree with a hash-set check — and that PR deserves the credit for identifying the problem and the fix. It was closed by the stale bot after 30 days of inactivity. @t3hw has since confirmed that their organisation moved off iceberg-rust and nobody is pushing it forward, and encouraged filing this.
This is an independent implementation written against current
mainrather than a revival of that branch, because #2343 cannot be pushed to from outside.NOTE 1
Does not include the fixes from #2873 and #2630 and therefore still carries the same bugs these PRs aim to fix. I decided against folding in those changes in this PR. I would rather rebase my PR after #2873 and #2630 have been merged.
NOTE 2
RowGroupMetricsEvaluator::not_eqreturnsROW_GROUP_MIGHT_MATCHunconditionally anyways.0 != -0and!=for differentNaNs. This only "hits" non-conforming writes. Might be worth a follow-up. See CommentRowConverter. This might improve performance even more, but is a bigger rebuild. See CommentAre these changes tested?
Yes — 11 new tests:
Delete-set construction and semantics (
caching_delete_file_loader.rs,delete_filter.rs):test_equality_delete_set_preserves_null_rowstest_equality_delete_set_matches_null_delete_valuetest_equality_delete_set_multiple_columnstest_equality_delete_set_multiple_delete_rowstest_build_equality_delete_sets_mixed_ids_not_mergedtest_build_equality_delete_sets_same_layout_unionedtest_union_rejects_mismatched_layoutEnd-to-end filtering through the reader (
reader/row_filter.rs):test_eq_delete_single_column_filters_matching_rowstest_eq_delete_multi_column_keeps_null_and_partial_matchestest_eq_delete_promotes_data_type_before_probetest_eq_delete_distinct_layouts_apply_independentlyAlso verified on the rebased branch:
cargo test -p iceberg --lib— 1500 passed, 0 failedcargo clippy -p iceberg --all-targets --all-features— cleancargo fmt --check— cleancargo public-api -p iceberg --all-features -ssdiffs empty againstcrates/iceberg/public-api.txt: no public API change (the new types arepub(crate)), sopublic-api.txtneeds no regeneration.AI Disclosure
I used AI to
iceberg-rustGitHub