From 4eba51ebf6e8facd968e23697ae2c3c7865036af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 12:51:45 +0300 Subject: [PATCH 1/3] Decode text timestamps without timegm() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS implements timegm() as a binary search over gmtime(), taking ~3.4µs per call. Counting days arithmetically decodes timestamps ~40x faster there, keeps nanoseconds for years the libc can't convert, and gives Windows the fast path. --- ext/extconf.rb | 1 - ext/pg_text_decoder.c | 57 +++++++++++++++++++++++++++++-------------- spec/pg/type_spec.rb | 6 +++++ 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/ext/extconf.rb b/ext/extconf.rb index cabaa924a..863dd3a45 100644 --- a/ext/extconf.rb +++ b/ext/extconf.rb @@ -309,7 +309,6 @@ module PG "or upgrade your database to at least PostgreSQL-10." # optional headers/functions have_func 'PQresultMemorySize', 'libpq-fe.h' # since PostgreSQL-12 -have_func 'timegm' have_func 'rb_hash_new_capa' # since ruby-3.2 have_header 'inttypes.h' diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index d0ca3d418..649a62e3c 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -622,6 +622,27 @@ static int parse_year(const char **str) { return year; } +/* + * Seconds since 1970-01-01 00:00:00 UTC in the proleptic Gregorian calendar. + * + * Replaces timegm(), which macOS implements as a binary search over gmtime() taking ~3.4µs per call. + * Days are counted per http://howardhinnant.github.io/date_algorithms.html#days_from_civil + */ +static int64_t seconds_since_epoch(int year, int mon, int day, int hour, int min, int sec) +{ + int64_t era, days; + int year_of_era, day_of_year, day_of_era; + + year -= mon <= 2; + era = (year >= 0 ? year : year - 399) / 400; + year_of_era = (int)(year - era * 400); + day_of_year = (153 * (mon > 2 ? mon - 3 : mon + 9) + 2) / 5 + day - 1; + day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + days = era * 146097 + day_of_era - 719468; + + return days * 86400 + hour * 3600 + min * 60 + sec; +} + #define TZ_NEG 1 #define TZ_POS 2 @@ -710,7 +731,6 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i VALUE gmt_offset_value; VALUE res; -#if (RUBY_API_VERSION_MAJOR > 2 || (RUBY_API_VERSION_MAJOR == 2 && RUBY_API_VERSION_MINOR >= 3)) && defined(HAVE_TIMEGM) /* Fast path for time conversion */ struct tm tm; struct timespec ts; @@ -724,38 +744,39 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i if (tz_given) { /* with timezone */ - time_t time = timegm(&tm); - if (time != -1){ - int gmt_offset; + int gmt_offset; + int64_t time; - gmt_offset = tz_hour * 3600 + tz_min * 60 + tz_sec; - if (tz_given == TZ_NEG) - { - gmt_offset = - gmt_offset; - } - ts.tv_sec = time - gmt_offset; - ts.tv_nsec = nsec; + gmt_offset = tz_hour * 3600 + tz_min * 60 + tz_sec; + if (tz_given == TZ_NEG) + { + gmt_offset = - gmt_offset; + } + time = seconds_since_epoch(year, mon, day, hour, min, sec) - gmt_offset; + ts.tv_sec = (time_t)time; + ts.tv_nsec = nsec; + /* A 32 bit time_t can't hold every PostgreSQL timestamp */ + if (ts.tv_sec == time) { return rb_time_timespec_new(&ts, gmt_offset); } } else { /* without timezone */ - time_t time; + int64_t time; if( conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL ) { time = mktime(&tm); } else { - time = timegm(&tm); + time = seconds_since_epoch(year, mon, day, hour, min, sec); } - if (time != -1){ - ts.tv_sec = time; - ts.tv_nsec = nsec; + ts.tv_sec = (time_t)time; + ts.tv_nsec = nsec; + if (time != -1 && ts.tv_sec == time){ return rb_time_timespec_new(&ts, conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1); } } - /* Some libc implementations fail to convert certain values, + /* mktime() fails on some values with some libc implementations, * so that we fall through to the slow path. */ -#endif if (nsec) { int sec_numerator = sec * 1000000 + nsec / 1000; int sec_denominator = 1000000; diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index e8d952838..ae28088e8 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -188,6 +188,12 @@ def encode(value) expect( textdec_timestamp.decode('1998-12-31 23:59:60.1234') ). to be_within(0.000001).of( Time.new(1998,12,31, 23, 59, 60.1234) ) end + it 'decodes nanoseconds regardless of the year' do + expect( textdec_timestamputc.decode('1000-03-15 12:00:00.123456789').nsec ).to eq( 123456789 ) + expect( textdec_timestamputc.decode('0044-03-15 12:00:00.123456789 BC').nsec ).to eq( 123456789 ) + expect( textdec_timestamptz.decode('0044-03-15 12:00:00.123456789+02 BC').nsec ).to eq( 123456789 ) + expect( textdec_timestamptz.decode('294276-12-31 23:58:59.123456789+03').nsec ).to eq( 123456789 ) + end def textdec_timestamptz_decode_should_fail(str) expect(textdec_timestamptz.decode(str)).to eq(str) From 5f051659c6858096cf3217ba330308f40b837fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 18:43:28 +0300 Subject: [PATCH 2/3] Keep Windows local timestamps on Time.new, nanoseconds on the slow path - Windows' mktime() fails on certain dates (be07045), so local timestamps keep using Time.new there, as before dropping the timegm() check - The slow path keeps nanoseconds, so 32 bit time_t builds decode the same values as the fast path - The UTC and explicit offset branches share one computation, and struct tm is only filled for mktime() - Specs cover february, leap years, centuries and BC dates --- ext/pg_text_decoder.c | 81 +++++++++++++++++-------------------------- spec/pg/type_spec.rb | 8 +++++ 2 files changed, 39 insertions(+), 50 deletions(-) diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index 649a62e3c..17b4c1bef 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -28,7 +28,6 @@ * */ -#include "ruby/version.h" #include "pg.h" #include "pg_util.h" #ifdef HAVE_INTTYPES_H @@ -623,9 +622,7 @@ static int parse_year(const char **str) { } /* - * Seconds since 1970-01-01 00:00:00 UTC in the proleptic Gregorian calendar. - * - * Replaces timegm(), which macOS implements as a binary search over gmtime() taking ~3.4µs per call. + * Seconds since 1970-01-01 00:00:00 UTC in the proleptic Gregorian calendar, like timegm() but much faster on macOS. * Days are counted per http://howardhinnant.github.io/date_algorithms.html#days_from_civil */ static int64_t seconds_since_epoch(int year, int mon, int day, int hour, int min, int sec) @@ -730,71 +727,55 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i VALUE sec_value; VALUE gmt_offset_value; VALUE res; - - /* Fast path for time conversion */ - struct tm tm; struct timespec ts; - tm.tm_year = year - 1900; - tm.tm_mon = mon - 1; - tm.tm_mday = day; - tm.tm_hour = hour; - tm.tm_min = min; - tm.tm_sec = sec; - tm.tm_isdst = -1; + int gmt_offset = tz_hour * 3600 + tz_min * 60 + tz_sec; - if (tz_given) { - /* with timezone */ - int gmt_offset; - int64_t time; + if (tz_given == TZ_NEG) + { + gmt_offset = - gmt_offset; + } - gmt_offset = tz_hour * 3600 + tz_min * 60 + tz_sec; - if (tz_given == TZ_NEG) - { - gmt_offset = - gmt_offset; - } - time = seconds_since_epoch(year, mon, day, hour, min, sec) - gmt_offset; - ts.tv_sec = (time_t)time; - ts.tv_nsec = nsec; - /* A 32 bit time_t can't hold every PostgreSQL timestamp */ - if (ts.tv_sec == time) { - return rb_time_timespec_new(&ts, gmt_offset); + /* Fast path for time conversion */ + ts.tv_nsec = nsec; + if (!tz_given && (conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL)) { + /* without timezone, interpreted as local time */ +#ifndef _WIN32 + /* Windows' mktime() fails on certain dates, so it takes the slow path */ + struct tm tm; + tm.tm_year = year - 1900; + tm.tm_mon = mon - 1; + tm.tm_mday = day; + tm.tm_hour = hour; + tm.tm_min = min; + tm.tm_sec = sec; + tm.tm_isdst = -1; + + 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); } +#endif } else { - /* without timezone */ - int64_t time; - - if( conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL ) { - time = mktime(&tm); - } else { - time = seconds_since_epoch(year, mon, day, hour, min, sec); - } + /* with timezone, or without timezone interpreted as UTC */ + int64_t time = seconds_since_epoch(year, mon, day, hour, min, sec) - gmt_offset; ts.tv_sec = (time_t)time; - ts.tv_nsec = nsec; - if (time != -1 && ts.tv_sec == time){ - return rb_time_timespec_new(&ts, conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1); + if (ts.tv_sec == time) { + return rb_time_timespec_new(&ts, tz_given ? gmt_offset : conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1); } } /* mktime() fails on some values with some libc implementations, + * and a 32 bit time_t can't hold every PostgreSQL timestamp, * so that we fall through to the slow path. */ if (nsec) { - int sec_numerator = sec * 1000000 + nsec / 1000; - int sec_denominator = 1000000; sec_value = rb_funcall(Qnil, s_id_Rational, 2, - INT2NUM(sec_numerator), INT2NUM(sec_denominator)); + LL2NUM((LONG_LONG)sec * 1000000000 + nsec), INT2NUM(1000000000)); } else { sec_value = INT2NUM(sec); } if (tz_given) { /* with timezone */ - int gmt_offset; - - gmt_offset = tz_hour * 3600 + tz_min * 60 + tz_sec; - if (tz_given == TZ_NEG) - { - gmt_offset = - gmt_offset; - } gmt_offset_value = INT2NUM(gmt_offset); } else { /* without timezone */ diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index ae28088e8..d15771d95 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -190,10 +190,18 @@ def encode(value) end it 'decodes nanoseconds regardless of the year' do expect( textdec_timestamputc.decode('1000-03-15 12:00:00.123456789').nsec ).to eq( 123456789 ) + expect( textdec_timestamp.decode('1000-03-15 12:00:00.123456789').nsec ).to eq( 123456789 ) expect( textdec_timestamputc.decode('0044-03-15 12:00:00.123456789 BC').nsec ).to eq( 123456789 ) expect( textdec_timestamptz.decode('0044-03-15 12:00:00.123456789+02 BC').nsec ).to eq( 123456789 ) expect( textdec_timestamptz.decode('294276-12-31 23:58:59.123456789+03').nsec ).to eq( 123456789 ) end + it 'decodes timestamps around february, leap years and centuries' do + [[2000, 2, 29], [2016, 2, 15], [2024, 1, 31], [2024, 3, 1], [1900, 2, 28], [1900, 3, 1], [2100, 3, 1], [1600, 2, 29], [-43, 2, 28], [-43, 3, 1]].each do |year, month, day| + text = year > 0 ? format("%04d-%02d-%02d 12:34:56", year, month, day) : format("%04d-%02d-%02d 12:34:56 BC", 1 - year, month, day) + expect( textdec_timestamputc.decode(text) ).to eq( Time.utc(year, month, day, 12, 34, 56) ) + expect( textdec_timestamptz.decode("#{text.delete_suffix(" BC")}+05#{" BC" if year <= 0}") ).to eq( Time.new(year, month, day, 12, 34, 56, "+05:00") ) + end + end def textdec_timestamptz_decode_should_fail(str) expect(textdec_timestamptz.decode(str)).to eq(str) From 048a7424491350496cf393f3509956e1dd3664c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 19:48:11 +0300 Subject: [PATCH 3/3] Keep the slow path's Rational a Fixnum on Windows On Windows long is 32 bit, so Fixnums stop at ~1.07 billion and sec * 1000000000 + nsec became a Bignum: TimestampLocal, which still takes Time.new there, decoded 12% slower with 17.8 objects per value instead of 10. Microseconds, all PostgreSQL sends, keep the numerator a Fixnum again. --- ext/pg_text_decoder.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index 17b4c1bef..1aef7b672 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -767,9 +767,13 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i * and a 32 bit time_t can't hold every PostgreSQL timestamp, * so that we fall through to the slow path. */ - if (nsec) { + if (nsec % 1000) { sec_value = rb_funcall(Qnil, s_id_Rational, 2, LL2NUM((LONG_LONG)sec * 1000000000 + nsec), INT2NUM(1000000000)); + } else if (nsec) { + /* Counted in microseconds, the numerator stays a Fixnum where long is 32 bit, like on Windows */ + sec_value = rb_funcall(Qnil, s_id_Rational, 2, + INT2NUM(sec * 1000000 + nsec / 1000), INT2NUM(1000000)); } else { sec_value = INT2NUM(sec); }