Skip to content

fix: make wide date-to-timestamp casts safe - #5457

Open
sunchao wants to merge 11 commits into
apache:mainfrom
sunchao:dev/chao/codex/fix-wide-date-timestamp
Open

fix: make wide date-to-timestamp casts safe#5457
sunchao wants to merge 11 commits into
apache:mainfrom
sunchao:dev/chao/codex/fix-wide-date-timestamp

Conversation

@sunchao

@sunchao sunchao commented Aug 24, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #5456. Follow-up to #5443.

Why are the changes needed?

Native make_date now accepts Spark's wide date range, but its timestamp consumers still assume narrower ranges. For example, casting make_date(300000, 6, 15) to TIMESTAMP_NTZ silently wraps the microsecond count in release builds instead of throwing Spark's ArithmeticException: long overflow. Casting make_date(262143, 1, 1) to TIMESTAMP in UTC panics inside chrono even though the timestamp fits in a signed 64-bit value.

The fix needs to preserve the expanded date range while making both casts safe. Restoring the old make_date null restriction would lose the compatibility improvement from #5443.

Checked casts must also ignore values that are not logically present. A native IF(flag, s, NULL) can leave an overflowing date in the child buffer of a null struct or map. Casting that null container must return null, not evaluate the hidden date and throw. Sliced lists and maps can likewise retain unused child values before or after their visible rows.

What changes were proposed in this PR?

Use direct epoch-day arithmetic for NTZ, UTC, and fixed whole-minute timezone offsets. Resolve fixed Java aliases through Spark and serialize a canonical UTC or +/-HH:MM offset, so spellings such as GMT, Etc/UTC, Z, UTC+00:00, GMT+05:30, and Etc/GMT+8 stay native, including inside nested casts. Apply the offset in seconds before checked multiplication to microseconds, so the final range check includes the timezone adjustment. Overflow becomes a plain ArithmeticException in both legacy and ANSI modes. Nullable scalar TRY_CAST returns null for overflowing rows without discarding valid rows in the same batch.

Checked arithmetic must also run only for rows Spark would consume. A whole-batch NTZ cast can otherwise throw for a later row below LIMIT, or a later semi/anti join candidate after an earlier match. Non-TRY DATE to TIMESTAMP_NTZ casts now keep the affected operator in Spark when the input date range is not provably safe. Both native serialization and the JVM batch dispatcher enforce this restriction, including nested casts and enclosing expressions.

This deliberately includes ordinary date columns: without a range proof, their projections, filters, or join conditions use Spark row execution. Safe literal/null dates and date_from_unix_date of byte/short inputs remain eligible for native execution. Nullable scalar TRY_CAST, fixed-zone DATE to TIMESTAMP, and ordinary widening retain their existing routes and guards. The checked Rust arithmetic and hidden-container handling are unchanged. No new timing is claimed for the NTZ fallback; the benchmark tables below retain their stated earlier revisions and scopes.

V1 Parquet schema conversion needs the same protection even when there is no Catalyst Cast in the query. Reading a physical DATE column as TIMESTAMP_NTZ can evaluate an overflowing value in a batch Spark would never consume. The native reader now declines scans that request a top-level NTZ data column, and the Spark-to-Arrow rule blocks both row and columnar bridges for those scans. Preserving each Spark batch in the columnar bridge is insufficient: a native filter can request another batch before returning its first selected row to LIMIT.

This is a conservative fallback for ordinary NTZ files as well, because the logical scan schema cannot establish each file's physical type. It applies even with spark.comet.convert.parquet.enabled=true or native scans disabled. Pruned NTZ fields, partition-only NTZ columns, nested fields using the separate conversion path, and native Iceberg scans are unaffected. Spark 3.x rejects the DATE-to-NTZ reader conversion; Spark 4.0+ supports it and can throw while decoding a consumed batch. Using Spark's reader without either bridge preserves both behaviors. Native kernel and operator tests now use fully materialized or nested Parquet fixtures where necessary, with their native execution assertions retained; separate tests assert the reader fallback. No new NTZ performance measurement is claimed.

Named regions and fixed offsets with seconds use Spark's existing JVM codegen dispatcher, or Spark row execution when the dispatcher is disabled. This keeps historical and far-future timezone rules with Spark. A chrono range guard cannot preserve valid Spark results: +262143-01-01 in Los Angeles is a valid timestamp outside chrono's range, and the pinned chrono-tz version is one hour wrong for Los Angeles on 2101-07-04, inside that range. Native parsing also drops offset seconds, so subminute offsets cannot use the minute-offset path. This routing applies to ordinary dates too. In the local ordinary-date column benchmark, the revised head's Los Angeles query means averaged 12.3% higher through the dispatcher and 21.5% higher with dispatch disabled than the exact PR base, which ran both cases natively. UTC remained native and its average query means fell 39–45%. These are workload-specific whole-query costs; the separate benchmark protocol and raw per-JVM results are below.

