ActiveRecord connection pool exhaustion intermittently under multithreaded requests
0 reputation · 08 Jun 2020, 02:38 UTC
0 reputation · 08 Jun 2020, 02:38 UTC
ActiveRecord connection pool exhaustion appears intermittently when the number of concurrent requests exceeds the configured pool size, causing checkout timeouts despite logs showing no explicit 'pool empty' messages.
The default pool size of five, combined with multi‑process server configurations such as Puma or Passenger, may break thread‑safety guarantees, while the default five‑second checkout timeout can mask saturation by returning connections early. Additionally, behavior can differ between Rails 6.x and 7.x due to connection pool refactoring, and external server settings influence the observed pattern.
Which Rails version-specific changes affect pool thread‑safety? What configuration or logging can distinguish pool size limits from latency‑driven checkout failures? What metrics reliably indicate intermittent pool exhaustion in a multi‑process deployment?
35775 reputation · 08 Jun 2020, 10:09 UTC
ActiveRecord creates a separate connection pool per process. With the default pool: 5 and a multi-process server (Puma clustered, Passenger), each worker gets its own pool of five connections. If max_threads exceeds five, or if async queries (Rails 7+) hold connections during I/O wait, threads block on checkout and eventually raise ActiveRecord::ConnectionTimeoutError after the default five-second timeout. The logs show the timeout exception but no explicit "pool empty" message, making saturation look like latency.
ConnectionPool::Queue) with strict FIFO checkout and removed the legacy @waiting list.reaping_frequency (default 60s) delayed dead-connection cleanup, inflating busy counts. Fixed in 6.1.4/7.0.1; set reaping_frequency: 10 in database.yml if on affected versions.load_async which holds connections until the future resolves, increasing effective contention.config.active_record.async_query_executor (default :global_thread_pool) — those threads also check out connections, further raising demand.Enable debug logging temporarily (config.log_level = :debug) to see Checkout/Checkin events with thread IDs and timestamps. Then inspect the pool state via ActiveRecord::Base.connection_pool.stat which returns { size:, connections:, busy:, dead:, waiting:, checkout_timeout: }.
busy == size and waiting > 0.busy < size but timeouts occur — indicates slow queries, external API calls, GC pauses, or network stalls holding connections longer than checkout_timeout.pool.stat[:waiting] sampled periodically (via Prometheus exporter or middleware) — sustained > 0 means undersized pool or leaks.ConnectionTimeoutError.connection.active_record notifications).pg_stat_activity or SHOW PROCESSLIST) vs. pool × processes.# Health endpoint (add to routes)
get '/pool-stat', to: ->(env) { [200, { 'Content-Type' => 'application/json' }, [ActiveRecord::Base.connection_pool.stat.to_json]] }
# Debug logging (staging only)
config.log_level = :debug
# Checkout duration instrumentation
ActiveSupport::Notifications.subscribe('connection.active_record') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
if event.payload[:name] == 'checkout'
Rails.logger.info "checkout_duration=#{event.duration}ms"
end
end
# DB-side check (PostgreSQL)
SELECT count(*) FROM pg_stat_activity WHERE state = 'active';
# Load test
wrk -t4 -c100 -d30s http://staging.example.com/pool-stat
What is the exact Rails version and config.active_record.async_query_executor setting? Rails 7.1+ with the global thread pool adds hidden connection demand that changes the pool-sizing calculation.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.