Skip to content

fix(spark): reject order-breaking casts and negative factors in data ... - #19475

Open
voonhous wants to merge 1 commit into
apache:masterfrom
voonhous:fix-19445-order-breaking-casts
Open

fix(spark): reject order-breaking casts and negative factors in data ...#19475
voonhous wants to merge 1 commit into
apache:masterfrom
voonhous:fix-19445-order-breaking-casts

Conversation

@voonhous

@voonhous voonhous commented Aug 3, 2026

Copy link
Copy Markdown
Member

...skipping

Describe the issue this Pull Request addresses

Closes #19445

Data skipping translates a filter over a transformed column (e.g. cast(a as int) > 100) into a filter over the column's min/max stats by re-applying the transformation to a_minValue / a_maxValue. This is only sound when the transformation preserves ordering, but the matcher accepted several order-breaking shapes:

  • Narrowing numeric casts: isCastPreservingOrdering only rejected String <-> Numeric and returned true for everything else. cast(4294967297L as int) wraps to 1 in non-ANSI mode, so [cast(min), cast(max)] is not a valid bound and files containing matching rows are pruned -- silently missing rows.
  • Collation-changing string casts (Spark 4): StringType is collation-parameterized on Spark 4, so a collated string fell through the stable-identifier case (StringType, ...) arms to case _ => true, even though a different collation sorts differently.
  • Negative multiply/divide factors: the Multiply/Divide arms of OrderPreservingTransformation matched any operand, including negative literals, which reverse ordering (a * -1 swaps min and max).

Summary and Changelog