Spark's nullability inference can miss date-to-timestamp overflow in TRY_CAST. Non-nullable scalar and complex TRY casts containing this conversion therefore stay on Spark's row path, avoiding nulls in non-nullable Arrow fields or map keys. The codegen eligibility check also catches these casts inside a larger dispatched expression.

Before recursively applying a potentially fallible cast to a struct, list, or map with null rows or unused backing values, use Arrow take with nullable identity indices. This propagates struct nulls to children and compacts list/map entries while preserving row validity, field metadata, and non-nullable map keys. A conservative whitelist avoids this copy for signed integer widenings and unchanged fixed-width leaves, including struct trees, beneath the current container. Structs can then reuse their input, as can lists/maps with sparse null rows and few hidden child values. Nested lists/maps and unchanged variable-width children retain the existing compaction path, avoiding large hidden allocations and buffer retention. Lists/maps with unused backing values or dense null rows still compact. Scalar casts, whole-cast identity, and contiguous containers without nulls keep their existing path. Visible overflowing dates still throw in legacy and ANSI modes; TRY behavior and admission remain unchanged. The native reuse optimization was measured separately against the original PR head, not the PR base: the 13 targeted nullable Int32 to Int64 struct and sparse list/map shapes used 12–92% less batch time, with no reproducible regression among the 46 measured shapes after repeating initial control flags.

How was this PR tested?

Reader follow-up validation (2026-08-28): the full Spark 4.1.3 Maven reactor passed 221 selected tests, with 3 expected version-dependent cancellations and 8 existing ignored tests. This includes the entire native cast and error-converter suites, all reader regressions, and the affected timestamp expression, dispatcher, dictionary-sort, and NTZ SQL fixtures. The previously failing short-circuiting cast test now reaches the cast admission guard through a native NTZ comparison fixture and passes with its original assertions retained.

The focused reader/NTZ selection also passed 28 tests per profile on Spark 3.4.3, 3.5.9, 4.0.4, and 4.2.0, with three expected version-dependent cancellations per profile. Spark 3.x checks the precise unsupported-schema error; Spark 4.x checks returned rows or consumed overflow. The matrix includes both batch-size directions, filtering below LIMIT, AQE, both bridge paths, disabled native scans, and genuine/pruned/partition NTZ controls. Spark 3.x used JDK 17 and Spark 4.x used JDK 21.

Broader validation passed 145 tests across the complete fuzz, aggregate-fuzz, math-fuzz, map, and Parquet-writer suites. All 33 temporal tests also passed on the unchanged temporal suite, shared helpers, and production sources. Additional SQL checks reported 130 successful cases; one Spark-3.4-only case is intentionally bypassed on Spark 4.1, so 129 SQL bodies were exercised. Native execution assertions remain in place. The final cast run supersedes the earlier cast-fixture failure; earlier compile/oracle attempts and the unrelated passing temporal results were retained separately.

The changed JVM sources were rebuilt, and actual loaded and bundled native libraries were checked against the unchanged native source tree and SHA-256 22b7310faaa011b4be25807bcba0b2c8696733463566c01e9695939342a3c855. No native source changed, and this follow-up does not claim a new native build or benchmark measurement. These are local checks; hosted CI for the new commit is pending. Earlier validation and benchmark results below retain their original revisions and scopes.

Spotless, Scalastyle, and CI's syntactic Scalafix 0.14.6 check passed on the final sources. The only edit after runtime validation was a one-line test-comment correction; the full reactor recompiled the test sources and repeated the style checks without rerunning tests.

Review fixes at 10a06cc64 (with the redundant-interpolator lint correction in 129cc1646): the full Spark 4.1.3 JVM reactor passed 201 tests, with 8 pre-existing ignored tests and no failures. This includes the entire CometNativeCastSuite, SparkErrorConverterSuite, and ten NTZ SQL-file configurations. The changed sources were rebuilt, and the actual loaded and bundled native library matched this PR's unchanged native source; no native rebuild was needed for this follow-up.

Six new tests cover skipped and consumed overflows in projections, filters, semi/anti joins, and nested/enclosing casts with ANSI and batch dispatch independently enabled or disabled. They assert Spark row execution for unsafe casts, continued native scans, matching consumed-overflow errors, and native nullable TRY_CAST and byte/short controls. The SQL fixture checks the specific fallback reason across four timezones and both dispatch settings. The unchanged-head test file reproduces the redundant-interpolator failure under CI's Scalafix 0.14.6, while all patched Spark sources pass that check. The full reactor also passes Spotless and Scalastyle. These runtime checks use Spark 4.1.3; the new commit's cross-version CI is pending.

