Skip to content

perf(deletes): apply equality deletes via a hashed key set inside the parquet RowFilter - #2961

Open
brgr-s wants to merge 4 commits into
apache:mainfrom
brgr-s:brgr-s/equality-delete-hash
Open

perf(deletes): apply equality deletes via a hashed key set inside the parquet RowFilter#2961
brgr-s wants to merge 4 commits into
apache:mainfrom
brgr-s:brgr-s/equality-delete-hash

Conversation

@brgr-s

@brgr-s brgr-s commented Aug 5, 2026

Copy link
Copy Markdown

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 a HashSet<EqDeleteKey>, where EqDeleteKey(Vec<Option<Datum>>) is one delete row's equality-field values. The set also carries the layout it was built for as Vec<(name, field_id, Type)>.
  • Delete files that share a layout are merged with EqDeleteSet::union, which returns Err when the layouts differ rather than trusting the caller.
  • build_equality_delete_predicates (arrow/reader/row_filter.rs) turns each set into one ArrowPredicate pushed into parquet's RowFilter, with a ProjectionMask restricted 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.
  • Sets with distinct layouts become independent predicates applied in sequence, so a table whose delete files disagree on equality-field ids stays correct.

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:

             data files   eq-delete files   delete refs      before        after   speedup
a120                120               120         7,140    27,536 ms    3,333 ms      8.3x
b500                500               500       124,750   295,268 ms   21,250 ms     13.9x

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 main rather 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

  • An eq delete key column that is required and absent from a data file previously deleted every row of that file and now deletes none, but this only "hits" for those specific layouts. Other layouts or position delete file are still applied on the surviving rows. Both behaviours are spec-divergent, but this fix focues on performance and the new behaviour might be regarded as "less bad". I'd regard this worth a follow-up. See Comment
  • Eq delete sets no longer feed into row-group pruning, but this should not have any effect. RowGroupMetricsEvaluator::not_eq returns ROW_GROUP_MIGHT_MATCH unconditionally anyways.
  • Float/double delete keys (out-of-spec) now canonicalize signed zero and NaN payloads, whereas previous code had 0 != -0 and != for different NaNs. This only "hits" non-conforming writes. Might be worth a follow-up. See Comment
  • There is a discussion regarding rebuilding the machinery with RowConverter. This might improve performance even more, but is a bigger rebuild. See Comment
  • There is discussion regarding the ordering of predicates and the hash probes. If we had a model for selectivity or evaluation cost, this might also be an opportunity for performance improvements. See Comment

Are 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_rows
  • test_equality_delete_set_matches_null_delete_value
  • test_equality_delete_set_multiple_columns
  • test_equality_delete_set_multiple_delete_rows
  • test_build_equality_delete_sets_mixed_ids_not_merged
  • test_build_equality_delete_sets_same_layout_unioned
  • test_union_rejects_mismatched_layout

End-to-end filtering through the reader (reader/row_filter.rs):

  • test_eq_delete_single_column_filters_matching_rows
  • test_eq_delete_multi_column_keeps_null_and_partial_matches
  • test_eq_delete_promotes_data_type_before_probe
  • test_eq_delete_distinct_layouts_apply_independently

Also verified on the rebased branch:

  • cargo test -p iceberg --lib — 1500 passed, 0 failed
  • cargo clippy -p iceberg --all-targets --all-features — clean
  • cargo fmt --check — clean
  • cargo public-api -p iceberg --all-features -ss diffs empty against
    crates/iceberg/public-api.txt: no public API change (the new types are
    pub(crate)), so public-api.txt needs no regeneration.

AI Disclosure

I used AI to

  • Identify the runtime problem I encountered and write a specific test that validated the identified problem
  • Search for filings of PRs and Issues in iceberg-rust GitHub
  • Check Iceberg Java for their approach
  • Review my changes before filing the PR
  • Help formulate the Issue and PR text

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

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +127 to +130
let Some(pos) = batch_positions[i] else {
columns.push(vec![None; num_rows]);
continue;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@brgr-s brgr-s Aug 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

};

