Skip to content

Read a Fixed/UTC offset the way the server does - #612

Merged
alex-clickhouse merged 1 commit into
mainfrom
fix/fixed-utc-offset-server-limits
Sep 7, 2026
Merged

Read a Fixed/UTC offset the way the server does#612
alex-clickhouse merged 1 commit into
mainfrom
fix/fixed-utc-offset-server-limits

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

What changed

The nightly "Test against ClickHouse HEAD" job started failing on
ReadDateTime_WithOutOfRangeFixedUtcOffset_FallsBackToUtcWallClock:

Code: 36. DB::Exception: Time zone Fixed/UTC+19:00:00 is not supported: a fixed UTC offset is
spelled `Fixed/UTC±HH:MM:SS` and has to be a whole number of quarters of an hour, no further
from UTC than 14 hours. (BAD_ARGUMENTS) (version 26.9.1.876)

The cause is server-side, in ClickHouse
#118286 (merged 2026-09-06, one nightly
before the failure). cctz synthesizes a zone for any Fixed/UTC±HH:MM:SS name up to 24 hours,
which is 172,801 distinct names, and each name that gets loaded costs ~4.6 MiB in a DateLUT
cache that never evicts. The new DateLUTImpl::isSupportedTimeZoneName bounds that by accepting
only the offsets a time zone can really have: a whole number of quarters of an hour, no further
from UTC than 14 hours
.

So the test's Fixed/UTC+19:00:00 is no longer a name any server will produce, and the branch it
covered is no longer reachable through the server.

Where the driver applies these limits

One place on main: AbstractDateTimeType.ResolveTimezone. (DateTimeZones.Resolve in
ClickHouse.Driver.Tcp has the same job but is not on main yet — see the note at the end.)

I checked its two limits against a real 26.3 (support floor) and a real 26.9.1 server:

Limit Verdict
±18 h range guard Correct, no change. It is NodaTime's Offset range, which is the widest the driver can represent, and it already covers everything any supported server can emit.
[0-5]\d on the minute and second fields Wrong. Fixed here.

The second one is a real bug. The comment claimed the restriction stops a malformed name from
"being misread as a different valid offset (e.g. 60 minutes as +1 h)" — but reading 60 minutes as
an extra hour is exactly what the server does, because cctz carries each field into the next:

                       26.3         26.9.1
Fixed/UTC+05:60:00     +06:00       +06:00
Fixed/UTC+05:00:60     +05:01:00    rejected (finer than 15 min)
Fixed/UTC+13:60:00     +14:00       +14:00

Fixed/UTC+05:60:00 is accepted by every supported server, including 26.9. The driver's regex
rejected the name, ResolveTimezone returned null, and the read fell through to the UTC
projection — a silent 6-hour wall-clock shift, the same class of bug as #370.

The fix is to parse the family the way the server does: two digits per field, carrying into the
next. The existing ±18 h guard still rejects whatever that produces out of range, so nothing
new is accepted — Fixed/UTC+99:99:99 carries to ~100 h and is still null.

- @"^Fixed/UTC([+-])(\d{2}):([0-5]\d):([0-5]\d)$",
+ @"^Fixed/UTC([+-])(\d{2}):(\d{2}):(\d{2})$",

The hours * 3600 + minutes * 60 + seconds arithmetic already carried correctly, so the regex was
the only change needed.

Tests

Removed ReadDateTime_WithOutOfRangeFixedUtcOffset_FallsBackToUtcWallClock. It needed the server
to emit a Fixed/UTC+19:00:00 column, which 26.9 refuses to do. Its coverage of the
ResolveTimezone null branch moves to ResolveTimezoneParseTests, the class that already exists
for branches the server cannot reach.

Two expectations flipped, because they pinned behavior that a real server contradicts.
ParseDateTime_FixedUtcMinutesOutOfRange and ParseDateTime_FixedUtcSecondsOutOfRange asserted a
null zone for +05:60:00 / +05:00:60; they are now
ParseDateTime_FixedUtcMinutesCarryIntoHour and ParseDateTime_FixedUtcSecondsCarryIntoMinute,
asserting the carried offsets shown in the table above.

