hyperion-async-pg
Async-aware shim for the pg gem. Patches PG::Connection so exec, exec_params, exec_prepared and friends cooperate with the Async fiber scheduler — while one fiber is parked on a Postgres socket waiting for query results, other fibers in the same OS thread serve other requests. Companion to the Hyperion HTTP server. Pure Ruby, drop-in, no behavior change outside an Async scheduler.
Install
# Gemfile
gem 'hyperion-async-pg'
# config/initializers/async_pg.rb (Rails) or wherever your app boots
require 'hyperion/async_pg'
Hyperion::AsyncPg.install!
install! is idempotent and thread-safe. Call once at boot, before any DB connections are opened. It returns true on the first call, false thereafter.
Compatibility
Works transparently with anything sitting on top of pg:
- ActiveRecord (the
postgresqladapter calls throughPG::Connection#exec_params/#exec_prepared). - Sequel (the
postgresadapter does the same). - ROM-sql + rom-pg.
- Raw
pg— your ownPG::Connection.new(...).exec_params(...)calls.
No driver-side opt-in required. Patches are prepended onto PG::Connection, so every caller in the process picks them up.
Caveats
- Only yields under a fiber scheduler. Outside
Async { ... }(Sidekiq workers, plain scripts, rake tasks, Rails console) the patched methods behave identically to plainpg—IO#wait_readablefalls back to its blocking implementation whenFiber.schedulerisnil. There is no perf regression in non-async contexts. - Long-running statements still block the calling fiber. The shim parks a fiber on the socket; it does not preempt the running query. A 10 s
SELECTstill ties up that fiber for 10 s. Cap runaway queries with Postgresstatement_timeout(or session-levelSET statement_timeout), not at the Ruby layer. - Connection pool sizing. Under Hyperion + this shim, fibers vastly outnumber threads — each fiber can hold a checked-out DB connection while it waits on Postgres. A worker with 10 OS threads and 200 concurrent fibers can hold 200 in-flight connections. Size your
pool:(ActiveRecord) or:max_connections(Sequel) and your Postgresmax_connectionsaccordingly. Rule of thumb: pool >= peak concurrent fibers per worker. - Single-statement only. The shim drains all results and returns the last one, matching pg's default
exec_paramssemantics. Multi-statement strings sent throughexecproduce the last result, as before.
Tuning
| Env var | Default | Meaning |
|---|---|---|
HYPERION_ASYNC_PG_READ_TIMEOUT |
unset (block forever) | Seconds passed to IO#wait_readable per poll. Unset matches pg's default — rely on Postgres statement_timeout for the upper bound. Set when you want a hard ceiling on a single socket-wait independent of server-side timeouts; on timeout the shim raises PG::ConnectionBad. |
Read at every dispatch; no restart required.
Expected gain
On a PG-bound Rack workload (handler issues one query taking ~50 ms, served by Hyperion -w 1 -t 5, 200 concurrent wrk connections), expect 5–10× the throughput of plain pg + Puma at the same thread count. Plain pg caps out at threads × workers concurrent in-flight queries — fibers all park their OS thread the moment they hit recv(). With this shim the OS thread keeps serving other fibers during each pg_sleep, so concurrency is bounded by pool_size rather than thread_count.
See bench/pg_concurrent.rb for a reproducible bench. Real numbers depend on the DB round-trip, query mix, and pool size.
How it works
PG::Connection#exec_params(...) (and the other patched methods) becomes:
- Call the non-blocking
send_query_params(...)C function — fires the query off, returns immediately. - Loop:
consume_input→ checkis_busy→ if busy,socket_io.wait_readable. UnderAsync::Scheduler,wait_readableyields the fiber. Without one, it blocks the OS thread. - Drain results with
get_result, return the final one (afterresult.checkto surface errors).
No threads, no extra IO objects, no copy of the result through Ruby. The C extension does all the work; we only swap the wait primitive.
License
MIT.