Skip to content

fix(array): convert a date scalar into a timestamp instead of re-labelling it - #93

Merged
lukekim merged 5 commits into
spiceai-54from
spice/date-cast-constant
Aug 30, 2026
Merged

lukekim merged 5 commits into
spiceai-54from
spice/date-cast-constant

Conversation

@lukekim

@lukekim lukekim commented Aug 30, 2026

Copy link
Copy Markdown

What was wrong

Scalar::cast sent every cast whose target is an extension type through that target's storage type and then re-labelled the result:

if let Some(ext_dtype) = target_dtype.as_extension_opt() {
    let cast_storage_scalar_value = self.cast(ext_dtype.storage_dtype())?.into_value();
    return Scalar::try_new(target_dtype.clone(), cast_storage_scalar_value);
}

That is sound for a source that carries no interpretation of its own. It is not sound for a source that is itself an extension type: vortex.date and vortex.timestamp both count from the epoch but in different units, so the number is not transferable between them.

The path that reaches it is file-statistics pruning. A scan falsifies cast(col as timestamp) > lit into cast(max(col) as timestamp) <= lit, binds max(col) to the file's statistic and casts that scalar to decide whether to read the file at all — vortex-file/src/pruning.rs::can_prune_file_stats. Extension's CastKernel converts the array, so the rows are handled; the statistic is not.

Both storage layouts went wrong, in opposite ways:

source storage result before
vortex.date[days] i32 storage cast refused → the scan fails: No CastReduce to cast constant array from vortex.date[days](i32?) to vortex.timestamp[ns](i64?)
vortex.date[ms] i64, same as timestamp[ns] storage cast succeeds and returns the millisecond count as a nanosecond one — an instant 10^6 too small

The second is the serious one. 2024-03-01 came back as 1970-01-01T00:28:29.2512Z, the falsifier concluded the file could hold no matching row, and the file was pruned. The query returned zero rows with no error.

What this does

Lets an extension source convert itself. Scalar::cast no longer re-labels through the target's storage type when the source is an extension type; ExtScalar::cast performs the vortex.date → vortex.timestamp rescale, and refuses any other pair of extension types rather than re-labelling one as the other. vortex.timestamp[ms] → vortex.timestamp[ns] now errors, matching what the array kernel already does (cast_different_ext_dtype), instead of silently returning a wrong instant.

The rescale is the one Extension's CastKernel already applied to arrays, moved into extension::datetime::cast so both callers share it. That sharing is the point: a file is pruned on the scalar conversion and its surviving rows are filtered on the array one, so the two disagreeing is what drops rows.

Evidence

Before, on a four-row Vortex file with event_date DATE (date[days]), through spiceai/spiceai's DataFusion adapter:

SELECT count(*) FROM events WHERE CAST(event_date AS TIMESTAMP) > TIMESTAMP '2024-01-15 12:00:00';

ERROR: External error: Failed to read Vortex file: date_cast/…vortex:
  No CastReduce to cast constant array from vortex.date[days](i32?) to vortex.timestamp[ns](i64?)

The same file with a date[ms] column and the row 2024-03-01, which matches:

+---------------------+
| event_date          |
+---------------------+
+---------------------+     <- empty; the file was pruned

After, both queries are right — count(*) = 1, and:

+---------------------+
| event_date          |
+---------------------+
| 2024-03-01T00:00:00 |
+---------------------+

And the scalar cast directly:

before:  date[ms] -> ts[ns]   = Ok("1970-01-01T00:28:29.2512Z")
         date[days] -> ts[ns] = Err(cannot cast extension dtype with id vortex.date and storage type i32? to i64?)
after:   date[ms] -> ts[ns]   = Ok("2024-03-01T00:00:00Z")
         date[days] -> ts[ns] = Ok("2024-03-01T00:00:00Z")

Tests

vortex-array/src/scalar/typed_view/extension/tests.rs:

  • test_ext_scalar_cast_date_to_timestamp — both storage layouts land on the right instant.
  • test_ext_scalar_cast_date_to_timestamp_matches_the_array_kernel — every date/timestamp unit pair, asserting the scalar cast and the array kernel agree value for value, and that the scalar cast refuses whatever the kernel refuses. This is the property the pruning path depends on.
  • test_ext_scalar_cast_between_timestamp_units_is_refused — the re-label that used to succeed.

