Skip to content

fix: keep Iceberg residual pushdown within Spark semantics - #7156

Open
rich7420 wants to merge 10 commits into
apache:mainfrom
rich7420:fix/7136-iceberg-residual-semantics
Open

rich7420 wants to merge 10 commits into
apache:mainfrom
rich7420:fix/7136-iceberg-residual-semantics

Conversation

@rich7420

@rich7420 rich7420 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #7136.
Closes #7137.
Part of #7138.

Rationale for this change

The native Iceberg reader uses residuals to prune reads and filter rows. Comparisons that differ from Spark can discard matching rows before the post-scan filter runs. These correctness fixes were split from #6847 so they can land and be backported independently.

What changes are included in this PR?

Leave unsafe residuals to the post-scan filter:

  • Floating-point ranges, zero/NaN literals, and FLOAT literals that do not narrow exactly. Other equality, != and IN predicates remain eligible.
  • Equality under NOT on nullable columns, unless an enclosing AND checks that same column with IS NOT NULL. This preserves pushdown for b = false.
  • Predicates on recorded promoted columns, except null checks and equality outside NOT. If the schema history is incomplete, every long, double, decimal, timestamp and timestamp_ns column is treated as promoted.

These predicates now rely on the post-scan filter. Iceberg still skips data files by their bounds, but the native scan no longer prunes row groups or pages for them. This matters most for FLOAT and DOUBLE range filters. #7588 tracks restoring that pruning.

Unrecorded promotions and imported files remain a known limitation under #7138. The Iceberg guide documents the exclusions.

How are these changes tested?

  • Spark 4.1/Scala 2.13/JDK 21/Iceberg 1.11: native scan, serde, reflection, residual pushdown and fuzz suites, 215 passed, 2 canceled by version gates.
  • Spark 3.5/Scala 2.12/JDK 17/Iceberg 1.8.1: the same suites, 206 passed, 11 canceled by version gates.
  • The new VERSION AS OF read returns 2 of 6 rows when promoted columns come from the table schema alone. The timestamp case fails with the previous list of promotable types. Both pass with the change.
  • All three simplified scan regressions fail with the original residual guards disabled and pass after restoring them.
  • Iceberg 1.11/Spark 4.1/JDK 17 upstream shard-2 at 4ea8723, with the freshly built debug native library: 1,794 passed, 528 skipped, no failures or errors. Not rerun after the latest commit.

Normal compilation, Scalastyle and Spotless passed. Other runtime profiles were not run.

