pg_pipeline
PostgreSQL pipeline multiplexing for Ruby — inspired by tokio-postgres.
What is PostgreSQL pipeline mode?
Normally every query follows a request/reply cycle:
fiber → SEND query → wait RTT → RECV result → done
With libpq pipeline mode (libpq ≥ 14) the client can send multiple queries without waiting for each result first:
fiber A → SEND query A ─┐
fiber B → SEND query B ─┤─── one RTT ───┐
fiber C → SEND query C ─┘ ├─ RECV A, B, C
└─ route back to fibers
At 10 ms RTT a naïve pool does ~100 queries/s per connection.
A pipelined connection can do thousands — the wire stays full instead of sitting idle.
pg_pipeline is the Ruby control plane that does exactly this: it multiplexes
independent queries from many Async fibers onto a small pool of libpq connections,
routes FIFO results back to the right fiber, and keeps transactional/session work
on separate pinned connections. All wire protocol work stays in libpq; zero C code here.
The single-owner connection model, FIFO result ownership, and lifecycle approach are directly inspired by tokio-postgres.
Installation
# Gemfile
gem "pg_pipeline"
Requires Ruby ≥ 3.3, async ~> 2.42, pg ≥ 1.5, and libpq ≥ 14 at runtime.
libpq ≥ 17 is recommended for maximum local throughput. Below 17 there is no
PQsendPipelineSync, so PQpipelineSync couples Sync with a flush and the driver
cannot batch writes across a dispatch burst. Correctness is identical and RTT
amortisation — the main reason to pipeline — still works on libpq 14; only local
throughput is capped. The driver detects this at connect time and warns once per
process; set PG_PIPELINE_SILENCE_WARNINGS=1 to suppress it, and check
db.stats[:pipeline][:drivers].first[:fast_sync] to see which path is active.
Usage
Multiplexed queries
require "pg_pipeline"
Sync do |task|
PgPipeline::Client.open(ENV["DATABASE_URL"]) do |db|
# 20 queries fly out in parallel over a handful of connections
results = (1..20).map do |id|
task.async { db.query("SELECT $1::int AS id, now() AS at", [id]) }
end.map(&:wait)
p results.first # => [{"id"=>"1", "at"=>"..."}]
end
end
Client.open manages the pool lifecycle. db.query is fiber-safe and multiplexed —
every fiber yields while waiting, the event loop stays unblocked.
Multiplexed prepared statements
For repeated SQL, prepare it once on every pipeline connection and execute the returned immutable handle:
by_id = db.prepare(
"user_by_id",
"SELECT id, email, name FROM users WHERE id = $1",
[23] # optional PostgreSQL parameter OIDs
)
result = by_id.query([42])
begin
p result.first
ensure
result.clear
end
Client#prepare returns only after the statement is ready on every currently
live pipeline connection. Replacement connections automatically prepare the
registered catalog before they begin accepting requests. The logical name is
client-local; the gem uses private generated server names on its owned connections.
Prepared handles are intentionally explicit rather than an unbounded automatic SQL cache. The SQL guard runs once at preparation time, while each execution still snapshots its bind values before submission.
Transactions
db.transaction do |tx|
tx.exec("INSERT INTO orders(user_id, total) VALUES ($1, $2)", [user_id, total])
tx.exec("UPDATE balances SET reserved = reserved + $1 WHERE user_id = $2", [total, user_id])
end
# COMMIT on success, ROLLBACK on any exception
Nested atomicity with savepoints:
db.transaction do |tx|
tx.exec("INSERT INTO events(kind) VALUES ('started')", [])
tx.savepoint do |sp|
sp.exec("INSERT INTO risky_table(x) VALUES ($1)", [val])
# raises → rolls back to savepoint only, outer tx survives
end
end
Session (DDL, LISTEN, SET, connection-local prepared statements)
db.session do |s|
s.exec("SET application_name = 'worker-1'")
s.exec("PREPARE by_id AS SELECT * FROM users WHERE id = $1")
rows = s.exec_prepared("by_id", [42])
end
The session gets an exclusive pinned connection. It's automatically cleaned up
(DISCARD ALL) before the connection returns to the pool.
Pool configuration
PgPipeline::Client.open(
ENV["DATABASE_URL"],
pipeline_size: 4, # pipelined connections per client
pinned_size: 2, # exclusive connections for session/transaction
guard: :strict
) do |db|
# ...
end
Total PostgreSQL connections per process: pipeline_size + pinned_size.
Observability
db.stats
# => {
# pipeline: { size: 4, live: 4, drivers: [{load: 3, in_flight: 2, ...}, ...] },
# pinned: { size: 2, active: 1, free: 1 },
# prepared_statements: 1,
# reconnects: 0
# }
load (pending + in-flight + submitting + dispatching) per driver is the routing/head-of-line signal.
Each driver also reports how well the pipeline is filling:
db.stats[:pipeline][:drivers].first
# => { ..., fast_sync: true,
# units_per_readable: 7.9, results_per_readable: 22.2,
# flush_calls_per_unit: 0.17, flush_incomplete: 0 }
units_per_readable is the number to watch. Around 1.0 means every query pays a
full socket wait plus scheduler round trip and the pipeline is not filling — that is
the expected shape for a single fiber issuing one query at a time, and no amount of
Ruby-side optimisation will change it. Values well above 1.0 mean one reactor
wakeup is amortised over many queries, which is the regime pipelining is for. Check
this before attributing a throughput number to control-plane cost.
The numbers above are from a saturated HTTP benchmark (4 workers, 4 connections
each, ~20k requests/second against a local server): roughly eight queries per
reactor wakeup and one flush per six queries. results_per_readable runs at
three times units_per_readable because a completed unit yields three protocol
results -- the data, the query boundary and the Sync.
Head-of-line blocking
Results on a pipelined connection arrive in FIFO order, and PostgreSQL offers no
safe way to cancel one request out of a multiplexed pipeline (see DESIGN.md §7).
Two consequences worth designing around:
- A slow query delays everything behind it on the same connection. With the
default
max_in_flight: 64, one multi-second query can hold up to 63 unrelated queries on that driver. Driver selection balances by queue depth, not by expected cost, so a slow query counts the same as a fast one. - A timeout is not a cancellation. Wrapping
db.queryinwith_timeoutreturns control to your fiber, but the unit stays in the pipeline until the server answers it, and the requests behind it still wait.
If your workload mixes fast and slow queries, prefer one of:
- lower
max_in_flightso a stall cannot capture a deep queue; - a second
Clientwith its own connections for the slow queries; Client#sessionfor anything long-running, which uses an exclusive pinned connection and cannot block multiplexed traffic.
Failure model
| Error | Meaning | Retry safe? |
|---|---|---|
NotDispatchedError |
query never reached the wire | ✅ yes |
IndeterminateResultError |
query was sent, Sync not observed | ⚠️ only if idempotent |
UnsafeMultiplexError |
session-mutating SQL on multiplexed path | — fix the call site |
Cancelling a fiber does not send CancelRequest to PostgreSQL — the query may
still execute. For mutations, do not retry blindly after a timeout.
Falcon integration
One Client per worker, created after fork:
# falcon.rb / config.ru
app = Rack::Builder.new do
use MyMiddleware
run MyApp
end
Async do
db = PgPipeline::Client.new(ENV["DATABASE_URL"]).start
run app # fibers in this worker share db
end
See examples/falcon_config.ru for a complete setup.
Benchmark
Performance test results and methodology.
docker compose up -d pg17
export PG_PIPELINE_URL=postgres://postgres:postgres@127.0.0.1:5417/postgres
bundle exec rake bench:throughput # fibers/s, p99
bundle exec rake bench:ab # pipeline vs naive pool
For realistic numbers inject latency first:
# terminal 1
bundle exec rake bench:proxy RTT_MS=10 UPSTREAM=127.0.0.1:5417
# terminal 2
PG_PIPELINE_URL=postgres://postgres:postgres@127.0.0.1:6432/postgres \
bundle exec rake bench:ab
At 10 ms RTT, pipeline mode delivers ~7× better p99 with 8× fewer server connections.
License
MIT