Post-merge validation on 3bd44648e (2026-08-27): merged Apache main 525c05b52 while retaining both parents. The only manual conflict was the Cargo benchmark list; the resolution keeps this PR's cast_nested and all upstream benchmark registrations. All 11 other files changed by this PR remain byte-for-byte unchanged from 23ae9bb.

A fresh default-feature release native build and the full Spark 4.1.3/JDK 17 Maven reactor passed. Native library unit tests passed: 35 common, 672 expression, 4 protocol, and 60 shuffle tests (771 total). The 28 focused date/error tests and 8 RSS/protocol/datatype tests passed, including actual JVM callbacks, exception propagation, registration cleanup, local/RSS/legacy plan serialization, and dictionary modes. Both JVM runs loaded the new native library, and the bundled resource matched its SHA-256. All-target expression Clippy, Rust formatting, JVM style checks, and git diff --check against main passed.

The benchmark tables below remain pinned to the measured c54a5310 implementation and their stated base revisions; timings were not rerun after this merge. Those native libraries, JVM classes, benchmark binaries, and results were preserved separately.

Review follow-up validation before the main merge (2026-08-27):

  • Native expression/common tests: 690 passed, including input-buffer reuse, sliced/empty/nested results, dense hidden-child protection, visible errors, all three evaluation modes, and all 2,161 minute offsets. New regressions verify that a null struct, list, or map hides 65,536 nested list values without widening them, and that an unchanged string sibling does not retain 65,536 hidden bytes. Both new test functions failed against the initial follow-up optimization and pass with the conservative guard.
  • A fresh default-feature release native library was rebuilt from the final guard implementation before the Spark run; the library copied into the JVM resources matched its SHA-256. The full Spark 4.1.3 Maven reactor passed with all 28 selected CometNativeCastSuite DateType to and SparkErrorConverterSuite tests green, including timezone serialization and nested alias routing.
  • An earlier full cast/error run had 184 passing tests, one failure, and 8 existing ignored tests. The failure was a new test assertion expecting native execution for the already unsupported direct ARRAY<DATE> to ARRAY<TIMESTAMP> shape. The test now uses the supported array-of-struct shape; the affected 28 tests passed afterward. The entire 185-test selection was not rerun after this test correction.
  • All-target native Clippy with warnings denied, Rust formatting, Spotless, Scalastyle, and git diff --check passed. Current follow-up JVM execution was on Spark 4.1.3/JDK 17; the older Spark-profile checks listed below were not repeated.
  • Benchmarks were run without concurrent builds or other test/benchmark jobs. Native reuse and full-query DATE routing were compared separately, against their respective pinned revisions.
Native nested-widening benchmark: original PR head to revised head

cast_nested.rs compared unmodified PR head 8b072abb394dfdd389687d60b71b68f8459a8123 with revised implementation c54a5310cc0e991d7a333b2abf4fe94dac0fa786. The original implementation was benchmarked before production edits, and its preserved executable was rerun immediately before the final executable. Both use the same 46-shape harness and repository optimized profile (thin LTO, one codegen unit), on AMD EPYC Milan. Criterion used 30 samples, one second warmup, and two seconds measurement per shape; batches have 8,192 parent rows, list/map widths 4 or 64, and 0/10/25/50/75/99% nominal parent nulls. The sliced cases also include 8,190 and 32 visible parents. Checked DATE and visible-overflow cases exercise the unchanged fallible path.