Decline FLOAT and DOUBLE range comparisons and zero or NaN literals.
Spark treats -0.0 as equal to 0.0 and sorts every NaN above all
numbers, while iceberg-rust compares by IEEE 754 total order, and its
row-group pruning trusts statistics that can omit NaN
(apache/iceberg-rust#3494).

Decline equality under NOT on nullable columns. Iceberg passes `<=>`
as `=`, so the native reader dropped NULL rows that Spark keeps for
`NOT (id <=> 1)`.

On columns whose type changed in the schema history, push only
equality outside NOT and null checks, since iceberg-rust compares
older, narrower files incorrectly (apache/iceberg-rust#3122). An
incomplete history treats every BIGINT, DOUBLE and DECIMAL column as
promoted.

Closes apache#7136. Closes apache#7137. Part of apache#7138.
@github-actions github-actions Bot added bug Something isn't working area:scan Parquet scan / data reading area:Iceberg labels Oct 22, 2026
Spark rewrites `b = false` on a nullable boolean as `b IS NOT NULL AND
NOT b`, which Iceberg passes as `not_null(b) AND not(b = true)`. Declining
equality under NOT on a nullable column left that residual unpushed, so
CometFuzzIcebergSuite found no residual for its boolean column.

A NULL value fails a NOT_NULL conjunct, and with it the whole AND, in
Spark and in the native reader alike. Treat a column checked that way as
non-nullable within the AND, so the null-safe concern no longer applies.
…cans

Extract referencedColumn so the residual converter and notNullColumns share
the bare-reference check. Share the residual predicate loop of the new native
tests, and assert that `id != 1` and `b = false` are still pushed while
`NOT (id <=> 1)` and `b IS NOT TRUE` are not.

CometFuzzIcebergSuite samples its equality literal from the data. Skip zero
as well as NaN, since floating-point comparisons with zero are no longer
pushed.

Note in the Iceberg guide that `!=` is not affected by the negated-equality
rule.
@rich7420
rich7420 marked this pull request as ready for review October 26, 2026 05:18

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

Each case the serde now declines matches a real gap between Spark and the pinned iceberg-rust, which also installs the residual as a Parquet RowFilter, so a looser residual is not safe here as it is for Iceberg Java. Three points on the tests, a reference and a helper.

* unpushed.
*/
private def checkResidualPredicates(table: String, predicates: Seq[String]): Unit =
for (predicate <- predicates; prefix <- Seq("", "l IS NOT NULL AND ")) {

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.

With the prefix, the residual never converts. The AND branch needs both sides, and the null check on the array column l is declined by isComplexType, so each prefixed query reruns the unprefixed one with nothing pushed. The docstring says as much. That doubles the query count of the three new tests without reaching the code this PR changes. The prefix starts to matter with partial conjuncts in #6847. Could the prefix and the l column move there, so this PR and its backports only run the cases they fix?

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.

Removed here; #6847 retains the partial-conjunct tests.

// Spark sorts every NaN above all numbers. The native row filter sorts negative NaNs
// below them, and iceberg-rust prunes row groups on min/max statistics that can
// leave NaN out, or hold it as both when parquet-mr 1.9 or earlier starts a row
// group with NaN (apache/iceberg-rust#3494).

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.

apache/iceberg-rust#3494 was closed on 2026-10-23 by apache/iceberg-rust#3502 ("treat NaN row group bounds as might-match for inequalities"), which is 54 commits past the pinned 1f3bc34. Could the comment name #3502, so the next pin bump knows which part of this exclusion it can revisit? The fix is for row-group bounds, so the page index side would still need checking then.

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.

Referenced #3502 and retained the page-index caveat.

def promotedColumns(table: Any, schema: Any): Set[String] = {
import scala.jdk.CollectionConverters._
val findField = getMethod(schema.getClass, "findField", classOf[Int])
def typeOf(field: Any): String = getMethod(field.getClass, "type").invoke(field).toString

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.

fieldTypeOf at line 1416 already does this lookup, so this can be fieldTypeOf(field).toString, the way this method already reuses fieldIdOf.

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.

Reused fieldTypeOf(field).toString.

@rich7420

Copy link
Copy Markdown
Contributor Author

@comphead could you take another look? thanks!

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

Thanks for splitting this out of #6847. I traced the new underNot and AND non-null handling by hand and the polarity reasoning holds. A NOT_NULL conjunct is safe even under a NOT, because rewrite_not turns it into an IsNull disjunct that keeps the NULL row, as Spark does. I also checked the pinned iceberg-rust row_group_metrics_evaluator.rs. Its eq and in already treat NaN row-group bounds as might-match, so leaving equality and IN with non-zero float literals eligible looks sound. The regression tests compare against Spark and cover the signed-zero, null-safe and promotion cases well.

I have a few things I would like addressed before merge.

Incomplete schema history and the v3 date promotion. In IcebergReflection.promotedColumns, promotable only covers long, double and decimal(. The v3 spec also allows date to timestamp and timestamp_ns. When the history is complete, comparing type strings catches it. When the history has a gap, would a TimestampNTZ column still push range and IN predicates against files that store date? Comet pushes TimestampNTZType literals, so this looks like the same bug class as apache/iceberg-rust#3122. Could promotable include timestamp and timestamp_ns, with a case in "an incomplete schema history treats every promotable column as promoted"?

The user guide undersells that rule. iceberg.md says the incomplete-history exclusion applies to "every BIGINT and DOUBLE column", but the code also covers DECIMAL (and the timestamp types if the previous point is accepted). Could the sentence list them?

The cost of the float exclusion. This trades native pruning on FLOAT and DOUBLE range filters for correctness, and I agree with that trade while the page-index and row-filter semantics are unverified. Those filters are common though, and after this change they get no native pruning at all, even on files with no NaN or signed zero. Could the PR description say that these filters now rely on the post-scan filter? Could you also open an issue to restore range pruning once iceberg-rust#3502 plus the page-index and row-filter side are verified, and reference it from the comment in CometIcebergNativeScan.scala? If #5883 already covers it, linking that is fine.

Time travel. promotedColumns is also called with metadata.scanSchema for VERSION AS OF, but only the unit test exercises that path. Could you add a VERSION AS OF read after the INT to BIGINT promotion in "literal residuals on promoted columns keep rows from older files", compared against Spark?

…is incomplete

Iceberg format v3 allows promoting a date column to timestamp or
timestamp_ns. When snapshot expiration has removed older schemas, such
a column can still have data files that store dates, so treat it as
promoted, as long, double and decimal columns already are.

Add a VERSION AS OF read to the promoted-column regression test. Iceberg
cannot plan a filter on a dropped column, so the test drops id and adds
it back as a new INT column first. Only the snapshot's schema then shows
that the id this read filters on was promoted. Correct the code comment
that said the scan schema covers a dropped column.

The Iceberg guide now lists DECIMAL and TIMESTAMP_NTZ in the
incomplete-history rule, says the native scan does not prune row groups
for these predicates, and links apache#7588, which tracks restoring FLOAT
and DOUBLE range pruning.
@rich7420

Copy link
Copy Markdown
Contributor Author

@andygrove thanks for the review! Addressed in e1afd07:

  • promotable now includes timestamp and timestamp_ns. The incomplete-history test adds a timestamp column and a timestamptz column. Only the timestamp column is treated as promoted. In Iceberg 1.11, TypeUtil.isPromotionAllowed doesn't yet allow promotion from date to timestamp, so Spark can't run that ALTER. Only the unit test covers this case.
  • The guide now lists BIGINT, DOUBLE, DECIMAL and TIMESTAMP_NTZ.
  • Preserve primitive Iceberg pruning beside unsupported complex null conjuncts #5883 only covers partial conjuncts. I opened Restore native row-group pruning for FLOAT and DOUBLE range filters in Iceberg scans #7588 to track restoring float range pruning. The comment in CometIcebergNativeScan.scala and the guide link to it. The description now says these filters rely on the filter above the scan.
  • The promoted-column test now drops id and adds it back as a new INT column, then reads the last snapshot with VERSION AS OF. A plain drop isn't enough, because Iceberg can't plan a filter on a dropped column. Only the scan schema still shows that the old id was promoted. With the table schema alone, id > 0 returns 2 of the 6 rows.

@rich7420

Copy link
Copy Markdown
Contributor Author

@andygrove when you have a sec, could you add run-iceberg-tests here? I can't add labels myself, and the Iceberg jobs are skipped on this PR for now. Thanks!

@andygrove

Copy link
Copy Markdown
Member

@andygrove when you have a sec, could you add run-iceberg-tests here? I can't add labels myself, and the Iceberg jobs are skipped on this PR for now. Thanks!

Done

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

Labels

area:Iceberg area:scan Parquet scan / data reading bug Something isn't working run-iceberg-tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native Iceberg scan drops NULL rows for NOT (col <=> value) Native Iceberg scan drops -0.0 rows for floating-point predicates

3 participants