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.
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.
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
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