pg_pipeline 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b73820c432250a5622916f522cc7df96e9be218120372d90d30447b2ea4364f
4
+ data.tar.gz: af6ac07e07a30e7812846c58b56cfb3fcf0722b6fcac99119be4529249a92a0f
5
+ SHA512:
6
+ metadata.gz: 70249da4404588815fd9b5122ad3bcd564ca8fb8deec8f9068788a27bad9427ebac189bb7ee3ae9f0e05c0461429a00c2a70001749d62f11199777a1d3394472
7
+ data.tar.gz: b030ef64f18383b8d3fd15177f7375b7347fed99e7e2e2dc387133306a35fccbe775a79aa8545005fef3bffc7d8ed1ba9547a389212de9a3c8801c0558a0d24e
data/CHANGELOG.md ADDED
@@ -0,0 +1,156 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] - unreleased
4
+
5
+ Initial experimental implementation.
6
+
7
+ - Single-owner `ConnectionDriver` with separate readiness watchers.
8
+ - Raw nonblocking libpq state machine over ruby-pg.
9
+ - FIFO demultiplexing with request completion only at `PGRES_PIPELINE_SYNC`.
10
+ - Per-unit Sync to isolate independent implicit transactions/error recovery.
11
+ - Real bounded pending queue with shutdown wakeup semantics.
12
+ - Future cancellation drains abandoned protocol slots without backend cancel.
13
+ - Session-neutral multiplexed path plus separate pinned `session` and
14
+ `transaction` APIs.
15
+ - Pinned connections sanitized with rollback-if-needed + `DISCARD ALL`.
16
+ - Capabilities keyed from `PG.library_version`/protocol version, not server
17
+ version; libpq 14 minimum, libpq 17 optional fast Sync path.
18
+ - Async-native: Ruby >= 3.3, async ~> 2.42, pg >= 1.5 (chosen for what
19
+ maximally simplifies the implementation; not held back to an older floor).
20
+ - Opportunistic result draining mirrors libpq's aborted-pipeline readiness
21
+ corner case instead of relying solely on socket-readable notifications.
22
+ - Strict result-status whitelist; unsupported streaming/COPY modes fail closed.
23
+ - Structured `Client.open` runs inside the caller's Async task instead of
24
+ creating a hidden task lifetime.
25
+ - Graceful close rejects re-entrant shutdown from inside a pinned block.
26
+ - Deferred `Async::Notification` wakeups avoid re-entrant owner deadlocks on
27
+ the connection owner; CI now exercises the exact async 2.42.0 dependency floor
28
+ in addition to the current lockfile-resolved 2.x release.
29
+ - Pinned PostgreSQL 9.3 sessions are physically reconnected after use because
30
+ pre-9.4 `DISCARD ALL` does not clear sequence `currval`/`lastval` state.
31
+ - Client use is rejected across thread/scheduler boundaries; shared clients are
32
+ explicitly reactor-local.
33
+ - Connection-loss semantics distinguish requests that were never dispatched from
34
+ dispatched units whose commit outcome is indeterminate until Sync is observed.
35
+ - String bind values (including the string `:value` in ruby-pg parameter hashes)
36
+ are snapshotted before asynchronous dispatch to avoid caller-side mutation races
37
+ while a request is queued; arbitrary custom mutable encoder objects remain the
38
+ caller's responsibility.
39
+ - Graceful shutdown now counts pinned connection establishment as active work,
40
+ closing a race where a yielding connect could outlive pool shutdown.
41
+ - Pinned `Session`/`Transaction` handles are fiber-local and expire at block exit,
42
+ so they cannot race from child tasks or use a recycled connection later.
43
+ - Queue ownership now starts only after `BoundedQueue` actually accepts a Request;
44
+ a close while a producer is blocked remains definitely pre-dispatch and can
45
+ fail over to a sibling driver.
46
+ - `NotDispatchedError` retries always use a fresh Request, including when the
47
+ failed Request was already settled by the losing driver.
48
+ - Timed-out idle health probes can abort only while the probe is still the sole
49
+ driver work; a concurrent user request makes the probe inconclusive instead
50
+ of risking an unrelated `IndeterminateResultError`.
51
+ - Client-side bind/encoding exceptions raised before `PQsendQueryParams` are
52
+ request-local and no longer tear down the shared connection. `PG::UnableToSend`
53
+ is `NotDispatched`; when the connection remains healthy in pipeline mode that
54
+ rejection is request-local, while other PG send failures stay driver-fatal.
55
+ Failure after the query was accepted but before Sync remains indeterminate.
56
+ - CI now separates server-version coverage from source-built client-libpq
57
+ coverage and exercises the libpq 14 and 16 fallback path explicitly.
58
+ - `reconnect: false` now remains authoritative even when health checks keep the
59
+ supervisor running; health probing no longer accidentally re-enables reconnect.
60
+ - Unexpected supervisor-loop failures are recorded in `stats[:supervisor_error]`
61
+ and the loop keeps running (no longer permanently disables reconnect/health).
62
+ - `Transaction#run` / `#savepoint` enforce fiber-local ownership via
63
+ `SessionOps.ensure_active!`; nested `run` is rejected (`already open`).
64
+ - Nested-run guard errors no longer trigger the outer transaction's
65
+ `rescue Exception` rollback path (would have ROLLBACK'd the outer BEGIN).
66
+ - `TransactionOps` accesses the physical connection only through
67
+ `SessionOps.connection` (private `conn` after Session encapsulation).
68
+
69
+ ### Style
70
+
71
+ - Refactored to a moderate functional-procedural shape: classes are thin state
72
+ wrappers holding the public API; behavior lives in `*Ops`/module_function
73
+ modules that receive state explicitly (Caps, DriverOps, PoolOps, RequestOps,
74
+ SessionOps, TransactionOps, ClientOps, SessionGuard).
75
+ - Implementation comments are kept only where they capture a non-obvious protocol or
76
+ ownership invariant; routine explanatory noise is omitted.
77
+ - `loop` retained only where idiomatic (BoundedQueue wait-retry); no `while true`.
78
+
79
+ ### Review response (hardening)
80
+
81
+ - Reconnect: pool supervisor replaces dead pipeline drivers (backoff); reconnect count in stats.
82
+ - BoundedQueue wake-one (no thundering herd under many blocked producers).
83
+ - BoundedQueue transfers a wake when a blocked waiter is cancelled after being
84
+ signalled, so free capacity cannot strand behind a dead fiber.
85
+ - Observability: client/pool/driver `stats`.
86
+ - Safer defaults: max_in_flight 64, max_pending 256, pinned_size 2.
87
+ - SessionGuard `:strict` mode + documented holes.
88
+ - Transaction savepoints.
89
+ - place_sync stays raw-first (send_pipeline_sync on 17+, sync_pipeline_sync on 14-16) + explicit flush.
90
+ - Falcon per-worker contract, sizing formula, honest example naming (async_app.rb, falcon_config.ru).
91
+ - Packaging: gemspec metadata; CI (unit + PG 14/16/17 matrix); docker-compose.
92
+ - Live integration suite under `spec/integration` (env-gated): multiplex, error
93
+ isolation, cancel-drain, savepoints, stats, backpressure-on-close, pinned
94
+ concurrency, driver abort → IndeterminateResultError, reconnect recovery.
95
+ - Unit suite aligned with Ops modules; reconnect/backoff and queue cancel-leak covered.
96
+
97
+ ### Second-pass reliability
98
+
99
+ - Idle health checks: pool probes idle drivers (SELECT 1 + timeout); timeout
100
+ aborts only when the probe is still the sole driver work; health_failures in stats.
101
+ - abort! cancels in-flight pinned work via CancelRequest.
102
+ - Submit failover: fresh Request per attempt; `NotDispatchedError` can retry
103
+ even when the losing Request was already settled.
104
+ - Strict guard precise denylist (no pg_typeof/setup/xact-advisory false positives).
105
+ - CI separates server-major coverage from source-built client-libpq 14/16 coverage.
106
+ - benchmarks/ (fiber-storm latency, multi-worker connection-count smoke).
107
+ - Pool driver slot arrays self-heal size (reconnect/health safe under partial init).
108
+ - Unit coverage: failover, health_probe, pinned cancel on abort.
109
+
110
+ - Pinned recycle rechecks shutdown after yielding reset/reconnect work so a
111
+ connection cannot be returned to the free list after pool shutdown.
112
+ - Replacement pipeline drivers are children of the pool's original parent task,
113
+ not the supervisor; ownership transfer is explicit so cancellation cannot orphan
114
+ a just-created replacement.
115
+ - `Session` no longer exposes its raw `PG::Connection`; Ruby-side typemap/callback
116
+ mutations cannot leak through a recycled pinned handle.
117
+ - `Transaction` lifecycle (`BEGIN`/`COMMIT`/`ROLLBACK`) is internal; callers cannot
118
+ recursively invoke the outer transaction runner or mutate its open state.
119
+ - `Client` lifecycle/reactor ownership state is private, preventing callers from
120
+ disabling close or bypassing the thread/scheduler guard.
121
+ - Timing options reject negative, NaN and infinite values; a closed Pool cannot be
122
+ restarted; unknown guard modes fail closed.
123
+ - CI YAML is valid, Linux platforms are present in the lockfile, and source-built
124
+ jobs assert the actual linked libpq 14/16 versions rather than confusing server
125
+ version coverage with client capability coverage.
126
+ - Documented upstream ruby-pg 1.6.3 parameter-encoding overflow (#719); its fix is
127
+ merged upstream but no newer RubyGems release is available as of July 2026.
128
+ - Documented the ordinary pinned-transaction commit-acknowledgement ambiguity: a
129
+ transport failure around `COMMIT` is not proof that the transaction rolled back.
130
+ - Re-entrant pinned checkout from the same task now fails immediately instead of
131
+ deadlocking at `pinned_size: 1` or silently using a second independent session.
132
+ - Supervisor wake cadence now respects a shorter health interval independently of
133
+ reconnect cadence; `health_interval: 0` keeps probe-on-each-supervisor-cycle
134
+ semantics without introducing a busy loop.
135
+ - Driver load accounting includes the narrow dispatching state so routing and
136
+ health checks cannot observe a transient false-idle driver.
137
+ - Pinned recycle cleanup now treats `Async::Cancel < Exception` as a cleanup
138
+ boundary: cancellation during rollback/sanitize/reconnect closes the physical
139
+ connection instead of orphaning it outside the pool. Graceful close also closes
140
+ already-idle pinned sockets before waiting for active checkouts.
141
+
142
+ ## 0.2.1 (perf + measurement)
143
+
144
+ - SessionGuard: bounded per-SQL memo cache for unsafe_reason. Repeated SQL
145
+ (parameterized queries) now costs ~0 allocations instead of ~330 objects/call
146
+ (measured); eliminates the code_only/byteslice line that showed up in profiles.
147
+ - Client#query: drop redundant sql dup (Request owns the one immutable copy).
148
+ - PoolOps.select_driver: allocation-free least-loaded scan (was 2-3 arrays/call);
149
+ behavior verified identical across 20k random cases.
150
+ - bench_kit/metrics.rb + rake bench:metrics: full picture in one run —
151
+ allocations/query + GC pressure, RubyProf process_time (CPU) and allocations,
152
+ RubyProf wall (sanity), and StackProf cpu/wall sampling. Asserts run outside
153
+ every profiler; warmup keeps connection setup out of frame.
154
+ - profile_ci_scenario.rb: correctness asserts moved out of the profiled block;
155
+ GC.start before profiling.
156
+ - Dev dependency: stackprof (~> 0.2).
data/DESIGN.md ADDED
@@ -0,0 +1,298 @@
1
+ # pg_pipeline design
2
+
3
+ ## 1. Scope
4
+
5
+ `pg_pipeline` is a driver-adjacent Ruby control plane over `ruby-pg`. It does not
6
+ parse the wire protocol and does not replace libpq.
7
+
8
+ The target workload is many concurrent Async/Falcon fibers issuing independent
9
+ small/medium PostgreSQL operations where RTT and/or connection count matter.
10
+ Pipeline mode does not parallelize execution inside one PostgreSQL backend.
11
+
12
+ ## 2. Non-negotiable ownership invariant
13
+
14
+ ```text
15
+ one ConnectionDriver == one PG::Connection == one task that calls it
16
+ ```
17
+
18
+ The owner task is the only code allowed to call `PG::Connection` methods.
19
+ Reader/writer watcher tasks only wait on the memoized socket IO and enqueue
20
+ readiness events.
21
+
22
+ This avoids concurrent mutation of libpq protocol state while still giving the
23
+ owner three wakeup sources:
24
+
25
+ ```text
26
+ submitted request ----\
27
+ socket readable -------+--> event queue --> OWNER --> PG::Connection
28
+ socket writable -------/
29
+ ```
30
+
31
+ ## 3. Raw nonblocking mode
32
+
33
+ At startup the driver calls:
34
+
35
+ ```ruby
36
+ conn.setnonblocking(true)
37
+ conn.enter_pipeline_mode
38
+ ```
39
+
40
+ In ruby-pg this means the application takes responsibility for send/flush
41
+ blocking states. The owner uses raw primitives where the distinction matters:
42
+
43
+ ```text
44
+ send_query_params
45
+ sync_flush
46
+ consume_input
47
+ is_busy
48
+ sync_get_result
49
+ ```
50
+
51
+ `is_busy` is only a readiness predicate: false means the next
52
+ `sync_get_result` will not block. It is not a request boundary.
53
+
54
+ The owner also checks `is_busy` opportunistically before going back to sleep,
55
+ not only after a socket-readable notification. PostgreSQL's own
56
+ `libpq_pipeline.c` does the same because libpq can synthesize an aborted-pipeline
57
+ result locally without making the socket newly readable.
58
+
59
+ ## 4. Protocol/FIFO model
60
+
61
+ Each multiplexed unit is encoded as:
62
+
63
+ ```text
64
+ extended-protocol query
65
+ Sync
66
+ ```
67
+
68
+ The request is appended to the in-flight FIFO only after both operations have
69
+ been queued successfully.
70
+
71
+ Results are associated with `@inflight.first`:
72
+
73
+ - ordinary/error `PG::Result` -> belongs to the FIFO front;
74
+ - `nil` -> end of that query's results;
75
+ - `PGRES_PIPELINE_SYNC` -> end of that logical unit; only here is FIFO front
76
+ removed and its waiter resolved;
77
+ - `PGRES_PIPELINE_ABORTED` -> record an error for the front, but do **not** pop
78
+ it until its Sync.
79
+
80
+ A server-side SQL error is therefore request-local. A client-side send/flush/
81
+ protocol error is connection-fatal because the driver can no longer prove how
82
+ its local FIFO maps to libpq's internal command queue.
83
+
84
+ The v1 driver accepts only the normal whole-result statuses
85
+ `EMPTY_QUERY`/`COMMAND_OK`/`TUPLES_OK`. COPY, single-row/chunked-row statuses and
86
+ unknown statuses are protocol errors because the public API does not enable
87
+ those result modes.
88
+
89
+ ## 5. Why Sync is per unit
90
+
91
+ Without an explicit transaction, commands between synchronization points form an
92
+ implicit transaction/error-recovery segment. Sharing one trailing Sync across
93
+ independent callers would couple their commit/error semantics.
94
+
95
+ Therefore v1 always uses one Sync per independent unit. On libpq 17+ the driver
96
+ uses `PQsendPipelineSync`; on older libpq it uses `PQpipelineSync`. In both cases
97
+ it explicitly checks `PQflush` afterwards because nonblocking output can remain
98
+ buffered.
99
+
100
+ ## 6. Shared-session contract
101
+
102
+ Autocommit alone is not sufficient. Multiplexed callers share a PostgreSQL
103
+ session. `SET`, `SET ROLE`, prepared statements, temp objects, LISTEN state and
104
+ session advisory locks can leak across callers.
105
+
106
+ `Client#query` therefore has a **session-neutral SQL contract**. `SessionGuard`
107
+ is intentionally conservative and catches common hazards, but it cannot prove
108
+ that arbitrary SQL or user-defined functions are session-neutral.
109
+
110
+ Anything stateful goes through an exclusive pinned connection:
111
+
112
+ - `Client#session` — no implicit BEGIN;
113
+ - `Client#transaction` — BEGIN/COMMIT/ROLLBACK.
114
+
115
+ Pinned connections are opened lazily up to `pinned_size`; a query-only process
116
+ does not reserve an otherwise idle second pool at startup. Before a pinned
117
+ physical connection is reused, the pool makes sure it is outside an explicit
118
+ transaction. PostgreSQL 9.4+ is sanitized with `DISCARD ALL`; PostgreSQL 9.3 is
119
+ physically reconnected because its `DISCARD ALL` cannot clear sequence state.
120
+
121
+ `Session`/`Transaction` handles are fiber-local scoped capabilities. They may
122
+ only be used by the fiber that received them and are invalidated when their block
123
+ exits. An escaped object therefore cannot race another fiber or keep using a
124
+ physical connection after the pool has handed it to another caller.
125
+
126
+ Calling `Client#close` from inside its own `session`/`transaction` block is
127
+ rejected instead of deadlocking while graceful shutdown waits for that same
128
+ pinned connection to be returned. A task that already owns a pinned checkout
129
+ also cannot recursively call `Client#session`/`Client#transaction`; that would
130
+ deadlock with `pinned_size: 1` or create a misleading second session with a
131
+ larger pool. Nested transactional work uses `Transaction#savepoint` instead.
132
+
133
+ ## 7. Cancellation
134
+
135
+ There are two unrelated concepts:
136
+
137
+ 1. **waiter cancellation** — caller stops caring about its Future/result;
138
+ 2. **backend cancellation** — PostgreSQL CancelRequest cancels the statement
139
+ currently executing on that backend.
140
+
141
+ Only (1) exists on the multiplexed API. An abandoned request already sent to the
142
+ server remains in the in-flight FIFO and is fully drained to Sync. Backend query
143
+ cancellation would be unsafe without a dedicated/pinned connection or stronger
144
+ knowledge that the target is the currently executing FIFO head. A cancelled
145
+ waiter therefore does **not** mean a mutating statement was cancelled; it can
146
+ still execute and commit.
147
+
148
+ A connection failure before dispatch is reported as `NotDispatchedError`. A
149
+ `PG::UnableToSend` from `PQsendQueryParams` is also definitely pre-dispatch; if
150
+ libpq still reports a healthy connection in pipeline mode, that rejection stays
151
+ request-local instead of poisoning unrelated in-flight units. Other `PG::Error`
152
+ send failures are treated as driver-fatal. Once a request has entered
153
+ dispatch/in-flight state, losing the connection before its `PGRES_PIPELINE_SYNC`
154
+ is observed is reported as `IndeterminateResultError`: for mutations the
155
+ server-side commit outcome is unknowable and automatic retry is unsafe without
156
+ an application-level idempotency strategy.
157
+
158
+ Pinned `Client#transaction` has the standard PostgreSQL commit-acknowledgement
159
+ ambiguity as well: a transport failure during `COMMIT` does not prove rollback
160
+ or commit. That path intentionally preserves the underlying `PG::Error`; callers
161
+ that retry non-idempotent transactions need application-level idempotency or
162
+ reconciliation rather than assuming the transaction did not commit.
163
+
164
+ ## 8. Backpressure and shutdown
165
+
166
+ `max_pending` is enforced by a small reactor-local bounded queue. On async 2.42
167
+ `Async::LimitedQueue` (with `#close`) would cover plain bounded backpressure; the
168
+ in-tree `BoundedQueue` is kept only for the two things it adds and the failure
169
+ model needs: a **typed** close (a producer blocked on backpressure during an
170
+ abort is woken with `NotDispatchedError`, not a generic closed error) and
171
+ `#drain` (hand back queued-but-undispatched requests to reject in one shot).
172
+
173
+ `max_in_flight` bounds the number of dispatched pipeline units.
174
+
175
+ Graceful shutdown:
176
+
177
+ ```text
178
+ stop accepting -> reject/wake blocked producers -> drain queued/in-flight work
179
+ -> flush -> exit pipeline mode -> stop watchers -> close socket
180
+ ```
181
+
182
+ Fatal shutdown:
183
+
184
+ ```text
185
+ stop accepting -> fail every owned request -> stop watchers -> close socket
186
+ ```
187
+
188
+ ## 9. Pinned connection hygiene
189
+
190
+ Pinned callers are allowed to mutate session state. Reusing such a connection
191
+ without reset would leak that state to the next caller. After each pinned block:
192
+
193
+ 1. if transaction status is `INTRANS`/`INERROR`, issue `ROLLBACK`;
194
+ 2. require an idle connection;
195
+ 3. on PostgreSQL 9.4+, run `DISCARD ALL`;
196
+ 4. on PostgreSQL 9.3, reconnect instead because pre-9.4 `DISCARD ALL` does not
197
+ clear sequence `currval`/`lastval` state;
198
+ 5. if cleanup fails, close and replace the physical connection;
199
+ 6. if replacement also fails, mark the pinned pool broken rather than reusing a
200
+ contaminated connection.
201
+
202
+ ## 10. Deferred fiber wakeups
203
+
204
+ Immediately resuming a waiter from inside the connection owner is unsafe: a
205
+ resumed caller could re-enter the driver (submit, or close the client and wait on
206
+ the owner) while the owner is mid-`signal`. Internal wait/notify points therefore
207
+ use `Async::Notification`, which resumes waiters on a later reactor turn, avoiding
208
+ reentrancy into the owner. The same rule applies to request completion,
209
+ bounded-queue backpressure and pinned-pool idle notifications.
210
+
211
+ > Note: the supported Async range starts at 2.42 and stays below 3. The lockfile
212
+ > exercises the current compatible 2.x release, while CI separately runs the unit
213
+ > suite against the exact 2.42.0 floor so this coordination behavior is not merely
214
+ > assumed from a broad pessimistic dependency range.
215
+
216
+ ## 11. Version policy
217
+
218
+ | Component | Minimum | Reason |
219
+ |---|---:|---|
220
+ | Ruby | 3.3 | our dev-env floor; required by the current async line |
221
+ | async | ~> 2.42 | Async-native; floor 2.42.0 is exercised separately in CI |
222
+ | pg | >= 1.5, < 2 | pipeline bindings + raw result/flush + setnonblocking |
223
+ | libpq | 14 | pipeline API introduced here (client-side) |
224
+ | server | protocol v3 | pipeline is client-side; NO server-14 requirement |
225
+
226
+ We go Async-native and depend on the current async line rather than reinventing
227
+ its coordination primitives to hold an older Ruby floor. Ruby/async are OUR
228
+ dev-env floors — chosen freely for what maximally simplifies the implementation.
229
+ libpq/server are the USER's floor and are NOT raised without cause: libpq 14 is
230
+ the true minimum (no pipeline mode below it); the server only needs protocol v3.
231
+ libpq 17 is an optional runtime-detected fast path (`PQsendPipelineSync`,
232
+ chunked-rows, non-blocking cancel), never a floor.
233
+
234
+ > The server floor is the user's environment; revisit annually against
235
+ > PostgreSQL's EOL calendar. Capability gating is by `PG.library_version`
236
+ > (the client libpq), never by `conn.server_version`.
237
+
238
+ ## 12. Known limitations before production
239
+
240
+ - Pipeline driver death is recovered by the pool supervisor (`reconnect: true`
241
+ default) with exponential backoff; while a slot is down traffic goes to live
242
+ drivers, and with zero live drivers `#query` raises `NotDispatchedError`.
243
+ Idle drivers are also probed (`health_check: true`) with `SELECT 1`. A
244
+ timed-out probe may abort the connection only while it remains the sole
245
+ driver work; concurrent user work makes the probe inconclusive instead of
246
+ allowing a background check to make that work indeterminate.
247
+ - No PostgreSQL query cancellation on the **multiplexed** path (would target
248
+ whichever statement is currently executing on the shared backend).
249
+ - `abort!` sends `CancelRequest` to checked-out **pinned** connections
250
+ (`cancel_pinned_on_abort: true`); it does not force-close the socket if the
251
+ backend ignores cancel.
252
+ - No streaming/single-row/chunked-row result API.
253
+ - No cross-thread/reactor sharing; `Client` rejects use from a different thread or Fiber scheduler.
254
+ - `SessionGuard` is policy/ergonomics, not a security boundary. `:strict` adds a
255
+ precise denylist (`nextval`/`setval`/`pg_export_snapshot`, …) without the old
256
+ broad `pg_*(` / `set*(` false positives; UDFs can still mutate session state.
257
+ - Live integration covers **server** PG 14/16/17. CI prints the linked **client**
258
+ libpq version, but does not yet matrix distinct client-libpq majors (one
259
+ runner libpq across all server jobs). Pipeline capability is client-side, so
260
+ a dedicated libpq 14/16/17 client matrix remains future work.
261
+ - On libpq 14–16, `place_sync` uses `PQpipelineSync` / ruby-pg `sync_pipeline_sync`
262
+ (flush coupled); libpq 17+ uses `send_pipeline_sync` + explicit `sync_flush`.
263
+ - Pinned recycle still runs full `DISCARD ALL` (no lighter reset profile / recycle
264
+ latency metric yet).
265
+ - The latest released ruby-pg (1.6.3 as of July 2026) has upstream issue #719
266
+ in very large query-parameter encoding. The fix is merged upstream but no newer
267
+ RubyGems release exists yet; untrusted multi-gigabyte parameter sets must be
268
+ bounded or deployments should build ruby-pg from a revision containing the fix.
269
+
270
+ ## 13. Roadmap status (review response)
271
+
272
+ Implemented:
273
+
274
+ - Automatic reconnect / replacement of dead pipeline drivers (pool supervisor
275
+ with exponential backoff); `client.stats[:reconnects]`.
276
+ - Idle health checks on zero-load drivers; `client.stats[:health_failures]`.
277
+ - BoundedQueue wake-one + cancel wake-transfer (no thundering herd / stranded slots).
278
+ - Observability: public `client.stats` plus internal per-driver `stats`.
279
+ - Safer fiber-storm defaults: `max_in_flight: 64`, `max_pending: 256`,
280
+ `pinned_size: 2`.
281
+ - `SessionGuard` `:strict` precise denylist + documented holes.
282
+ - Savepoints (`Transaction#savepoint`).
283
+ - Submit failover: `NotDispatchedError` retries on a **new** Request against a
284
+ live sibling; the failed Request object itself is never re-used.
285
+ - `abort!` CancelRequest on in-use pinned connections.
286
+ - Falcon per-worker contract + sizing formula; reactor-local Client fail-fast.
287
+ - Packaging: gemspec metadata, CI (unit + PG server 14/16/17), docker-compose,
288
+ examples, `benchmarks/pipeline_bench.rb` + `benchmarks/multiworker_smoke.rb`.
289
+ - Live integration suite (multiplex, isolation, cancel-drain, savepoints, stats,
290
+ backpressure, pinned concurrency, indeterminate on abort, reconnect, backend
291
+ terminate recovery, pinned abort cancel).
292
+
293
+ Still open:
294
+
295
+ - Dedicated client-libpq 14/16/17 CI matrix (not only server majors).
296
+ - Lighter pinned reset / recycle-latency metrics under high-TPS tx.
297
+ - Force socket close if pinned cancel is ignored.
298
+ - Published bench numbers in README; tighten multiworker smoke to peak occupancy.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # pg_pipeline
2
+
3
+ PostgreSQL pipeline multiplexing for Ruby — inspired by [tokio-postgres](https://github.com/sfackler/rust-postgres).
4
+
5
+ ## What is PostgreSQL pipeline mode?
6
+
7
+ Normally every query follows a request/reply cycle:
8
+
9
+ ```
10
+ fiber → SEND query → wait RTT → RECV result → done
11
+ ```
12
+
13
+ With [libpq pipeline mode](https://www.postgresql.org/docs/current/libpq-pipeline-mode.html) (libpq ≥ 14)
14
+ the client can send multiple queries without waiting for each result first:
15
+
16
+ ```
17
+ fiber A → SEND query A ─┐
18
+ fiber B → SEND query B ─┤─── one RTT ───┐
19
+ fiber C → SEND query C ─┘ ├─ RECV A, B, C
20
+ └─ route back to fibers
21
+ ```
22
+
23
+ At 10 ms RTT a naïve pool does ~100 queries/s per connection.
24
+ A pipelined connection can do thousands — the wire stays full instead of sitting idle.
25
+
26
+ `pg_pipeline` is the Ruby control plane that does exactly this: it multiplexes
27
+ independent queries from many Async fibers onto a small pool of libpq connections,
28
+ routes FIFO results back to the right fiber, and keeps transactional/session work
29
+ on separate pinned connections. All wire protocol work stays in libpq; zero C code here.
30
+
31
+ The single-owner connection model, FIFO result ownership, and lifecycle approach
32
+ are directly inspired by tokio-postgres.
33
+
34
+ ## Installation
35
+
36
+ ```ruby
37
+ # Gemfile
38
+ gem "pg_pipeline"
39
+ ```
40
+
41
+ Requires Ruby ≥ 3.3, `async ~> 2.42`, `pg ≥ 1.5`, and **libpq ≥ 14** at runtime.
42
+
43
+ ## Usage
44
+
45
+ ### Multiplexed queries
46
+
47
+ ```ruby
48
+ require "pg_pipeline"
49
+
50
+ Sync do |task|
51
+ PgPipeline::Client.open(ENV["DATABASE_URL"]) do |db|
52
+ # 20 queries fly out in parallel over a handful of connections
53
+ results = (1..20).map do |id|
54
+ task.async { db.query("SELECT $1::int AS id, now() AS at", [id]) }
55
+ end.map(&:wait)
56
+
57
+ p results.first # => [{"id"=>"1", "at"=>"..."}]
58
+ end
59
+ end
60
+ ```
61
+
62
+ `Client.open` manages the pool lifecycle. `db.query` is fiber-safe and multiplexed —
63
+ every fiber yields while waiting, the event loop stays unblocked.
64
+
65
+ ### Transactions
66
+
67
+ ```ruby
68
+ db.transaction do |tx|
69
+ tx.exec("INSERT INTO orders(user_id, total) VALUES ($1, $2)", [user_id, total])
70
+ tx.exec("UPDATE balances SET reserved = reserved + $1 WHERE user_id = $2", [total, user_id])
71
+ end
72
+ # COMMIT on success, ROLLBACK on any exception
73
+ ```
74
+
75
+ Nested atomicity with savepoints:
76
+
77
+ ```ruby
78
+ db.transaction do |tx|
79
+ tx.exec("INSERT INTO events(kind) VALUES ('started')", [])
80
+ tx.savepoint do |sp|
81
+ sp.exec("INSERT INTO risky_table(x) VALUES ($1)", [val])
82
+ # raises → rolls back to savepoint only, outer tx survives
83
+ end
84
+ end
85
+ ```
86
+
87
+ ### Session (DDL, LISTEN, SET, prepared statements)
88
+
89
+ ```ruby
90
+ db.session do |s|
91
+ s.exec("SET application_name = 'worker-1'")
92
+ s.exec("PREPARE by_id AS SELECT * FROM users WHERE id = $1")
93
+ rows = s.exec_prepared("by_id", [42])
94
+ end
95
+ ```
96
+
97
+ The session gets an exclusive pinned connection. It's automatically cleaned up
98
+ (`DISCARD ALL`) before the connection returns to the pool.
99
+
100
+ ### Pool configuration
101
+
102
+ ```ruby
103
+ PgPipeline::Client.open(
104
+ ENV["DATABASE_URL"],
105
+ pipeline_size: 4, # pipelined connections per client
106
+ pinned_size: 2, # exclusive connections for session/transaction
107
+ guard: :strict
108
+ ) do |db|
109
+ # ...
110
+ end
111
+ ```
112
+
113
+ Total PostgreSQL connections per process: `pipeline_size + pinned_size`.
114
+
115
+ ### Observability
116
+
117
+ ```ruby
118
+ db.stats
119
+ # => {
120
+ # pipeline: { size: 4, live: 4, drivers: [{load: 3, in_flight: 2, ...}, ...] },
121
+ # pinned: { size: 2, active: 1, free: 1 },
122
+ # reconnects: 0
123
+ # }
124
+ ```
125
+
126
+ `load` (pending + in_flight) per driver is the head-of-line signal.
127
+
128
+ ## Failure model
129
+
130
+ | Error | Meaning | Retry safe? |
131
+ |---|---|---|
132
+ | `NotDispatchedError` | query never reached the wire | ✅ yes |
133
+ | `IndeterminateResultError` | query was sent, Sync not observed | ⚠️ only if idempotent |
134
+ | `UnsafeMultiplexError` | session-mutating SQL on multiplexed path | — fix the call site |
135
+
136
+ Cancelling a fiber does **not** send `CancelRequest` to PostgreSQL — the query may
137
+ still execute. For mutations, do not retry blindly after a timeout.
138
+
139
+ ## Falcon integration
140
+
141
+ One `Client` per worker, created after fork:
142
+
143
+ ```ruby
144
+ # falcon.rb / config.ru
145
+ app = Rack::Builder.new do
146
+ use MyMiddleware
147
+ run MyApp
148
+ end
149
+
150
+ Async do
151
+ db = PgPipeline::Client.new(ENV["DATABASE_URL"]).start
152
+ run app # fibers in this worker share db
153
+ end
154
+ ```
155
+
156
+ See `examples/falcon_config.ru` for a complete setup.
157
+
158
+ ## Benchmark
159
+
160
+ ```bash
161
+ docker compose up -d pg17
162
+ export PG_PIPELINE_URL=postgres://postgres:postgres@127.0.0.1:5417/postgres
163
+
164
+ bundle exec rake bench:throughput # fibers/s, p99
165
+ bundle exec rake bench:ab # pipeline vs naive pool
166
+ ```
167
+
168
+ For realistic numbers inject latency first:
169
+
170
+ ```bash
171
+ # terminal 1
172
+ bundle exec rake bench:proxy RTT_MS=10 UPSTREAM=127.0.0.1:5417
173
+
174
+ # terminal 2
175
+ PG_PIPELINE_URL=postgres://postgres:postgres@127.0.0.1:6432/postgres \
176
+ bundle exec rake bench:ab
177
+ ```
178
+
179
+ At 10 ms RTT, pipeline mode delivers ~7× better p99 with 8× fewer server connections.
180
+
181
+ ## License
182
+
183
+ MIT