Shape Original head (us) Revised head (us) Mean change
list_date_to_timestamp/width=4/nulls=0% 183.66 182.70 -0.5%
list_date_to_timestamp/width=4/nulls=10% 371.84 376.67 +1.3%
list_date_to_timestamp/width=4/nulls=25% 311.34 317.06 +1.8%
list_date_to_timestamp/width=4/nulls=50% 208.72 208.25 -0.2%
list_date_to_timestamp/width=4/nulls=75% 107.26 106.21 -1.0%
list_date_to_timestamp/width=4/nulls=99% 8.50 8.31 -2.2%
list_date_to_timestamp/width=64/nulls=0% 2926.13 2852.68 -2.5%
list_date_to_timestamp/width=64/nulls=10% 2838.88 2857.31 +0.6%
list_date_to_timestamp/width=64/nulls=25% 2462.99 2393.51 -2.8%
list_date_to_timestamp/width=64/nulls=50% 1623.42 1557.86 -4.0%
list_date_to_timestamp/width=64/nulls=75% 769.71 794.02 +3.2%
list_date_to_timestamp/width=64/nulls=99% 35.74 36.55 +2.3%
list_date_to_timestamp/visible_overflow 88.30 83.94 -4.9%
list_int_to_long/width=4/nulls=0% 27.26 26.61 -2.4%
list_int_to_long/width=4/nulls=10% 238.80 27.22 -88.6%
list_int_to_long/width=4/nulls=25% 203.10 35.44 -82.5%
list_int_to_long/width=4/nulls=50% 138.37 134.95 -2.5%
list_int_to_long/width=4/nulls=75% 69.82 69.37 -0.7%
list_int_to_long/width=4/nulls=99% 6.83 6.98 +2.2%
list_int_to_long/width=64/nulls=0% 462.23 457.86 -0.9%
list_int_to_long/width=64/nulls=10% 607.74 448.31 -26.2%
list_int_to_long/width=64/nulls=25% 516.08 453.78 -12.1%
list_int_to_long/width=64/nulls=50% 337.95 329.17 -2.6%
list_int_to_long/width=64/nulls=75% 164.16 165.83 +1.0%
list_int_to_long/width=64/nulls=99% 11.43 11.36 -0.6%
list_int_to_long/one_large_null_parent 104.55 102.73 -1.7%
map_int_to_long/width=4/nulls=0% 27.39 26.85 -2.0%
map_int_to_long/width=4/nulls=10% 329.91 27.44 -91.7%
map_int_to_long/width=4/nulls=25% 272.79 35.96 -86.8%
map_int_to_long/width=4/nulls=50% 186.79 189.13 +1.2%
map_int_to_long/width=4/nulls=75% 97.48 96.12 -1.4%
map_int_to_long/width=4/nulls=99% 10.60 10.50 -1.0%
map_int_to_long/width=64/nulls=0% 445.05 456.59 +2.6%
map_int_to_long/width=64/nulls=10% 772.66 445.19 -42.4%
map_int_to_long/width=64/nulls=25% 613.13 464.64 -24.2%
map_int_to_long/width=64/nulls=50% 421.62 413.53 -1.9%
map_int_to_long/width=64/nulls=75% 207.50 199.95 -3.6%
map_int_to_long/width=64/nulls=99% 16.69 16.78 +0.6%
sliced_list_int_to_long/width=4/nulls=10% 236.92 237.13 +0.1%
small_slice_int_to_long/width=64/nulls=0% 4.58 4.44 -3.0%
struct_int_to_long/width=1/nulls=0% 2.58 2.66 +3.3%
struct_int_to_long/width=1/nulls=10% 27.64 2.60 -90.6%
struct_int_to_long/width=1/nulls=25% 25.23 2.68 -89.4%
struct_int_to_long/width=1/nulls=50% 21.87 2.65 -87.9%
struct_int_to_long/width=1/nulls=75% 18.95 2.63 -86.1%
struct_int_to_long/width=1/nulls=99% 15.71 2.63 -83.2%

Six controls initially exceeded Criterion's 1% noise threshold with a positive 95% change interval. Repeating those shapes in the opposite binary order did not reproduce any regression; do not interpret the small control fluctuations as optimization gains. The no-regression finding is limited to these measured native shapes, not the intentional named-zone routing change below.

Repeated control Initial mean change Opposite-order mean change Opposite-order 95% interval
list_date_to_timestamp/width=4/nulls=25% +1.8% +0.14% -0.27% to +0.79%
list_date_to_timestamp/width=64/nulls=75% +3.2% -3.59% -3.93% to -3.24%
list_date_to_timestamp/width=64/nulls=99% +2.3% -1.16% -1.36% to -0.97%
list_int_to_long/width=4/nulls=99% +2.2% -3.32% -5.20% to -1.96%
map_int_to_long/width=64/nulls=0% +2.6% -1.27% -2.60% to -0.01%
struct_int_to_long/width=1/nulls=0% +3.3% -3.15% -5.38% to -0.38%
Spark DATE column benchmark: exact PR base to revised head

CometCastDateToTimestampBenchmark was run unchanged on exact PR base bd7dc601aa9a99289530598d4778c40e6ded7772 and revised implementation c54a5310cc0e991d7a333b2abf4fe94dac0fa786. Both native libraries were rebuilt with default features and the repository release profile; both JVM reactors used Spark 4.1.3 and JDK 17. Each fresh JVM wrote the same 1,048,576-row DATE column to one Parquet file: ordinary dates from 1960–2029 with 1% nulls. The timed query was SELECT CAST(d AS TIMESTAMP) FROM date_cast_input into Spark's noop sink, with Comet enabled, local[1], AQE disabled, batch size 8,192, and at least 10 warmed iterations per case. Native library loading and actual execution routes were checked.

