Fix GetDateTimeOffset: return the stored instant for DST-ambiguous timestamps - #517
Fix GetDateTimeOffset: return the stored instant for DST-ambiguous timestamps#517polyglotAI-bot wants to merge 6 commits into
Conversation
…mestamps GetDateTimeOffset decoded the column to a wall-clock DateTime and re-interpreted that wall clock in the column timezone. Inside a DST fall-back hour a wall clock occurs twice at two different offsets, and the lenient resolution used for a wall clock always picks the earlier one, so the second occurrence came back as a different instant (one hour early). The instant a date/time value decoded from is now captured while the row is read and used directly by GetDateTimeOffset, so the returned DateTimeOffset preserves the instant exactly. Fixes: #515
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR fixes ClickHouseDataReader.GetDateTimeOffset(int) for DST fall-back ambiguous timestamps by preserving and reusing the decoded absolute instant (rather than re-interpreting an ambiguous wall-clock DateTime), ensuring the returned DateTimeOffset matches the stored instant for timezone-aware DateTime/DateTime64 columns (issue #515).
Changes:
- Added an internal
ReadWithInstant(..., out Instant?)decode hook to capture decoded instants for types that encode them. - Updated the ADO reader to capture per-row instants for date/time columns and prefer them in
GetDateTimeOffset. - Added integration regression tests for DST-ambiguous instants and updated CHANGELOG/RELEASENOTES.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| RELEASENOTES.md | Documents the DST-ambiguous GetDateTimeOffset bug fix (issue #515). |
| CHANGELOG.md | Adds the corresponding unreleased bug-fix entry. |
| ClickHouse.Driver/Types/ClickHouseType.cs | Introduces ReadWithInstant to optionally report a decoded Instant. |
| ClickHouse.Driver/Types/DateTimeType.cs | Overrides ReadWithInstant to report the decoded Instant. |
| ClickHouse.Driver/Types/DateTime64Type.cs | Centralizes tick→instant conversion and overrides ReadWithInstant to report the decoded Instant. |
| ClickHouse.Driver/Types/NullableType.cs | Ensures nullable date/time types can propagate instants through the null-byte wrapper. |
| ClickHouse.Driver/Types/AbstractDateTimeType.cs | Exposes ToDateTimeOffset(Instant) internally for reuse by the reader. |
| ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs | Captures per-row instants during decoding and uses them in GetDateTimeOffset. |
| ClickHouse.Driver.Tests/Types/TimezoneHandlingTests.cs | Adds regression coverage for ambiguous DST instants and mixed-column alignment/row isolation. |
Date/Date32 are AbstractDateTimeType but encode a day number, not an instant, so a result set of only those columns allocated the capture arrays and took the branchy decode loop while ReadWithInstant could only ever report null. Types now declare the capability via ClickHouseType.ReportsInstant (DateTime/DateTime64 true, NullableType delegating), and the reader keys the capture path off that.
|
I don't like the design with the instantColumns and rowInstants in the Reader...what are the options here in terms of the structure, is it possible to encapsulate this fix in the DateTime types? |
The reader no longer tracks instants itself: an internal IInstantReader, implemented by DateTimeType, DateTime64Type and NullableType (delegating), declares which types decode an absolute instant, and a RowInstantReader in Types/ owns the per-column capability map, the per-row instants and the conversion to DateTimeOffset. ClickHouseDataReader keeps a single field and forwards to it, and ClickHouseType is untouched again. Behavior is unchanged.
|
Fair objection — I've restructured it in d585059 so the reader no longer knows anything about instants. Options I considered, and why this one: 1. Fully inside the date/time types, no reader involvement at all — not possible. It can't be remembered on the type instance: the same instance decodes every row and every element of a composite column ( 2. Store a 3. What's pushed now: the reader delegates, the types own the logic.
Also shortened the changelog entry per AGENTS.md ("keep entries short, no root-cause analysis"). Behavior is unchanged from the previous head — full suite still green on net10.0 (9786 passed, 142 skipped, 0 failed). The regression test that pinned the type-capability flag now pins the same thing through Two related gaps I deliberately did not fold in here: |
…tdatetimeoffset-dst-instant # Conflicts: # CHANGELOG.md # RELEASENOTES.md
|
Rebased/merged Resolution: No code changes in this push beyond the merge itself. Full suite green on the merged tree (net10.0: 10174 passed, 142 skipped, 0 failed). |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 31e8508. Configure here.
…tdatetimeoffset-dst-instant # Conflicts: # ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs # ClickHouse.Driver/Types/AbstractDateTimeType.cs # ClickHouse.Driver/Types/DateTimeType.cs
|
Rebuilt on current The conflict was semantic, not textual: #449 (box-free POCO read fast path) reworked the date/time read API, so this branch was re-expressed on top of it.
No behavior change from the merge. Full suite green on net10.0: 10505 passed, 142 skipped, 0 failed. Still waiting on a review of the |
…tdatetimeoffset-dst-instant # Conflicts: # ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs
|
Rebased onto
Verification: full suite green on net10.0 — 10800 passed, 142 skipped, 0 failed. The regression fixture fails 8 of 19 cases with the capture disabled and passes with it. Added cases from a self-review pass: Note for CI: No approval was dismissed — the PR had none ( |

Description
Fixes #515.
ClickHouseDataReader.GetDateTimeOffset(int)did not read the offset from the storedinstant. It decoded the column to a wall-clock
DateTime(Kind=Unspecifiedfor atimezone-aware column) and then re-interpreted that wall clock in the column timezone via
AbstractDateTimeType.CoerceToDateTimeOffset(DateTime)→ NodaTimeAtLeniently. Inside aDST fall-back hour a local wall clock is ambiguous — it occurs twice, at two different
offsets — and
AtLenientlyalways resolves the ambiguity to the earlier offset. That isright for the first occurrence and wrong for the second, and the information needed to
tell them apart had already been discarded by the round trip through the wall clock. The
result was a
DateTimeOffsetone hour before the instant the server stored (e.g.2025-11-02T01:30:00-04:00instead of-05:00forAmerica/New_York), i.e. a genuinelydifferent point in time, not an equivalent representation of the same one.
The fix keeps the instant instead of trying to recover it.
ClickHouseTypegains aninternal
ReadWithInstantthat reads a value exactly likeReadwhile also reporting theabsolute
Instantit decoded (DateTimeType/DateTime64Typereport it,NullableTypedelegates after the null byte, everything else reports
null).ClickHouseDataReaderdecodes date/time columns through it and
GetDateTimeOffsetconverts the captured instantwith the same
Instant → DateTimeOffsetconversion the type already uses, so the accessornow agrees with the stored instant for both occurrences of an ambiguous wall clock.
Reproduces on
DateTime('<zone>'),DateTime64(p, '<zone>'), and theirNullable(...)forms; every other input keeps the value and offset it had before (the first occurrence of
the ambiguous hour, non-ambiguous instants, zero-offset-at-that-instant zones, columns with
no timezone, and
Date/Date32/Timecolumns, which carry no instant and still take theold coercion path).
Changes
ClickHouse.Driver/Types/ClickHouseType.cs— newinternal virtual object ReadWithInstant(ExtendedBinaryReader, out Instant?); the default reports no instant and delegates toRead.ClickHouse.Driver/Types/DateTimeType.cs,DateTime64Type.cs— override it to report the decoded instant (DateTime64Type.FromClickHouseTicksnow goes through a newToInstant, so the tick→instant conversion has a single definition). No extra timezone resolution orDateTimeOffsetconstruction happens on the row-decode path: only theInstantis kept, and theDateTimeOffsetis built in the accessor exactly as before.ClickHouse.Driver/Types/NullableType.cs— override it to pass through to the underlying type after the null byte, soNullable(DateTime(...))is covered too (a NULL reports no instant).ClickHouse.Driver/Types/AbstractDateTimeType.cs—ToDateTimeOffset(Instant)is nowinternalso the reader can use the type's own conversion.ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs— precompute the date/time ordinals once per result set (BuildDateTimeColumns;nullwhen the result set has none, in which case the row loop is byte-for-byte the old one) and capture their instants per row;GetDateTimeOffsetprefers the captured instant. With anIReadValueConverterconfigured the converter may replace the value, so the captured instant is used only while the value the caller sees still matches the one it was captured for; otherwise the old coercion is used.CHANGELOG.md/RELEASENOTES.md— Bug Fixes entry.Test
New fixture
ReadDateTimeOffsetAmbiguousDstTestsinClickHouse.Driver.Tests/Types/TimezoneHandlingTests.cs(integration,TestCaseSourcefollowing the existing
ReadDateTimeFixedUtcOffsetTestsstyle). Expected instants/offsetsare hard-coded from the IANA rules for the 2025
America/New_Yorkfall-back day(
1762061400=05:30Z,-04:00;1762065000=06:30Z,-05:00; both render as local01:30:00, verified against the server), not read back from the driver.DateTime,DateTime64(3)andNullable(DateTime): the instant and offset must be the stored ones.-04:00), a non-ambiguous summer instant, andEurope/London's fall-back hour where the offset is zero at that instant (so the value keepsKind=Utcand a zero offset).String,DateTime('tz'),Nullable(DateTime('tz'))NULL,Date,DateTime64(3,'tz'),Int32,String) asserting every column still decodes correctly — the new decode path must stay byte-aligned — and that aDatecolumn still reports a zero offset.5 of the 9 cases fail on
mainand all 9 pass with the fix. Full suite green:dotnet test ClickHouse.Driver.Tests --framework net10.0→ 9779 passed, 0 failed(142 skipped), no existing test edited or weakened.
Pre-PR validation gate
main, pass with the fix)AGENTS.md(read + write paths considered — the write path's lenient resolution of anUnspecifiedwall clock is unchanged and still correct there;Releasebuild produces no new analyzer warnings; CHANGELOG + RELEASENOTES updated)GetDateTimeOffset's signature is unchanged; the new member isinternal), soPublicAPI/*.txtis untouched