Repository navigation
Resolve DST-ambiguous and nonexistent local times in string_to_datetime - #11054
Conversation
|
Self-review QA pass — this is our own PR, so treat this as our own adversarial check, not an independent review. No blocking defect. The highest-risk part of this stack is the change of 1. The generalisation does not change the original cast call site — verified byte for byteThis was the item that could regress #11038 silently. I ran the identical battery of cast-path readings on both branches and compared the raw output:
43 rows, and the two files are identical. The cast path on this branch is bit-for-bit the cast path on #11038. The refactor moves One note on the new wrapper. 2. The stated safety argument for the 24-hour probe is not the load-bearing oneThis PR repeats #11038's justification: no two transitions are closer than 167 hours. That figure is correct, and I reproduce it. But it is not the property that makes the probe resolvable. Two separate invariants are at work:
Seven zones have a local gap of exactly 24.00 hours — the dateline changes: The code is correct today. I tested the first instant, the midpoint and the last second of all four surviving dateline gaps through the cast path, and all 13 readings match PostgreSQL 17.11 exactly. But the doc comment in 3. The give-up branch is unreachable, and this PR widens who depends on that
This PR raises the stakes, because the branch now has four callers rather than one. I read all four. All handle
The three parser sites return a 4. The known divergence deserves the same prominence it has in #11038
#11038 carries a full table and a paragraph on why the two must be reconciled. This PR mentions it in one parenthesis inside "What changes are included". I recommend the fuller disclosure here too. This is the PR that creates the single shared helper, so it is the PR that makes the surviving divergence look deliberate. What I checked and found correctBoth paths now share one policy — measured, not assumed. I ran 5,012 boundary readings across 226 zones through the cast path and the string path in the same run. Every DST transition of every zone from 2020 to 2025, sampled at the start and the midpoint of each gap and each repeated interval: 2,506 in a gap, 2,506 ambiguous.
That agreement is the central claim of this PR, and it holds across the whole tzdb, not just the tested zones. Against PostgreSQL. 3,908 of those readings use a zone PostgreSQL 17.11 accepts. 16 disagree, on both paths equally. All 16 are PostgreSQL and DuckDB agree with each other, so chrono-tz is the outlier. Please name Per-row resolution on the string path. One
The four hard zones the PR names. All verified against PostgreSQL independently:
The site-3 argument holds. I confirmed the PostgreSQL claim directly. No call site is missed. After this PR the only remaining local-time resolution in the tree outside Suite. The fixed-offset caveat is over-broad in the body. I measured it. PostgreSQL uses POSIX west-positive signs only when |
d7f566f to
289f19a
Compare
No behaviour change. The string parser needs the same DST resolution policy as the cast kernel, so `resolve_local_offset` moves out of `cast/temporal.rs` into a new private `arrow-cast/src/local_time.rs` module and becomes `pub(crate)`. It is generalised from `&Tz` to any `chrono::TimeZone`, because `string_to_datetime` is generic over its target timezone. It now returns `T::Offset`, so the one existing caller, `adjust_timestamp_to_timezone`, calls `.fix()` itself. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…to_datetime` These tests fail on this commit and pass after the next one. Each expectation was taken from PostgreSQL 17.11. They cover the three `string_to_datetime` call sites (a date-only string, a naive datetime, and a datetime with a trailing IANA zone name), each with an ambiguous and a nonexistent reading, through both `string_to_datetime` and the `Utf8`/`LargeUtf8`/`Utf8View` -> `Timestamp(_, Some(tz))` cast. `arrow/tests/timezone.rs` asserted the old "error computing timezone offset" error for two `America/Los_Angeles` cases. Those move from `test_parse_timezone_invalid` to `test_parse_timezone` with the instants PostgreSQL produces. `test_string_to_timestamp_nanos_unaffected_by_dst` passes already. It is a control: UTC has no transitions. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ime`
`string_to_datetime` resolved a wall clock reading with `LocalResult::single()`,
which is `None` both when the reading is ambiguous (the repeated hour when the
clocks go back) and when it does not exist (the skipped hour when the clocks go
forward). So such values failed with "error computing timezone offset" under
`CastOptions { safe: false }` and silently became NULL under `safe: true`.
The three call sites (date-only, naive datetime, trailing IANA zone name) now go
through a new `resolve_local_datetime` wrapper around the shared
`resolve_local_offset`, so they apply the same policy as the cast kernel since
apache#11038: an ambiguous reading takes the
*later* instant, and a nonexistent one is shifted forward by the length of the
gap. This makes the tests from the previous commit pass.
Closes apache#11039
Co-Authored-By: Claude Opus 5.5 <[email protected]>
289f19a to
b9c4018
Compare
adriangb
left a comment
There was a problem hiding this comment.
Reviewer guide: the commits are now split into refactor, then failing tests, then fix. Only the comments marked Behaviour change point at code that changes what users see. Everything else is a move, a test, or docs.
| return timezone | ||
| .from_local_datetime(&datetime) | ||
| .single() | ||
| return resolve_local_datetime(timezone, &datetime) |
There was a problem hiding this comment.
Behaviour change (1/3): date-only input. This was timezone.from_local_datetime(&datetime).single(), which gave None (so an error) when local midnight is ambiguous or skipped, for example America/Sao_Paulo on 2018-11-04 or America/Havana on 2024-11-03. Now it resolves to the later instant, or shifts forward past the gap.
| return timezone | ||
| .from_local_datetime(&datetime) | ||
| .single() | ||
| return resolve_local_datetime(timezone, &datetime) |
There was a problem hiding this comment.
Behaviour change (2/3): naive datetime read in the target timezone. This is the Utf8 -> Timestamp(_, Some(tz)) cast path. It was .single() before, so for example 2024-03-10 02:30:00 in America/New_York became an error, or NULL with safe: true. Now it gives 03:30 EDT.
| let parsed = parsed_tz | ||
| .from_local_datetime(&datetime) | ||
| .single() | ||
| let parsed = resolve_local_datetime(&parsed_tz, &datetime) |
There was a problem hiding this comment.
Behaviour change (3/3): datetime with a trailing IANA zone name, for example 2024-11-03 01:30:00 America/New_York. This was .single() before. It now uses the same policy as the other two call sites, which is what PostgreSQL does.
| /// | ||
| /// This is [`TimeZone::from_local_datetime`] with the two unresolvable | ||
| /// [`LocalResult`] cases decided rather than rejected. | ||
| pub(crate) fn resolve_local_datetime<T: TimeZone>( |
There was a problem hiding this comment.
New in the fix commit. It only wraps resolve_local_offset (moved unchanged in the first commit) to return a DateTime<T>, which is the type the three parser call sites need.
| let adjust = |o| { | ||
| let local = as_datetime::<T>(o)?; | ||
| let offset = resolve_local_offset(to_tz, &local)?; | ||
| let offset = resolve_local_offset(to_tz, &local)?.fix(); |
There was a problem hiding this comment.
No behaviour change. resolve_local_offset now returns T::Offset because it is generic over the timezone type, so this caller calls .fix() itself. Before, the function called .fix() internally.
|
@kosiew any interest in reviewing this since we've worked on tz changes together recently? |
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working on this. The shared DST resolution logic looks good, and the regression tests cover the important cases. I found no blocking issues, so I'm approving with a few suggestions about documentation, test duplication, and a possible optimization.
| /// | ||
| /// If a timestamp is ambiguous, for example as a result of daylight-savings time, an error | ||
| /// will be returned | ||
| /// A timestamp with no explicit offset is read as a wall clock time in the |
There was a problem hiding this comment.
This change also affects the CSV and JSON readers, which call string_to_datetime directly. Timestamps in DST gaps or overlaps that previously failed will now be accepted. Could you mention this behavior in the rustdoc and PR description? A reader-level regression test would also be useful.
| fn test_string_to_timestamp_nanos_unaffected_by_dst() { | ||
| assert_eq!( | ||
| string_to_timestamp_nanos("2024-03-10 02:30:00").unwrap(), | ||
| 1_710_038_000_000_000_000 - 200_000_000_000 |
There was a problem hiding this comment.
Nit: Could you write the expected value as 1_710_037_800 * 1_000_000_000 instead of subtracting two large numbers? That would make the timestamp easier to verify, as in the existing cast test.
| // and so do we: the zone is named more explicitly, but the wall clock reading | ||
| // is no less ambiguous for it. | ||
| #[test] | ||
| fn test_string_to_datetime_dst_named_zone_in_string() { |
There was a problem hiding this comment.
Nit: Some named-zone cases are duplicated across the parser, cast, and integration tests. Could you remove the two Los Angeles rows already covered by test_parse_timezone and the separate test_cast_string_to_timezone_named_in_string_dst test? The remaining tests should still cover the distinct parser and cast behaviors.
| local: &NaiveDateTime, | ||
| ) -> Option<DateTime<T>> { | ||
| let offset = resolve_local_offset(tz, local)?; | ||
| Some(tz.from_utc_datetime(&(*local - offset.fix()))) |
There was a problem hiding this comment.
The 24-hour probe assumes IANA timezone rules, but this helper also accepts other TimeZone implementations. Could you clarify in the documentation that a non-IANA timezone may return None or select an incorrect offset if its transitions do not satisfy those assumptions?
| local: &NaiveDateTime, | ||
| ) -> Option<DateTime<T>> { | ||
| let offset = resolve_local_offset(tz, local)?; | ||
| Some(tz.from_utc_datetime(&(*local - offset.fix()))) |
There was a problem hiding this comment.
Nit: from_utc_datetime performs another timezone lookup even though we already have the offset. Could you use local.checked_sub_offset(offset.fix()).map(|utc| DateTime::from_naive_utc_and_offset(utc, offset)) instead? This avoids the extra lookup and handles subtraction overflow without panicking.
…obe assumptions and reader impact Also avoid a second timezone lookup in resolve_local_datetime and use checked_sub_offset so overflow returns None instead of panicking. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Jefffrey
left a comment
There was a problem hiding this comment.
just some minor notes on documentation
| /// reading (the skipped hour when the clocks go forward) is shifted forward by | ||
| /// the length of the gap. | ||
| /// | ||
| /// These rules also apply to the CSV and JSON readers, which call this |
There was a problem hiding this comment.
it feels odd to mention CSV and JSON here; it seems more appropriate to locate that in csv/json related code, as that is where users of csv/json would look for such documentation
| /// | ||
| /// Timezones with a fixed offset are never ambiguous and have no gaps. | ||
| /// | ||
| /// The gap rule probes the offset 24 hours earlier. This is correct for the |
There was a problem hiding this comment.
this section should be combined with the section above about None not being expected in practice as they describe the same thing
|
@adriangb do you have time to address Jeffrey's comments? I can then merge it in |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Done! |
|
thanks all |
Which issue does this PR close?
string_to_datetime(andUtf8→Timestamp(tz)casts) fail on DST-ambiguous and nonexistent local times #11039 and Cast fromTimestamp(_, None)to a named timezone errors on DST boundaries datafusion#25084Rationale for this change
What a user hits today
Parse a timestamp string into a named timezone. Pick a wall clock reading that the zone skips or repeats. The parse fails.
Today:
After this PR:
Every
Utf8/LargeUtf8/Utf8Viewcast toTimestamp(_, Some(tz))goes through that function. UnderCastOptions { safe: true }the error becomes a silent NULL for the whole row.How this reaches a SQL user
A DataFusion user reaches the parser through ordinary SQL. #11038 fixed the cast kernel, so the first line below now works and the other two still fail:
To a user those are the same query, and the last spelling is the most natural one. With only #11038 merged, an explicit
::timestampin the middle of an expression makes a query start to work. That is harder to explain than the current uniform failure. So this PR completes #11038, it is not an optional follow-up.Why the parser fails
string_to_datetimeinarrow-cast/src/parse.rsresolves a wall clock reading withLocalResult::single(). That isNonein two distinct cases:There are three such call sites, one per shape of input:
'2018-11-04''2024-03-10 02:30:00''2024-03-10 02:30:00 America/New_York'What changes are included in this PR?
Overview
One shared function now decides every wall clock reading in arrow-cast. The three parser sites and the cast kernel all call it. The policy itself does not change.
Detail
The resolution policy is deliberately shared with #11038, not restated. Two copies of this policy that drift apart is exactly the bug this PR avoids. One such divergence already exists in the tree, and this PR leaves it alone. See the follow-up section below.
So #11038's
resolve_local_offsetmoves out ofcast/temporal.rsinto a new privatearrow-cast/src/local_time.rsmodule and becomespub(crate). It generalises from&Tzto anychrono::TimeZone, becausestring_to_datetimeis generic over its target timezone. A thinresolve_local_datetimewrapper returns aDateTime<T>, which is the shape the three parser sites want. All four call sites now go through the one function.The policy is unchanged from #11038:
Two properties of the timezone database make the probe safe:
Both were verified exhaustively for #11038. This PR does not re-derive them.
On site 3, the zone named in the string
Site 3 resolves against a zone the user spelled out, rather than one taken from the target type. So it is fair to ask whether it deserves stricter treatment. It gets the same policy, for two reasons:
'2024-03-10 02:30:00 America/New_York'is exactly as unresolvable as'2024-03-10 02:30:00'read inAmerica/New_York, and the user has no other spelling for what they meant.SET TimeZone='America/New_York'; SELECT '2024-03-10 02:30:00'::timestamptz;andSELECT timestamptz '2024-03-10 02:30:00 America/New_York';both return2024-03-10 07:30:00+00. This was measured, not assumed.A different rule for site 3 would put the "same query, different spelling, different outcome" split back inside a single function. That split is what this PR exists to remove.
Are these changes tested?
Yes. Every expected instant in the new tests came from a real PostgreSQL 17.11 (
postgres:17). None were derived by hand. All three call sites are covered for both an ambiguous and a nonexistent reading:America/New_YorkandAmerica/Los_Angeles— a whole-hour DST step.Australia/Lord_Howe— a thirty minute step, so a gap shifts02:15to02:45, not03:15. This rules out any implementation that assumes a one-hour gap.Pacific/Chatham—+12:45/+13:45, an offset that is not a whole number of hours on either side.Australia/Sydney— southern hemisphere, so the gap falls in October and the repeated hour in April. This catches implementations that reach foroffset_from_utc_datetimeas a shortcut, which returns the post-transition offset and is wrong here.America/Sao_Paulo2018-11-04— local midnight does not exist, because Brazil started DST at 00:00. Result:2018-11-04T03:00:00Z.America/Havana2024-11-03— local midnight happens twice, because Cuba ends DST at 00:00. Result: the later instant,2024-11-03T05:00:00Z.+05:30), which has no transitions and must be unaffected.CastOptions { safe: true }againstsafe: falseat the array level. The former produced a silent NULL and the latter an error. Both now produce the resolved instant, and genuinely unparseable input still nulls or errors as before.StringArray,LargeStringArrayandStringViewArray. This confirms the offset resolves per row, not once for the array.Counterfactually validated. Revert the three call sites and keep the tests, and 9 of the new tests fail. The
safe: truecase fails withleft: 0, which is the silent-NULL mode.arrow-castalready gainedarrow-array/chrono-tzas a dev-dependency in #11038, so no manifest change is needed here.Field research: PostgreSQL 17 and DuckDB
The resolution policy is the one arbitrary decision in this stack, so it needs more than one reference. Both engines were measured directly: PostgreSQL 17.11 in
postgres:17, and the DuckDB 1.5.2 CLI with the ICU extension. All times below are UTC.America/New_York2024-11-03 01:30— ambiguous2024-11-03 06:30:002024-11-03 06:30:002024-11-03 06:30:00America/Havana2024-11-03 00:00— ambiguous local midnight2024-11-03 05:00:002024-11-03 05:00:002024-11-03 05:00:00America/New_York2024-03-10 02:30— gap2024-03-10 07:30:002024-03-10 07:30:002024-03-10 07:30:00America/Sao_Paulo2018-11-04 00:00— local midnight does not exist2018-11-04 03:00:002018-11-04 03:00:002018-11-04 03:00:00Australia/Sydney2024-10-06 02:30— southern hemisphere gap2024-10-05 16:30:002024-10-05 16:30:002024-10-05 16:30:00Australia/Lord_Howe2024-10-06 02:15— 30 minute DST step2024-10-05 15:45:002024-10-05 15:45:002024-10-05 15:45:00Pacific/Chatham2024-09-29 03:00— +12:45 / +13:452024-09-28 14:15:002024-09-28 14:15:002024-09-28 14:15:00The two ambiguous rows carry the argument. A gap has only one sensible answer, so it discriminates nothing. An ambiguous reading has two real answers, and the choice between them is the policy.
2024-11-03 01:30in New York is either05:30Z(EDT) or06:30Z(EST). Both engines give06:30Z. Havana's ambiguous midnight is either04:00Z(CDT) or05:00Z(CST). Both engines give05:00Z. In both cases the answer is the later instant, which is what this PR does.The case neither engine can arbitrate
A fixed-offset string such as
'+05:30'has no agreed reading, so it is recorded here rather than hidden. Measured:SET TimeZone='+05:30'then'2024-01-01 12:00:00'::timestamptz17:30Z'2024-01-01 12:00:00'::timestamp AT TIME ZONE '+05:30'17:30Ztimestamptz '2024-01-01 12:00:00 +05:30'06:30ZPostgreSQL agrees with arrow-rs when the offset sits inside the literal. It disagrees when the offset acts as a zone name. DuckDB 1.5.2 rejects the zone-name spelling outright:
Not implemented Error: Unknown TimeZone '+05:30'.So neither engine settles it. This PR does not change that behaviour. It is tracked downstream in apache/datafusion#25170.
Are there any user-facing changes?
Yes, and the change is intentional. A string that previously failed to parse, or silently became NULL, now parses to the instant PostgreSQL and DuckDB produce. There is no API change.
resolve_local_offsetstays crate-private.Together with #11038 this closes DataFusion #25084
Neither PR closes it alone. #11038 covers the cast kernel. This PR covers the parser. A user hits both through the same SQL, so both must land.
One existing test changes expectation
arrow/tests/timezone.rsasserted the old error for twoAmerica/Los_Angelescases. They move fromtest_parse_timezone_invalidtotest_parse_timezone:2023-03-12 02:05:06 America/Los_Angeleserror computing timezone offset2023-03-12T10:05:06+00:002023-11-05 01:30:06 America/Los_Angeleserror computing timezone offset2023-11-05T09:30:06+00:00Downstream
DataFusion has one matching expectation, at
datafusion/sqllogictest/test_files/datetime/timestamps.slt:2360-2366. It asserts thatSELECT TIMESTAMPTZ '2023-03-12 02:00:00 America/Los_Angeles'errors, and its own comment already notes# postgresql: accepts. On the next arrow upgrade it becomes aquery Pthat returns2023-03-12T10:00:00Z, which is what PostgreSQL returns. This PR includes no DataFusion change.Follow-up:
arrow-arrayresolves ambiguity the opposite wayThis PR creates one shared helper for arrow-cast, but one divergence remains in the tree. It is deliberately untouched here:
arrow-cast::local_time::resolve_local_offsetarrow-array::types::from_naive_datetime(types.rs:347)Ambiguous(dt1, _))NoneMeasured in
America/New_Yorkfor2024-11-03 01:30:00:from_naive_datetimegives05:30Z, and this stack gives06:30Z.This matters downstream. DataFusion calls the
arrow-arrayform directly with aSome(tz)indatafusion/functions/src/datetime/date_part.rs(date_to_scalar, four call sites). Where the local midnight it builds is ambiguous, the earlier instant becomes an upper bound, so valid rows can drop out. The behaviour predates this stack.Changing
from_naive_datetimeaffects every caller ofarrow-array, so it deserves its own review rather than a ride along with a parser fix.🤖 Generated with Claude Code