From 9b872e7cb4545309642b4a5b46a0c9c2afec213c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 21:38:11 +0300 Subject: [PATCH 1/7] Decode binary local timestamps with Time.local 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. --- ext/pg_binary_decoder.c | 23 ++++++++++++++++++++--- spec/pg/type_spec.rb | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/ext/pg_binary_decoder.c b/ext/pg_binary_decoder.c index 0e299e484..8c9504c29 100644 --- a/ext/pg_binary_decoder.c +++ b/ext/pg_binary_decoder.c @@ -285,6 +285,10 @@ pg_bin_dec_array(t_pg_coder *conv, const char *input_line, int len, int tuple, i #define PG_INT64_MIN (-0x7FFFFFFFFFFFFFFFL - 1) #define PG_INT64_MAX 0x7FFFFFFFFFFFFFFFL +#define POSTGRES_EPOCH_JDATE 2451545 /* == date2j(2000, 1, 1) */ +#define USECS_PER_DAY 86400000000LL + +void j2date(int jd, int *year, int *month, int *day); /* * Document-class: PG::BinaryDecoder::Timestamp < PG::SimpleDecoder @@ -340,8 +344,22 @@ pg_bin_dec_timestamp(t_pg_coder *conv, const char *val, int len, int tuple, int } #endif if( conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL ) { - /* interpret it as local time */ - t = rb_funcall(t, rb_intern("-"), 1, rb_funcall(t, rb_intern("utc_offset"), 0)); + /* interpret it as local time: Time.local resolves DST gaps and overlaps like PostgreSQL does */ + int64_t days = timestamp / USECS_PER_DAY; + int64_t usecs = timestamp % USECS_PER_DAY; + int year, month, day, secs; + + if( usecs < 0 ){ + usecs += USECS_PER_DAY; + days--; + } + secs = (int)(usecs / 1000000); + j2date((int)(days + POSTGRES_EPOCH_JDATE), &year, &month, &day); + t = rb_funcall(rb_cTime, rb_intern("local"), 7, INT2NUM(year), INT2NUM(month), INT2NUM(day), + INT2NUM(secs / 3600), INT2NUM(secs / 60 % 60), INT2NUM(secs % 60), INT2NUM((int)(usecs % 1000000))); + if( !(conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { + t = rb_funcall(t, rb_intern("utc"), 0); + } } return t; } @@ -349,7 +367,6 @@ pg_bin_dec_timestamp(t_pg_coder *conv, const char *val, int len, int tuple, int #define PG_INT32_MIN (-0x7FFFFFFF-1) #define PG_INT32_MAX (0x7FFFFFFF) -#define POSTGRES_EPOCH_JDATE 2451545 /* == date2j(2000, 1, 1) */ #define MONTHS_PER_YEAR 12 /* taken from PostgreSQL sources at src/backend/utils/adt/datetime.c */ diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index e8d952838..d770a2234 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -234,6 +234,39 @@ def textdec_timestamptz_decode_should_fail(str) end end + context 'binary local timestamps' do + around do |example| + tz = ENV['TZ'] + ENV['TZ'] = 'Europe/Paris' + example.run + ensure + ENV['TZ'] = tz + end + + def binary_timestamp(*wall_clock) + [((Time.utc(*wall_clock) - Time.utc(2000)) * 1_000_000).round].pack("q>") + end + + it 'decodes wall clocks around DST changes like Time.local and PostgreSQL' do + decoder = PG::BinaryDecoder::TimestampLocal.new + [ + [2024, 1, 15, 12, 0], [2024, 7, 1, 12, 0, Rational(123456, 1000000)], + [2024, 3, 31, 1, 30], [2024, 3, 31, 2, 30], [2024, 3, 31, 3, 30], + [2024, 10, 27, 1, 30], [2024, 10, 27, 2, 30], [2024, 10, 27, 3, 30], + ].each do |wall_clock| + time = decoder.decode(binary_timestamp(*wall_clock)) + expected = Time.local(*wall_clock) + expect( [time, time.utc_offset] ).to eq( [expected, expected.utc_offset] ) + end + end + it 'decodes wall clocks to UTC with TIMESTAMP_DB_LOCAL alone' do + decoder = PG::BinaryDecoder::Timestamp.new(flags: PG::Coder::TIMESTAMP_DB_LOCAL) + time = decoder.decode(binary_timestamp(2024, 7, 1, 12, 0)) + expect( time ).to eq( Time.local(2024, 7, 1, 12, 0) ) + expect( time ).to be_utc + end + end + context 'dates' do let(:textdec_date) { PG::TextDecoder::Date.new } From d11350b0f6a1a3476481c63c59a8975aada39f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Tue, 27 Oct 2026 07:47:20 +0300 Subject: [PATCH 2/7] Build local timestamps before the UTC Time, not after 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. --- ext/pg_binary_decoder.c | 36 +++++++++++++++++++----------------- spec/pg/type_spec.rb | 8 ++------ 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/ext/pg_binary_decoder.c b/ext/pg_binary_decoder.c index 8c9504c29..e0be97f9e 100644 --- a/ext/pg_binary_decoder.c +++ b/ext/pg_binary_decoder.c @@ -326,23 +326,6 @@ pg_bin_dec_timestamp(t_pg_coder *conv, const char *val, int len, int tuple, int case PG_INT64_MIN: return rb_str_new2("-infinity"); default: - /* PostgreSQL's timestamp is based on year 2000 and Ruby's time is based on 1970. - * Adjust the 30 years difference. */ - sec = (timestamp / 1000000) + 10957L * 24L * 3600L; - nsec = (timestamp % 1000000) * 1000; - -#if (RUBY_API_VERSION_MAJOR > 2 || (RUBY_API_VERSION_MAJOR == 2 && RUBY_API_VERSION_MINOR >= 3)) && defined(NEGATIVE_TIME_T) && defined(SIZEOF_TIME_T) && SIZEOF_TIME_T >= 8 - /* Fast path for time conversion */ - { - struct timespec ts = {sec, nsec}; - t = rb_time_timespec_new(&ts, conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1); - } -#else - t = rb_funcall(rb_cTime, rb_intern("at"), 2, LL2NUM(sec), LL2NUM(nsec / 1000)); - if( !(conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { - t = rb_funcall(t, rb_intern("utc"), 0); - } -#endif if( conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL ) { /* interpret it as local time: Time.local resolves DST gaps and overlaps like PostgreSQL does */ int64_t days = timestamp / USECS_PER_DAY; @@ -360,7 +343,26 @@ pg_bin_dec_timestamp(t_pg_coder *conv, const char *val, int len, int tuple, int if( !(conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { t = rb_funcall(t, rb_intern("utc"), 0); } + return t; } + + /* PostgreSQL's timestamp is based on year 2000 and Ruby's time is based on 1970. + * Adjust the 30 years difference. */ + sec = (timestamp / 1000000) + 10957L * 24L * 3600L; + nsec = (timestamp % 1000000) * 1000; + +#if (RUBY_API_VERSION_MAJOR > 2 || (RUBY_API_VERSION_MAJOR == 2 && RUBY_API_VERSION_MINOR >= 3)) && defined(NEGATIVE_TIME_T) && defined(SIZEOF_TIME_T) && SIZEOF_TIME_T >= 8 + /* Fast path for time conversion */ + { + struct timespec ts = {sec, nsec}; + t = rb_time_timespec_new(&ts, conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1); + } +#else + t = rb_funcall(rb_cTime, rb_intern("at"), 2, LL2NUM(sec), LL2NUM(nsec / 1000)); + if( !(conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { + t = rb_funcall(t, rb_intern("utc"), 0); + } +#endif return t; } } diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index d770a2234..83acb438d 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -243,10 +243,6 @@ def textdec_timestamptz_decode_should_fail(str) ENV['TZ'] = tz end - def binary_timestamp(*wall_clock) - [((Time.utc(*wall_clock) - Time.utc(2000)) * 1_000_000).round].pack("q>") - end - it 'decodes wall clocks around DST changes like Time.local and PostgreSQL' do decoder = PG::BinaryDecoder::TimestampLocal.new [ @@ -254,14 +250,14 @@ def binary_timestamp(*wall_clock) [2024, 3, 31, 1, 30], [2024, 3, 31, 2, 30], [2024, 3, 31, 3, 30], [2024, 10, 27, 1, 30], [2024, 10, 27, 2, 30], [2024, 10, 27, 3, 30], ].each do |wall_clock| - time = decoder.decode(binary_timestamp(*wall_clock)) + time = decoder.decode(binaryenc_timestamputc.encode(Time.utc(*wall_clock))) expected = Time.local(*wall_clock) expect( [time, time.utc_offset] ).to eq( [expected, expected.utc_offset] ) end end it 'decodes wall clocks to UTC with TIMESTAMP_DB_LOCAL alone' do decoder = PG::BinaryDecoder::Timestamp.new(flags: PG::Coder::TIMESTAMP_DB_LOCAL) - time = decoder.decode(binary_timestamp(2024, 7, 1, 12, 0)) + time = decoder.decode(binaryenc_timestamputc.encode(Time.utc(2024, 7, 1, 12, 0))) expect( time ).to eq( Time.local(2024, 7, 1, 12, 0) ) expect( time ).to be_utc end From 2af4492aa65afa86dfe6b5c274208fe68fda8e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Tue, 27 Oct 2026 12:23:32 +0300 Subject: [PATCH 3/7] Interpret binary local timestamps with PostgreSQL's rule, not Time.local 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. --- ext/pg_binary_decoder.c | 106 ++++++++++++++++++++++++++++++++-------- spec/pg/type_spec.rb | 46 ++++++++++------- 2 files changed, 114 insertions(+), 38 deletions(-) diff --git a/ext/pg_binary_decoder.c b/ext/pg_binary_decoder.c index 7e08c3501..78b550ddc 100644 --- a/ext/pg_binary_decoder.c +++ b/ext/pg_binary_decoder.c @@ -285,9 +285,85 @@ pg_bin_dec_array(t_pg_coder *conv, const char *input_line, int len, int tuple, i #define PG_INT64_MIN (-0x7FFFFFFFFFFFFFFFL - 1) #define PG_INT64_MAX 0x7FFFFFFFFFFFFFFFL #define POSTGRES_EPOCH_JDATE 2451545 /* == date2j(2000, 1, 1) */ +#define UNIX_EPOCH_JDATE 2440588 /* == date2j(1970, 1, 1) */ #define USECS_PER_DAY 86400000000LL +#define SECS_PER_DAY 86400 void j2date(int jd, int *year, int *month, int *day); +int date2j(int year, int month, int day); + +/* + * Get the UTC offset of the local time zone at the given instant. + * Returns 0 when the C library can't convert it. + */ +static int +local_utc_offset(int64_t seconds, int *offset) +{ + time_t time = (time_t)seconds; + struct tm tm; + + if( time != seconds || localtime_r(&time, &tm) == NULL ) return 0; + *offset = (int)((date2j(tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday) - UNIX_EPOCH_JDATE) * (int64_t)SECS_PER_DAY + + tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec - seconds); + return 1; +} + +/* + * Get the UTC offset to interpret a wall clock as local time, given as seconds since 1970-01-01 00:00:00. + * Like PostgreSQL's DetermineTimeZoneOffset(), a wall clock in a DST gap or overlap takes the later instant. + * Returns 0 when the C library can't convert it. + */ +static int +wall_clock_utc_offset(int64_t wall_clock, int *offset) +{ + int offset_before, offset_after, offset_at_before, offset_at_after; + +#ifdef _WIN32 + _tzset(); +#else + tzset(); +#endif + if( !local_utc_offset(wall_clock - SECS_PER_DAY, &offset_before) || + !local_utc_offset(wall_clock + SECS_PER_DAY, &offset_after) ) return 0; + + if( offset_before == offset_after ){ + *offset = offset_before; + return 1; + } + + /* A transition within a day: keep the only interpretation that gives back the wall clock, if any */ + if( !local_utc_offset(wall_clock - offset_before, &offset_at_before) || + !local_utc_offset(wall_clock - offset_after, &offset_at_after) ) return 0; + if( (offset_at_before == offset_before) != (offset_at_after == offset_after) ){ + *offset = offset_at_before == offset_before ? offset_before : offset_after; + } else { + *offset = offset_before < offset_after ? offset_before : offset_after; + } + return 1; +} + +/* Interpret the timestamp as local time through Time.local, when the C library can't convert it */ +static VALUE +time_local_from_timestamp(t_pg_coder *conv, int64_t timestamp) +{ + int64_t days = timestamp / USECS_PER_DAY; + int64_t usecs = timestamp % USECS_PER_DAY; + int year, month, day, secs; + VALUE t; + + if( usecs < 0 ){ + usecs += USECS_PER_DAY; + days--; + } + secs = (int)(usecs / 1000000); + j2date((int)(days + POSTGRES_EPOCH_JDATE), &year, &month, &day); + t = rb_funcall(rb_cTime, rb_intern("local"), 7, INT2NUM(year), INT2NUM(month), INT2NUM(day), + INT2NUM(secs / 3600), INT2NUM(secs / 60 % 60), INT2NUM(secs % 60), INT2NUM((int)(usecs % 1000000))); + if( !(conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { + t = rb_funcall(t, rb_intern("getutc"), 0); + } + return t; +} /* * Document-class: PG::BinaryDecoder::Timestamp < PG::SimpleDecoder @@ -325,31 +401,21 @@ pg_bin_dec_timestamp(t_pg_coder *conv, const char *val, int len, int tuple, int case PG_INT64_MIN: return rb_str_new2("-infinity"); default: - if( conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL ) { - /* interpret it as local time: Time.local resolves DST gaps and overlaps like PostgreSQL does */ - int64_t days = timestamp / USECS_PER_DAY; - int64_t usecs = timestamp % USECS_PER_DAY; - int year, month, day, secs; - - if( usecs < 0 ){ - usecs += USECS_PER_DAY; - days--; - } - secs = (int)(usecs / 1000000); - j2date((int)(days + POSTGRES_EPOCH_JDATE), &year, &month, &day); - t = rb_funcall(rb_cTime, rb_intern("local"), 7, INT2NUM(year), INT2NUM(month), INT2NUM(day), - INT2NUM(secs / 3600), INT2NUM(secs / 60 % 60), INT2NUM(secs % 60), INT2NUM((int)(usecs % 1000000))); - if( !(conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { - t = rb_funcall(t, rb_intern("utc"), 0); - } - return t; - } - /* PostgreSQL's timestamp is based on year 2000 and Ruby's time is based on 1970. * Adjust the 30 years difference. */ sec = (timestamp / 1000000) + 10957L * 24L * 3600L; nsec = (timestamp % 1000000) * 1000; + if( conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL ) { + /* interpret it as local time */ + int offset; + + if( !wall_clock_utc_offset(sec - (nsec < 0), &offset) ){ + return time_local_from_timestamp(conv, timestamp); + } + sec -= offset; + } + #if defined(NEGATIVE_TIME_T) && defined(SIZEOF_TIME_T) && SIZEOF_TIME_T >= 8 /* Fast path for time conversion */ { diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 98745aa74..5ad58a1b8 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -258,24 +258,34 @@ def textdec_timestamptz_decode_should_fail(str) ensure ENV['TZ'] = tz end - - it 'decodes wall clocks around DST changes like Time.local and PostgreSQL' do - decoder = PG::BinaryDecoder::TimestampLocal.new - [ - [2024, 1, 15, 12, 0], [2024, 7, 1, 12, 0, Rational(123456, 1000000)], - [2024, 3, 31, 1, 30], [2024, 3, 31, 2, 30], [2024, 3, 31, 3, 30], - [2024, 10, 27, 1, 30], [2024, 10, 27, 2, 30], [2024, 10, 27, 3, 30], - ].each do |wall_clock| - time = decoder.decode(binaryenc_timestamputc.encode(Time.utc(*wall_clock))) - expected = Time.local(*wall_clock) - expect( [time, time.utc_offset] ).to eq( [expected, expected.utc_offset] ) - end - end - it 'decodes wall clocks to UTC with TIMESTAMP_DB_LOCAL alone' do - decoder = PG::BinaryDecoder::Timestamp.new(flags: PG::Coder::TIMESTAMP_DB_LOCAL) - time = decoder.decode(binaryenc_timestamputc.encode(Time.utc(2024, 7, 1, 12, 0))) - expect( time ).to eq( Time.local(2024, 7, 1, 12, 0) ) - expect( time ).to be_utc + before do + skip "Europe/Paris isn't available as TZ" unless Time.local(2024, 7, 1).utc_offset == 7200 + end + + # Instants given by PostgreSQL for `wall_clock::timestamp AT TIME ZONE 'Europe/Paris'` + { + [2024, 1, 15, 12, 0] => Time.utc(2024, 1, 15, 11, 0), + [2024, 7, 1, 12, 0, Rational(123456, 1000000)] => Time.utc(2024, 7, 1, 10, 0, Rational(123456, 1000000)), + [2024, 3, 31, 1, 30] => Time.utc(2024, 3, 31, 0, 30), + [2024, 3, 31, 2, 30] => Time.utc(2024, 3, 31, 1, 30), # DST gap + [2024, 3, 31, 3, 30] => Time.utc(2024, 3, 31, 1, 30), + [2024, 10, 27, 1, 30] => Time.utc(2024, 10, 26, 23, 30), + [2024, 10, 27, 2, 30] => Time.utc(2024, 10, 27, 1, 30), # DST overlap + [2024, 10, 27, 3, 30] => Time.utc(2024, 10, 27, 2, 30), + [2038, 10, 31, 2, 30] => Time.utc(2038, 10, 31, 1, 30), # DST overlap beyond 32 bit time_t + [2040, 3, 25, 2, 30] => Time.utc(2040, 3, 25, 1, 30), # DST gap beyond 32 bit time_t + }.each do |wall_clock, instant| + it "decodes #{wall_clock.first(5)} as local time like PostgreSQL" do + wire = binaryenc_timestamputc.encode(Time.utc(*wall_clock)) + + time = PG::BinaryDecoder::TimestampLocal.new.decode(wire) + expect( time ).to eq( instant ) + expect( time.utc? ).to be false + + time = PG::BinaryDecoder::Timestamp.new(flags: PG::Coder::TIMESTAMP_DB_LOCAL).decode(wire) + expect( time ).to eq( instant ) + expect( time.utc? ).to be true + end end end From d95329cca87769ed3f3d1e5386c6fc7a0c5f1b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Tue, 27 Oct 2026 12:40:01 +0300 Subject: [PATCH 4/7] Keep Ruby's own offsets before 1901, without j2date or date2j 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. --- ext/pg_binary_decoder.c | 43 +++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/ext/pg_binary_decoder.c b/ext/pg_binary_decoder.c index 78b550ddc..b4c716eb5 100644 --- a/ext/pg_binary_decoder.c +++ b/ext/pg_binary_decoder.c @@ -284,14 +284,8 @@ pg_bin_dec_array(t_pg_coder *conv, const char *input_line, int len, int tuple, i #define PG_INT64_MIN (-0x7FFFFFFFFFFFFFFFL - 1) #define PG_INT64_MAX 0x7FFFFFFFFFFFFFFFL -#define POSTGRES_EPOCH_JDATE 2451545 /* == date2j(2000, 1, 1) */ -#define UNIX_EPOCH_JDATE 2440588 /* == date2j(1970, 1, 1) */ -#define USECS_PER_DAY 86400000000LL #define SECS_PER_DAY 86400 -void j2date(int jd, int *year, int *month, int *day); -int date2j(int year, int month, int day); - /* * Get the UTC offset of the local time zone at the given instant. * Returns 0 when the C library can't convert it. @@ -300,11 +294,17 @@ static int local_utc_offset(int64_t seconds, int *offset) { time_t time = (time_t)seconds; + int64_t utc_days = seconds / SECS_PER_DAY - (seconds % SECS_PER_DAY < 0); + int utc_weekday = (int)(((utc_days + 4) % 7 + 7) % 7); /* 1970-01-01 was a Thursday */ + int local_days_ahead; struct tm tm; if( time != seconds || localtime_r(&time, &tm) == NULL ) return 0; - *offset = (int)((date2j(tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday) - UNIX_EPOCH_JDATE) * (int64_t)SECS_PER_DAY - + tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec - seconds); + /* local and UTC dates are at most a day apart */ + local_days_ahead = (tm.tm_wday - utc_weekday + 7) % 7; + if( local_days_ahead == 6 ) local_days_ahead = -1; + *offset = local_days_ahead * SECS_PER_DAY + tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec + - (int)(seconds - utc_days * SECS_PER_DAY); return 1; } @@ -318,6 +318,9 @@ wall_clock_utc_offset(int64_t wall_clock, int *offset) { int offset_before, offset_after, offset_at_before, offset_at_after; + /* Before 1901-12-14, Ruby on macOS renders local times without the LMT offsets of the C library */ + if( wall_clock < (int64_t)INT32_MIN + SECS_PER_DAY ) return 0; + #ifdef _WIN32 _tzset(); #else @@ -342,23 +345,16 @@ wall_clock_utc_offset(int64_t wall_clock, int *offset) return 1; } -/* Interpret the timestamp as local time through Time.local, when the C library can't convert it */ +/* + * Interpret the wall clock with the UTC offset Ruby gives to it read as UTC, when the C library can't convert it. + * That's off by the DST shift within the offset's distance of a DST change, which doesn't happen before 1901. + */ static VALUE -time_local_from_timestamp(t_pg_coder *conv, int64_t timestamp) +time_local_from_wall_clock(t_pg_coder *conv, int64_t sec, int64_t nsec) { - int64_t days = timestamp / USECS_PER_DAY; - int64_t usecs = timestamp % USECS_PER_DAY; - int year, month, day, secs; - VALUE t; + VALUE t = rb_funcall(rb_cTime, rb_intern("at"), 3, LL2NUM(sec), LL2NUM(nsec), ID2SYM(rb_intern("nsec"))); - if( usecs < 0 ){ - usecs += USECS_PER_DAY; - days--; - } - secs = (int)(usecs / 1000000); - j2date((int)(days + POSTGRES_EPOCH_JDATE), &year, &month, &day); - t = rb_funcall(rb_cTime, rb_intern("local"), 7, INT2NUM(year), INT2NUM(month), INT2NUM(day), - INT2NUM(secs / 3600), INT2NUM(secs / 60 % 60), INT2NUM(secs % 60), INT2NUM((int)(usecs % 1000000))); + t = rb_funcall(t, rb_intern("-"), 1, rb_funcall(t, rb_intern("utc_offset"), 0)); if( !(conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { t = rb_funcall(t, rb_intern("getutc"), 0); } @@ -411,7 +407,7 @@ pg_bin_dec_timestamp(t_pg_coder *conv, const char *val, int len, int tuple, int int offset; if( !wall_clock_utc_offset(sec - (nsec < 0), &offset) ){ - return time_local_from_timestamp(conv, timestamp); + return time_local_from_wall_clock(conv, sec, nsec); } sec -= offset; } @@ -434,6 +430,7 @@ pg_bin_dec_timestamp(t_pg_coder *conv, const char *val, int len, int tuple, int #define PG_INT32_MIN (-0x7FFFFFFF-1) #define PG_INT32_MAX (0x7FFFFFFF) +#define POSTGRES_EPOCH_JDATE 2451545 /* == date2j(2000, 1, 1) */ #define MONTHS_PER_YEAR 12 /* taken from PostgreSQL sources at src/backend/utils/adt/datetime.c */ From 1f946aaf462fbb1be665720716c05f4432b119cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Fri, 30 Oct 2026 21:48:32 +0200 Subject: [PATCH 5/7] Test the x86_64-linux gem on Ruby 3.1 with ubuntu-24.04 ubuntu-latest now points to ubuntu-26.04, where ruby/setup-ruby has no Ruby < 3.2. The aarch64-linux job already runs Ruby 3.1 on ubuntu-24.04-arm. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/binary-gems.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/binary-gems.yml b/.github/workflows/binary-gems.yml index 31c347f4a..8c27ec9ec 100644 --- a/.github/workflows/binary-gems.yml +++ b/.github/workflows/binary-gems.yml @@ -74,7 +74,7 @@ jobs: - os: ubuntu-latest ruby: "4.0" platform: "x86_64-linux" - - os: ubuntu-latest + - os: ubuntu-24.04 ruby: "3.1" platform: "x86_64-linux" - os: ubuntu-24.04-arm From 99cb78c06a063e90d0640e533d8edfd40f62c106 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Sat, 7 Nov 2026 16:44:55 +0100 Subject: [PATCH 6/7] Use Windows compatible form of 'Europe/Paris' --- spec/pg/type_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 5ad58a1b8..4b010ff63 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -253,7 +253,8 @@ def textdec_timestamptz_decode_should_fail(str) context 'binary local timestamps' do around do |example| tz = ENV['TZ'] - ENV['TZ'] = 'Europe/Paris' + # Windows compatible form of 'Europe/Paris' + ENV['TZ'] = 'CET-1CEST,M3.5.0,M10.5.0/3' example.run ensure ENV['TZ'] = tz From c98f4720fe4a04593af3ec6d8154df01dbc2fbb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= <14306+czj@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:21:29 +0100 Subject: [PATCH 7/7] Pick up a changed TZ before mktime() on Windows Windows' mktime() reads TZ once, then keeps its rules until _tzset() runs again. The binary local timestamp decoder now calls _tzset(), so once its spec restored TZ, the text decoder kept interpreting local timestamps with the spec's TZ until Ruby itself refreshed it: one text decoder spec failed on Windows, depending on the seed. Assisted-by: Claude Opus 5.5 [Claude Code] --- ext/pg_text_decoder.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index cc2d42d64..ea74ac177 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -747,6 +747,10 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i tm.tm_sec = sec; tm.tm_isdst = -1; +#ifdef _WIN32 + /* Windows' mktime() reads TZ only once, unlike POSIX: pick up a TZ changed since */ + _tzset(); +#endif ts.tv_sec = mktime(&tm); if (ts.tv_sec != -1) { return rb_time_timespec_new(&ts, conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1);