All three fail on this branch's parent and pass on it. cargo test -p vortex-array is green (3001 passed); vortex-file, vortex-scan and vortex-arrow are unchanged, and vortex-layout's 7 layouts::table::tests failures reproduce on the parent commit (they need an installed vortex-io runtime).

SPICE_PATCHES.md gets a row, and row 7 — the array-side half of this cast — gets the Verify command it was missing.

Reported as spiceai/spiceai#13624.

…lling it

`Scalar::cast` sent every cast whose *target* is an extension type through that
target's storage type and then re-labelled the result. For a source that is
itself an extension type that discards what the source means: `vortex.date` and
`vortex.timestamp` both count from the epoch but in different units, so the
number is not transferable between them.

Two ways that surfaced, both through the same expression. A scan falsifies
`cast(col as timestamp) > lit` into `cast(max(col) as timestamp) <= lit`, binds
`max(col)` to the file's statistic and casts that scalar to decide whether to
read the file at all:

- `date[days]` stores `i32` and `timestamp[ns]` stores `i64`, so the storage
  cast was refused and the scan failed with `No CastReduce to cast constant
  array from vortex.date[days](i32?) to vortex.timestamp[ns](i64?)`.
- `date[ms]` stores `i64`, the same as `timestamp[ns]`, so the storage cast
  succeeded and handed back the millisecond count as a nanosecond one — an
  instant 10^6 too small. The file was then pruned as unable to match, and rows
  that did match were silently dropped.

Let an extension source convert itself: `ExtScalar::cast` now performs the
`vortex.date` to `vortex.timestamp` rescale, and refuses any other pair of
extension types rather than re-labelling one as the other. The rescale is the
one `Extension`'s `CastKernel` already applied to arrays, moved into
`extension::datetime::cast` so both callers share it — a file is pruned on the
scalar conversion and its surviving rows are filtered on the array one, so the
two disagreeing is what drops rows.

Reported as spiceai/spiceai#13624.
Copilot AI balanced review requested due to automatic review settings August 30, 2026 02:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes date-scalar-to-timestamp conversion so file-statistics pruning matches row-level array casting.

Changes:

  • Routes extension scalar casts through extension-aware conversion.
  • Shares checked date-to-timestamp scaling between scalar and array paths.
  • Adds regression tests and patch tracking.

Checks: Static review only; tests were not run.

File summaries
File Description
vortex-array/src/scalar/cast.rs Dispatches extension sources correctly.
vortex-array/src/scalar/typed_view/extension/mod.rs Implements scalar temporal conversion.
vortex-array/src/scalar/typed_view/extension/tests.rs Adds scalar and array agreement tests.
vortex-array/src/extension/datetime/mod.rs Exposes shared conversion helpers.
vortex-array/src/extension/datetime/cast.rs Centralizes checked temporal scaling.
vortex-array/src/arrays/extension/compute/cast.rs Reuses shared conversion logic.
SPICE_PATCHES.md Records verification commands.
Review details

Suppressed comments (2)

vortex-array/src/scalar/typed_view/extension/tests.rs:384

  • Move this test's imports into the module-level import block at tests.rs:4-15, as done in arrays/extension/compute/cast.rs:167-186. Function-scoped imports are inconsistent with the repository's established import placement.
    use crate::IntoArray;
    use crate::arrays::ExtensionArray;
    use crate::arrays::PrimitiveArray;
    use crate::builtins::ArrayBuiltins;
    use crate::executor::VortexSessionExecute;

vortex-array/src/scalar/typed_view/extension/tests.rs:462

  • Move these imports into the module-level import block at tests.rs:4-15, consistent with arrays/extension/compute/cast.rs:167-186; imports in this test module should not be scoped to individual test functions.
    use crate::extension::datetime::TimeUnit;
    use crate::extension::datetime::Timestamp;
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread vortex-array/src/extension/datetime/cast.rs Outdated
Comment thread vortex-array/src/scalar/typed_view/extension/tests.rs Outdated
@lukekim

lukekim commented Aug 30, 2026

Copy link
Copy Markdown
Author

One consequence worth flagging, since it is a deliberate choice rather than an oversight.