Added:

  • ReadDateTime_FixedUtcOffsetWithMinuteCarry — integration coverage of the fix. +05:60:00 reads
    as +06:00 against a real server, and the name is accepted on 26.3 through 26.9.
  • ReadDateTime_MaxFixedUtcOffset / ReadDateTime_MinFixedUtcOffset±14:00:00, now the widest
    offset a column can carry.
  • ParseDateTime_FixedUtcHoursOutOfRange, ParseDateTime64_FixedUtcHoursOutOfRange,
    ParseDateTime_FixedUtcAllFieldsOutOfRange — the range guard.
  • ParseDateTime_FixedUtcSingleDigitHour — the two-digit shape, which the server also requires.

ResolveTimezone has three branches (IANA hit, fixed-offset parse, null fallback) and all three
are exercised, so no separate coverage run.

Verified locally on net9.0 against two servers, both green:

  • 26.9.1.879 (matches the failing nightly): full ClickHouse.Driver.Tests suite.
  • 26.3.19.3 (support floor): the fixed-offset and parse fixtures.

Follow-up, not in this PR

ClickHouse.Driver.Tcp/Types/Codecs/DateTimeZones.cs on the unmerged tcp/** stack has the same
parsing with the same [0-5]\d restriction, so it needs the same one-line change when that stack
is next restacked. Its ±14 h and whole-minute limits are the BCL's
TimeZoneInfo.CreateCustomTimeZone constraints and should stay: they happen to match 26.9's rule
exactly, so nothing there is broken today.

🤖 Generated with Claude Code

ClickHouse 26.9 caps a fixed UTC offset at a whole number of quarters of
an hour, no further from UTC than 14 hours, so the server no longer emits
the Fixed/UTC+19:00:00 name an integration test needed. Move that null
branch to the parse fixture that exists for names the server cannot send.

Each field of the name carries into the next, which is how cctz reads it:
26.3 and 26.9 both resolve Fixed/UTC+05:60:00 to +06:00. Restricting the
minute and second fields to 00-59 rejected such a name, so the read fell
through to the UTC projection and shifted the wall clock. Accept two
digits per field and let the existing 18 h range guard reject the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 7, 2026 07:15
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The behavioral change is small, server-aligned, and covered by updated tests, with only minor changelog traceability feedback remaining.

Pull request overview

This PR fixes a silent wall-clock shift when reading DateTime/DateTime64 columns whose server-emitted Fixed/UTC±HH:MM:SS timezone name contains minute/second fields above 59 (e.g. Fixed/UTC+05:60:00), by parsing fixed-offset names the same way ClickHouse does (field carry), and updating tests to match real server behavior across supported versions.

Changes:

  • Loosened the Fixed/UTC parsing regex to accept any two-digit MM/SS and rely on carry + the existing ±18h range guard.
  • Updated/expanded timezone handling tests: removed the now-unreachable integration test for Fixed/UTC+19:00:00, added integration coverage for minute-carry and ±14:00:00 extremes, and adjusted parse expectations.
  • Added a changelog.d/ fragment documenting the user-visible fix.
File summaries
File Description
ClickHouse.Driver/Types/AbstractDateTimeType.cs Updates Fixed/UTC timezone parsing to match server carry semantics while keeping the existing ±18h representability guard.
ClickHouse.Driver.Tests/Types/TimezoneHandlingTests.cs Refreshes integration + parse tests to reflect real server acceptance rules and adds coverage for minute-carry and ±14h offsets.
changelog.d/fixed-utc-offset-field-carry.fixes.md Adds a changelog fragment describing the bug fix.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

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

Comment thread changelog.d/fixed-utc-offset-field-carry.fixes.md
@alex-clickhouse
alex-clickhouse enabled auto-merge (squash) September 7, 2026 07:24
@alex-clickhouse
alex-clickhouse merged commit 0eee6aa into main Sep 7, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants