Skip to content

fix(spark): keep to_date/to_timestamp data skipping working after ReplaceExpressions - #19474

Open
voonhous wants to merge 3 commits into
apache:masterfrom
voonhous:issue-19446-to-date-data-skipping
Open

fix(spark): keep to_date/to_timestamp data skipping working after ReplaceExpressions#19474
voonhous wants to merge 3 commits into
apache:masterfrom
voonhous:issue-19446-to-date-data-skipping

Conversation

@voonhous

@voonhous voonhous commented Aug 3, 2026

Copy link
Copy Markdown
Member

Describe the issue this Pull Request addresses

Closes #19446

Since SPARK-38240, ParseToDate/ParseToTimestamp are RuntimeReplaceable, so the optimizer rewrites them into Cast/GetTimestamp trees before any filter reaches file pruning. The order-preserving whitelist only matched the pre-replacement shapes, so to_date/to_timestamp with an explicit format translated to TrueLiteral and data skipping silently pruned nothing. The existing green to_timestamp case in TestDataSkippingUtils only runs OptimizeIn, which is why it never caught this.

Summary and Changelog

  • Match GetTimestamp in the shared order-preserving whitelist, gated for soundness:
    • the format must be a literal, fixed-width, year-first pattern (ORDER_PRESERVING_DATE_FORMATS allowlist): the translation re-applies the parse on top of string min/max column stats, which is only sound when lexicographic ordering of parseable strings agrees with chronological ordering. Non-order-preserving formats (e.g. 'MM/dd/yyyy', variable-width 'yyyy-M-d') fall back to no pruning instead of wrong pruning.
    • failOnError must be false: under ANSI mode (the Spark 4 default) probing a stat value that does not parse would throw from the index lookup and could fail queries whose files are pruned by other predicates. Under ANSI the arm refuses to match and translation falls back to keeping every file.
    • It is a typed match because the constructor arity differs between Spark 3.x and 4.x while left()/right() are stable, so no per-version changes are needed.
  • Make transformed index-lookup bounds null-tolerant in DataSkippingUtils (Coalesce(bound, true)): whitelisted transformations can be partial functions of the source column (GetTimestamp parsing a string returns null for unparseable values), in which case f(max(col)) != max(f(col)) and a null bound must keep the file rather than silently prune it. Bare-attribute translations are unchanged.
  • Delete the per-version ParseToDate/ParseToTimestamp hook (unapplyOrderPreservingDateParsing and its four overrides): its only production consumer receives post-optimizer dataFilters (Hudi's pruning rule is injected via injectOptimizerRule, after the FinishAnalysis batch), so the analysis-time shapes never reach it. The expression-index path has its own matcher with the same optimizer-boundary problem; tracked separately in [BUG] Expression index with to_date/to_timestamp + format is never selected by real queries; test harness feeds shapes production never produces #19480.
  • Add a TestDataSkippingUtils entry point that runs the resolved filter through the full session optimizer before translating (shared HoodieDummyExpressionHolder in testutils, see SPARK-44219), covering: the replaced to_date/to_timestamp shapes, the Cast-retaining date_add composite, negative-format rows (no pruning), an unparseable-stat row (null-tolerant bound keeps the file), and an ANSI-mode test (no throw, no pruning).
  • Pin the replacement shapes and the gate directly in TestHoodieCatalystExpressionUtils, built via replacement() under an explicitly pinned ANSI setting so they compile and behave the same on every profile.

Impact

Data skipping works again for to_date(col, fmt)/to_timestamp(col, fmt) predicates with order-preserving format patterns, without introducing wrong-results pruning for non-order-preserving formats, unparseable stat values, or ANSI mode. Note that under Spark 4 default config (ANSI on) these predicates intentionally do not prune: safe fallback, not wrong results. No API changes.

Risk Level

low. The arm only fires for allowlisted literal formats with failOnError off; everything else falls back to the pre-existing no-pruning behavior. The null-tolerant bound wrapping only ever keeps more files than before. Covered by the new optimizer-realistic tests, negative-format tests, and the ANSI test.

Follow-ups filed during review: #19479 (left(col, n) whitelist arm is dead for the same RuntimeReplaceable reason), #19480 (expression-index matcher has the same optimizer-boundary bug), #19481 (unix_timestamp parity and format-monotonicity holes in the live arms).

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

…real optimizer

The existing to_timestamp case in TestDataSkippingUtils only runs OptimizeIn, so the
RuntimeReplaceable ParseToDate/ParseToTimestamp nodes survive into the translation.
On a real read path the FinishAnalysis batch (ReplaceExpressions) rewrites them into
Cast/GetTimestamp trees before filters reach file pruning.

Add an entry point that runs the full session optimizer over the resolved filter
(via a HoodieDummyExpressionHolder fork, see SPARK-44219) and asserts on the
translated pruning behavior. The no-format variants still prune via the Cast
whitelist arm; the with-format variants currently translate to TrueLiteral and
prune nothing, which is the gap tracked by apache#19446.
…ceExpressions

The order-preserving whitelist only matched the analysis-time
ParseToDate/ParseToTimestamp shapes via the per-version hook. Since SPARK-38240
those nodes are RuntimeReplaceable, and the optimizer rewrites them into
GetTimestamp trees before filters reach file pruning, so with an explicit format
the translation fell through to TrueLiteral and pruned nothing.

Match GetTimestamp in the shared whitelist with a typed pattern (its constructor
arity differs across Spark versions but left() is stable), flip the new
optimizer-realistic test expectations to pruning, and pin the replacement shapes
directly in TestHoodieCatalystExpressionUtils. The per-version hook arms stay:
the expression-index and partition-stats paths still inspect un-replaced shapes.

Fixes apache#19446
@voonhous voonhous changed the title Issue 19446 to date data skipping fix(spark): keep to_date/to_timestamp data skipping working after ReplaceExpressions 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! The PR restores data skipping for to_date/to_timestamp predicates with explicit formats by matching the post-ReplaceExpressions GetTimestamp shape in the order-preserving whitelist, and backs it with optimizer-realistic tests. The core recursion (GetTimestampgt.left, and to_date's Cast(GetTimestamp(...) as date) composing through the existing Cast arm) looks correct, and the typed match is a sensible way to handle the cross-version arity differences. One edge case around non-order-preserving formats is worth double-checking in the inline comment. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. A few naming and simplification suggestions below — code is generally clean and the new comments are helpful.

@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 95.12195% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.96%. Comparing base (d98f2f1) to head (b993e7d).
⚠️ Report is 11 commits behind head on master.

Files with missing lines Patch % Lines
.../spark/sql/BaseHoodieCatalystExpressionUtils.scala 90.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master   #19474   +/-   ##
=========================================
  Coverage     76.96%   76.96%           
+ Complexity    33850    33840   -10     
=========================================
  Files          2575     2575           
  Lines        143372   143383   +11     
  Branches      17572    17569    -3     
=========================================
+ Hits         110349   110360   +11     
- Misses        24758    24763    +5     
+ Partials       8265     8260    -5     
Components Coverage Δ
hudi-common 82.27% <ø> (+<0.01%) ⬆️
hudi-client 81.83% <ø> (+0.01%) ⬆️
hudi-flink 83.97% <ø> (+<0.01%) ⬆️
hudi-spark-datasource 75.11% <95.12%> (+<0.01%) ⬆️
hudi-utilities 73.63% <ø> (+<0.01%) ⬆️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.48% <ø> (-0.02%) ⬇️
hudi-sync 70.87% <ø> (ø)
hudi-io 79.60% <ø> (ø)
hudi-timeline-service 83.44% <ø> (-0.30%) ⬇️
hudi-cloud 64.00% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 49.53% <39.02%> (+<0.01%) ⬆️
flink-integration-tests 48.80% <ø> (+<0.01%) ⬆️
hadoop-mr-java-client 43.70% <ø> (-0.07%) ⬇️
integration-tests 13.58% <0.00%> (+<0.01%) ⬆️
spark-client-hadoop-common 48.67% <ø> (-0.01%) ⬇️
spark-java-tests 51.34% <95.12%> (+<0.01%) ⬆️
spark-scala-tests 47.41% <63.41%> (-0.03%) ⬇️
utilities 36.61% <53.65%> (+0.03%) ⬆️

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

Files with missing lines Coverage Δ
.../org/apache/spark/sql/hudi/DataSkippingUtils.scala 78.41% <100.00%> (+0.38%) ⬆️
...ark/sql/HoodieSpark33CatalystExpressionUtils.scala 66.66% <ø> (-8.34%) ⬇️
...ark/sql/HoodieSpark34CatalystExpressionUtils.scala 55.55% <ø> (-13.68%) ⬇️
...ark/sql/HoodieSpark35CatalystExpressionUtils.scala 55.55% <ø> (-13.68%) ⬇️
...park/sql/HoodieSpark4CatalystExpressionUtils.scala 55.55% <ø> (-13.68%) ⬇️
.../spark/sql/BaseHoodieCatalystExpressionUtils.scala 56.66% <90.00%> (+17.13%) ⬆️

... and 14 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.

… the dead date parsing hook

Self-review follow-up on the GetTimestamp whitelist arm:

- Match GetTimestamp only when the format is a literal fixed-width
  year-first pattern: the translation re-applies the parse over string
  min/max stats, which is only sound when lexicographic order of
  parseable strings agrees with chronological order. Also require
  failOnError off so ANSI-mode index lookups can never throw on an
  unparseable stat value.
- Wrap transformed index-lookup bounds in Coalesce(bound, true):
  partial transformations (string parsing) return null on unparseable
  stats, in which case f(max(col)) != max(f(col)) and a null bound must
  keep the file, not prune it.
- Delete unapplyOrderPreservingDateParsing and its four per-version
  overrides: the whitelist's only production consumer receives
  post-optimizer dataFilters (the pruning rule is injected after the
  FinishAnalysis batch), so the analysis-time ParseToDate/
  ParseToTimestamp shapes never reach it. The expression-index
  matcher's copy of the same problem is tracked in apache#19480.
- Move HoodieDummyExpressionHolder to testutils and reuse it from
  TestBucketIndexSupport and TestDataSkippingUtils.
- Tests: negative-format rows (MM/dd/yyyy, yyyy-M-d), unparseable-stat
  row, Cast-retaining date_add row, ANSI-mode lookup test, and gate
  pins for the replacement shapes; ANSI is pinned off in the positive
  fixtures because Spark 4 defaults it on.

Follow-ups filed during self-review: apache#19479 (dead left arm), apache#19480
(expression-index optimizer boundary), apache#19481 (whitelist tightening).
@github-actions github-actions Bot added size:L PR with lines of changes in (300, 1000] and removed size:M PR with lines of changes in (100, 300] labels Aug 3, 2026
@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

@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 the thorough revisions here — both prior findings are addressed. The missing-period nit is moot since that Scaladoc block was fully rewritten (and now ends with a period), and the order-preservation question is substantively resolved: the new ORDER_PRESERVING_DATE_FORMATS whitelist, the !gt.failOnError gate, and the null-tolerant Coalesce(bound, TrueLiteral) wrapping together restrict pruning to sound formats and safely keep files when a stat value is unparseable or ANSI would throw. I traced the three-valued-logic soundness of wrapTransformedBoundNullSafe and it holds (null→keep is always safe; the surviving null AND false→prune case is independently valid), and the added tests for divergent formats, variable-width, ANSI, and unparseable-max are good coverage. One behavior change worth double-checking is noted inline (dropping the analysis-time ParseToDate/ParseToTimestamp matching that master had). Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here.

case Some(child) => unapply(child)
case None => None
}
case _ => None

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.

🤖 Removing unapplyOrderPreservingDateParsing means raw ParseToDate/ParseToTimestamp (analysis-time shapes) are no longer matched here — origin/master did match them, and the earlier version of this PR documented them as "still inspected by the expression-index and partition-stats paths." I traced that the expression-index path unwraps ParseTo* via attributeFetcher before the matcher sees it, and the read path sees post-ReplaceExpressions shapes, so this looks safe to me. Could you confirm you verified no path feeds a raw ParseTo* into translateIntoColumnStatsIndexFilterExpr and thereby silently loses pruning that worked on master?

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

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

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Verify to_date/to_timestamp data skipping still works after ReplaceExpressions

4 participants