Skip to content

feat(parquet): add statistics-based row group filtering - #956

Open
YinZheng-Sun wants to merge 16 commits into
apache:mainfrom
YinZheng-Sun:parquet-row-group-filter-reader
Open

YinZheng-Sun wants to merge 16 commits into
apache:mainfrom
YinZheng-Sun:parquet-row-group-filter-reader

Conversation

@YinZheng-Sun

@YinZheng-Sun YinZheng-Sun commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds ParquetMetricsRowGroupFilter to evaluate Iceberg predicates against Parquet footer statistics before reading row groups.

Adds tests covering split intersection, non-contiguous row groups, physical row positions, unprojected filter columns, schema evolution, missing statistics, predicate binding, and delete handling.

Why

The Parquet reader previously selected row groups only by file split. Filter predicates could not eliminate row groups whose footer statistics ruled out matching rows.

Behavior change

  • Enables statistics-based pruning by default through read.parquet.row-group-filter.enabled.
  • Retained row groups still require residual predicate evaluation; this does not implement exact row filtering.
  • Batches stop at row-group boundaries, including when pruning is disabled.

Testing

Built and ran parquet_test, data_test, avro_test, and expression_test locally during implementation. Rebuilt and reran parquet_test and data_test after the binding and field-mapping refactors; both passed.

The IN regression covers 201 input literals that deduplicate to 200 values, ensuring the limit applies to the bound set.

@wgtmac wgtmac 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.

I haven't finished reviewing it yet, just post my finding so far. Please let me know what you think.

Comment thread src/iceberg/data/file_scan_task_reader.cc Outdated
Comment thread src/iceberg/parquet/parquet_metrics_row_group_filter.cc
Comment thread src/iceberg/file_reader.h Outdated
Comment thread src/iceberg/data/file_scan_task_reader.cc Outdated
Handle constant expressions with tri-state binding detection and reject partially bound expressions. Bind unbound filters against the reader projection and pass case sensitivity explicitly from scan options.

Disable floating-point bounds pruning while preserving null-count checks. Add regression coverage for finite bounds that omit NaNs, constant residuals, case sensitivity, and caller-provided read schemas.
@YinZheng-Sun

YinZheng-Sun commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Sorry for the late reply, and thanks for the detailed review. @wgtmac

@YinZheng-Sun
YinZheng-Sun requested a review from wgtmac October 9, 2026 07:09
Comment thread src/iceberg/expression/predicate.cc
yinzhengsun added 2 commits October 9, 2026 16:41
@YinZheng-Sun

Copy link
Copy Markdown
Contributor Author

I noticed a difference in how Iceberg-CPP and Java handle null comparisons.

Literal::operator<=> currently returns std::partial_ordering::unordered when either operand is null. Therefore, when key is null:

key < 100 // false
!(key < 100) // true
key >= 100 // false

However, RewriteNot rewrites NOT(key < 100) into key >= 100. These expressions are not equivalent under the current C++ semantics. Using the rewritten expression for row-group pruning can discard rows that the original expression would match in Evaluator.
Java’s literal comparator uses nullsFirst, under which both NOT(null < 100) and null >= 100 evaluate to false.
Was treating null as unordered an intentional semantic choice in C++. Should we preserve this behavior and make the rewrite null-aware, or adopt Java’s nullsFirst comparison semantics? @wgtmac Please let me know what you think. Thanks.

@wgtmac wgtmac 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.

I think you're right that this indeed is a problem and different impls are not consistent:

impl x < v on null NOT
Java true (Literals nulls-first) rewritten in every pruning evaluator
Go true (nullsFirstCmp) rewritten in every pruning evaluator (RewriteNotExpr)
Rust false rewritten once in the scan; evaluator errors on a leftover Not
Python false metrics evaluator raises on a leftover Not

Perhaps in the short term (in this PR), we can keep RewriteNot in the pruning filter and make the evaluator use the same rule. Apply RewriteNot when building the Evaluator, or error on a leftover Not like iceberg-rust. Then not(key < 100) is key >= 100 for every value, so pruning keeps full strength on nullable columns.

