module StatementTimeout
extend ActiveSupport::Concern
class TimeoutExceeded < StandardError; end
def with_statement_timeout(milliseconds)
connection = ActiveRecord::Base.connection
previous = connection.select_value("SHOW statement_timeout")
connection.execute("SET statement_timeout = #{connection.quote(milliseconds)}")
yield
rescue ActiveRecord::QueryCanceled => e
raise TimeoutExceeded, "query exceeded #{milliseconds}ms budget: #{e.message}"
ensure
if connection
restore = connection.quote(previous.presence || "0")
connection.execute("SET statement_timeout = #{restore}")
end
end
end
class ReportsController < ApplicationController
include StatementTimeout
rescue_from StatementTimeout::TimeoutExceeded do
response.headers["Retry-After"] = "5"
render json: { error: "report timed out, please retry" }, status: :service_unavailable
end
def show
report = with_statement_timeout(2_000) do
Invoice
.where(account_id: params[:account_id])
.where(created_at: date_range)
.group(:status)
.sum(:amount_cents)
end
render json: { totals: report }
end
private
def date_range
Time.zone.parse(params[:from])..Time.zone.parse(params[:to])
end
end
class ExportBatchJob < ApplicationJob
include StatementTimeout
queue_as :exports
retry_on StatementTimeout::TimeoutExceeded, wait: 1.minute, attempts: 3
def perform(export_id)
export = Export.find(export_id)
rows = with_statement_timeout(30_000) do
export.source_scope
.includes(:line_items)
.order(:created_at)
.to_a
end
CsvWriter.new(export).write(rows)
export.update!(status: :completed, row_count: rows.size)
end
end
This snippet shows how to protect a Rails application from runaway Postgres queries by scoping a statement_timeout to a block of code, rather than setting one global value. Postgres exposes statement_timeout as a session-level setting: any statement that runs longer than the configured milliseconds is aborted by the server with a QueryCanceled error. The trick is to set it on the current connection, run some work, and always restore the previous value — even when the query blows up.
In StatementTimeout concern, with_statement_timeout captures the connection's existing statement_timeout with SHOW statement_timeout, applies the requested value via SET, and uses an ensure block to put the old value back. Wrapping the setting in the same connection matters: ActiveRecord's connection is checked out from the pool for the current thread, so the timeout applies only to statements issued on that checkout. Values are passed through quote to avoid injecting arbitrary SQL into the SET command. QueryCanceled is caught and re-raised as a domain-specific TimeoutExceeded so callers can distinguish a deliberate guard from an unexpected failure.
ReportsController demonstrates the request-facing use case: an expensive aggregation is wrapped in with_statement_timeout(2_000) so a single slow report can never hold a web worker hostage for more than two seconds. When the guard trips, rescue_from turns it into a clean 503 with a Retry-After header instead of a stack trace or a hung Puma thread.
ExportBatchJob shows the opposite end of the spectrum: background work legitimately needs longer budgets, so it uses a generous 30_000 ms timeout. This illustrates the core trade-off — a timeout that is too tight kills valid work, while one that is too loose lets a pathological query exhaust the connection pool. Scoping the value per operation lets each context pick its own budget.
A key pitfall is restoring the original setting: without the ensure, a leaked timeout would silently apply to every later query reusing that pooled connection. Because the reset runs even on cancellation, the connection returns to the pool clean. This pattern is worth reaching for whenever untrusted filters, ad-hoc analytics, or user-driven queries could otherwise run unbounded.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.