Four fresh JVMs ran in base/head/head/base order. Each table cell preserves the two per-JVM mean times in chronological order within its revision, rounded to milliseconds by Spark Benchmark. The final column compares the averages of those two means; these small local repeats are descriptive, not confidence intervals or an estimate for every workload. The measurement includes scan, projection, and sink overhead; it does not isolate the cast kernel or only the planner change.

Timezone / dispatcher Exact base route Revised head route Base mean ms (two JVMs) Head mean ms (two JVMs) Change in average mean
UTC / on native native 127 / 122 79 / 72 -39.4%
UTC / off native native 126 / 112 67 / 64 -45.0%
America/Los_Angeles / on native JVM dispatcher 131 / 130 137 / 156 +12.3%
America/Los_Angeles / off native Spark row projection 128 / 128 149 / 162 +21.5%

In particular, disabling dispatch still ran the base Los Angeles cast natively; only the revised head used Spark row projection. Both revisions kept UTC native. These numbers quantify the correctness tradeoff for named-zone DATE columns, independently of the native nested-widening improvement.

The earlier wide-date and null-container fix was validated as follows:

  • make core rebuilt the native library before JVM testing.
  • cargo test --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr -p datafusion-comet-common --lib: 684 passed.
  • Full CometNativeCastSuite, CometCodegenSuite, CometCodegenSourceSuite, CometCodegenHOFSuite, and SparkErrorConverterSuite on Spark 4.1.3: 332 passed, with 8 existing ignored tests.
  • Focused CometNativeCastSuite DateType to and SparkErrorConverterSuite on Spark 3.5.9 and 4.0.4: 27 passed per profile. All JVM tests used JDK 17, with clean builds between Spark profiles.
  • The new native null-parent and sliced-input regressions failed before the fix and passed afterward; visible-overflow controls still throw.
  • An independent harness linked against the rebuilt production spark_cast passed 69,120 list/map casts across slice boundaries, null masks, and legacy/ANSI/TRY modes, plus nested, non-nullable, empty, all-null, and visible-overflow controls.
  • Native SQL reproductions for reused IF aliases containing structs and maps match Spark in both ANSI settings, with JVM dispatch disabled and native operators verified.
  • cargo clippy --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr --all-targets -- -D warnings, cargo fmt --manifest-path native/Cargo.toml --all -- --check, Spotless, Scalastyle, and git diff --check passed.

The JVM regressions use Parquet-backed columns and assert native execution, JVM dispatcher execution, or full Spark fallback as appropriate. They cover wide positive/negative dates, timestamp boundaries, offset-induced overflow, nulls, both ANSI settings, and nested TRY casts. The null-container regression also covers map keys and arrays of structs. These are local checks, not hosted CI results.

@sunchao

sunchao commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

cc @peterxcli @comphead

val optEx = convertErrorType(errorJson.errorType, errorClass, params, sparkContext, summary)
// Math.multiplyExact throws a plain JVM exception in every Spark version, without an
// ANSI error class or configuration advice. Delegate other errors to the version-specific shim.
val optEx = if (errorJson.errorType == "LongOverflow") {

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.

also handle IntegerOverflow here? as I remember there are at least these two type of arithmetic overflow in spark sql error result

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Spark does have integer-overflow cases, but this date-to-timestamp path widens the Date32 value to i64 before converting to seconds. Only the checked i64 microsecond multiplication can overflow, so there is no IntegerOverflow producer on this path.

Existing ANSI integer arithmetic already uses ArithmeticOverflow and keeps its structured Spark exception handling. I'm keeping this new plain-exception case scoped to the long overflow that this conversion actually produces.

}

