fix: prevent silent overflow when reading Parquet TIMESTAMP_MILLIS values - #5177
fix: prevent silent overflow when reading Parquet TIMESTAMP_MILLIS values#5177peterxcli wants to merge 18 commits into
Conversation
andygrove
left a comment
There was a problem hiding this comment.
Thanks for working on this. Replacing the hand-written loops with Arrow kernels is a good direction, and I especially like that you spotted Arrow's timezone adjustment in cast_with_options and worked around it with a metadata-only relabel. The comment explaining why the relabel has to happen first is really helpful, and the new +07:00 test cases genuinely guard it, since without the relabel they would be off by seven hours.
I have a few things I would like to work through before this goes in.
1. Micros to millis: Arrow truncates, Spark floors
native/core/src/parquet/cast_column.rs
Arrow's timestamp downscale is plain integer division (time_array.unary(|o| o / divisor) in arrow-cast), so it truncates toward zero. Spark's SparkDateTimeUtils.microsToMillis is Math.floorDiv(micros, MICROS_PER_MILLIS), and there is a comment there specifically about pre-1970 timestamps needing that adjustment. So for -1_500_001 micros Spark produces -1501 and we produce -1500.
The old unary(|v| v / 1000) had the same behavior, so this is not something the PR introduces. My concern is that the new test asserts -1_500_001 -> -1500, which bakes the divergence into a test as though it were intended. Moving to Arrow's cast also takes away the easy fix, since the hand-written closure could have simply become v.div_euclid(1000).
Would it make sense to keep a small kernel for this one case so the floor semantics can be matched? If you would rather keep the Arrow cast, could we file an issue and reference it next to the negative assertion so the expected value does not read as deliberate?
2. CastOptions::default() is safe: true
native/spark-expr/src/utils.rs
Could the millis to micros cast use safe: false instead of CastOptions::default()? Arrow branches on that flag for the upscale path. With safe: true it takes unary_opt(|o| o.checked_mul(mul)), which allocates a fresh null buffer and checks every element. With safe: false it takes try_unary, which reuses the input null buffer. So the default is doing an extra pass per batch compared to the unary this replaces.
There is a behavior argument too. Spark's millisToMicros is Math.multiplyExact and throws on overflow, so raising an error is closer to Spark than silently producing NULL. It would also line up with DataFusion's DEFAULT_CAST_OPTIONS, which is what cast_column.rs uses in this same PR.
3. Inconsistent cast options in date_from_unix_date
native/spark-expr/src/datetime_funcs/date_from_unix_date.rs
The two branches end up with different options. The array path gets CastOptions::default(), which is safe: true, while scalar.cast_to(...) resolves to cast_to_with_options(target, &DEFAULT_CAST_OPTIONS), which is safe: false. Int32 -> Date32 goes through cast_reinterpret_arrays, so it cannot fail either way today. But if the Signature::exact(vec![Int32]) is ever widened, the two paths would quietly disagree, one nulling and one erroring. Worth making them match while it is cheap to do?
For what it is worth, I checked the two things in this file that looked riskiest and both are fine. Int32 -> Date32 stays zero-copy, so there is no regression versus the manual Date32Array::new. And dropping the explicit ScalarValue::Null arm is safe, because can_cast_types has (Null, _) => true and the cast returns new_null_array.
4. Test coverage in cast_column.rs
Both evaluate tests moved from Timestamp(ms, None) to Timestamp(ms, Some("+07:00")), and the three deleted unit tests were the ones covering target_tz = None. I think that leaves the no-timezone case uncovered, which is the branch where relabel_array early-returns because the types already match. Could one of these keep a None target?
It would also be good to have a case where the input array already carries a timezone, say Timestamp(us, Some("UTC")) to Timestamp(ms, Some("America/New_York")). relabel_array overwrites whatever timezone the input has, and "relabel, do not shift" is exactly the property the workaround is protecting, so having that pinned down would help.
A note, not a request
In cast_date_to_timestamp, Arrow's Date32 -> Timestamp(us) is a plain unary(|x| (x as i64) * MICROSECONDS_IN_DAY) that ignores the safe flag, so a large Date32 wraps silently. Spark's daysToMicros uses Math.multiplyExact. That is identical to the old (d as i64) * 86_400 * 1_000_000, so I am not asking for anything here. I only wanted to note it so it is not mistaken for something the Arrow cast fixed.
|
@andygrove thanks for the review!
Changed this one conversion back to a small custom kernel because Arrow truncates negative values toward zero, while Spark uses floor division.
Changed to use I also added a regression assertion using
Changed to use
Expanded the array test to cover all three relevant timezone layouts:
|
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. All four points from the last round look addressed, and pinning the new test values to Spark's own DateTimeUtilsSuite example is a nice touch. I ran the touched tests locally and they pass, and I re-checked the claims against arrow-cast 58.4.0 and Spark master.
A couple of things I confirmed so nobody has to re-derive them. SparkDateTimeUtils.microsToMillis really is Math.floorDiv, with a comment about pre-1970 timestamps, so div_euclid is the right call. millisToMicros really is Math.multiplyExact, so safe: false in utils.rs is the Spark-faithful choice. And there is no performance regression anywhere: the millis-to-micros upscale reinterprets to Int64 zero-copy and then uses try_unary, which reuses the input null buffer, so it is the same single allocation the old unary did. Date32 -> Timestamp(us) is one unary plus a same-type cast that early-returns, and Int32 -> Date32 is still cast_reinterpret_arrays. I also grepped for other micros-to-millis sites that might share the truncation bug, and the only ones are the two this PR fixes.
One thing on packaging that I would like to sort out before this merges. The div_euclid change is a real behavior fix rather than a refactor, since any pre-1970 timestamp with a sub-second component now yields a different value than before. Our changelog is generated from PR titles, so as refactor: this lands with no signal that timestamp results changed. Could we retitle to fix:, or split the floor fix out so it gets its own entry? Either way it would help to have the divergence tracked in an issue we can link from the code comment.
The description needs a refresh too. The first bullet still says we use Arrow casts for the Parquet micros-to-millis conversion, and that is the one case that ended up keeping a hand-written kernel. It would also be good to mention that millis-to-micros overflow went from silently wrapping to raising an error, since that is user visible as well.
Nothing needed on docs. No serde or expression registration changed, so the compatibility pages and expressions.md stay as they are. date_from_unix_date already has good SQL test coverage including the Spark min and max date boundaries, and both of its branches are now consistent, so I have nothing to raise there.
The rest of my comments are inline.
|
@andygrove thanks for the review! review change is pushed. please take another look, thanks!
Retitled the PR. The final patch rejects that invalid read-schema pair instead of using
Updated it to describe the planning rejection and that millis→micros overflow now errors instead of wrapping.
No. Spark read schemas use microsecond logical timestamps. Construction now returns
The pair is now rejected before scalar or array evaluation. The planning test covers timezone-free and timezone-bearing fields.
Done. The timezone-free
Added links to Spark’s Parquet call site and checked |
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the rework. Rejecting the pair outright is a better answer than either version of the division fix, and the ParquetSchemaConverter link makes the invariant checkable. CI is fully green, including Iceberg 1.11, which is the PR-gated Iceberg job, so the scan paths that matter here did get exercised.
A few things I confirmed independently so you do not have to re-derive them.
Your reachability claim is correct. to_arrow_datatype at native/core/src/execution/serde.rs:93-96 maps Spark timestamps to microseconds only, and the JVM side agrees at Utils.scala:157-160. IcebergScanExec builds its schema from the same convert_spark_types_to_arrow_schema at planner.rs:1651. A millisecond logical target genuinely cannot occur.
Deleting the three tests costs no coverage. types_differ_only_in_field_names returns false for a flat (Timestamp, Timestamp) pair, so relabel_array was never on the timestamp path, and its List, Map, and Struct coverage is intact.
temporal.rs and date_from_unix_date.rs are now consistent on DEFAULT_CAST_OPTIONS, and both look right to me.
Nothing needed on docs. No serde or expression registration changed, so the compatibility pages and expressions.md stay as they are.
The one thing I would like to sort out before this merges is whether the overflow fix actually reaches the Parquet reader. I do not think it does. Details inline.
|
@andygrove thanks for another round of review, addressed all of your review. please take another look. TIA!
Moved the checked millis -> micros conversion into
Added a native Parquet scan regression covering TimestampType and TimestampNTZType, positive and negative overflow, dictionary on/off, and ANSI on/off. It verifies both Spark and Comet report overflow.
Confirmed it is unreachable for Parquet reads. Removed the dead arm, its imports, and its unit test. The conversion is now tested where it actually runs.
Narrowed the comment to explicitly state that the guard applies to top-level timestamp columns, so it does not imply nested timestamp validation. |
Turning the silent Several things. This conflicts directly with #5457 #5457 rewrites
The old code was: Date32Array::new(int_array.values().clone(), int_array.nulls().cloned())which is O(1): cloning an Arrow Could you confirm which it is? If Arrow copies, the old code was better and I would keep it. The general principle of delegating to Arrow is right, but not when the hand-written version is asymptotically cheaper. Separately, the new version accepts any type Arrow can cast to Removing the millis-to-micros arm from That arm is gone, so the same conversion in What does the new overflow error look like to a user?
The new microsecond-physical to millisecond-target check returns |
- 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>
|
@andygrove thanks for the review! Addressed three points and pushing back on two, details below.
Good catch. Since #5457 rewrites
Confirmed on arrow-cast 58.4.0:
This was settled in the previous round — the arm was removed at your suggestion after we verified it unreachable: Spark logical timestamps are exclusively microseconds (
I'd rather not convert it in this PR. Spark's exception here is an untyped
Fallback isn't reachable from there — by the time the native schema adapter runs, the JVM has already committed to the native scan. I extended the message to state it indicates a Comet bug, link the issue tracker, and suggest |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feel free to merge this first! |
sunchao
left a comment
There was a problem hiding this comment.
Reviewed afa3673 with five specialist agents; no blocking correctness findings. The inline dictionary fixture note does not block approval.
Local validation passed: 82 native Parquet tests, 643 native expression tests, and the Spark 4.1.3 overflow regression. A temporary 16-row variant also passed after verifying dictionary pages for both timestamp types and both signs. Native validation excluded HDFS; the full Spark suite was not run locally.
…regression A single row falls back to PLAIN even with dictionary encoding enabled, so the dictionary leg never covered a dictionary read. Write 16 repeated rows and assert hasDictionaryEncodedPages matches the writer setting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed 5c8de42 with five specialist agents. One additional regression is described inline; the earlier dictionary fixture issue is fixed.
Local validation: 82 native Parquet tests, 660 native expression tests, and the Spark 4.1.3 overflow regression passed. The additional filtered-read regression passes with base c067e4e and fails on this head. Native builds excluded HDFS; the full Spark suite was not rerun locally.
| let micros = array | ||
| .as_primitive::<TimestampMillisecondType>() | ||
| .try_unary::<_, TimestampMicrosecondType, _>(|value| value.mul_checked(1_000))? | ||
| .with_timezone_opt(target_tz.clone()); |
There was a problem hiding this comment.
[P2] Preserve timestamp pruning before checked conversion
With a TimestampType column stored as TIMESTAMP_MILLIS and containing 9223372036854776 milliseconds, WHERE ts < TIMESTAMP '1970-01-01 00:00:00' returns no rows in Spark 4.1.3 and the base native build (c067e4e), but 5c8de42 throws Overflow happened on: 9223372036854776 * 1000. I reproduced this with plain/dictionary encoding, ANSI on/off, and Comet row-filter pushdown on/off: all eight Spark/base cases succeed and all eight head cases fail.
The existing CometCastColumnExpr prevents DataFusion from recognizing the timestamp statistics predicate, so Comet converts values from row groups Spark skips. This new error turns that pruning limitation into a query failure. Please preserve pruning before the checked conversion and add this filtered case as a regression test, while retaining overflow errors for values actually read.
There was a problem hiding this comment.
Confirmed and fixed — thanks for the thorough repro. The predicate's column was wrapped in CometCastColumnExpr, which is opaque to DataFusion's pruning analyzer, so the row group Spark prunes from millisecond statistics was being read and converted. The fix rewrites predicate comparisons over a TIMESTAMP_MILLIS file column into the millisecond domain (exact integer rescaling of the literal, like Spark's ParquetFilters pushing predicates in the file's physical unit), plus IS NULL/IS NOT NULL unwrapping. Pruning works again, predicate evaluation never converts file values, and the scan output conversion stays checked, so values actually read still fail like millisToMicros.
Added your filtered case as a regression test over dictionary × ANSI × rowFilterPushdown (8 configs), and native unit tests pinning the rounding table for all six comparison operators, both operand orders, and negative/sub-millisecond literals.
One deliberate divergence to note: with row-filter pushdown on and a non-pruned row group, rows the filter discards are no longer converted, so Comet can succeed where Spark (which converts the whole row group during decode) throws — the benign direction of "errors only for values actually read." (d465192)
…econd domain The predicate over a TIMESTAMP_MILLIS file column was wrapped in CometCastColumnExpr, which DataFusion's pruning analyzer cannot see through, so row groups Spark prunes from millisecond statistics were read and hit the checked millis->micros conversion. Rewrite predicate comparisons into the millisecond domain (exact integer rescaling of the literal, mirroring Spark's ParquetFilters) and unwrap IS NULL checks, so pruning works and predicate evaluation never converts file values. The scan output conversion stays checked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rationale for this change
Spark's vectorized Parquet reader converts TIMESTAMP_MILLIS values with the checked
millisToMicros(Math.multiplyExact), so overflow throws — independent of ANSI mode. Comet's reader used an uncheckedv * 1000, silently wrapping the value. This change makes the conversion checked on the real Parquet read path, and delegates to Arrow kernels where Arrow's behavior matches Spark.What changes are included in this PR?
Timestamp(Millisecond) -> Timestamp(Microsecond)conversion inparquet_convert_array, the path the native Parquet scan actually takes. Overflow now raises an error instead of silently wrapping, matching Spark'smillisToMicrosin every eval mode.array_with_timezoneand its unit test: Spark logical timestamps are always microseconds (serde.rs,Utils.scala), socast_arraycan never see a millisecond input. The arm was dead code, and the conversion is now implemented and tested where it actually runs.CometCastColumnExpr::try_new), since Spark read schemas represent logical timestamps in microseconds. The error names it as a Comet bug and suggestsspark.comet.scan.enabled=falseas a workaround.Int32 -> Date32indate_from_unix_date(a zero-copy reinterpret in arrow-cast), withDEFAULT_CAST_OPTIONSfor scalar/array consistency.ParquetReadSuite."TIMESTAMP_MILLIS overflow fails in native scan") covering TimestampType and TimestampNTZType, positive and negative overflow, dictionary on/off, and ANSI on/off, asserting both Spark and Comet report overflow.Note: an earlier revision also reworked
cast_date_to_timestampintemporal.rs; that hunk was dropped to avoid conflicting with #5457, which rewrites the same function as a safety fix.Follow-up: #5517 tracks surfacing the overflow as a Spark-faithful
ArithmeticException("long overflow")instead of a raw Arrow compute error.How are these changes tested?
ParquetReadSuiteregression test described above.cargo test --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr --libcargo test --manifest-path native/Cargo.toml -p datafusion-comet --lib parquet::cast_column::testscargo clippy --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr -p datafusion-comet --lib --tests -- -D warningscargo fmt --manifest-path native/Cargo.toml --all -- --check