From 73c8f22d6eca149663ef61c92cd83aa1665378a9 Mon Sep 17 00:00:00 2001 From: August Thornton Date: Tue, 11 Aug 2026 11:00:44 -0700 Subject: [PATCH] connect before dirtying a transaction with a timeout Setting an explicit timeout calls dirty!, which makes the transaction unrestorable. That disables Rails' lazy connect -- with_raw_connection only connects when reconnect_can_restore_state? is true -- so on a connection that had never been used, the first statement (BEGIN, issued by RealTransaction#materialize!) ran against a nil raw connection: NoMethodError: undefined method 'async_exec' for nil Connect before dirty! rather than after. reset_transaction only carries the transaction manager across a reconnect while it is still restorable, so connecting once the transaction is dirty silently discards it along with the timeout just set -- no BEGIN, no SET LOCAL, and the block runs with no timeout at all. The second spec covers that, since the crash alone is fixed by either ordering. --- .../pg_extensions/transaction.rb | 3 +++ spec/postgresql_adapter_spec.rb | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/active_record/pg_extensions/transaction.rb b/lib/active_record/pg_extensions/transaction.rb index aac3d02..94826c4 100644 --- a/lib/active_record/pg_extensions/transaction.rb +++ b/lib/active_record/pg_extensions/transaction.rb @@ -14,6 +14,9 @@ def #{kind}(local: false) private def #{kind}=(timeout) return if @#{kind} == timeout + # must precede dirty!, which disables lazy connect; connecting after would discard this transaction + connection.connect! unless connection.connected? + @#{kind} = timeout # If we have set an explicit timeout, the transaction has state that must be materialized # separately from any other transaction, so it cannot be `restartable` diff --git a/spec/postgresql_adapter_spec.rb b/spec/postgresql_adapter_spec.rb index b3dac06..7ca6599 100644 --- a/spec/postgresql_adapter_spec.rb +++ b/spec/postgresql_adapter_spec.rb @@ -437,6 +437,26 @@ end end + describe "#statement_timeout= on a connection that hasn't been used yet" do + before { connection.disconnect! } + + it "materializes the transaction against a live connection" do + expect do + connection.transaction do + connection.statement_timeout = 30 + connection.select_value("SELECT 1") + end + end.not_to raise_error + end + + it "still applies the timeout" do + connection.transaction do + connection.statement_timeout = 0.01 + expect { connection.execute("SELECT pg_sleep(3)") }.to raise_error(ActiveRecord::QueryCanceled) + end + end + end + describe "#rename_constraint" do around do |example| connection.dont_execute(&example)