[SPARK-57975][SQL] Add an opt-in lossless Arrow struct representation for nanosecond timestamps - #57053
Closed
viirya wants to merge 3 commits into
Closed
[SPARK-57975][SQL] Add an opt-in lossless Arrow struct representation for nanosecond timestamps#57053viirya wants to merge 3 commits into
viirya wants to merge 3 commits into
Conversation
… for nanosecond timestamps The default Arrow mapping for TimestampNTZNanosType/TimestampLTZNanosType packs the value into a single int64 of epoch-nanoseconds, which only covers roughly years 1677-2262, while the Spark types are defined over years 0001-9999 (a common sentinel like 9999-12-31 overflows with DATETIME_OVERFLOW). That mapping must stay as-is for interchange (pandas, Arrow UDFs), but internal Arrow storage needs the full domain. This adds an opt-in mapping (toArrowSchema/toArrowField with losslessTimestampNanos = true) that represents a nanosecond timestamp as a struct of (epochMicros: int64, nanosWithinMicro: int16) -- TimestampNanosVal's own layout, no unit conversion, no overflow. The struct is tagged through child-field metadata (following the geometry/variant tag pattern) so the Spark type, NTZ/LTZ kind, and precision are recovered on read; ArrowWriter writes it via new struct writers and ArrowColumnVector reads it via a dedicated accessor, including nested inside arrays, structs, and maps. The default mapping and all existing callers are unchanged. Co-authored-by: Claude Code
… and permanent Record the design rationale at the flag definition sites: the interchange/int64 vs internal/struct split is structural (Arrow's timestamp physical type is int64 by spec, interchange consumers' domains are equally int64-bound, so failing loudly there is correct), the per-call-site flag follows the largeVarTypes pattern, and only schema construction needs the flag because the struct is self-describing on read. Co-authored-by: Claude Code
sql/api is scalafmt-enforced; formatting only, no code change. Co-authored-by: Claude Code
viirya
added a commit
that referenced
this pull request
Jul 7, 2026
… for nanosecond timestamps ### What changes were proposed in this pull request? This adds an opt-in Arrow mapping for the nanosecond timestamp types (`TimestampNTZNanosType` / `TimestampLTZNanosType`), selected by a new `losslessTimestampNanos` parameter on `ArrowUtils.toArrowSchema` / `toArrowField` (default `false`). When enabled, a nanosecond timestamp column maps to an Arrow struct of `(epochMicros: int64, nanosWithinMicro: int16)` -- `TimestampNanosVal`'s own layout -- instead of the default single int64 of epoch-nanoseconds: - **Schema (`ArrowUtils`)**: the struct's `epochMicros` child is tagged through field metadata with the NTZ/LTZ kind and the column precision (following the geometry/variant struct tag pattern), so `fromArrowField` recovers the exact Spark type on read with no out-of-band information. Nested occurrences (array/struct/map/UDT sqlType) are covered by threading the flag through the recursive schema construction. - **Write (`ArrowWriter`)**: new `TimestampNTZNanosStructWriter` / `TimestampLTZNanosStructWriter` store the two components as-is -- no unit conversion, hence no overflow. `TimestampNanosTypeOps.createArrowFieldWriter` now dispatches on the vector shape instead of unconditionally casting to the native nanos vectors. - **Read (`ArrowColumnVector`)**: a dedicated `TimestampNanosStructAccessor` recognizes the tagged struct and serves `getTimestampNTZNanos` / `getTimestampLTZNanos` from the child vectors, including nested inside arrays, structs, and maps. The default `Timestamp(NANOSECOND)` mapping and every existing caller are unchanged. ### Why are the changes needed? Spark defines the nanosecond timestamp types over years 0001-9999, and stores values losslessly as `(epochMicros, nanosWithinMicro)`. The standard Arrow mapping packs the value into a single int64 of epoch-nanoseconds, which only covers roughly years 1677-2262: a common sentinel value like `9999-12-31 23:59:59.999999999` fails with `DATETIME_OVERFLOW`. Internal Arrow-based storage -- specifically the Arrow-backed Dataset cache proposed in #56334, where the default in-memory cache handles the full domain (SPARK-57735) -- needs a representation that covers the full domain of the types. This was raised in #56334 (comment). **Why an opt-in parameter instead of changing the default mapping?** The mismatch is structural, so the two representations serve two permanently distinct needs: - **Interchange paths must keep the standard int64 encoding.** `toPandas()`, Arrow UDFs, and Connect result sets hand the produced bytes directly to external consumers (pandas, PyArrow, arrow-rs clients) that only understand the standard `Timestamp(NANOSECOND)` encoding -- SPARK-57159 added that mapping precisely so pandas receives real timestamps. Moreover, those consumers' own timestamp domains are equally int64-bound (pandas `datetime64[ns]` is itself int64 epoch-nanos), so the reduced domain on interchange paths is inherent to the destination: even a struct encoding could not deliver year 9999 into `datetime64[ns]`. Failing loudly at write with `DATETIME_OVERFLOW` is the correct behavior there, not a limitation to be fixed. - **Internal storage is a closed write-then-read-back loop** with no external consumer, where the only requirement is fidelity to Spark semantics -- hence the lossless struct. Since Arrow's timestamp physical type is fixed at int64 by the Arrow format spec and Spark's type domain will not shrink, this is not a transitional state to be unified later. The per-call-site boolean follows the existing `largeVarTypes` pattern (one Spark type, two Arrow encodings, chosen by the consumer's needs), and only schema construction needs the flag: the struct is self-describing through its child-field metadata tag, so `fromArrowField`, `ArrowWriter`, and `ArrowColumnVector` recognize both shapes unconditionally and no mode mismatch is possible. Placing the encoding in the shared machinery (rather than a cache-private fork of schema/writer/reader) keeps it next to its sibling encodings, covered by the shared test suites, and reusable by any future internal Arrow storage. ### Does this PR introduce _any_ user-facing change? No. The new mapping is opt-in via an internal API parameter that defaults to off; no existing behavior changes. ### How was this patch tested? New tests: - `ArrowUtilsSuite` "timestamp nanos lossless struct": schema shape (struct of int64 + int16, non-null children), type/precision round-trip for NTZ/LTZ at p=7/8/9, LTZ requiring no time zone, nested array/struct/map coverage, user-metadata preservation, precision fallback for a missing/invalid tag, no misfire on an untagged struct with the same child names, and the default mapping staying unchanged. - `ArrowWriterSuite` "timestamp nanos lossless struct round-trip covers the full value domain": write-and-read-back through `ArrowWriter` + `ArrowColumnVector` for values including `9999-12-31T23:59:59.999999999` and `0001-01-01T00:00:00.000000001` (both far outside the int64 epoch-nanos range) plus nulls, for NTZ/LTZ at p=9 and p=7. - `ArrowWriterSuite` "timestamp nanos lossless struct round-trip inside nested types": the same extreme values inside `array<...>`, `struct<...>`, and `map<int, ...>`. Existing regression suites pass: `ArrowUtilsSuite`, `ArrowWriterSuite`, `ArrowConvertersSuite`, `ColumnVectorSuite`, `ColumnarBatchSuite`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code This pull request and its description were written by Claude Code. Closes #57053 from viirya/nanos-arrow-lossless. Authored-by: Liang-Chi Hsieh <viirya@gmail.com> Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com> (cherry picked from commit 7dc70c1) Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
Member
Author
Member
Author
|
Thanks @dongjoon-hyun |
viirya
added a commit
that referenced
this pull request
Jul 8, 2026
… for CalendarInterval ### What changes were proposed in this pull request? This extends the opt-in lossless Arrow encoding introduced by SPARK-57975 (#57053) to `CalendarIntervalType`, and hardens the default interval writer's overflow error: - **Lossless struct encoding**: with the opt-in flag, a `CalendarInterval` column maps to an Arrow struct of `(months: int32, days: int32, microseconds: int64)` -- the type's own field layout, mirroring the default in-memory cache's `CALENDAR_INTERVAL` `ColumnType`. The components are stored as-is with no unit conversion, so the full `Long` microsecond domain round-trips. The struct is tagged through child-field metadata (the geometry/variant pattern) and is self-describing on read: `fromArrowField` recovers `CalendarIntervalType`, `ArrowWriter` selects a dedicated struct writer, and `ArrowColumnVector` serves `getInterval` from the child vectors, including nested inside arrays, structs, and maps. - **Flag rename**: the parameter is renamed from `losslessTimestampNanos` to `losslessInternalTypes`, since it now selects the lossless encoding for both kinds of types whose standard Arrow encoding cannot cover their full Spark value domain. `ArrowUtils` is `private[sql]`, so the rename has no compatibility impact; the only intended caller (the Arrow-based Dataset cache, #56334) wants both types, and the flag expresses one intent: internal storage wants fidelity. - **Structured error at the conversion site**: `IntervalMonthDayNanoWriter` now catches the `Math.multiplyExact(microseconds, 1000L)` overflow exactly at the conversion and raises the structured `DATETIME_OVERFLOW` (new `QueryExecutionErrors.calendarIntervalArrowNanosOverflowError`, the same pattern as `TimestampNTZNanosWriter`'s `timestampNanosEpochNanosOverflowError`) instead of letting a raw `ArithmeticException: long overflow` escape. Because the catch is scoped to the single conversion expression, it cannot re-label unrelated arithmetic failures (e.g. an ANSI `DIVIDE_BY_ZERO` raised by lazily-evaluated upstream input), which was a live mis-attribution risk with any wider catch (see #56334 (comment)). The default `Interval(MONTH_DAY_NANO)` mapping and every existing caller are unchanged. ### Why are the changes needed? Spark permits the full `Long` microsecond range in `CalendarInterval`, but Arrow's `IntervalMonthDayNano` stores the sub-day component as int64 nanoseconds, so any `|microseconds| > Long.MaxValue / 1000` (roughly +/-292 years) is structurally unrepresentable in the standard encoding -- the default in-memory cache serializer stores the three components raw and has no such limit. As with the nanosecond timestamps in SPARK-57975, the interchange mapping must keep the standard encoding for external consumers, so internal storage (the Arrow-based Dataset cache proposed in #56334) needs a per-call-site lossless alternative; with it, the cache can delete its schema-wide overflow-translation wrapper entirely. Raised in #56334 (comment) and #56334 (comment). ### Does this PR introduce _any_ user-facing change? The lossless encoding itself is opt-in via an internal API parameter and changes nothing by default. One user-visible improvement on the existing paths: writing an out-of-range `CalendarInterval` through Arrow (e.g. `toPandas`, Arrow UDFs) now fails with the structured `DATETIME_OVERFLOW` condition naming the value and the limit, instead of an opaque `java.lang.ArithmeticException: long overflow`. ### How was this patch tested? New tests: - `ArrowUtilsSuite` "calendar interval lossless struct": schema shape (struct of int32/int32/int64, non-null children), round-trip, nested array/struct/map coverage, user-metadata preservation, no misfire on an untagged struct with the same child names, and the default `Interval(MONTH_DAY_NANO)` mapping staying unchanged when the flag is off. - `ArrowWriterSuite` "calendar interval overflow raises DATETIME_OVERFLOW at the conversion site": the default writer raises the structured condition for `microseconds = Long.MaxValue / 1000 + 1`. - `ArrowWriterSuite` "calendar interval lossless struct round-trip covers the full value domain": write-and-read-back through `ArrowWriter` + `ArrowColumnVector` for values including `Long.MaxValue` / `Long.MinValue` microseconds and full-range months/days (all far outside the default mapping's limit) plus nulls. - `ArrowWriterSuite` "calendar interval lossless struct round-trip inside nested types": the same extreme values inside `array<...>`, `struct<...>`, and `map<int, ...>`. Existing regression suites pass: `ArrowUtilsSuite`, `ArrowWriterSuite`, `ArrowConvertersSuite`, `ColumnVectorSuite`, `ColumnarBatchSuite`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code This pull request and its description were written by Claude Code. Closes #57088 from viirya/interval-arrow-lossless. Authored-by: Liang-Chi Hsieh <viirya@gmail.com> Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
viirya
added a commit
that referenced
this pull request
Jul 8, 2026
… for CalendarInterval ### What changes were proposed in this pull request? This extends the opt-in lossless Arrow encoding introduced by SPARK-57975 (#57053) to `CalendarIntervalType`, and hardens the default interval writer's overflow error: - **Lossless struct encoding**: with the opt-in flag, a `CalendarInterval` column maps to an Arrow struct of `(months: int32, days: int32, microseconds: int64)` -- the type's own field layout, mirroring the default in-memory cache's `CALENDAR_INTERVAL` `ColumnType`. The components are stored as-is with no unit conversion, so the full `Long` microsecond domain round-trips. The struct is tagged through child-field metadata (the geometry/variant pattern) and is self-describing on read: `fromArrowField` recovers `CalendarIntervalType`, `ArrowWriter` selects a dedicated struct writer, and `ArrowColumnVector` serves `getInterval` from the child vectors, including nested inside arrays, structs, and maps. - **Flag rename**: the parameter is renamed from `losslessTimestampNanos` to `losslessInternalTypes`, since it now selects the lossless encoding for both kinds of types whose standard Arrow encoding cannot cover their full Spark value domain. `ArrowUtils` is `private[sql]`, so the rename has no compatibility impact; the only intended caller (the Arrow-based Dataset cache, #56334) wants both types, and the flag expresses one intent: internal storage wants fidelity. - **Structured error at the conversion site**: `IntervalMonthDayNanoWriter` now catches the `Math.multiplyExact(microseconds, 1000L)` overflow exactly at the conversion and raises the structured `DATETIME_OVERFLOW` (new `QueryExecutionErrors.calendarIntervalArrowNanosOverflowError`, the same pattern as `TimestampNTZNanosWriter`'s `timestampNanosEpochNanosOverflowError`) instead of letting a raw `ArithmeticException: long overflow` escape. Because the catch is scoped to the single conversion expression, it cannot re-label unrelated arithmetic failures (e.g. an ANSI `DIVIDE_BY_ZERO` raised by lazily-evaluated upstream input), which was a live mis-attribution risk with any wider catch (see #56334 (comment)). The default `Interval(MONTH_DAY_NANO)` mapping and every existing caller are unchanged. ### Why are the changes needed? Spark permits the full `Long` microsecond range in `CalendarInterval`, but Arrow's `IntervalMonthDayNano` stores the sub-day component as int64 nanoseconds, so any `|microseconds| > Long.MaxValue / 1000` (roughly +/-292 years) is structurally unrepresentable in the standard encoding -- the default in-memory cache serializer stores the three components raw and has no such limit. As with the nanosecond timestamps in SPARK-57975, the interchange mapping must keep the standard encoding for external consumers, so internal storage (the Arrow-based Dataset cache proposed in #56334) needs a per-call-site lossless alternative; with it, the cache can delete its schema-wide overflow-translation wrapper entirely. Raised in #56334 (comment) and #56334 (comment). ### Does this PR introduce _any_ user-facing change? The lossless encoding itself is opt-in via an internal API parameter and changes nothing by default. One user-visible improvement on the existing paths: writing an out-of-range `CalendarInterval` through Arrow (e.g. `toPandas`, Arrow UDFs) now fails with the structured `DATETIME_OVERFLOW` condition naming the value and the limit, instead of an opaque `java.lang.ArithmeticException: long overflow`. ### How was this patch tested? New tests: - `ArrowUtilsSuite` "calendar interval lossless struct": schema shape (struct of int32/int32/int64, non-null children), round-trip, nested array/struct/map coverage, user-metadata preservation, no misfire on an untagged struct with the same child names, and the default `Interval(MONTH_DAY_NANO)` mapping staying unchanged when the flag is off. - `ArrowWriterSuite` "calendar interval overflow raises DATETIME_OVERFLOW at the conversion site": the default writer raises the structured condition for `microseconds = Long.MaxValue / 1000 + 1`. - `ArrowWriterSuite` "calendar interval lossless struct round-trip covers the full value domain": write-and-read-back through `ArrowWriter` + `ArrowColumnVector` for values including `Long.MaxValue` / `Long.MinValue` microseconds and full-range months/days (all far outside the default mapping's limit) plus nulls. - `ArrowWriterSuite` "calendar interval lossless struct round-trip inside nested types": the same extreme values inside `array<...>`, `struct<...>`, and `map<int, ...>`. Existing regression suites pass: `ArrowUtilsSuite`, `ArrowWriterSuite`, `ArrowConvertersSuite`, `ColumnVectorSuite`, `ColumnarBatchSuite`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code This pull request and its description were written by Claude Code. Closes #57088 from viirya/interval-arrow-lossless. Authored-by: Liang-Chi Hsieh <viirya@gmail.com> Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com> (cherry picked from commit 5ca6b10) Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
This adds an opt-in Arrow mapping for the nanosecond timestamp types (
TimestampNTZNanosType/TimestampLTZNanosType), selected by a newlosslessTimestampNanosparameter onArrowUtils.toArrowSchema/toArrowField(defaultfalse). When enabled, a nanosecond timestamp column maps to an Arrow struct of(epochMicros: int64, nanosWithinMicro: int16)--TimestampNanosVal's own layout -- instead of the default single int64 of epoch-nanoseconds:ArrowUtils): the struct'sepochMicroschild is tagged through field metadata with the NTZ/LTZ kind and the column precision (following the geometry/variant struct tag pattern), sofromArrowFieldrecovers the exact Spark type on read with no out-of-band information. Nested occurrences (array/struct/map/UDT sqlType) are covered by threading the flag through the recursive schema construction.ArrowWriter): newTimestampNTZNanosStructWriter/TimestampLTZNanosStructWriterstore the two components as-is -- no unit conversion, hence no overflow.TimestampNanosTypeOps.createArrowFieldWriternow dispatches on the vector shape instead of unconditionally casting to the native nanos vectors.ArrowColumnVector): a dedicatedTimestampNanosStructAccessorrecognizes the tagged struct and servesgetTimestampNTZNanos/getTimestampLTZNanosfrom the child vectors, including nested inside arrays, structs, and maps.The default
Timestamp(NANOSECOND)mapping and every existing caller are unchanged.Why are the changes needed?
Spark defines the nanosecond timestamp types over years 0001-9999, and stores values losslessly as
(epochMicros, nanosWithinMicro). The standard Arrow mapping packs the value into a single int64 of epoch-nanoseconds, which only covers roughly years 1677-2262: a common sentinel value like9999-12-31 23:59:59.999999999fails withDATETIME_OVERFLOW. Internal Arrow-based storage -- specifically the Arrow-backed Dataset cache proposed in #56334, where the default in-memory cache handles the full domain (SPARK-57735) -- needs a representation that covers the full domain of the types. This was raised in #56334 (comment).Why an opt-in parameter instead of changing the default mapping? The mismatch is structural, so the two representations serve two permanently distinct needs:
toPandas(), Arrow UDFs, and Connect result sets hand the produced bytes directly to external consumers (pandas, PyArrow, arrow-rs clients) that only understand the standardTimestamp(NANOSECOND)encoding -- SPARK-57159 added that mapping precisely so pandas receives real timestamps. Moreover, those consumers' own timestamp domains are equally int64-bound (pandasdatetime64[ns]is itself int64 epoch-nanos), so the reduced domain on interchange paths is inherent to the destination: even a struct encoding could not deliver year 9999 intodatetime64[ns]. Failing loudly at write withDATETIME_OVERFLOWis the correct behavior there, not a limitation to be fixed.Since Arrow's timestamp physical type is fixed at int64 by the Arrow format spec and Spark's type domain will not shrink, this is not a transitional state to be unified later. The per-call-site boolean follows the existing
largeVarTypespattern (one Spark type, two Arrow encodings, chosen by the consumer's needs), and only schema construction needs the flag: the struct is self-describing through its child-field metadata tag, sofromArrowField,ArrowWriter, andArrowColumnVectorrecognize both shapes unconditionally and no mode mismatch is possible. Placing the encoding in the shared machinery (rather than a cache-private fork of schema/writer/reader) keeps it next to its sibling encodings, covered by the shared test suites, and reusable by any future internal Arrow storage.Does this PR introduce any user-facing change?
No. The new mapping is opt-in via an internal API parameter that defaults to off; no existing behavior changes.
How was this patch tested?
New tests:
ArrowUtilsSuite"timestamp nanos lossless struct": schema shape (struct of int64 + int16, non-null children), type/precision round-trip for NTZ/LTZ at p=7/8/9, LTZ requiring no time zone, nested array/struct/map coverage, user-metadata preservation, precision fallback for a missing/invalid tag, no misfire on an untagged struct with the same child names, and the default mapping staying unchanged.ArrowWriterSuite"timestamp nanos lossless struct round-trip covers the full value domain": write-and-read-back throughArrowWriter+ArrowColumnVectorfor values including9999-12-31T23:59:59.999999999and0001-01-01T00:00:00.000000001(both far outside the int64 epoch-nanos range) plus nulls, for NTZ/LTZ at p=9 and p=7.ArrowWriterSuite"timestamp nanos lossless struct round-trip inside nested types": the same extreme values insidearray<...>,struct<...>, andmap<int, ...>.Existing regression suites pass:
ArrowUtilsSuite,ArrowWriterSuite,ArrowConvertersSuite,ColumnVectorSuite,ColumnarBatchSuite.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
This pull request and its description were written by Claude Code.