module ConnectionHealth
extend ActiveSupport::Concern
def with_fresh_connection
conn = ActiveRecord::Base.connection
conn.verify! # pings and reconnects if the socket is dead
yield
ensure
ActiveRecord::Base.clear_active_connections!
end
def checkout_fresh
conn = ActiveRecord::Base.connection
conn.reconnect! unless safe?(conn)
yield conn
end
private
def safe?(conn)
conn.active? && conn.verify!
rescue ActiveRecord::ConnectionNotEstablished, PG::Error
false
end
end
class LongJobBase
include Sidekiq::Job
include ConnectionHealth
sidekiq_options retry: 3
def perform(*args)
run(*args)
rescue ActiveRecord::StatementInvalid, PG::Error => e
raise unless recoverable?(e)
Rails.logger.warn("stale connection reclaimed: #{e.class}")
ActiveRecord::Base.connection.reconnect!
run(*args)
ensure
ActiveRecord::Base.clear_active_connections!
end
def run(*args)
raise NotImplementedError
end
private
def recoverable?(error)
error.is_a?(PG::ConnectionBad) ||
error.message.include?("server closed the connection") ||
error.message.include?("connection is closed")
end
end
class ExportLargeReportJob < LongJobBase
BATCH_SIZE = 500
def run(report_id)
report = with_fresh_connection { Report.find(report_id) }
writer = ChunkedS3Writer.new(report.s3_key)
Order.where(report_id: report.id).in_batches(of: BATCH_SIZE) do |relation|
rows = with_fresh_connection { relation.pluck(:id, :total, :placed_at) }
# slow work happens WITHOUT holding a pooled connection
writer.upload_chunk(serialize(rows))
end
with_fresh_connection do
report.update!(status: :completed, exported_at: Time.current, url: writer.finalize)
end
end
private
def serialize(rows)
rows.map { |id, total, placed_at| { id: id, total: total.to_s, placed_at: placed_at&.iso8601 } }
end
end
Long-running background jobs are the classic place where ActiveRecord's connection pool bites back. A worker might hold a checked-out connection for minutes while it does slow, non-database work (HTTP calls, file parsing, sleeping between batches), and in that window the connection can go stale: the server closes it after tcp_keepalives or a proxy like PgBouncer drops it, leaving the next query to fail with PG::ConnectionBad. This set of files shows how to keep connections healthy without leaking them out of the pool.
The ConnectionHealth concern in the first tab wraps a block in with_fresh_connection. It first calls verify! on the current connection, which pings the server and transparently reconnects if the socket is dead. Crucially it also uses clear_active_connections! after the work completes so the connection returns to the pool rather than being pinned to the thread for the whole job. The checkout_fresh helper is for the harder case where a connection must be discarded entirely: reconnect! forces a new socket even if active? optimistically reports true.
In ExportLargeReportJob, the pattern is applied to a batch loop. The job deliberately does NOT hold one connection across the whole run. Instead it processes with in_batches and, on every iteration, releases the connection during the slow upload_chunk step by wrapping only the query portion in with_fresh_connection. This means the pool stays available to other threads and each batch re-verifies its connection, so a mid-job network blip only costs one retry rather than failing the whole export.
The LongJobBase tab ties it together as a reusable superclass. around_perform-style logic reaps idle connections with ActiveRecord::Base.clear_active_connections! and rescues ActiveRecord::StatementInvalid and PG::Error to retry once with a fresh connection before giving up. The trade-off is subtle: verifying a connection adds a round-trip, so it is applied per batch rather than per row. Reaching for this pattern makes sense whenever a job runs longer than the database or pooler idle timeout, or when many workers contend for a small pool. The main pitfall is forgetting to release the connection, which silently exhausts the pool under load.
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.