combined_predicate = combined_predicate.and(predicate);
let layout = set.fields.iter().map(|(_, id, _)| *id).collect();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Grouping key is set.fields.iter().map(|(_, id, _)| *id).collect(), field id only, no type. In isolation this looks like it could let two type-mismatched equality-delete sets reach union() and hard-fail the read. It can't in practice: CachingDeleteFileLoader evolves every equality-delete file's batch stream against task.schema before parsing it into an EqDeleteSet (caching_delete_file_loader.rs:333-343, via BasicDeleteFileLoader::evolve_schema / RecordBatchTransformer, which casts present-but-differently-typed columns, record_batch_transformer.rs:810). So every set sharing the same scan's task.schema already carries the same recorded type for the same field id by construction, and grouping by id only mirrors Java's own grouping (Sets.newHashSet(delete.equalityFieldIds()), DeleteFilter.java:194), which also doesn't carry type in the key, for the same reason: Java projects every delete file into requiredSchema up front instead of comparing delete-file-recorded types against each other. Given that, the union() type check and its comment are effectively dead code / defense-in-depth for an invariant that already holds structurally, not the live bug I'd have guessed from reading this function alone. Worth a comment saying why the invariant holds (ties to evolve_schema in the other file) instead of "should a change break this," since the current comment reads as a guess rather than a traced guarantee. Not worth blocking on.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I would improve the comment and explain why the invariant holds, as you suggested. I'd argue against dead code, though.

The invariant belongs to the caller, not union(). Also, DeleteFilter's state is Arc, CachingDeleteFileLoader holds one, and ArrowReader is Clone. FileScanTask is also "very pub" and "Serde", so a hand-built or deserialized plan, or a re-used reader, can put two tasks with different schemas through the same "path-keyed" cache. That is very unlikely, but it is reachable though the public API. I'd rather keept it.

