From d13907d6dcec16b4c92905d6be2e1ce55d5b202b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Wed, 28 Oct 2026 11:35:57 +0300 Subject: [PATCH 1/6] Cache short text numerics as frozen BigDecimals Money columns repeat a handful of values (0.00, VAT rates, round prices), and every one of them went through BigDecimal() with a fresh String. Values up to 8 characters now go through a process-wide cache of 4096 slots keyed by their raw text, so a hit returns the same frozen BigDecimal without allocating. A native lock guards the slots against Ractors running in parallel, and is never held across BigDecimal(). The flag PG::Coder::NUMERIC_UNCACHED restores a new object for every value. Co-Authored-By: Claude Opus 5.5 --- ext/pg.h | 1 + ext/pg_coder.c | 1 + ext/pg_text_decoder.c | 72 ++++++++++++++++++++++++++++++++++++++++++- spec/pg/type_spec.rb | 41 ++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/ext/pg.h b/ext/pg.h index 65d5fe4ce..6afd8598c 100644 --- a/ext/pg.h +++ b/ext/pg.h @@ -195,6 +195,7 @@ typedef VALUE (* t_pg_typecast_copy_get)( t_typemap *, VALUE, int, int, int ); #define PG_CODER_FORMAT_ERROR_TO_RAISE 0x4 #define PG_CODER_FORMAT_ERROR_TO_STRING 0x8 #define PG_CODER_FORMAT_ERROR_TO_PARTIAL 0xc +#define PG_CODER_NUMERIC_UNCACHED 0x10 struct pg_coder { t_pg_coder_enc_func enc_func; diff --git a/ext/pg_coder.c b/ext/pg_coder.c index 965619992..4e367025a 100644 --- a/ext/pg_coder.c +++ b/ext/pg_coder.c @@ -627,6 +627,7 @@ init_pg_coder(void) rb_define_const( rb_cPG_Coder, "FORMAT_ERROR_TO_RAISE", INT2NUM(PG_CODER_FORMAT_ERROR_TO_RAISE)); rb_define_const( rb_cPG_Coder, "FORMAT_ERROR_TO_STRING", INT2NUM(PG_CODER_FORMAT_ERROR_TO_STRING)); rb_define_const( rb_cPG_Coder, "FORMAT_ERROR_TO_PARTIAL", INT2NUM(PG_CODER_FORMAT_ERROR_TO_PARTIAL)); + rb_define_const( rb_cPG_Coder, "NUMERIC_UNCACHED", INT2NUM(PG_CODER_NUMERIC_UNCACHED)); /* * Name of the coder or the corresponding data type. diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index cc2d42d64..7f8b7b6c6 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -39,6 +39,7 @@ #include #endif #include +#include "ruby/thread_native.h" VALUE rb_mPG_TextDecoder; static ID s_id_Rational; @@ -157,6 +158,42 @@ pg_text_dec_integer(t_pg_coder *conv, const char *val, int len, int tuple, int f return rb_cstr2inum(val, 10); } +#define NUMERIC_CACHE_SLOT_BITS 12 +/* Up to 8 characters covers 0.00, VAT rates and prices like 1299.00, and fits the key into one uint64_t. */ +#define NUMERIC_CACHE_MAX_LENGTH 8 + +typedef struct { + uint64_t text; + int len; + VALUE value; +} t_numeric_cache_slot; + +static t_numeric_cache_slot numeric_cache[1 << NUMERIC_CACHE_SLOT_BITS]; +static rb_nativethread_lock_t numeric_cache_lock; +static VALUE numeric_cache_marker; + +static void +numeric_cache_mark(void *_cache) +{ + t_numeric_cache_slot *cache = _cache; + size_t i; + for( i = 0; i < sizeof(numeric_cache) / sizeof(*numeric_cache); i++ ){ + rb_gc_mark(cache[i].value); + } +} + +static const rb_data_type_t numeric_cache_type = { + "PG::TextDecoder::Numeric cache", + { numeric_cache_mark, NULL, NULL }, + 0, 0, 0, +}; + +static VALUE +pg_text_dec_numeric_uncached(const char *val, int len) +{ + return rb_funcall(rb_cObject, s_id_BigDecimal, 1, rb_str_new(val, len)); +} + /* * Document-class: PG::TextDecoder::Numeric < PG::SimpleDecoder * @@ -165,11 +202,41 @@ pg_text_dec_integer(t_pg_coder *conv, const char *val, int len, int tuple, int f * * As soon as this class is used, it requires the 'bigdecimal' gem. * + * Short values like +0.00+ or +19.90+ repeat a lot, so their frozen BigDecimal objects are + * cached process-wide and the same object is returned for equal values. + * Set the flag PG::Coder::NUMERIC_UNCACHED to get a new object for every value. + * */ static VALUE pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int field, int enc_idx) { - return rb_funcall(rb_cObject, s_id_BigDecimal, 1, rb_str_new(val, len)); + uint64_t text = 0; + t_numeric_cache_slot *slot; + VALUE value; + + if( (conv->flags & PG_CODER_NUMERIC_UNCACHED) || len < 1 || len > NUMERIC_CACHE_MAX_LENGTH ){ + return pg_text_dec_numeric_uncached(val, len); + } + + memcpy(&text, val, len); + slot = &numeric_cache[(text * UINT64_C(0x9E3779B97F4A7C15)) >> (64 - NUMERIC_CACHE_SLOT_BITS)]; + + /* The lock guards against Ractors running in parallel. It is never held across BigDecimal(), which can raise. */ + rb_nativethread_lock_lock(&numeric_cache_lock); + value = slot->len == len && slot->text == text ? slot->value : Qundef; + rb_nativethread_lock_unlock(&numeric_cache_lock); + if( value != Qundef ) return value; + + value = pg_text_dec_numeric_uncached(val, len); + /* Rows and Ractors share cached objects. bigdecimal makes frozen BigDecimals Ractor-shareable. */ + if( RB_OBJ_FROZEN(value) ){ + rb_nativethread_lock_lock(&numeric_cache_lock); + slot->text = text; + slot->len = len; + slot->value = value; + rb_nativethread_lock_unlock(&numeric_cache_lock); + } + return value; } /* called per autoload when TextDecoder::Numeric is used */ @@ -178,6 +245,9 @@ init_pg_text_decoder_numeric(VALUE rb_mPG_TextDecoder) { rb_funcall(rb_mPG, rb_intern("require_bigdecimal_without_warning"), 0); s_id_BigDecimal = rb_intern("BigDecimal"); + rb_nativethread_lock_initialize(&numeric_cache_lock); + rb_gc_register_address(&numeric_cache_marker); + numeric_cache_marker = TypedData_Wrap_Struct(0, &numeric_cache_type, numeric_cache); /* dummy = rb_define_class_under( rb_mPG_TextDecoder, "Numeric", rb_cPG_SimpleDecoder ); */ pg_define_coder( "Numeric", pg_text_dec_numeric, rb_cPG_SimpleDecoder, rb_mPG_TextDecoder ); diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 203567136..e070a5f43 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -250,6 +250,47 @@ def textdec_timestamptz_decode_should_fail(str) end end + context 'numerics', :bigdecimal do + let(:textdec_numeric) { PG::TextDecoder::Numeric.new } + + it 'decodes like BigDecimal()' do + ['0', '0.00', '-0', '1.200', '1299.00', '12345678', '123456789', 'NaN', 'Infinity', '-Infinity'].each do |value| + expect( textdec_numeric.decode(value)._dump ).to eq( BigDecimal(value)._dump ) + end + end + it 'returns the same frozen object for equal short values' do + expect( textdec_numeric.decode('1299.00') ).to be_frozen + expect( textdec_numeric.decode('1299.00') ).to equal( textdec_numeric.decode('1299.00') ) + expect( textdec_numeric.decode('1299.00') ).not_to equal( textdec_numeric.decode('1299.0') ) + end + it 'keeps cached objects alive across GC' do + textdec_numeric.decode('7.77') + GC.start + expect( textdec_numeric.decode('7.77') ).to eq( BigDecimal('7.77') ) + end + it 'returns a new object for values longer than 8 characters' do + expect( textdec_numeric.decode('123456789') ).not_to equal( textdec_numeric.decode('123456789') ) + end + it 'returns a new object with NUMERIC_UNCACHED' do + textdec_numeric.flags = PG::Coder::NUMERIC_UNCACHED + expect( textdec_numeric.decode('0.00') ).not_to equal( textdec_numeric.decode('0.00') ) + end + it 'raises on invalid input like BigDecimal()' do + ['', '-', '1.5x', "1\0002"].each do |value| + 2.times { expect{ textdec_numeric.decode(value) }.to raise_error( ArgumentError ) } + end + end + it 'decodes in parallel Ractors', :ractor do + ractors = 4.times.map do + Ractor.new do + decoder = PG::TextDecoder::Numeric.new + 10_000.times.all? { |i| decoder.decode("#{i % 2000}.00") == i % 2000 } + end + end + expect( ractors.map(&:value) ).to all( be true ) + end + end + context 'dates' do let(:textdec_date) { PG::TextDecoder::Date.new } From dbe2500333720021e33b457358059f71426fda63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Wed, 28 Oct 2026 11:50:24 +0300 Subject: [PATCH 2/6] Keep NaN and Infinity out of the numeric cache, and reset it after fork A cached NaN or Infinity skipped BigDecimal.mode exceptions, so only texts ending with a digit are cached now. Values are stored only when rb_ractor_shareable_p() says they are, since the cache hands them to every Ractor. The lock and the GC marker are set up once with the TextDecoder module, and a forked child resets the lock and empties the cache, in case another Ractor held it during the fork. Co-Authored-By: Claude Opus 5.5 --- ext/pg_text_decoder.c | 39 +++++++++++++++++++++++++++++---------- spec/pg/type_spec.rb | 14 ++++++++++++-- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index 7f8b7b6c6..a28161aa7 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -39,7 +39,11 @@ #include #endif #include +#include "ruby/ractor.h" #include "ruby/thread_native.h" +#if !defined(_WIN32) +#include +#endif VALUE rb_mPG_TextDecoder; static ID s_id_Rational; @@ -159,7 +163,7 @@ pg_text_dec_integer(t_pg_coder *conv, const char *val, int len, int tuple, int f } #define NUMERIC_CACHE_SLOT_BITS 12 -/* Up to 8 characters covers 0.00, VAT rates and prices like 1299.00, and fits the key into one uint64_t. */ +/* Up to 8 characters covers values like 0.00, 19.90 or 1299.00, and fits the key into one uint64_t. */ #define NUMERIC_CACHE_MAX_LENGTH 8 typedef struct { @@ -173,12 +177,11 @@ static rb_nativethread_lock_t numeric_cache_lock; static VALUE numeric_cache_marker; static void -numeric_cache_mark(void *_cache) +numeric_cache_mark(void *_unused) { - t_numeric_cache_slot *cache = _cache; size_t i; for( i = 0; i < sizeof(numeric_cache) / sizeof(*numeric_cache); i++ ){ - rb_gc_mark(cache[i].value); + rb_gc_mark(numeric_cache[i].value); } } @@ -188,6 +191,16 @@ static const rb_data_type_t numeric_cache_type = { 0, 0, 0, }; +#if !defined(_WIN32) +/* Another Ractor may have held the lock or been writing a slot when the process forked. */ +static void +numeric_cache_reset_in_child(void) +{ + rb_nativethread_lock_initialize(&numeric_cache_lock); + memset(numeric_cache, 0, sizeof(numeric_cache)); +} +#endif + static VALUE pg_text_dec_numeric_uncached(const char *val, int len) { @@ -214,7 +227,8 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f t_numeric_cache_slot *slot; VALUE value; - if( (conv->flags & PG_CODER_NUMERIC_UNCACHED) || len < 1 || len > NUMERIC_CACHE_MAX_LENGTH ){ + /* NaN and Infinity don't end with a digit. They stay uncached to honor BigDecimal.mode exceptions. */ + if( (conv->flags & PG_CODER_NUMERIC_UNCACHED) || len < 1 || len > NUMERIC_CACHE_MAX_LENGTH || !rb_isdigit(val[len - 1]) ){ return pg_text_dec_numeric_uncached(val, len); } @@ -228,8 +242,8 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f if( value != Qundef ) return value; value = pg_text_dec_numeric_uncached(val, len); - /* Rows and Ractors share cached objects. bigdecimal makes frozen BigDecimals Ractor-shareable. */ - if( RB_OBJ_FROZEN(value) ){ + /* Rows and Ractors share cached objects. */ + if( rb_ractor_shareable_p(value) ){ rb_nativethread_lock_lock(&numeric_cache_lock); slot->text = text; slot->len = len; @@ -245,9 +259,6 @@ init_pg_text_decoder_numeric(VALUE rb_mPG_TextDecoder) { rb_funcall(rb_mPG, rb_intern("require_bigdecimal_without_warning"), 0); s_id_BigDecimal = rb_intern("BigDecimal"); - rb_nativethread_lock_initialize(&numeric_cache_lock); - rb_gc_register_address(&numeric_cache_marker); - numeric_cache_marker = TypedData_Wrap_Struct(0, &numeric_cache_type, numeric_cache); /* dummy = rb_define_class_under( rb_mPG_TextDecoder, "Numeric", rb_cPG_SimpleDecoder ); */ pg_define_coder( "Numeric", pg_text_dec_numeric, rb_cPG_SimpleDecoder, rb_mPG_TextDecoder ); @@ -1091,6 +1102,14 @@ init_pg_text_decoder(void) s_pos_inf = rb_eval_string("1.0/0.0"); s_neg_inf = rb_eval_string("-1.0/0.0"); + rb_nativethread_lock_initialize(&numeric_cache_lock); +#if !defined(_WIN32) + pthread_atfork(NULL, NULL, numeric_cache_reset_in_child); +#endif + rb_gc_register_address(&numeric_cache_marker); + /* The GC skips dmark for a NULL data pointer, so it must point somewhere. */ + numeric_cache_marker = TypedData_Wrap_Struct(0, &numeric_cache_type, numeric_cache); + /* This module encapsulates all decoder classes with text input format */ rb_mPG_TextDecoder = rb_define_module_under( rb_mPG, "TextDecoder" ); rb_define_private_method(rb_singleton_class(rb_mPG_TextDecoder), "init_date", init_pg_text_decoder_date, 0); diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index e070a5f43..297b58ed0 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -264,9 +264,18 @@ def textdec_timestamptz_decode_should_fail(str) expect( textdec_numeric.decode('1299.00') ).not_to equal( textdec_numeric.decode('1299.0') ) end it 'keeps cached objects alive across GC' do - textdec_numeric.decode('7.77') + cached_object_id = textdec_numeric.decode('7.77').object_id GC.start - expect( textdec_numeric.decode('7.77') ).to eq( BigDecimal('7.77') ) + expect( textdec_numeric.decode('7.77').object_id ).to eq( cached_object_id ) + end + it 'raises on NaN and Infinity in BigDecimal exception mode' do + textdec_numeric.decode('NaN') + textdec_numeric.decode('Infinity') + BigDecimal.save_exception_mode do + BigDecimal.mode(BigDecimal::EXCEPTION_NaN | BigDecimal::EXCEPTION_INFINITY, true) + expect{ textdec_numeric.decode('NaN') }.to raise_error( FloatDomainError ) + expect{ textdec_numeric.decode('Infinity') }.to raise_error( FloatDomainError ) + end end it 'returns a new object for values longer than 8 characters' do expect( textdec_numeric.decode('123456789') ).not_to equal( textdec_numeric.decode('123456789') ) @@ -281,6 +290,7 @@ def textdec_timestamptz_decode_should_fail(str) end end it 'decodes in parallel Ractors', :ractor do + PG::TextDecoder::Numeric # autoload from the main Ractor ractors = 4.times.map do Ractor.new do decoder = PG::TextDecoder::Numeric.new From b5ee46cfe335ea241f918742b772025c44a6939d Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Thu, 29 Oct 2026 06:45:29 +0100 Subject: [PATCH 3/6] Improve TextDecoder::Numeric Remove unnecessary length member, make the cache GC compation and generational friendly --- ext/pg_text_decoder.c | 28 +++++++++++++++++----------- spec/pg/gc_compact_spec.rb | 12 ++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index a28161aa7..2055cba7e 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -163,12 +163,9 @@ pg_text_dec_integer(t_pg_coder *conv, const char *val, int len, int tuple, int f } #define NUMERIC_CACHE_SLOT_BITS 12 -/* Up to 8 characters covers values like 0.00, 19.90 or 1299.00, and fits the key into one uint64_t. */ -#define NUMERIC_CACHE_MAX_LENGTH 8 typedef struct { uint64_t text; - int len; VALUE value; } t_numeric_cache_slot; @@ -181,14 +178,23 @@ numeric_cache_mark(void *_unused) { size_t i; for( i = 0; i < sizeof(numeric_cache) / sizeof(*numeric_cache); i++ ){ - rb_gc_mark(numeric_cache[i].value); + rb_gc_mark_movable(numeric_cache[i].value); + } +} + +static void +numeric_cache_compact(void *_unused) +{ + size_t i; + for( i = 0; i < sizeof(numeric_cache) / sizeof(*numeric_cache); i++ ){ + pg_gc_location(numeric_cache[i].value); } } static const rb_data_type_t numeric_cache_type = { "PG::TextDecoder::Numeric cache", - { numeric_cache_mark, NULL, NULL }, - 0, 0, 0, + { numeric_cache_mark, NULL, NULL, numeric_cache_compact }, + 0, 0, RUBY_TYPED_WB_PROTECTED, }; #if !defined(_WIN32) @@ -227,8 +233,9 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f t_numeric_cache_slot *slot; VALUE value; - /* NaN and Infinity don't end with a digit. They stay uncached to honor BigDecimal.mode exceptions. */ - if( (conv->flags & PG_CODER_NUMERIC_UNCACHED) || len < 1 || len > NUMERIC_CACHE_MAX_LENGTH || !rb_isdigit(val[len - 1]) ){ + /* NaN and Infinity don't end with a digit. They stay uncached to honor BigDecimal.mode exceptions. + * Up to 8 characters covers values like 0.00, 19.90 or 1299.00, and fits the key into one uint64_t. */ + if( (conv->flags & PG_CODER_NUMERIC_UNCACHED) || len < 1 || len > (int)sizeof(text) || !rb_isdigit(val[len - 1]) ){ return pg_text_dec_numeric_uncached(val, len); } @@ -237,7 +244,7 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f /* The lock guards against Ractors running in parallel. It is never held across BigDecimal(), which can raise. */ rb_nativethread_lock_lock(&numeric_cache_lock); - value = slot->len == len && slot->text == text ? slot->value : Qundef; + value = slot->text == text ? slot->value : Qundef; rb_nativethread_lock_unlock(&numeric_cache_lock); if( value != Qundef ) return value; @@ -246,8 +253,7 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f if( rb_ractor_shareable_p(value) ){ rb_nativethread_lock_lock(&numeric_cache_lock); slot->text = text; - slot->len = len; - slot->value = value; + RB_OBJ_WRITE(numeric_cache_marker, &slot->value, value); rb_nativethread_lock_unlock(&numeric_cache_lock); } return value; diff --git a/spec/pg/gc_compact_spec.rb b/spec/pg/gc_compact_spec.rb index 8d74076c5..7a1929d42 100644 --- a/spec/pg/gc_compact_spec.rb +++ b/spec/pg/gc_compact_spec.rb @@ -63,6 +63,13 @@ def conv_array(value) CANCON.socket_io end + begin + TEXTDEC_NUMERIC = PG::TextDecoder::Numeric.new + rescue LoadError + else + BIGDECIMAL = TEXTDEC_NUMERIC.decode('1299.00') + end + begin # Use GC.verify_compaction_references instead of GC.compact . # This has the advantage that all movable objects are actually moved. @@ -127,6 +134,11 @@ def conv_array(value) expect( CANCON.socket_io ).to be_kind_of( IO ) end + it "should compact PG::TextDecoder::Numeric cache" do + skip "bigdecimal not available" unless defined?(BIGDECIMAL) + expect( BIGDECIMAL ).to equal( TEXTDEC_NUMERIC.decode('1299.00') ) + end + after :all do CONN2.close CONN3.close From 503e8321f48bf42f105dff0e385a2b67cfbc293f Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Fri, 30 Oct 2026 21:47:50 +0100 Subject: [PATCH 4/6] Fix compat with Truffleruby which doesn't provide rb_ractor_shareable_p() --- ext/extconf.rb | 1 + ext/pg_text_decoder.c | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ext/extconf.rb b/ext/extconf.rb index 863dd3a45..1c46c3a83 100644 --- a/ext/extconf.rb +++ b/ext/extconf.rb @@ -309,6 +309,7 @@ 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 'rb_ractor_shareable_p', 'ruby/ractor.h' # since ruby-3.0, but not in Truffleruby 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 2055cba7e..3d9fb0d45 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -249,8 +249,16 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f if( value != Qundef ) return value; value = pg_text_dec_numeric_uncached(val, len); - /* Rows and Ractors share cached objects. */ + +#ifdef HAVE_RB_RACTOR_SHAREABLE_P + /* Write cache only if value can be shared across ractors. */ if( rb_ractor_shareable_p(value) ){ +#else + /* Truffleruby doesn't support ractor. + * Cache the frozen value. */ + rb_obj_freeze(value); + { +#endif rb_nativethread_lock_lock(&numeric_cache_lock); slot->text = text; RB_OBJ_WRITE(numeric_cache_marker, &slot->value, value); From 100c63c37c44732c09b6b595b56dec9ee0f32b43 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Sat, 31 Oct 2026 22:45:14 +0100 Subject: [PATCH 5/6] Rename PG::Coder::NUMERIC_UNCACHED to DISABLE_CACHE To make it usable for possible other encoders/decoders using a cache in the future. --- ext/pg.h | 2 +- ext/pg_coder.c | 2 +- ext/pg_text_decoder.c | 4 ++-- spec/pg/type_spec.rb | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ext/pg.h b/ext/pg.h index 6afd8598c..2e067a96b 100644 --- a/ext/pg.h +++ b/ext/pg.h @@ -195,7 +195,7 @@ typedef VALUE (* t_pg_typecast_copy_get)( t_typemap *, VALUE, int, int, int ); #define PG_CODER_FORMAT_ERROR_TO_RAISE 0x4 #define PG_CODER_FORMAT_ERROR_TO_STRING 0x8 #define PG_CODER_FORMAT_ERROR_TO_PARTIAL 0xc -#define PG_CODER_NUMERIC_UNCACHED 0x10 +#define PG_CODER_DISABLE_CACHE 0x10 struct pg_coder { t_pg_coder_enc_func enc_func; diff --git a/ext/pg_coder.c b/ext/pg_coder.c index 4e367025a..14b111d23 100644 --- a/ext/pg_coder.c +++ b/ext/pg_coder.c @@ -627,7 +627,7 @@ init_pg_coder(void) rb_define_const( rb_cPG_Coder, "FORMAT_ERROR_TO_RAISE", INT2NUM(PG_CODER_FORMAT_ERROR_TO_RAISE)); rb_define_const( rb_cPG_Coder, "FORMAT_ERROR_TO_STRING", INT2NUM(PG_CODER_FORMAT_ERROR_TO_STRING)); rb_define_const( rb_cPG_Coder, "FORMAT_ERROR_TO_PARTIAL", INT2NUM(PG_CODER_FORMAT_ERROR_TO_PARTIAL)); - rb_define_const( rb_cPG_Coder, "NUMERIC_UNCACHED", INT2NUM(PG_CODER_NUMERIC_UNCACHED)); + rb_define_const( rb_cPG_Coder, "DISABLE_CACHE", INT2NUM(PG_CODER_DISABLE_CACHE)); /* * Name of the coder or the corresponding data type. diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index 3d9fb0d45..aa09ccaf5 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -223,7 +223,7 @@ pg_text_dec_numeric_uncached(const char *val, int len) * * Short values like +0.00+ or +19.90+ repeat a lot, so their frozen BigDecimal objects are * cached process-wide and the same object is returned for equal values. - * Set the flag PG::Coder::NUMERIC_UNCACHED to get a new object for every value. + * Set the flag PG::Coder::DISABLE_CACHE to get a new object for every value. * */ static VALUE @@ -235,7 +235,7 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f /* NaN and Infinity don't end with a digit. They stay uncached to honor BigDecimal.mode exceptions. * Up to 8 characters covers values like 0.00, 19.90 or 1299.00, and fits the key into one uint64_t. */ - if( (conv->flags & PG_CODER_NUMERIC_UNCACHED) || len < 1 || len > (int)sizeof(text) || !rb_isdigit(val[len - 1]) ){ + if( (conv->flags & PG_CODER_DISABLE_CACHE) || len < 1 || len > (int)sizeof(text) || !rb_isdigit(val[len - 1]) ){ return pg_text_dec_numeric_uncached(val, len); } diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 297b58ed0..888af8f49 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -280,8 +280,8 @@ def textdec_timestamptz_decode_should_fail(str) it 'returns a new object for values longer than 8 characters' do expect( textdec_numeric.decode('123456789') ).not_to equal( textdec_numeric.decode('123456789') ) end - it 'returns a new object with NUMERIC_UNCACHED' do - textdec_numeric.flags = PG::Coder::NUMERIC_UNCACHED + it 'returns a new object with DISABLE_CACHE' do + textdec_numeric.flags = PG::Coder::DISABLE_CACHE expect( textdec_numeric.decode('0.00') ).not_to equal( textdec_numeric.decode('0.00') ) end it 'raises on invalid input like BigDecimal()' do From 93961cf37659cdba8fe4dfa0832758102fb3553c 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 12:26:51 +0100 Subject: [PATCH 6/6] Cache a short numeric the second time it's seen A miss called rb_ractor_shareable_p(), which walks the object, and stored the value into the old cache marker, so it got promoted. A column of mostly distinct values like 1234.56 decoded 1.8x slower than without the cache. Values seen once now leave only their key, and are stored when seen again. Assisted-by: Claude Opus 5.5 [Claude Code] --- ext/pg_text_decoder.c | 18 +++++++++++++----- spec/pg/gc_compact_spec.rb | 1 + spec/pg/type_spec.rb | 7 +++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index aa09ccaf5..770fc5658 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -222,7 +222,7 @@ pg_text_dec_numeric_uncached(const char *val, int len) * As soon as this class is used, it requires the 'bigdecimal' gem. * * Short values like +0.00+ or +19.90+ repeat a lot, so their frozen BigDecimal objects are - * cached process-wide and the same object is returned for equal values. + * cached process-wide once seen twice in a row, and the same object is returned for the same text. * Set the flag PG::Coder::DISABLE_CACHE to get a new object for every value. * */ @@ -243,10 +243,19 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f slot = &numeric_cache[(text * UINT64_C(0x9E3779B97F4A7C15)) >> (64 - NUMERIC_CACHE_SLOT_BITS)]; /* The lock guards against Ractors running in parallel. It is never held across BigDecimal(), which can raise. */ + /* A text is cached the second time it's seen in a row in its slot. Texts seen only once only leave their key, + * which spares them rb_ractor_shareable_p() and a promotion to the old generation. */ rb_nativethread_lock_lock(&numeric_cache_lock); - value = slot->text == text ? slot->value : Qundef; + if( slot->text == text ){ + value = slot->value; + } else { + value = Qundef; + slot->text = text; + slot->value = Qnil; + } rb_nativethread_lock_unlock(&numeric_cache_lock); - if( value != Qundef ) return value; + if( value != Qundef && value != Qnil ) return value; + if( value == Qundef ) return pg_text_dec_numeric_uncached(val, len); value = pg_text_dec_numeric_uncached(val, len); @@ -260,8 +269,7 @@ pg_text_dec_numeric(t_pg_coder *conv, const char *val, int len, int tuple, int f { #endif rb_nativethread_lock_lock(&numeric_cache_lock); - slot->text = text; - RB_OBJ_WRITE(numeric_cache_marker, &slot->value, value); + if( slot->text == text ) RB_OBJ_WRITE(numeric_cache_marker, &slot->value, value); rb_nativethread_lock_unlock(&numeric_cache_lock); } return value; diff --git a/spec/pg/gc_compact_spec.rb b/spec/pg/gc_compact_spec.rb index 7a1929d42..128f9f51d 100644 --- a/spec/pg/gc_compact_spec.rb +++ b/spec/pg/gc_compact_spec.rb @@ -67,6 +67,7 @@ def conv_array(value) TEXTDEC_NUMERIC = PG::TextDecoder::Numeric.new rescue LoadError else + TEXTDEC_NUMERIC.decode('1299.00') # cached the second time it's seen BIGDECIMAL = TEXTDEC_NUMERIC.decode('1299.00') end diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 888af8f49..9390f8b62 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -263,7 +263,14 @@ def textdec_timestamptz_decode_should_fail(str) expect( textdec_numeric.decode('1299.00') ).to equal( textdec_numeric.decode('1299.00') ) expect( textdec_numeric.decode('1299.00') ).not_to equal( textdec_numeric.decode('1299.0') ) end + it 'returns a new object for a value seen only once' do + first = textdec_numeric.decode('31.4159') + cached = textdec_numeric.decode('31.4159') + expect( cached ).not_to equal( first ) + expect( textdec_numeric.decode('31.4159') ).to equal( cached ) + end it 'keeps cached objects alive across GC' do + textdec_numeric.decode('7.77') cached_object_id = textdec_numeric.decode('7.77').object_id GC.start expect( textdec_numeric.decode('7.77').object_id ).to eq( cached_object_id )