/// Recursive casts must not evaluate child values hidden by a null parent or outside a slice.
fn prepare_nested_cast_input(array: ArrayRef) -> DataFusionResult<ArrayRef> {

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.

looks like this is for:

Checked casts must also ignore values that are not logically present. A native IF(flag, s, NULL) can leave an overflowing date in the child buffer of a null struct or map. Casting that null container must return null, not evaluate the hidden date and throw. Sliced lists and maps can likewise retain unused child values before or after their visible rows.

so it seems like the root cause is native IF(flag, s, NULL) doesnt actually respect the null buffer? should we turn to fix it instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

IF is respecting Arrow's null semantics here. For IF(flag, column, NULL), DataFusion can use Arrow's nullif to mask the parent without rewriting the child buffers. A null struct is allowed to retain nonnull child values, as described in the Arrow struct-validity specification.

The recursive cast must honor that enclosing validity before evaluating the children. We also need this normalization for sliced lists and maps, whose child arrays retain values outside the visible slice independently of IF. Changing only IF would leave those cases uncovered. The regressions cover both hidden parent values and slices, preserve visible-overflow errors, and verify that map keys remain nonnull.


// Spark's checked date/timestamp conversions throw this even with ANSI disabled.
#[error("long overflow")]
LongOverflow,

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.

wondering can ArithmeticOverflow be reused?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The existing ArithmeticOverflow maps to Spark's structured SparkArithmeticException with error class ARITHMETIC_OVERFLOW, including ANSI configuration advice.

For DATE to TIMESTAMP, Spark instead calls daysToMicros -> instantToMicros -> Math.multiplyExact, which throws a plain ArithmeticException("long overflow") even with ANSI disabled. Reusing the existing variant unchanged would change both the exception class and message, so LongOverflow preserves that distinction. The regressions check the exact class and message with ANSI both on and off.

@sunchao
sunchao force-pushed the dev/chao/codex/fix-wide-date-timestamp branch from 0bd0041 to 8b072ab Compare August 25, 2026 16:24
@andygrove

Copy link
Copy Markdown
Member

Note on this review: this was generated by an LLM (Claude Code) at my request while I worked through a review backlog. I have not verified the individual findings myself. Please treat everything below as suggestions to evaluate rather than as authoritative review feedback, and push back on anything that is wrong or already handled.

Thanks for following up on #5443 so quickly. The panic inside chrono and the silent wraparound are both real, and the observation that a null struct or a sliced list can hide an overflowing value in a non-nullable child buffer is a subtle one that I would not have thought of. The SparkError::LongOverflow mapping to a bare ArithmeticException("long overflow") matches what Math.multiplyExact actually throws, which is the right level of fidelity.

I think the fix costs more than it needs to, though, and I would like to see that addressed before merge.

Region-zone dates lose native execution entirely

canCastFromDate now returns Unsupported for any timezone that is not UTC or literally +/-HH:MM. CAST(d AS TIMESTAMP) is one of the most common expressions there is, and spark.sql.session.timeZone is a region zone like America/Los_Angeles or Europe/London for most deployments. So this PR takes a very common operation off the native path for most users in order to make an extreme edge case correct. The description acknowledges "named-zone casts may be slower" in one sentence, which understates it.

The failing values require a date beyond roughly year 262143, which in practice means make_date with an absurd year rather than any real data. Could the native path keep the chrono-based region-zone code and guard it per value instead? Something like: if the epoch day is inside chrono's NaiveDate range, do exactly what the old code did, otherwise return SparkError::LongOverflow (or null under TRY). That fixes the panic, keeps the plan-time support level Compatible, and costs one comparison per row. If there is a reason that does not work, it would be good to have it written down, because the current tradeoff is a large regression for a tiny correctness win.

If the fallback does stay, please put a number in the description. How much slower is CAST(date AS TIMESTAMP) through the codegen dispatcher than natively, on a realistic batch?

prepare_nested_cast_input runs on every nested cast, not just fallible ones

cast_array now calls prepare_nested_cast_input unconditionally at the top. Any struct, list, or map with a non-zero null count gets a full take copy on every cast, whether or not the inner cast can throw. Casting a nullable array<int> to array<bigint> now allocates and copies the whole array for no benefit.

Could this be gated on whether the cast is actually fallible, so that infallible widening casts keep their current path? Even a coarse check would avoid the cost in the common case.

DataType::List only, not LargeList or the view types

prepare_nested_cast_input matches DataType::List and DataType::Map and returns the array untouched for everything else, including LargeList, ListView, and LargeListView. If a LargeList cast ever reaches here, the hidden-value problem comes straight back with no error. Since the whole point of the function is to be defensive, could it either handle LargeList too or return an explicit error for the list-like types it does not cover, rather than silently passing them through?

The timezone allowlist and the native parser can disagree

Scala uses timezone == "UTC" || timezone.matches("[+-][0-9]{2}:[0-9]{2}"). The native side uses chrono's FixedOffset::from_str. Those are two different grammars maintained in two languages, and they have to agree exactly or you get a runtime ArrowError where you expected a plan-time fallback.

Concretely, the Scala regex rejects several spellings that mean UTC and would be fine: GMT, Etc/UTC, Z, and +00:00 is accepted but UTC+00:00 is not. A user on spark.sql.session.timeZone=Etc/UTC silently loses native date-to-timestamp casts for no reason. Could the Scala side normalize through ZoneId.of(...) and check whether the resulting rules are fixed, rather than pattern-matching the string? That also removes the risk of the two grammars drifting.

Dead code question

resolve_local_datetime is still used elsewhere in utils.rs, so nothing is orphaned. But the DST resolution comment block that this PR deletes from cast_date_to_timestamp contains the only written explanation I could find of why Spark's spring-forward-at-midnight behavior needs the pre-transition offset (the America/Sao_Paulo case). If that reasoning is not captured next to resolve_local_datetime itself, it would be worth moving it there rather than losing it.

@sunchao

sunchao commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Updated in 23ae9bb95.

@andygrove I checked each suggestion against the cast boundary and added changes for the avoidable copies and fixed-zone aliases.

  1. Named-zone correctness. I am retaining the Spark dispatcher, with Spark row execution when dispatch is disabled. An in-range/out-of-range chrono check cannot preserve Spark results: Spark successfully casts +262143-01-01 in America/Los_Angeles to 8210266905600000000 microseconds, although chrono cannot represent that date. Returning overflow or TRY null would be incorrect. There is also an in-range counterexample: on 2101-07-04, the pinned chrono-tz version uses -08:00 for Los Angeles while Java uses -07:00. The SQL regression now covers that date as well as the wide values.

    I added and ran the same Parquet-backed column benchmark on exact base bd7dc601 and revised head c54a5310: 1,048,576 ordinary dates, 1% nulls, batch size 8,192, Spark 4.1.3/JDK 17, release native libraries, local[1], AQE off, and four fresh JVMs in base/head/head/base order. For Los Angeles, the per-JVM query means were 131/130 ms on base and 137/156 ms with head dispatch (12.3% higher average); with dispatch disabled they were 128/128 ms on base, still native, and 149/162 ms with head Spark row projection (21.5% higher average). UTC stayed native and its average query means fell 39–45%. These are scan + cast + noop-sink timings for this workload, not isolated kernel costs or a general deployment estimate. The PR body includes the routes and raw per-run numbers.

  2. Nested widening copies. Added a conservative whitelist for signed integer widenings and unchanged fixed-width children, including structs composed of those leaves. Nullable structs and sparse nullable lists/maps can reuse input buffers only when their children satisfy that whitelist. Nested lists/maps and unchanged strings/binary keep the existing compaction path, which prevents the added reuse path from allocating or retaining an unbounded hidden payload. Lists/maps with unused backing values or dense null rows still compact; the density check counts visible child values as well as null parent rows. Checked casts continue to mask hidden children in legacy, ANSI, and TRY modes. Tests compare logical results with the existing normalization and verify buffer reuse, offsets, null parents, map keys, empty slices, visible errors, and compaction of 65,536 hidden values/bytes through four nested shapes. Whole-cast identity remains the existing no-op; the new guard only bounds the added reuse path.

    Separately, the native reuse optimization was compared with unmodified PR head 8b072abb, not the PR base. The 13 targeted nullable Int32 to Int64 struct and sparse list/map shapes used 12–92% less batch time. All 46 shapes include no-null, dense-null, slice, large-hidden-list, and checked-DATE controls. Six initial 1.8–3.3% control increases did not reproduce in opposite-order repeats (−3.6% to +0.14%); no reproducible regression was found among those measured shapes.

  3. LargeList/list views. Spark ArrayType becomes Arrow List at the SQL serialization boundary. Recursive SQL casts support List to List; differing unsupported list-like types already reach the outer explicit unsupported-cast error. Returning an array from the preparation helper is not an admission decision. I have not expanded the supported type set in this fix.

  4. Timezone aliases. Fixed aliases now resolve through Spark's DateTimeUtils.getZoneId and serialize as UTC or a canonical signed minute offset. This keeps GMT, Etc/UTC, Z, UTC+00:00, GMT+05:30, and Etc/GMT+8 native, including inside nested casts. The old regex accepted a safe subset of the native parser; the confirmed problem was unnecessary fallback, rather than an admitted spelling that failed parsing. Checking only fixed rules would be unsafe: chrono rejects some original alias strings and parses +05:30:15 as +05:30, dropping the seconds. Subminute offsets therefore remain on Spark. Added parity coverage for all 2,161 minute offsets, both signed zero spellings, aliases, subminute exclusions, and offset-induced overflow.

  5. DST helper comment. resolve_local_datetime still documents gap/ambiguity handling and explicitly selects the pre-transition offset in its gap branch. Its remaining callers convert NTZ timestamps; DATE now uses Spark's own start-of-day rules. I kept that documentation rather than moving a DATE-specific explanation onto a different caller.

Final validation against the revised release library: 690 native expression/common unit tests and 28 focused Spark 4.1.3 date-cast/error tests passed; all-target Clippy and the full JVM reactor style checks passed. An earlier full cast/error run had 184 passes and one failure in a newly added test that expected native execution for the already unsupported direct ARRAY<DATE> to ARRAY<TIMESTAMP> shape. I corrected that test to the supported array-of-struct shape and reran all 28 affected tests successfully; I have not claimed a subsequent full-suite run.

@sunchao

sunchao commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Updated in 3bd44648e.

Merged Apache main 525c05b52 into this branch as 3bd44648e, retaining both parents. The sole manual conflict was the Cargo benchmark list; cast_nested and all upstream registrations are retained. The other 11 PR files are unchanged.

Because main changes JNI and shuffle protocols, I rebuilt the release native library and ran the full Spark 4.1.3 reactor with the 28 date/error tests, then 8 RSS/protocol/datatype tests. All 36 passed and loaded the newly built library. The 771 native common/expression/protocol/shuffle unit tests, all-target expression Clippy, formatting, and style checks also passed.

The PR body now records this merged validation separately. The benchmark results remain pinned to c54a5310; no timings were rerun after the merge.

@sunchao

sunchao commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Fixed both review issues. 10a06cc64 keeps unbounded non-TRY DATE to TIMESTAMP_NTZ casts on Spark row execution in both native and JVM-dispatch admission. This preserves LIMIT and semi/anti join short circuiting, including nested/enclosing casts, while still throwing when Spark consumes an overflowing value. Safe literals and byte/short-derived dates remain eligible for native execution; nullable scalar TRY_CAST and fixed-zone DATE to TIMESTAMP retain their existing routes. Arbitrary date columns, including ordinary dates, now incur operator fallback. The checked Rust arithmetic is unchanged.

129cc1646 removes the redundant interpolator that failed Scalafix. I reproduced that failure against the unchanged-head test file and verified that the full patched Spark source passes CI's exact syntactic check.

The full Spark 4.1.3 JVM reactor passed 201 tests, with 8 existing ignored tests and no failures, including the full cast/error suites and ten NTZ SQL configurations. The actual loaded native library matches this PR's unchanged native source. Spotless and Scalastyle also pass. The PR description now records the fallback cost and validation limits; CI for the new commit is pending.

peterxcli added a commit to peterxcli/datafusion-comet that referenced this pull request Aug 28, 2026
- Revert cast_date_to_timestamp to main's version to avoid conflicting
  with apache#5457, which rewrites the same function as a safety fix
- Document the Int32 input guarantee and zero-copy reinterpret path in
  date_from_unix_date
- Make the microsecond-physical/millisecond-target rejection message
  actionable (report link + scan fallback workaround)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sunchao

sunchao commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Fixed the remaining Parquet reader-boundary issue in 6d016e289097a0fa8e63dbc9659d5b21c23a35bf.

The implicit conversion from a physical DATE column to a requested TIMESTAMP_NTZ column bypassed the Catalyst cast guard. On Spark 4.x, a file with an overflowing date at row 5000 should still let filter("id = 0").limit(1) return its valid first row when Spark reads batches of 4096. Native conversion could instead consume that overflow. Blocking only the native scan was insufficient: a native filter could request another Spark batch through the columnar Arrow bridge before returning its first selected row.

The fix keeps V1 Parquet scans requesting top-level NTZ data columns on Spark and blocks both Arrow bridge paths, including with native scans disabled or AQE enabled. Ordinary NTZ files conservatively fall back too because the logical schema cannot prove each file's physical type. Pruned, partition-only, nested, and Iceberg cases retain their existing scope. The guide explains this tradeoff and the Spark 3.x schema rejection versus Spark 4.x conversion behavior. Existing native kernel/operator assertions remain, using materialized or nested fixtures where needed.

Validation passed:

  • Spark 4.1.3: 221 selected tests, including the complete cast and error-converter suites; 3 expected cancellations and 8 existing ignored tests.
  • Spark 3.4.3, 3.5.9, 4.0.4, and 4.2.0: 28 focused reader/NTZ tests per profile, with 3 expected cancellations each.
  • 145 broader fuzz/map/writer tests, all 33 temporal tests on unchanged suite/helper/production sources, and 129 executed SQL cases. One additional SQL case is intentionally bypassed outside Spark 3.4.
  • Spotless, Scalastyle, and CI's syntactic Scalafix 0.14.6 check. Actual loaded and bundled native-library hashes match the unchanged native source tree; no native rebuild or new performance measurement is claimed.

The PR description now includes the new behavior and validation, while retaining earlier benchmark revisions. Hosted CI for this commit is pending.

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.

Date-to-timestamp casts can overflow or panic for wide dates

4 participants