In the long run, we can give the evaluator three states for null comparisons (null-first, null-last, unknown), so not(unknown) stays unknown. Pruning stays sound and semantics match SQL exactly.

WDYT? @YinZheng-Sun

@YinZheng-Sun

Copy link
Copy Markdown
Contributor Author

I think you're right that this indeed is a problem and different impls are not consistent:

impl x < v on null NOT
Java true (Literals nulls-first) rewritten in every pruning evaluator
Go true (nullsFirstCmp) rewritten in every pruning evaluator (RewriteNotExpr)
Rust false rewritten once in the scan; evaluator errors on a leftover Not
Python false metrics evaluator raises on a leftover Not
Perhaps in the short term (in this PR), we can keep RewriteNot in the pruning filter and make the evaluator use the same rule. Apply RewriteNot when building the Evaluator, or error on a leftover Not like iceberg-rust. Then not(key < 100) is key >= 100 for every value, so pruning keeps full strength on nullable columns.

In the long run, we can give the evaluator three states for null comparisons (null-first, null-last, unknown), so not(unknown) stays unknown. Pruning stays sound and semantics match SQL exactly.

WDYT? @YinZheng-Sun

Thanks for the suggestion. I will apply the short-term approach firstly: Evaluator::Make() applies RewriteNot, so row evaluation and row-group pruning use the same normalized predicates.

Rewrite NOT after binding so nullable range comparisons use the same normalized predicates as row-group pruning.

Add regression coverage for null values, comparison boundaries, and pruning nullable row groups.
Comment thread src/iceberg/data/file_scan_task_reader.h Outdated
};

