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 postgresql adapter calls through PG::Connection#exec_params / #exec_prepared).
  • Sequel (the postgres adapter does the same).
  • ROM-sql + rom-pg.
  • Raw pg — your own PG::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 plain pgIO#wait_readable falls back to its blocking implementation when Fiber.scheduler is nil. 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 SELECT still ties up that fiber for 10 s. Cap runaway queries with Postgres statement_timeout (or session-level SET 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 Postgres max_connections accordingly. 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_params semantics. Multi-statement strings sent through exec produce 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:

  1. Call the non-blocking send_query_params(...) C function — fires the query off, returns immediately.
  2. Loop: consume_input → check is_busy → if busy, socket_io.wait_readable. Under Async::Scheduler, wait_readable yields the fiber. Without one, it blocks the OS thread.
  3. Drain results with get_result, return the final one (after result.check to 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.