Order-breaking cast/multiply/divide shapes no longer translate into column-stats filters; they fall back to TrueLiteral (no pruning for that predicate), so queries return correct results at the cost of scanning more files.

  • HoodieSparkTypeUtils.isCastPreservingOrdering:
    • keeps the String <-> Numeric rejections first (now type patterns, so Spark 4 collated strings are also rejected); they must stay ahead of the numeric arm because Cast.canUpCast treats atomic-to-string casts as legal up-casts
    • string -> string requires the identical StringType (collation-aware equality on Spark 4; trivially true on Spark 3)
    • numeric -> numeric requires Cast.canUpCast(from, to), rejecting narrowing casts; conservative for weakly monotonic casts like double -> float, which only costs pruning opportunity
  • BaseHoodieCatalystExpressionUtils.OrderPreservingTransformation: the Multiply/Divide arms now require a strictly positive numeric literal factor (isPositiveNumericLiteral). Operand positions are unchanged (attr * lit, lit * attr, attr / lit). The optimizer folds constant factors to literals before data skipping runs, so requiring a plain Literal costs nothing in practice.
  • Tests:
    • TestHoodieCatalystExpressionUtils: flipped the TODO(#19445) pinned assertion (narrowing cast now returns None); new tests for the canUpCast boundaries, the multiply/divide guard, and a reflection-based collation test (assumption-skipped on Spark 3 profiles, since collation APIs only exist on Spark 4)
    • TestDataSkippingUtils: new parameterized source with the issue repro (CAST(A AS INT) > 100 over a file with A_maxValue = 4294967297L must not be pruned) plus widening-cast and positive-factor controls proving legitimate pruning still works

Impact

No public API change. Correctness fix for data skipping: queries whose filters contain narrowing casts, collation-changing casts, or negative constant factors previously could silently miss rows; they now skip nothing for those predicates. Queries that relied on the unsound pruning will scan more files.

Risk Level

low. Every newly rejected shape falls back to TrueLiteral (no pruning), so the failure direction is only lost pruning opportunity, never lost rows. The touched files have a single shared copy compiled under all supported Spark profiles (3.3 - 4.2), and Cast.canUpCast exists on all of them.

Documentation Update

none

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

…skipping

Data skipping translated filters over cast/multiply/divide transformations by
re-applying them to column min/max stats, assuming they preserve ordering.
Narrowing numeric casts wrap around in non-ANSI mode, collation-changing
string casts re-sort on Spark 4, and negative multiply/divide factors reverse
ordering, so the translated bounds could silently prune files containing
matching rows.

- isCastPreservingOrdering now requires Cast.canUpCast for numeric pairs and
  identical string types (collation-aware on Spark 4) for string pairs
- Multiply/Divide transformations now require a strictly positive numeric
  literal factor

Non-order-preserving shapes fall back to no pruning for that predicate.

Fixes apache#19445
@voonhous voonhous changed the title fix(spark): reject order-breaking casts and negative factors in data … fix(spark): reject order-breaking casts and negative factors in data skipping Aug 3, 2026
@voonhous voonhous changed the title fix(spark): reject order-breaking casts and negative factors in data skipping fix(spark): reject order-breaking casts and negative factors in data ... Aug 3, 2026

@hudi-agent hudi-agent 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.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR tightens the data-skipping order-preservation matcher so order-breaking transforms (narrowing numeric casts, collation-changing string casts on Spark 4, and negative/zero multiply/divide factors) fall back to no pruning instead of silently dropping matching files. The logic traces cleanly: the cast guard uses Cast.canUpCast and keeps the String↔Numeric arms ahead of the numeric arm, isPositiveNumericLiteral correctly rejects negative/zero/null/non-literal and NaN factors, and Divide only matches the numerator position. Every change moves in the conservative direction (less pruning, never incorrect pruning), and the added tests reproduce the original bug. No correctness issues found. A few style/readability suggestions in the inline comments. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. One minor over-commenting nit on isPositiveNumericLiteral; the rest of the diff is clean and readable.

cc @yihua

// statically (the optimizer folds constant factors to literals before data skipping runs).
// Typed null literals carry a null value and fail the value match
private def isPositiveNumericLiteral(expr: Expression): Boolean = expr match {
case Literal(value, _) => value match {

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: the block comment above could be trimmed — the method name already says "positive numeric literal", and the case _ => false arm makes the null/non-literal behavior self-evident. The most non-obvious part (why non-literals are excluded) could stand on its own as a one-liner: // non-literal operands can't be evaluated statically; the optimizer folds constant expressions before data skipping runs.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Aug 3, 2026
@codecov-commenter

codecov-commenter commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.84%. Comparing base (b65bc18) to head (c5f1d5e).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
.../spark/sql/BaseHoodieCatalystExpressionUtils.scala 50.00% 0 Missing and 7 partials ⚠️
...la/org/apache/spark/sql/HoodieSparkTypeUtils.scala 50.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19475      +/-   ##
============================================
- Coverage     77.00%   76.84%   -0.17%     
+ Complexity    33873    33756     -117     
============================================
  Files          2576     2575       -1     
  Lines        143463   143385      -78     
  Branches      17589    17633      +44     
============================================
- Hits         110476   110181     -295     
- Misses        24716    24935     +219     
+ Partials       8271     8269       -2     
Components Coverage Δ
hudi-common 82.28% <ø> (+0.01%) ⬆️
hudi-client 81.83% <ø> (-0.28%) ⬇️
hudi-flink 83.96% <ø> (+0.02%) ⬆️
hudi-spark-datasource 74.40% <50.00%> (-0.58%) ⬇️
hudi-utilities 73.63% <ø> (-0.04%) ⬇️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.49% <ø> (-0.04%) ⬇️
hudi-sync 70.87% <ø> (-0.03%) ⬇️
hudi-io 79.60% <ø> (-0.10%) ⬇️
hudi-timeline-service 83.44% <ø> (-0.40%) ⬇️
hudi-cloud 64.00% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 49.53% <0.00%> (+0.02%) ⬆️
flink-integration-tests 48.80% <ø> (+<0.01%) ⬆️
hadoop-mr-java-client 43.76% <ø> (+0.34%) ⬆️
integration-tests 13.58% <0.00%> (+<0.01%) ⬆️
spark-client-hadoop-common 48.68% <ø> (-0.01%) ⬇️
spark-java-tests 51.10% <50.00%> (-0.31%) ⬇️
spark-scala-tests 47.24% <5.55%> (-0.16%) ⬇️
utilities 36.57% <5.55%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...la/org/apache/spark/sql/HoodieSparkTypeUtils.scala 55.55% <50.00%> (-15.88%) ⬇️
.../spark/sql/BaseHoodieCatalystExpressionUtils.scala 44.44% <50.00%> (+4.90%) ⬆️

... and 45 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hudi-bot

hudi-bot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

case Multiply(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef)
case Multiply(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef)
case Divide(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef)
case Multiply(OrderPreservingTransformation(attrRef), factor, _) if isPositiveNumericLiteral(factor) => Some(attrRef)

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.

A strictly positive factor is not sufficient to make multiplication order-preserving because integral arithmetic wraps in non-ANSI mode. For a bigint file containing {51, Long.MaxValue}, A * 2L > 100 is accepted here and translated using the transformed max. Spark evaluates Long.MaxValue * 2L as -2, so the file is pruned, although 51 * 2L = 102 matches the original predicate.

Please restrict multiplication to analyzed types/factors for which overflow cannot break monotonicity, or conservatively reject the unsafe integral/decimal cases. This exact file-stats scenario should be added as a regression test.

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

Labels

size:M PR with lines of changes in (100, 300]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data skipping treats order-breaking casts as order-preserving (isCastPreservingOrdering)

5 participants