Comment on lines +217 to 231
let mut result = Vec::with_capacity(groups.len());
for mut sets in groups.into_values() {
if sets.len() == 1 {
result.push(sets.pop().unwrap());
} else {
let mut combined = (*sets[0]).clone();
for other in &sets[1..] {
// `union` checks if `other`s' layout matches `combined`,
// which is currently always the case. This fails should a change
// break this current invariant.
combined.union(other)?;
}
result.push(Arc::new(combined));
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

build_equality_delete_sets runs once per data-file scan task, and whenever a data file has more than one delete file sharing a layout, it clones the first set's entire HashSet<EqDeleteKey> and unions in the rest, from scratch, every time. The PR's own benchmark numbers (7,140 and 124,750 "delete refs") are exactly the count of (data-file, delete-file) edges this repeats over, so the real cost is closer to O(data_rows + delete_refs) than the claimed O(data_rows + delete_keys) once delete files span many data files, which is the compaction case motivating the PR. Java doesn't have this cost at all: it builds one StructLikeSet per equality-id group once per DeleteFilter (DeleteFilter.java:191-211), not per data file. Fix: pass Vec<Arc<EqDeleteSet>> per layout to the row filter and probe against all of them (row is deleted if it matches any), instead of merging into one owned set per task. Arc::clone is a refcount bump; the HashSet clone is not.

@brgr-s brgr-s Aug 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You are absolutely correct about the O(data_rows + delete_keys) claim, it is incorrect.

The correct bound ist O(data_rows + applicable_delete_refs).

Or, if we set m = applicable delete files sharing a layout, K = keys per delete file, D = rows in the data file

Old = D*m*K
New = D + m*K

My intuition on your suggestion Vec<Arc<EqDeleteSet>> is (with c_prope cost for lookup, c_clone cost for Clone):

merge:  m*K*c_clone  (build, once)  +  D*c_probe   (one lookup per row)
N-set:  0            (Arc bumps)    +  D*m*c_probe (m lookups per row)

Merging wins when m*K*c_copy < D*(m-1)*c_probe, or, D/K > (m/(m-1))*(c_clone/c_probe).

When m grows, (m/(m-1)) -> 1, so the threshold gets smaller (assuming relativ constant c_clone vs c_probe, but I think thats Ok), and merging gets "relativly better" in the "fanout case" (for m=1, we never merge). N-set probing wins when D/K ~ small, but I'd argue that delete file application is not the dominant cost for reading in that case.

Regarding Java, I took a look at DeleteFilter.java:191-211, and I think you have applyEqDeletes and isInDeleteSets in mind. isInDeleteSets lives in a DeleteFilter instance, which is constructed per data file. I might be looking at the wrong thing, however?

Comment on lines +118 to +186
let predicate_func =
move |batch: RecordBatch| -> std::result::Result<BooleanArray, ArrowError> {
let num_rows = batch.num_rows();

// Change each key column into `Datum`s once, promoting to the
// table type so the keys match the parsed delete keys under schema
// evolution. A column absent from this file reads as all-null.
let mut columns: Vec<Vec<Option<Datum>>> = Vec::with_capacity(num_cols);
for (i, target_type) in target_types.iter().enumerate() {
let Some(pos) = batch_positions[i] else {
columns.push(vec![None; num_rows]);
continue;
};
let array = batch.column(pos);
let source_type = arrow_type_to_type(array.data_type())
.map_err(|e| ArrowError::ComputeError(e.to_string()))?;
let source_primitive = source_type
.as_primitive_type()
.ok_or_else(|| {
ArrowError::ComputeError(
"equality delete key column is not a primitive type"
.to_string(),
)
})?
.clone();
let needs_promotion = source_type != *target_type;
let literals = arrow_primitive_to_literal(array, &source_type)
.map_err(|e| ArrowError::ComputeError(e.to_string()))?;

let mut column = Vec::with_capacity(num_rows);
for literal in literals {
let datum = match literal {
Some(literal) => {
let primitive =
literal.as_primitive_literal().ok_or_else(|| {
ArrowError::ComputeError(
"failed to convert to primitive literal"
.to_string(),
)
})?;
let datum = Datum::new(source_primitive.clone(), primitive);
let datum = if needs_promotion {
datum
.to(target_type)
.map_err(|e| ArrowError::ComputeError(e.to_string()))?
} else {
datum
};
Some(datum)
}
None => None,
};
column.push(datum);
}
columns.push(column);
}

// One hash lookup per row.
let mut keep = Vec::with_capacity(num_rows);
let mut probe = EqDeleteKey(vec![None; num_cols]);
for row in 0..num_rows {
for (i, column) in columns.iter_mut().enumerate() {
// we can `take` because each cell is probed once.
probe.0[i] = std::mem::take(&mut column[row]);
}
keep.push(!set.keys.contains(&probe));
}
Ok(BooleanArray::from(keep))
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Per batch, per key column: arrow_primitive_to_literal (row_filter.rs:144) builds an owned Vec<Option<Literal>> for the whole column, then row_filter.rs:148-171 walks it again to build a Vec<Option<Datum>>. Two full passes and, for string/binary columns, two rounds of per-cell heap ownership, on the exact hot path this PR is trying to make cheap. Separately, row_filter.rs:158 does source_primitive.clone() inside the per-row loop even though source_primitive is fixed per column (computed once at row_filter.rs:134-142); cheap since PrimitiveType doesn't allocate, but still pointless per-row work. caching_delete_file_loader.rs:541-556 has the same double-pass/box-per-cell pattern already, unmodified by this PR, but it runs once per delete-file load; this PR adds a second copy of it on the probe side, which runs once per batch per applicable data file, i.e. far more often. arrow::row::RowConverter (crate arrow-row, already in Cargo.lock at 59.1.0, not yet a direct dep of crates/iceberg) builds comparable/hashable rows straight from Arrow arrays without this boxing, and is what DataFusion uses for multi-column join/group keys. For the common single-column case, DataFusion's InListExpr static filters (datafusion/physical-expr/src/expressions/in_list/{primitive_filter,static_filter}.rs) use a native-typed hashbrown/ahash set (datafusion_common::HashSet, see datafusion/common/src/lib.rs:119) instead of boxed values in std::collections::HashSet with the default SipHash, which is what EqDeleteSet::keys uses here. Worth noting Java's generic DeleteFilter doesn't push equality-delete filtering into the file decoder at all, it applies a row-by-row Predicate<T> after full materialization; pushing it into parquet-rs's RowFilter the way this PR does is a real advantage over that, so this finding is about the representation used inside the predicate, not the decision to use a RowFilter predicate.

@brgr-s brgr-s Aug 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Regarding RowConverter, I am hesitant to put this in this PR. It is not a small change and I think it should propably go in a follow-up, with benchmarks that prove that it improves performance beyond what this PR does. Same reasoning for not trying to remove arrow_primitive_to_literal.

For double pass and the double String allocation, I have an idea that I will push today.

)?;

let row_filter = ArrowReader::get_row_filter(
row_filter_predicates.push(ArrowReader::build_scan_predicate(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also line 440.

Scan predicate is always pushed before the eq-delete predicates, unconditionally. parquet-rs applies RowFilter predicates in list order, threading a shrinking RowSelection between them (arrow-rs parquet/src/arrow/arrow_reader/filter.rs:139-149), so a cheap/selective predicate run first prunes work for everything after it. DataFusion's ParquetSource reorders predicates by estimated column byte-size before building the RowFilter for exactly this reason (datafusion/datasource-parquet/src/row_filter.rs:33-52). A hash-probe over one or two narrow key columns is often cheaper and more selective than an arbitrary scan predicate; this PR never reorders to take advantage of that.

@brgr-s brgr-s Aug 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

TBH, I'd keep it that way at the moment.

The machinery does exist in DataFusion, but it is opt-in (reorder_filters defaults to false).

The heuristic is compressed column byte size, as you state, so a cpu-heavy predicate over column A cannot be distinguished from a cheaper hash probe over the same column A. Across different columns, a CPU heavy predicate over a narrow column sorts before a one lookup probe over a wide one, regardless which one is cheaper or more selective.

It is a good proxy for decoder effort, which is frequently the right thing to optimize, but it is not a proxy for evaluation cost, which is what this PR aims to improve. Getting the order right would need a cost model for selectivity or per-predicate cost.

I think this is more a follow-up situation if a workload is discovered where the order measurably matters.

Comment on lines +85 to +86
let field_id_map =
Self::resolve_field_id_map(parquet_schema, arrow_schema, use_position_fallback)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

resolve_field_id_map recomputes the parquet-schema-to-field-id map whenever eq_delete_sets is non-empty, even though pipeline.rs already computed an equivalent map for the scan predicate a few lines earlier via ArrowReader::build_field_id_set_and_map (projection.rs:49-62, which itself calls resolve_field_id_map). Same schema both times; minor duplicated per-file work, not per-row, so low severity, but easy to thread through instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Threading it through is do-able, but it ties the predicate arm to the eq delete arm. If neither is present, we should skip calculating the schema-to-field-id-map. I felt like this was an odd shape, and the current form is more readable, while the impact is low.

@brgr-s

brgr-s commented Aug 5, 2026

Copy link
Copy Markdown
Author

@mbutrovich Thank you for your thourough review, really appreciate it. I will address your points shortly :)

@brgr-s

brgr-s commented Aug 6, 2026

Copy link
Copy Markdown
Author

@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: Datum does not use IEE-754 float semantics. PrimitiveLiteral stores OrderedFloat<..>, which treats NaN == NaN and Hash uses CANONICAL_NAN_BITS and canonicalize_signed_zero, so both agree. However, this does introduce a change: on main, eq-deletes go through arrow's kernel where is_eq is bitwise, so -0.0 != 0.0 and different NaN bits also do not match. I feel like the spec is not really clear on what is correct... but at the end of the day, it only applies to input that the spec dissallows and would only hit non-conforming writes. But the old implemenation also already did not Err on this, so we'd introduce a regression for those non-conforming writer. I'd opt for the test and against the error.

if set.is_empty() { continue; } untested: correct, but a test is limited. If continue is removed, you get num_cols = 0, empty keys, and contains is always false, so every row is kept for that pass. It is performance guard, not a correctness guard. “doesn’t affect row-group/row-selection filtering” is true by construction: after this PR only task.predicat drives row-group and page-index filtering, eq delete sets never do.

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:

Ok(field_id_to_mapped_schema_map
  .get(field_id)
  .ok_or(Error::new(ErrorKind::Unexpected, "field not found"))? // <------ here
  .0
  .clone())

I checked DeleteFilter.java:76 and DeleteFilter.java:318: I think this is the same failure. I think this is worth an issue rather than checking on an error string here. WDYT?

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.

Equality delete file application scales with O(data rows × applicable delete keys)

2 participants