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 +7 -0
- data/CHANGELOG.md +156 -0
- data/DESIGN.md +298 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/lib/pg_pipeline/bounded_queue.rb +102 -0
- data/lib/pg_pipeline/client.rb +180 -0
- data/lib/pg_pipeline/connection_driver.rb +502 -0
- data/lib/pg_pipeline/errors.rb +27 -0
- data/lib/pg_pipeline/pool.rb +420 -0
- data/lib/pg_pipeline/request.rb +146 -0
- data/lib/pg_pipeline/server_caps.rb +83 -0
- data/lib/pg_pipeline/session.rb +71 -0
- data/lib/pg_pipeline/session_guard.rb +213 -0
- data/lib/pg_pipeline/transaction.rb +92 -0
- data/lib/pg_pipeline/version.rb +5 -0
- data/lib/pg_pipeline.rb +15 -0
- metadata +155 -0
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
|