class ICEBERG_EXPORT IsBoundVisitor : public ExpressionVisitor<bool> {
class ICEBERG_EXPORT IsBoundVisitor : public ExpressionVisitor<std::optional<bool>> {

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.

why this change?

I think whether an expression is bounded or not should be deterministic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The optional represents an internal traversal state, not uncertainty about whether an expression is bound. nullopt means that the subtree contains no references, as with True or False.
Constants need to be neutral when combining binding states: And(True, unbound) should be unbound, while And(True, bound) should be bound. Mixed bound and unbound predicates are still rejected. The public IsBound() API continues to return a definite boolean.
This follows Java’s IsBoundVisitor.

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.

This is a good point. Let's make a separate pr of this fix? It's not part of the row group metrics filter but rather a prerequisite.
Also BTW, I think we should hide the IsBoundVisitor from the public contract, otherwise, we would be more careful about the iceberg_export APIs.


Result<bool> AlwaysFalse() override { return kRowsCannotMatch; }

Result<bool> Not(bool) override { return kRowsMightMatch; }

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.

why is this?

it should be Not(bool result) override { return !result }?
If this is on purpose, then at least a comment should be added to justify.

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.

I agree that we should add a comment for this workaround.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think !result is a latent correctness bug.

The contract of this visitor is: return ROWS_CANNOT_MATCH only when it is proven that
no row in the group matches. ROWS_MIGHT_MATCH means "unknown", and negating "unknown"
is still "unknown", not "cannot match". So !result breaks the contract.

Example: stats x in [5, 15], filter NOT(x < 10). The child returns might-match, so
!result returns cannot-match and the group is skipped, although rows with
x in [10, 15] match. That is silent data loss.

Comment thread src/iceberg/parquet/parquet_metrics_row_group_filter.cc Outdated
Comment on lines +217 to +221
if (!ref || !ref->type()->is_primitive() ||
MetadataColumns::IsMetadataColumn(ref->field_id()) ||
MetadataColumns::IsRowLineageColumn(ref->field_id())) {
return nullptr;
}

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.

this seems unnecessary? if the ref column is nested, or meta column, then it should not be presented in the field_id_column_idx_ mapping.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That is true for complex-type containers and virtual metadata columns. However, nested primitive leaves do appear in the map. Physically stored _row_id and _last_updated_sequence_number can also appear there, since the map currently includes every physical leaf with a valid field ID.
Row-lineage nulls may be replaced with inherited values during reading, so their physical statistics do not necessarily describe the returned values. The null-reference guard is also needed for transform terms.
There is a redundant check here, though: IsMetadataColumn() already includes the row-lineage IDs. I’ll remove the separate IsRowLineageColumn() check.

Comment on lines +168 to +170
if (!lower || lower->type()->type_id() != TypeId::kString) {
return kRowsMightMatch;
}

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.

hmmm. this is strange. I think you are mixing validation and metrics evaluation here. I'm not sure it is good way to add such check here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

OK, I will remove it.

return kRowsMightMatch;
}
const auto& prefix = std::get<std::string>(value.value());
if (std::get<std::string>(lower->value()).compare(0, prefix.size(), prefix) > 0) {

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.

  1. I'm not sure we can use std::string to compare string columns directly. Per iceberg's spec String column must be encoded as UTF-8 bytes. we may compare unsigned utf-8 bytes directly. cc @wgtmac if you have more insights.

@YinZheng-Sun YinZheng-Sun Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

std::string::compare uses std::char_traits, which the standard specifies
compares as unsigned char, so this is an unsigned byte-wise comparison and matches
UTF-8 code point order.

}

template <typename Comparator>
bool VisitInequality(const std::shared_ptr<BoundReference>& ref, const Literal& 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.

nit: VisitInequality is not a good name. There's no visit logic in the method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The name follows iceberg-rust’s visit_inequality(), which serves the same purpose.

Comment thread src/iceberg/parquet/parquet_reader.cc Outdated
Comment on lines +413 to +419
// Share a reader across adjacent row groups so Arrow can coalesce pre-buffered
// reads. Stop at gaps to keep physical row positions contiguous within batches.
while (end < context_->row_groups_.size() &&
context_->row_groups_[end].index == context_->row_groups_[end - 1].index + 1) {
row_groups.push_back(context_->row_groups_[end].index);
++end;
}

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.

why this change?

Seems like unrelated change to the metrics filter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This change is needed when integrating row-group pruning with physical row-position tracking. Previously, the reader passed all selected row groups to one GetRecordBatchReader() call. Pruning can introduce gaps, so the new implementation splits the reader at those gaps to prevent a batch from spanning discontinuous file positions.
Keeping adjacent groups together preserves the existing multi-row-group batching and pre-buffering behavior, instead of creating a reader for every group. I’ll clarify this motivation in the comment.

context_->record_batch_reader_,
reader_->GetRecordBatchReader(row_groups, SelectedColumnIndices(projection_)));
context_->next_row_group_ = end;
metadata_context_.next_file_pos = context_->row_groups_[begin].first_row;

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.

hmmm. It looks like that how iceberg-cpp currently support _pos metadata column is problematic. It tracks next_file_pos in MetadataColumnContext and updates/incrs per batch. This might work for parquet reader without data skipping.. Once row group filtering, and later page skipping is kicked in, it would be hard to track that correctly. I think we should follow how parquet-rs: apache/arrow-rs#8715 or iceberg-java's approach(org.apache.iceberg.parquet.ParquetValueReaders.PositionReader) to support _pos metadata column. WDYT @wgtmac

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.

Yes, this is a new issue if data skipping is supported. I think either of your proposed approach is fine but we would wait longer if we want to fix it in Arrow C++.

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.

Let me see if whether we can add the support in iceberg-cpp first, then we can contribute back to arrow c++.

yinzhengsun added 3 commits October 10, 2026 18:43
Merge origin/main while retaining nested nullability and unknown-type predicate tests. Return the negated result from the row group metrics NOT visitor, with RewriteNot still applied in Make().
@YinZheng-Sun
YinZheng-Sun force-pushed the parquet-row-group-filter-reader branch from 7e379e7 to aca3b9e Compare October 11, 2026 14:00

namespace {

Result<std::optional<bool>> CombineResults(const std::optional<bool>& left_result,

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.

This method should be scoped to IsBoundVisitor? CombineBoundResults or make it a private helper method of IsBoundVisitor?

@advancedxy

Copy link
Copy Markdown
Contributor

I did a quick pass on the new code and responses. I think most of them are addressed and will do a more detailed round later.

This branch has not been deployed

No deployments
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.

3 participants