Refusing every extension pair other than date → timestamp also refuses a timezone-label-only difference — vortex.timestamp[us,"+00:00"] → vortex.timestamp[us,"UTC"]. Re-labelling was correct there: same unit, same instant, only the tz string differs, and Spice stores tz strings verbatim (Iceberg writes +00:00, an IANA name stays an IANA name).

Two reasons to leave it refused rather than widen the conversion:

  • The array kernel already refuses it. CastReduce for Extension requires eq_ignore_nullability on the whole ExtDType, and a tz-string difference fails that, so the pair falls through to CastKernel, which handles only Date → Timestamp. Making the scalar succeed where the array fails puts the two back out of step, which is the property this PR exists to establish.
  • A general timestamp → timestamp conversion is not just a rescale. Arrow's Timestamp(unit, None) is a naive local time and Timestamp(unit, Some(tz)) is a UTC instant, so converting between those two is a timezone interpretation, not arithmetic. Getting that right is a larger change than this one and wants its own review.

The cost is bounded and is not correctness. In spiceai/spiceai's crates/vortex/src/persistent/format.rs::scalar_stat_to_df, a refused cast is swallowed into Precision::Absent, so a column whose file dtype differs from the table's declared dtype loses its min/max and therefore some pruning — it does not return a wrong bound. On the same path today a unit mismatch (timestamp[ms] file column under a Timestamp(ns) schema) produces a bound 10^6 too small and DataFusion prunes on it, which is the same defect as the one this PR fixes; Absent is the better answer.

…oth paths

The conversion checked only that the rescaled value fits `i64`, and the `i64`
range is wider than the instants a timestamp can hold. So `date[days]` at
`i32::MAX` — an ordinary `Date32` value — converts to `timestamp[s]` in the
array path, while the scalar path builds a scalar from the result and
`Timestamp`'s validation rejects it. That is the disagreement this patch set
exists to remove, at the other end of the range: a scan would fail casting a
file's statistic while the rows it gates convert happily.

Fold the bound into the conversion, which is now a `DateToTimestamp` built once
per cast and carrying the target's representable range alongside the scale, so
neither caller can apply the scale without the check.

The scalar path did not merely error there, either. `Timestamp::unpack_native`
built its Jiff span with the unchecked constructors, which panic outside Jiff's
*span* range — wider than its timestamp range — so a value between the two
limits aborted instead of failing the `checked_add` below it. Build the span
through `TimeUnit::to_jiff_span`, which is checked. That path is reachable from
any read of such a value, not only from a cast.

Reported by review on spiceai/spiceai#13745.
Copilot AI review requested due to automatic review settings August 30, 2026 03:08
lukekim pushed a commit to spiceai/spiceai that referenced this pull request Aug 30, 2026
Review on this PR found the previous pin still let the scalar and array casts
disagree, at the far end of the range rather than the near one: the shared
conversion checked only that the rescaled value fits `i64`, which is wider than
the instants a timestamp can hold. `date[days]` at `i32::MAX` — an ordinary
`Date32` value — converted in the array path and was then rejected by
`Timestamp`'s validation in the scalar path, so a scan could still fail casting
a file's statistic while the rows it gates converted fine. Worse, that
rejection was a panic rather than an error for part of the range, because
`Timestamp::unpack_native` built its Jiff span with the unchecked constructors.

spiceai/vortex#93 now folds the target's representable range into the
conversion both paths share, and makes that validation report instead of
aborting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lukekim

lukekim commented Aug 30, 2026

Copy link
Copy Markdown
Author

Pushed 53b1edd0e, which fixes a real gap review found in the first commit.

The conversion checked only that the rescaled value fits i64, and the i64 range is wider than the instants a timestamp can hold. So the two paths still disagreed — at the far end of the range rather than the near one. Casting each value on its own through both paths, before the fix:

days=  2147483647 ->  s: array=ok    scalar=PANIC
days=  2147483647 -> ms: array=ok    scalar=PANIC
days=     3000000 ->  s: array=ok    scalar=ERR(Invalid timestamp scalar: adding span overflowed
                                     timestamp: parameter 'Unix timestamp seconds' is not in the
                                     required range of -377705023201..=253402207200)
days=     2932896 ->  s: array=ok    scalar=ERR(same)
days=      106751 -> ns: array=ok    scalar=ok

i32::MAX days is an ordinary Date32 value, so this is not a synthetic input: a scan would fail casting a file's statistic while the rows it gates converted fine — the same shape of defect as the one this PR fixes.

The PANIC is a second, pre-existing defect. Timestamp::unpack_native built its Jiff span with Span::new().seconds(v), which panics outside Jiff's span range — wider than its timestamp range — so a storage value between the two limits aborted before reaching the checked_add meant to reject it. Reachable from any read of such a value, not only from a cast.

Two changes:

  • The conversion is now a DateToTimestamp, built once per cast and carrying the target's representable range (jiff::Timestamp::MIN/MAX in the target unit) alongside the scale, so neither caller can apply the scale without the check. Refusing in the shared code is what keeps the paths in step — widening the scalar side to accept what the array accepts would instead have produced statistics no scalar can hold.
  • unpack_native builds its span through the checked TimeUnit::to_jiff_span.

The conformance test now casts one value per array rather than a batch: an array cast fails as a whole, so a batch containing one unconvertible value says nothing about the ones beside it. It covers i32::MAX days, 10000-01-01 and the last representable day, for both date[days] and date[ms] sources across all four target units.

Both guards were checked by mutation. Removing the range check fails the conformance test and the three a_date_beyond_the_last_representable_instant_is_refused cases; restoring the unchecked span constructors fails four of the five an_out_of_range_storage_value_is_an_error_not_a_panic cases with the Jiff panic. cargo test -p vortex-array is green at 3011 passed, and vortex-file, vortex-scan and vortex-arrow are unchanged.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread vortex-array/src/extension/datetime/timestamp.rs Outdated
Comment thread SPICE_PATCHES.md Outdated
Comment thread vortex-array/src/extension/datetime/timestamp.rs Outdated
@lukekim

lukekim commented Aug 30, 2026

Copy link
Copy Markdown
Author

For anyone reading the red checks: Rust tests (coverage) is the runner running out of disk, on both commits, and neither failure is this change.

On 0065a343 the test step passed in full and the job died generating the report:

Summary [ 273.901s] 7089 tests run: 7089 passed (2 slow), 557 skipped
##[warning]You are running out of disk space. … Free space left: 57 MB
grcov . --binary-path target/debug/ -s . -t lcov --llvm …
[ERROR] A panic occurred at src/output.rs:267: called `Result::unwrap()` on an `Err` value:
        Os { code: 28, kind: StorageFull, message: "No space left on device" }

On 53b1edd0e the squeeze arrived earlier and a slow test hit its 150s cap:

Summary [ 333.931s] 7099 tests run: 7098 passed (2 slow), 1 timed out, 557 skipped
TIMEOUT [ 150.013s] vortex-sqllogictest::sqllogictests slt::duckdb::duckdb/projection_expression_pushdown.slt
##[warning]You are running out of disk space. … Free space left: 38 MB

projection_expression_pushdown.slt contains no date, timestamp or cast — grep -inE 'date|timestamp|cast' over its 415 lines returns nothing — so it does not reach the code this PR touches. It was already climbing through the SLOW thresholds at 30s/60s/90s/120s before the cap, alongside vortex-fsst tests::fsst_compress_offsets_overflow_i32 at 115s, which is the shape of a starved runner rather than a behaviour change.

License Check and Audit Check (advisories) is RUSTSEC-2026-0258 (h2 unbounded empty DATA frames). This PR touches no manifest and adds no dependency, so it cannot have introduced it.

I'll re-run the coverage job once the in-progress workflow finishes and this becomes possible.

`Timestamp::unpack_native` validated a storage value by building a Jiff span
from it and adding that to the epoch, and a span's limits are not a
timestamp's at either end.

They stop one short of `i64::MIN` nanoseconds, which is
1677-09-21T00:12:43.145224192Z — an instant a `vortex.timestamp[ns]` array
holds and that `DateToTimestamp` converts a date into, so the scalar refused a
value the array path produced. That is the disagreement this change set exists
to remove: a scan prunes a file on the scalar conversion and filters the rows
it kept on the array one.

They also run past the last instant, and outside them the span constructors
abort rather than report.

Both ends now come from `Timestamp::storage_range`, which is the range
`DateToTimestamp` already converts into, so a scalar accepts exactly the values
the array kernel produces and there is one definition of the range rather than
two that have to agree.

