Repository navigation
Conversation
TimestampLocal subtracted the UTC offset of the wall clock read as UTC, which is one hour off within the offset's distance of a DST change. With TIMESTAMP_DB_LOCAL alone, the Time was already in UTC mode, so its utc_offset was 0 and the wall clock came back labelled UTC. Time.local resolves both, and handles DST gaps and overlaps like PostgreSQL.
The UTC Time was built then thrown away: 2 objects per value instead of 1. The specs reuse BinaryEncoder::TimestampUtc to build the wire values.
czj
marked this pull request as draft
October 27, 2026 09:18
Contributor
Author
|
A deeper review found that relying on |
Time.local resolves DST gaps and overlaps the opposite way to PostgreSQL on 64-bit macOS after 2038, and picks the summer offset on every overlap on TruffleRuby. Instead, take the local UTC offsets a day before and after the wall clock with localtime_r(), and when they differ, keep the interpretation that gives back the wall clock, or the later instant in a gap or an overlap, like DetermineTimeZoneOffset(). The Time is then built like the UTC one, so its local fields stay lazy, and Time.local is only a fallback when the C library can't convert the instant. The specs now compare with PostgreSQL's answers instead of Time.local, up to 2040, and skip when Europe/Paris isn't available as TZ.
Before 1901-12-14, Ruby on macOS renders local times without the LMT offsets the C library gives, so decoded wall clocks didn't read back as written. Those dates now keep master's subtraction of Ruby's offset, which has no DST to get wrong there and covers the whole int64 range, unlike j2date(). The local and UTC dates are at most a day apart, so their weekdays give the offset without calendar arithmetic.
czj
marked this pull request as ready for review
October 27, 2026 17:30
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.
The review of #760 turned up two bugs in the
TIMESTAMP_DB_LOCALbranch ofPG::BinaryDecoder::Timestamp, both older than any recent change. Neither shows up in CI because every job runs withTZ=UTC.TimestampLocalsubtracted the UTC offset found at the wall clock read as UTC, instead of the one at the real instant, so values near a DST change came back one hour off. WithTIMESTAMP_DB_LOCALalone, theTimewas already in UTC mode, itsutc_offsetwas 0, and the wall clock came back labelled UTC (12:00 UTCinstead of10:00 UTCfor a Paris summer noon), unlike the text decoder with the same flags.This version follows PostgreSQL's own rule (
DetermineTimeZoneOffset) in C: it reads the offsets one day before and one day after the wall clock withlocaltime_r, uses that offset when they agree, and otherwise keeps the candidate that maps back to the wall clock, or the later instant when both or neither do (overlaps and gaps). I compared it withwall_clock::timestamp AT TIME ZONE zonefor every quarter hour of 1950, 1990, 2011, 2024, 2038, 2040 and 2100 in 10 zones (Paris, London, New York, Santiago, St John's, Cairo, Kathmandu, Sydney, Lord Howe, Apia): 0 mismatches out of 2,454,720 values, for both flag combinations. With 1.7.0,TimestampLocalhad 28 (London) to 2,968 (Apia) mismatches, andTIMESTAMP_DB_LOCALalone was wrong whenever the offset wasn't 0. The new specs hardcode PostgreSQL's answers underTZ=Europe/Paris, including a gap and an overlap in 2024 and both beyond 2038, and fail on master.An earlier version used
Time.local, which I dropped after a review: on macOS, Ruby refuseslocaltimeoutside 32-bittime_t, so it was wrong after 2038, and TruffleRuby resolves overlaps differently.tzset()is called per value because glibc'slocaltime_rdoesn't re-readTZ(macOS does). Before 1901-12-14, Ruby on macOS renders local times without the C library's LMT offsets, so those keep master's subtraction, which already round-trips there.This is a behavior change, so I would rather say it upfront. Anyone who worked around the hour shift or the UTC label will see different values. Speed depends on the platform. These are medians of 7 runs over 10,000 values with Ruby 4.0.9, ranges over 3 alternating rounds in
TZ=Europe/ParisandTZ=UTC.TimestampLocalallocates 1 object instead of 2 everywhere. On Linux it gets faster: from 210–320 to 130–160 ns per value on x86_64, and from 370–420 to 245–315 ns on aarch64 (GitHub runners, ubuntu-24.04). On arm64-darwin it gets slower: from ~490 to ~895 ns, because macOSlocaltime_ris much more expensive than glibc's.TIMESTAMP_DB_LOCALalone used to skip the work, so it costs more everywhere: from ~110 to ~130–160 ns on x86_64, from ~230 to ~250–320 ns on aarch64, and from ~145 to ~900 ns on macOS. On the same Linux runners,TimestampLocalalso matched PostgreSQL for every quarter hour of 1950, 2011, 2024, 2038, 2040 and 2100 in Paris, New York, Sydney, Apia and Lord Howe, against 60 to 430 mismatches for 1.7.0, and the type specs pass under glibc.The text decoder still differs on overlaps:
mktime()picks the DST side on macOS, where PostgreSQL picks standard time. I left it out of this one. Siblings: #759 (the text decoder'stimegm(), merged) and #760. The comparison and benchmark scripts are in https://github.com/czj/ruby-pg/tree/bench/binary-local-timestamp if you'd like to rerun them.Does the per-value
tzset()look acceptable to you, or would you rather leaveTZchanges at runtime to the caller?🤖 Generated with Claude Code, but prompted, read, and verified by @czj, a caring human :)