Reported by review on spiceai/spiceai#13745.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy
Copilot AI review requested due to automatic review settings August 30, 2026 07:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread vortex-array/src/extension/datetime/timestamp.rs
`Display` built its instant with `Span::new().nanoseconds(v)` and
`Timestamp + Span`, both of which abort rather than report. A `Display` impl
has no way to report a failure, so it cannot be built on them — and admitting
the whole `i64` nanosecond range put a value that aborts here within reach of
`format!` on an ordinary scalar.

Build the instant with the reporting constructors: `Timestamp::from_nanosecond`
for nanoseconds, whose range covers every `i64`, and the checked span plus
`checked_add` for the other units, whose own limits are the timestamp's. A
count that denotes no instant renders as itself rather than taking the process
down, alongside the existing fallback for a timezone that does not resolve.

Four of the five cases guarded here aborted before this change, not one: the
seconds and milliseconds extrema and the first instant past the range were all
reachable through a `TimestampValue` built directly.

Reported by review on #93.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy
Copilot AI review requested due to automatic review settings August 30, 2026 08:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

Review details

Suppressed comments (4)

Previously missed (2) — in code that hasn't changed since the last review.

vortex-array/src/extension/datetime/cast.rs:172

  • This boundary date is mislabeled: 2,932,896 days after 1970-01-01 is 9999-12-31, not 10000-01-01. It is rejected because Jiff's timestamp ceiling falls before that day's midnight, so the current explanation gives the wrong reason for the boundary.

This issue also appears on line 187 of the same file.

        // Jiff stops at the end of year 9999; 2_932_896 days is 10000-01-01.

vortex-array/src/scalar/typed_view/extension/tests.rs:392

  • The named boundary is off by one day: the rejected day count in this test is 9999-12-31, not 10000-01-01. Please describe the actual first date whose midnight exceeds Jiff's timestamp range.

This issue also appears on line 407 of the same file.

    // Ordinary dates, then the boundaries: the largest `Date32` value, the first day no
    // timestamp can represent (10000-01-01) and the last one that can. The boundaries are

vortex-array/src/extension/datetime/cast.rs:187

  • last_day is 2,932,895 days after the epoch, which is 9999-12-30, not 9999-12-31. The latter day's midnight is already beyond Jiff's timestamp range, so the comment currently contradicts the boundary asserted above.
        // 9999-12-31 fits in seconds through microseconds; nanoseconds run out in 2262.

vortex-array/src/scalar/typed_view/extension/tests.rs:408

  • These Date64 cases do not test the boundary described above: they are 10000-01-01 and 9999-12-31T23:59:59, so both are already outside Jiff's timestamp range. Use the same adjacent date boundary as the Date32 cases—9999-12-31T00:00 (rejected) and 9999-12-30T00:00 (accepted)—to verify scalar/array agreement on both sides for millisecond storage.
                253_402_300_800_000,
                253_402_300_799_000,
  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread SPICE_PATCHES.md Outdated
Two overclaims in the patch row and the test that carries it.

It said a timestamp value never goes through a Jiff span. Rendering still
builds one for seconds through microseconds, whose limits enclose the
timestamp's — through the checked constructors and `checked_add`. Only
nanoseconds bypass a span, because a span's nanosecond floor is one above
`i64::MIN`.

It also said row 15's conversion produces `i64::MIN` nanoseconds. It cannot: a
`vortex.date` is days or milliseconds, and neither divides that value
(`i64::MIN % 1_000_000 == 224192`, `% 86_400_000_000_000 == 763145224192`), and
days are stored in `i32` besides. What reaches it is a `vortex.timestamp[ns]`
column that holds the value directly, whose statistic the scan then binds as a
scalar — so the range is what has to be right, not the conversion.

Reported by review on #93.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy
Copilot AI review requested due to automatic review settings August 30, 2026 08:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

vortex-array/src/extension/datetime/cast.rs:172

  • 2_932_896 days after 1970-01-01 is 9999-12-31, not 10000-01-01. This value is rejected because Jiff's timestamp range ends earlier on 9999-12-30, so the current explanation misidentifies both the date and the boundary being tested.

This issue also appears on line 187 of the same file.

        // Jiff stops at the end of year 9999; 2_932_896 days is 10000-01-01.

vortex-array/src/scalar/typed_view/extension/tests.rs:394

  • This description does not match both value sets: the day pair straddles Jiff's whole-day boundary, but the millisecond pair (253_402_300_799_000 and 253_402_300_800_000) is entirely beyond Jiff's maximum. Describe these as accepted/rejected and out-of-range cases rather than claiming each source unit contains the first unrepresentable day and the last representable one.
    // Ordinary dates, then the boundaries: the largest `Date32` value, the first day no
    // timestamp can represent (10000-01-01) and the last one that can. The boundaries are
    // where a range check applied to one path and not the other shows up — the array holds
    // the converted value and the scalar's validation rejects it.

vortex-array/src/extension/datetime/cast.rs:187

  • last_day is 2_932_895, which denotes 9999-12-30, not 9999-12-31. The latter is already outside Jiff's timestamp range, as the preceding test asserts.
        // 9999-12-31 fits in seconds through microseconds; nanoseconds run out in 2262.
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@lukekim

lukekim commented Aug 30, 2026

Copy link
Copy Markdown
Author

Differential check of the validation change, since replacing checked_add on a span with a range comparison could in principle have relaxed more than intended.

Both validations reproduced against jiff 0.2.31 and run over the same 816,068 values per the four stored units — every boundary of Timestamp::storage_range and i64 plus ±2 around each, ~200k xorshift values, and a dense sweep:

probed 816068 values across 4 units

  S:  0 disagreement(s)
  Ms: 0 disagreement(s)
  Us: 0 disagreement(s)
  Ns: 4 disagreement(s)

first disagreements: [(Ns, -9223372036854775808, old=false, new=true), ...]

All four nanosecond disagreements are the same value — i64::MIN, which appears four times in the probe set (as i64::MIN, as the range floor, and through two boundary deltas). So the change accepts exactly one value it did not before, and it is the one it was meant to: 1677-09-21T00:12:43.145224192Z.

Seconds, milliseconds and microseconds are bit-for-bit unchanged, which is the part worth knowing — Timestamp::MAX.as_millisecond() is 253402207200999 while Timestamp::from_millisecond stops at 253402207200000, so those units genuinely needed the wider bound the span path already gave them, and they still have it.

Harness: Span::new().try_*(v).map_or(false, |s| Timestamp::UNIX_EPOCH.checked_add(s).is_ok()) for the old path, (min..=max).contains(&v) over storage_range(unit) for the new one.

@lukekim lukekim self-assigned this Aug 30, 2026
@lukekim
lukekim merged commit 3c52468 into spiceai-54 Aug 30, 2026
70 of 72 checks passed
@lukekim
lukekim deleted the spice/date-cast-constant branch August 30, 2026 20:30
lukekim added a commit to spiceai/spiceai that referenced this pull request Aug 30, 2026
spiceai/vortex#93 squash-merged as 3c5246867, so the commit this branch pinned
is no longer reachable from `spiceai-54` — the ledger already recorded it as
being on that branch, which it was not.

The squash carries all of it: the scalar cast, `Timestamp::storage_range` and
the validation that uses it, and the rendering path that never aborts.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy
Jeadie pushed a commit to spiceai/spiceai that referenced this pull request Aug 31, 2026
… on it (fixes #13624) (#13745)

* fix(vortex): convert a date statistic into a timestamp before pruning on it (fixes #13624)

A pushed-down `CAST(date_col AS TIMESTAMP)` failed the scan of a Vortex file:

    No CastReduce to cast constant array from vortex.date[days](i32?)
    to vortex.timestamp[ns](i64?)

The scan uses the filter twice. It falsifies `cast(col as timestamp) > lit`
into `cast(max(col) as timestamp) <= lit` and casts the file's `max`
statistic to decide whether to read the file at all, then casts the column's
rows to filter the ones it read. The fork's `vortex.date` cast (fork PR #28)
is registered on `ExtensionArray` and covers the second; the first is a
scalar, and `Scalar::cast` routed an extension source through the *target's*
storage type and re-labelled the result instead of converting it.

That failed loudly for `Date32` and silently for `Date64`. `vortex.date[ms]`
stores `i64`, the same as `vortex.timestamp[ns]`, so the re-label succeeded
and returned the millisecond count as a nanosecond one — `2024-03-01` came
back as `1970-01-01T00:28:29.2512Z`. The falsifier then concluded the file
could hold no matching row, the scan skipped it, and the query returned no
rows and no error.

Fixed on the fork (spiceai/vortex#93): an extension scalar now converts
itself, sharing the rescale with the array kernel so the statistic and the
rows it gates cannot disagree, and refusing any extension pair the kernel
refuses rather than re-labelling one as the other.

Guards both halves in `crates/vortex`, and records the patch in
`docs/dev/fork_patches.md` — the ledger's "patch that is present but
incomplete" section was this defect, so it goes away with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(vortex): assert the date cast really pushes into the scan in both guards

Both guards assert a row count, and DataFusion returns the same count when it
evaluates the filter itself above the scan — so a pushdown that stopped
happening would leave them green and guarding nothing. Share the plan
assertion the Date32 guard already made, and give the Date64 one the same
check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(vortex): repin past the date-cast range check the fork was missing

Review on this PR found the previous pin still let the scalar and array casts
disagree, at the far end of the range rather than the near one: the shared
conversion checked only that the rescaled value fits `i64`, which is wider than
the instants a timestamp can hold. `date[days]` at `i32::MAX` — an ordinary
`Date32` value — converted in the array path and was then rejected by
`Timestamp`'s validation in the scalar path, so a scan could still fail casting
a file's statistic while the rows it gates converted fine. Worse, that
rejection was a panic rather than an error for part of the range, because
`Timestamp::unpack_native` built its Jiff span with the unchecked constructors.

spiceai/vortex#93 now folds the target's representable range into the
conversion both paths share, and makes that validation report instead of
aborting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(vortex): move the pin past the timestamp-range half of the fork fix

The scalar cast lands a converted date inside the range a timestamp scalar
accepts. The fork validated that range by building a Jiff span, whose
nanosecond floor is one above `i64::MIN` — so `timestamp[ns]` refused
1677-09-21T00:12:43.145224192Z, an instant the column holds and the conversion
produces, while the array path admitted it. A scan prunes a file on the scalar
conversion and filters the rows it kept on the array one, so the two ranges
disagreeing is a file dropped by a comparison the row filter would never have
made.

The fork now validates against the timestamp's own range, which is the range
the conversion targets.

`a_nanosecond_timestamp_scalar_spans_the_whole_i64_range` guards it. The two
cast guards pass on either pin, so without it half of the fork patch could go
missing silently — which is what the ledger row now says.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy

* fix(vortex): move the pin past the timestamp rendering fix, and split its ledger row

The fork also keeps a timestamp value off Jiff spans when rendering it, not
only when validating it. `Display` has no way to report a failure, so building
it on the unchecked span constructors turned a count no timestamp can hold into
an abort — and admitting the whole `i64` nanosecond range put such a value
within reach of formatting an ordinary scalar.

`a_nanosecond_timestamp_scalar_spans_the_whole_i64_range` now renders the
scalar it builds, so it covers both paths, and the ledger carries the span
removal as its own row: it shares a fork PR with the date-to-timestamp scalar
cast but is separately losable on a re-cut, and the two cast guards pass either
way.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy

* docs: say what the timestamp patch does, not more than it does

Two overclaims in the ledger row.

The title said a timestamp value never goes through a Jiff span. Rendering
still builds one for seconds through microseconds, whose limits enclose the
timestamp's — through the checked constructors and `checked_add`. Only
nanoseconds bypass a span, because a span's nanosecond floor is one above
`i64::MIN`.

The body said the date-to-timestamp conversion produces `i64::MIN`
nanoseconds. It cannot: a `vortex.date` is days or milliseconds, and neither
divides that value (`i64::MIN % 1_000_000 == 224192`,
`% 86_400_000_000_000 == 763145224192`), and days are stored in `i32` besides.
What reaches it is a `vortex.timestamp[ns]` column holding the value directly,
as read from Arrow, whose statistic the scan then binds as a scalar — so the
range is what was wrong, not the conversion.

Matches the wording of the fork's own row for the same patch.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy

* test(vortex): guard the rendering fallback for the units that keep a span

The nanosecond guard covers the half of the fork patch where the whole `i64`
is admitted. The other three units keep a Jiff span, so what the patch changes
for them is that the span is built with the checked constructors and added with
`checked_add` — and only a count outside the range exercises that. A re-cut
that restored either unchecked form would abort on those counts while the
nanosecond guard stayed green, which is the gap the ledger row was claiming to
cover.

`a_timestamp_count_that_is_not_an_instant_renders_instead_of_aborting` pins
both abort paths: `i64::MAX` seconds and milliseconds and `i64::MAX`
microseconds are past the span range, and 253_402_300_800 seconds is inside it
but past the last instant, so it reaches `Timestamp + Span`. Both fail on the
pin before the rendering fix with the aborts they name, and an ordinary instant
still renders as one rather than falling back.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy

* fix(vortex): move the pin onto the merged spiceai-54 tip

spiceai/vortex#93 squash-merged as 3c5246867, so the commit this branch pinned
is no longer reachable from `spiceai-54` — the ledger already recorded it as
being on that branch, which it was not.

The squash carries all of it: the scalar cast, `Timestamp::storage_range` and
the validation that uses it, and the rendering path that never aborts.

Claude-Session: https://claude.ai/code/session_01KfJw4MeXt3YzD26SgKVdyy

---------

Co-authored-by: code <Code@Lukes-Mac-mini.localdomain>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit ff94ca0)
krinart added a commit that referenced this pull request Sep 22, 2026
…ys to timestamps instead of re-labelling (#75, #78, #93)

Re-ports two related fork patch families from spiceai-54 to 0.85.0 as fresh
implementations rather than cherry-picks, since neither applies: both touch
functions upstream restructured, and the second depends on the first already
being in place (resolve_timezone) to even compile against.

Fixed-offset timezone resolution (#75, #78): Arrow allows a timestamp's
timezone to be an IANA name or a fixed offset (+00:00), but jiff only
resolves IANA names, so any offset-form timezone aborted the process via
`.vortex_expect("unknown timezone")` -- in scalar validation, Display
rendering, and TemporalMetadata::to_jiff. Ports the fork's resolve_timezone
(new file, 38 passing tests carried over verbatim) and threads it through
all three sites, plus Scalar::try_extension_ref (a fallible counterpart to
the panicking extension_ref) through the three call sites that built
extension scalars infallibly on a read path (min/max aggregation, scalar_at,
the constant-folding reduce rule) and DateTimeParts::scalar_at.

Date -> timestamp conversion (#93, spiceai/spiceai#13624): Scalar::cast sent
any cast targeting an extension type through the target's storage type and
re-labelled the result, discarding what the source meant -- vortex.date and
vortex.timestamp both count from the epoch but in different units. A scan
falsifies `cast(col as timestamp) > lit` into `cast(max(col) as timestamp)
<= lit` and casts a file's statistic scalar to decide whether to read it at
all, so this silently pruned files whose rows would have matched. Ports
DateToTimestamp (new file, shared by both the array CastKernel and the
scalar cast so the two cannot disagree about which files/rows to keep) and
Timestamp::storage_range, replacing Jiff Span-based validation whose limits
are not a timestamp's at either end (span floor is one nanosecond above
i64::MIN, which a real timestamp[ns] column holds).

This supersedes a98a78c's array-only port on this branch: that port used
legacy_session() as a workaround for CastReduce's missing ExecutionCtx.
Discovered from an untracked later Spice commit that this fork's own answer
was to split into CastReduce (buffer-free restructuring) and a proper
CastKernel (which does get a real ctx) instead -- the same split this commit
uses, requiring an explicit `register_execute_parent_kernel(Cast.id(),
Extension, ...)` that has no upstream equivalent (Extension had no Cast
kernel at 0.85.0 at all, only the reduce path).

Verified: cargo test -p vortex-array -p vortex-datetime-parts --lib: 3428 +
72 passed, 0 failed. cargo check across vortex-array, vortex-arrow,
vortex-layout, vortex-file, vortex-io, vortex-utils, vortex-duckdb: clean,
zero warnings.

Not ported: the DuckDB TIMESTAMP_TZ dtype/scalar conversion's is_utc_timezone
gate (vortex-duckdb/src/convert/{dtype,scalar}.rs) -- vortex-duckdb is not a
crate Spice's Cargo.toml consumes, directly or transitively, so this is fork
hygiene rather than something Spice's re-pin needs. Left for a follow-up.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants