pg_pipeline 0.2.1 → 0.2.3
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 +4 -4
- data/CHANGELOG.md +131 -18
- data/DESIGN.md +31 -11
- data/README.md +32 -2
- data/lib/pg_pipeline/client.rb +37 -9
- data/lib/pg_pipeline/connection_driver.rb +66 -40
- data/lib/pg_pipeline/errors.rb +1 -0
- data/lib/pg_pipeline/pool.rb +238 -46
- data/lib/pg_pipeline/prepared_statement.rb +47 -0
- data/lib/pg_pipeline/request.rb +55 -3
- data/lib/pg_pipeline/session.rb +7 -3
- data/lib/pg_pipeline/session_guard.rb +10 -3
- data/lib/pg_pipeline/transaction.rb +13 -12
- data/lib/pg_pipeline/version.rb +1 -1
- data/lib/pg_pipeline.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7135257ec52d199f4a7e0d3666909eebd8effa277f7efe80d27ca8ff00717f93
|
|
4
|
+
data.tar.gz: 8f3b976cc4b9f8400439a1504bbad9a019865a88a7f41e6556907ba649587c80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 90422fd9a948509c2e53b9058d052a822e76f303a736a39488a0870b6fa903d6c1b2b8248e00649269a63ed606a9a8d04b1c2c081cf0f2b89e81000af8a36e54
|
|
7
|
+
data.tar.gz: fa76f361560fa078903685d69de81600b8337509b8f37b89cd93fdfdc71ebc3953180edf8487b4aa24d79d521237ef98bc727cde48ca959acb41c0d589749999
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,135 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.2.
|
|
3
|
+
## [0.2.3] - 2026-07-30
|
|
4
|
+
|
|
5
|
+
Performance-focused release based on CPU profiles from the live pipeline
|
|
6
|
+
workload. It removes redundant libpq polling, adds an explicit prepared-
|
|
7
|
+
statement hot path, and trims several per-query control-plane costs without
|
|
8
|
+
changing the failure model.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Client#prepare(name, sql, param_types = nil)` prepares immutable SQL on every
|
|
13
|
+
currently live pipeline connection and returns a `PreparedStatement` handle.
|
|
14
|
+
- `PreparedStatement#query(params = [])` executes through
|
|
15
|
+
`send_query_prepared`, avoiding repeated Parse/Describe work for hot SQL.
|
|
16
|
+
- Replacement pipeline connections replay the registered prepared-statement
|
|
17
|
+
catalog before becoming available, including registrations that race with a
|
|
18
|
+
reconnect.
|
|
19
|
+
- Prepared-statement unit and live integration coverage, including execution
|
|
20
|
+
across multiple drivers and automatic re-prepare after reconnect.
|
|
21
|
+
|
|
22
|
+
### Performance
|
|
23
|
+
|
|
24
|
+
- The connection owner drains results only after `consume_input` actually read
|
|
25
|
+
a readable socket event. Removed unconditional pre/post-dispatch drain passes
|
|
26
|
+
that repeatedly called `PQisBusy` without new input.
|
|
27
|
+
- A blocked `PQflush` is retried by the writer watcher instead of again on every
|
|
28
|
+
unrelated owner event. Newly queued output remains buffered in libpq until the
|
|
29
|
+
socket becomes writable.
|
|
30
|
+
- Driver selection is now one circular least-load pass: each available driver's
|
|
31
|
+
`load` is read once and equal-load ties rotate from the selected slot.
|
|
32
|
+
- The normalized SQL guard mode is reused on the query hot path instead of
|
|
33
|
+
coercing and validating it for every request.
|
|
34
|
+
- Already-frozen SQL strings and frozen string bind values are reused rather
|
|
35
|
+
than duplicated; mutable caller input is still snapshotted before submission.
|
|
36
|
+
- Prepared-only metadata lives on internal request subclasses, so ordinary
|
|
37
|
+
query requests do not grow extra instance variables for the new API.
|
|
38
|
+
- The throughput and A/B harnesses explicitly clear every result and support
|
|
39
|
+
`PREPARED=1`, preparing both pipeline and baseline clients for a fair hot-path
|
|
40
|
+
comparison.
|
|
41
|
+
|
|
42
|
+
### Semantics
|
|
43
|
+
|
|
44
|
+
- Prepared statements are explicit client-scoped handles, not an automatic
|
|
45
|
+
unbounded SQL cache. The SQL guard runs once during registration.
|
|
46
|
+
- Failed registration is removed from the reconnect catalog. Successfully
|
|
47
|
+
prepared but unreachable internal names may remain on already-prepared
|
|
48
|
+
physical connections until those connections close.
|
|
49
|
+
- Multiplexed prepared handles live for the lifetime of the client; this release
|
|
50
|
+
intentionally does not add a concurrent `DEALLOCATE` API.
|
|
51
|
+
|
|
52
|
+
## [0.2.2] - 2026-07-30
|
|
53
|
+
|
|
54
|
+
Lifecycle-hardening release. Fixes five pool/task-ownership defects surfaced by
|
|
55
|
+
review, adds input validation, and ships deterministic regression coverage for
|
|
56
|
+
each fix.
|
|
57
|
+
|
|
58
|
+
### Fixed (pool & task lifecycle)
|
|
59
|
+
|
|
60
|
+
- Replacement pipeline drivers are now owned by the pool's stable parent task,
|
|
61
|
+
not the supervisor. Stopping the supervisor during shutdown can no longer
|
|
62
|
+
cascade-cancel a live replacement driver before it is gracefully drained.
|
|
63
|
+
- Returning a pinned connection re-checks the closing state after the yielding
|
|
64
|
+
recycle step (`ROLLBACK`/`DISCARD ALL`/reconnect); a connection recycled while
|
|
65
|
+
a concurrent `abort!` runs is closed instead of leaked into the free list.
|
|
66
|
+
- Task cancellation during pinned recycle (`Async::Cancel`/`Async::Stop` are
|
|
67
|
+
`Exception`s, not `StandardError`s) is caught solely to close the connection
|
|
68
|
+
and then re-raised, so cancellation semantics are preserved and the connection
|
|
69
|
+
is not leaked.
|
|
70
|
+
- Nested `Client#session`/`Client#transaction` on the same task is rejected with
|
|
71
|
+
`RecursiveCheckoutError` before the pinned semaphore is acquired, preventing a
|
|
72
|
+
self-deadlock (`pinned_size: 1`) or a silently different connection
|
|
73
|
+
(`pinned_size > 1`). Use `Transaction#savepoint` for nested atomicity.
|
|
74
|
+
- A closing or closed pool is terminal: `Pool#start` raises rather than
|
|
75
|
+
half-restarting onto stale driver state, including after an interrupted close.
|
|
76
|
+
Create a new pool instead.
|
|
77
|
+
- Partial startup now also cleans up if spawning the supervisor task fails after
|
|
78
|
+
drivers were created.
|
|
79
|
+
- Once shutdown begins, availability checks report `ShutdownError` before the
|
|
80
|
+
generic not-started state, preserving the definitely-not-dispatched failure
|
|
81
|
+
classification for concurrent submitters.
|
|
82
|
+
|
|
83
|
+
### Changed (hardening)
|
|
84
|
+
|
|
85
|
+
- Timing options (`reconnect_interval`, `reconnect_backoff_max`,
|
|
86
|
+
`health_interval`, `health_timeout`) reject negative, `NaN`, and infinite
|
|
87
|
+
values; `health_interval` additionally allows `0` ("probe every cycle").
|
|
88
|
+
- The supervisor wakes on the shorter of the enabled reconnect/health cadences,
|
|
89
|
+
so a short `health_interval` is honored independently of a large
|
|
90
|
+
`reconnect_interval`, using scheduler-aware `Kernel#sleep` rather than the
|
|
91
|
+
deprecated `Async::Task#sleep` wrapper.
|
|
92
|
+
- Reconnect backoff uses floating-point exponentiation and caps non-finite or
|
|
93
|
+
over-limit delays, avoiding giant integers without prematurely flattening the
|
|
94
|
+
backoff for very small base intervals.
|
|
95
|
+
- `Transaction#open`/`#savepoint_seq` are no longer publicly writable; control
|
|
96
|
+
state is read via `#open?` and mutated only internally.
|
|
97
|
+
- `Session#exec` accepts optional bind parameters (`exec(sql, params)`),
|
|
98
|
+
matching the documented API; any explicitly supplied params value, including
|
|
99
|
+
an empty array, uses `exec_params`, while omitted params keep simple-query mode.
|
|
100
|
+
- Driver shutdown attempts every driver even if one close raises or the closing
|
|
101
|
+
task is cancelled, then re-raises cancellation after best-effort cleanup;
|
|
102
|
+
watcher cleanup still stops the writer when stopping the reader fails.
|
|
103
|
+
- `Client#close`/`#abort!` now clear client lifecycle state even when terminal pool
|
|
104
|
+
cleanup reports an error, while a rejected re-entrant close leaves the live
|
|
105
|
+
client started.
|
|
106
|
+
|
|
107
|
+
### Compatibility
|
|
108
|
+
|
|
109
|
+
- Shutdown cleanup handles `Async::Cancel` explicitly. The supported dependency
|
|
110
|
+
floor (`async 2.42.0`) already defines that class, so no dead compatibility
|
|
111
|
+
fallback is required.
|
|
112
|
+
- CI now runs the unit suite against exact `async 2.42.0`, builds ruby-pg against
|
|
113
|
+
source-built client libpq 14.23/16.14/17.10 and runs each build against a live
|
|
114
|
+
PostgreSQL server, plus a separate server integration matrix for 14/16/17/18.
|
|
115
|
+
|
|
116
|
+
## [0.2.1]
|
|
117
|
+
|
|
118
|
+
- SessionGuard: bounded per-SQL memo cache for unsafe_reason. Repeated SQL
|
|
119
|
+
(parameterized queries) now costs ~0 allocations instead of ~330 objects/call
|
|
120
|
+
(measured); eliminates the code_only/byteslice line that showed up in profiles.
|
|
121
|
+
- Client#query: drop redundant sql dup (Request owns the one immutable copy).
|
|
122
|
+
- PoolOps.select_driver: allocation-free least-loaded scan (was 2-3 arrays/call);
|
|
123
|
+
behavior verified identical across 20k random cases.
|
|
124
|
+
- bench_kit/metrics.rb + rake bench:metrics: full picture in one run —
|
|
125
|
+
allocations/query + GC pressure, RubyProf process_time (CPU) and allocations,
|
|
126
|
+
RubyProf wall (sanity), and StackProf cpu/wall sampling. Asserts run outside
|
|
127
|
+
every profiler; warmup keeps connection setup out of frame.
|
|
128
|
+
- profile_ci_scenario.rb: correctness asserts moved out of the profiled block;
|
|
129
|
+
GC.start before profiling.
|
|
130
|
+
- Dev dependency: stackprof (~> 0.2).
|
|
131
|
+
|
|
132
|
+
## [0.2.0]
|
|
4
133
|
|
|
5
134
|
Initial experimental implementation.
|
|
6
135
|
|
|
@@ -103,7 +232,7 @@ Initial experimental implementation.
|
|
|
103
232
|
even when the losing Request was already settled.
|
|
104
233
|
- Strict guard precise denylist (no pg_typeof/setup/xact-advisory false positives).
|
|
105
234
|
- CI separates server-major coverage from source-built client-libpq 14/16 coverage.
|
|
106
|
-
-
|
|
235
|
+
- `bench_kit/` (fiber-storm latency, multi-worker connection-count smoke).
|
|
107
236
|
- Pool driver slot arrays self-heal size (reconnect/health safe under partial init).
|
|
108
237
|
- Unit coverage: failover, health_probe, pinned cancel on abort.
|
|
109
238
|
|
|
@@ -138,19 +267,3 @@ Initial experimental implementation.
|
|
|
138
267
|
boundary: cancellation during rollback/sanitize/reconnect closes the physical
|
|
139
268
|
connection instead of orphaning it outside the pool. Graceful close also closes
|
|
140
269
|
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
CHANGED
|
@@ -51,10 +51,13 @@ sync_get_result
|
|
|
51
51
|
`is_busy` is only a readiness predicate: false means the next
|
|
52
52
|
`sync_get_result` will not block. It is not a request boundary.
|
|
53
53
|
|
|
54
|
-
The owner
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
The owner calls `consume_input` on a socket-readable event and then drains until
|
|
55
|
+
`is_busy` becomes true or the in-flight FIFO is empty. A drain pass exhausts all
|
|
56
|
+
currently buffered results, including locally represented pipeline-aborted and
|
|
57
|
+
pipeline-sync statuses. Request, writable and bookkeeping events therefore do
|
|
58
|
+
not poll `is_busy` again: without a new `consume_input` they cannot reveal new
|
|
59
|
+
server input, and the redundant Ruby-to-C calls were the largest control-plane
|
|
60
|
+
CPU hot spot in profiling.
|
|
58
61
|
|
|
59
62
|
## 4. Protocol/FIFO model
|
|
60
63
|
|
|
@@ -112,6 +115,21 @@ Anything stateful goes through an exclusive pinned connection:
|
|
|
112
115
|
- `Client#session` — no implicit BEGIN;
|
|
113
116
|
- `Client#transaction` — BEGIN/COMMIT/ROLLBACK.
|
|
114
117
|
|
|
118
|
+
The one deliberate exception is `Client#prepare`, which is not an arbitrary
|
|
119
|
+
session mutation exposed to callers. It registers immutable SQL in a
|
|
120
|
+
client-owned catalog and prepares the same generated physical name on every
|
|
121
|
+
pipeline connection. The returned `PreparedStatement` handle can then use
|
|
122
|
+
`send_query_prepared` through the normal FIFO protocol. A replacement connection
|
|
123
|
+
replays the full catalog before it becomes available, so routing never selects a
|
|
124
|
+
driver that lacks a successfully registered handle.
|
|
125
|
+
|
|
126
|
+
Registration is explicit: there is no unbounded automatic SQL cache. A failed
|
|
127
|
+
registration is removed from the reconnect catalog; statements that had already
|
|
128
|
+
been prepared on another connection may remain there under an unreachable
|
|
129
|
+
generated name until that connection is replaced or closed. Version 0.2.3 does
|
|
130
|
+
not expose `DEALLOCATE` for multiplexed handles. Server-side plan invalidation is
|
|
131
|
+
reported as the ordinary request-local `QueryError`.
|
|
132
|
+
|
|
115
133
|
Pinned connections are opened lazily up to `pinned_size`; a query-only process
|
|
116
134
|
does not reserve an otherwise idle second pool at startup. Before a pinned
|
|
117
135
|
physical connection is reused, the pool makes sure it is outside an explicit
|
|
@@ -250,14 +268,16 @@ chunked-rows, non-blocking cancel), never a floor.
|
|
|
250
268
|
(`cancel_pinned_on_abort: true`); it does not force-close the socket if the
|
|
251
269
|
backend ignores cancel.
|
|
252
270
|
- No streaming/single-row/chunked-row result API.
|
|
271
|
+
- No multiplexed prepared-statement deallocation API; registered handles live
|
|
272
|
+
for the lifetime of the client.
|
|
253
273
|
- No cross-thread/reactor sharing; `Client` rejects use from a different thread or Fiber scheduler.
|
|
254
274
|
- `SessionGuard` is policy/ergonomics, not a security boundary. `:strict` adds a
|
|
255
275
|
precise denylist (`nextval`/`setval`/`pg_export_snapshot`, …) without the old
|
|
256
276
|
broad `pg_*(` / `set*(` false positives; UDFs can still mutate session state.
|
|
257
|
-
- Live integration covers **server** PG 14/16/17.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
277
|
+
- Live integration covers **server** PG 14/16/17/18. A separate CI matrix
|
|
278
|
+
builds ruby-pg against source-built **client** libpq 14/16/17, asserts the
|
|
279
|
+
linked major, and runs both unit and live integration suites because pipeline
|
|
280
|
+
capability is determined by client libpq rather than server version.
|
|
261
281
|
- On libpq 14–16, `place_sync` uses `PQpipelineSync` / ruby-pg `sync_pipeline_sync`
|
|
262
282
|
(flush coupled); libpq 17+ uses `send_pipeline_sync` + explicit `sync_flush`.
|
|
263
283
|
- Pinned recycle still runs full `DISCARD ALL` (no lighter reset profile / recycle
|
|
@@ -284,15 +304,15 @@ Implemented:
|
|
|
284
304
|
live sibling; the failed Request object itself is never re-used.
|
|
285
305
|
- `abort!` CancelRequest on in-use pinned connections.
|
|
286
306
|
- Falcon per-worker contract + sizing formula; reactor-local Client fail-fast.
|
|
287
|
-
- Packaging: gemspec metadata, CI (unit + PG server 14/16/17
|
|
288
|
-
|
|
307
|
+
- Packaging: gemspec metadata, CI (unit + PG server 14/16/17/18 + client-libpq
|
|
308
|
+
14/16/17 + exact async floor), docker-compose,
|
|
309
|
+
examples, `bench_kit/pipeline_throughput.rb` + `bench_kit/multiworker_smoke.rb`.
|
|
289
310
|
- Live integration suite (multiplex, isolation, cancel-drain, savepoints, stats,
|
|
290
311
|
backpressure, pinned concurrency, indeterminate on abort, reconnect, backend
|
|
291
312
|
terminate recovery, pinned abort cancel).
|
|
292
313
|
|
|
293
314
|
Still open:
|
|
294
315
|
|
|
295
|
-
- Dedicated client-libpq 14/16/17 CI matrix (not only server majors).
|
|
296
316
|
- Lighter pinned reset / recycle-latency metrics under high-TPS tx.
|
|
297
317
|
- Force socket close if pinned cancel is ignored.
|
|
298
318
|
- Published bench numbers in README; tighten multiworker smoke to peak occupancy.
|
data/README.md
CHANGED
|
@@ -62,6 +62,35 @@ end
|
|
|
62
62
|
`Client.open` manages the pool lifecycle. `db.query` is fiber-safe and multiplexed —
|
|
63
63
|
every fiber yields while waiting, the event loop stays unblocked.
|
|
64
64
|
|
|
65
|
+
### Multiplexed prepared statements
|
|
66
|
+
|
|
67
|
+
For repeated SQL, prepare it once on every pipeline connection and execute the
|
|
68
|
+
returned immutable handle:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
by_id = db.prepare(
|
|
72
|
+
"user_by_id",
|
|
73
|
+
"SELECT id, email, name FROM users WHERE id = $1",
|
|
74
|
+
[23] # optional PostgreSQL parameter OIDs
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
result = by_id.query([42])
|
|
78
|
+
begin
|
|
79
|
+
p result.first
|
|
80
|
+
ensure
|
|
81
|
+
result.clear
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`Client#prepare` returns only after the statement is ready on every currently
|
|
86
|
+
live pipeline connection. Replacement connections automatically prepare the
|
|
87
|
+
registered catalog before they begin accepting requests. The logical name is
|
|
88
|
+
client-local; the gem uses private generated server names on its owned connections.
|
|
89
|
+
|
|
90
|
+
Prepared handles are intentionally explicit rather than an unbounded automatic
|
|
91
|
+
SQL cache. The SQL guard runs once at preparation time, while each execution
|
|
92
|
+
still snapshots its bind values before submission.
|
|
93
|
+
|
|
65
94
|
### Transactions
|
|
66
95
|
|
|
67
96
|
```ruby
|
|
@@ -84,7 +113,7 @@ db.transaction do |tx|
|
|
|
84
113
|
end
|
|
85
114
|
```
|
|
86
115
|
|
|
87
|
-
### Session (DDL, LISTEN, SET, prepared statements)
|
|
116
|
+
### Session (DDL, LISTEN, SET, connection-local prepared statements)
|
|
88
117
|
|
|
89
118
|
```ruby
|
|
90
119
|
db.session do |s|
|
|
@@ -119,11 +148,12 @@ db.stats
|
|
|
119
148
|
# => {
|
|
120
149
|
# pipeline: { size: 4, live: 4, drivers: [{load: 3, in_flight: 2, ...}, ...] },
|
|
121
150
|
# pinned: { size: 2, active: 1, free: 1 },
|
|
151
|
+
# prepared_statements: 1,
|
|
122
152
|
# reconnects: 0
|
|
123
153
|
# }
|
|
124
154
|
```
|
|
125
155
|
|
|
126
|
-
`load` (pending +
|
|
156
|
+
`load` (pending + in-flight + submitting + dispatching) per driver is the routing/head-of-line signal.
|
|
127
157
|
|
|
128
158
|
## Failure model
|
|
129
159
|
|
data/lib/pg_pipeline/client.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "async"
|
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "pool"
|
|
7
7
|
require_relative "request"
|
|
8
|
+
require_relative "prepared_statement"
|
|
8
9
|
require_relative "session_guard"
|
|
9
10
|
require_relative "session"
|
|
10
11
|
require_relative "transaction"
|
|
@@ -27,6 +28,7 @@ module PgPipeline
|
|
|
27
28
|
|
|
28
29
|
def start(parent: Async::Task.current) = ClientOps.start(self, parent)
|
|
29
30
|
def query(sql, params = []) = ClientOps.query(self, sql, params)
|
|
31
|
+
def prepare(name, sql, param_types = nil) = ClientOps.prepare(self, name, sql, param_types)
|
|
30
32
|
def stats = ClientOps.stats(self)
|
|
31
33
|
|
|
32
34
|
def session(&block)
|
|
@@ -71,11 +73,31 @@ module PgPipeline
|
|
|
71
73
|
|
|
72
74
|
def query(client, sql, params)
|
|
73
75
|
ensure_started!(client)
|
|
74
|
-
sql = sql
|
|
75
|
-
SessionGuard.
|
|
76
|
+
sql = RequestOps.snapshot_sql(sql)
|
|
77
|
+
SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
wait_for_request do
|
|
80
|
+
submit_with_failover(client) { Request.new(sql: sql, params: params) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def prepare(client, name, sql, param_types)
|
|
85
|
+
ensure_started!(client)
|
|
86
|
+
sql = RequestOps.snapshot_sql(sql)
|
|
87
|
+
SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
|
|
88
|
+
pool(client).__send__(:prepare_statement, client, name, sql, param_types)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def query_prepared(client, statement, params)
|
|
92
|
+
ensure_started!(client)
|
|
93
|
+
|
|
94
|
+
wait_for_request do
|
|
95
|
+
submit_with_failover(client) { Request.prepared_query(statement, params: params) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
78
98
|
|
|
99
|
+
def wait_for_request
|
|
100
|
+
request = yield
|
|
79
101
|
begin
|
|
80
102
|
request.wait
|
|
81
103
|
ensure
|
|
@@ -85,13 +107,13 @@ module PgPipeline
|
|
|
85
107
|
|
|
86
108
|
# NotDispatchedError is safe to retry on a fresh Request by contract.
|
|
87
109
|
# ShutdownError is retried only while the current Request is still pre-dispatch.
|
|
88
|
-
def submit_with_failover(client
|
|
110
|
+
def submit_with_failover(client)
|
|
89
111
|
attempts = 0
|
|
90
112
|
limit = [pool(client).pipeline_size, 1].max
|
|
91
113
|
last_error = nil
|
|
92
114
|
|
|
93
115
|
while attempts < limit
|
|
94
|
-
request =
|
|
116
|
+
request = yield
|
|
95
117
|
begin
|
|
96
118
|
pool(client).__send__(:pipeline_driver).submit(request)
|
|
97
119
|
return request
|
|
@@ -144,16 +166,22 @@ module PgPipeline
|
|
|
144
166
|
return unless started?(client)
|
|
145
167
|
|
|
146
168
|
ensure_context!(client)
|
|
147
|
-
|
|
148
|
-
|
|
169
|
+
begin
|
|
170
|
+
pool(client).graceful_close
|
|
171
|
+
ensure
|
|
172
|
+
client.__send__(:started=, false) if pool(client).closing?
|
|
173
|
+
end
|
|
149
174
|
end
|
|
150
175
|
|
|
151
176
|
def abort!(client)
|
|
152
177
|
return unless started?(client)
|
|
153
178
|
|
|
154
179
|
ensure_context!(client)
|
|
155
|
-
|
|
156
|
-
|
|
180
|
+
begin
|
|
181
|
+
pool(client).abort!
|
|
182
|
+
ensure
|
|
183
|
+
client.__send__(:started=, false) if pool(client).closing?
|
|
184
|
+
end
|
|
157
185
|
end
|
|
158
186
|
|
|
159
187
|
def pool(client)
|
|
@@ -196,44 +196,46 @@ module PgPipeline
|
|
|
196
196
|
end
|
|
197
197
|
|
|
198
198
|
def owner_loop(d)
|
|
199
|
-
while d.running
|
|
200
|
-
event = d.events.dequeue
|
|
201
|
-
|
|
202
|
-
case event
|
|
203
|
-
when :requests
|
|
204
|
-
d.request_event_pending = false
|
|
205
|
-
when :submission_finished
|
|
206
|
-
nil
|
|
207
|
-
when :readable
|
|
208
|
-
begin
|
|
209
|
-
read_available(d)
|
|
210
|
-
ensure
|
|
211
|
-
d.reader_rearm.enqueue(:rearm) if d.running
|
|
212
|
-
end
|
|
213
|
-
when :writable
|
|
214
|
-
d.writer_armed = false
|
|
215
|
-
flush_output(d)
|
|
216
|
-
when :close
|
|
217
|
-
d.draining = true
|
|
218
|
-
when Array
|
|
219
|
-
tag, payload = event
|
|
220
|
-
if tag == :abort
|
|
221
|
-
fatal_close(d, payload)
|
|
222
|
-
break
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
drain_results(d)
|
|
227
|
-
pump_requests(d)
|
|
228
|
-
drain_results(d)
|
|
229
|
-
finish_graceful_close(d) if d.draining && drained?(d)
|
|
230
|
-
end
|
|
199
|
+
process_event(d, d.events.dequeue) while d.running
|
|
231
200
|
rescue StandardError => e
|
|
232
201
|
fatal_close(d, ConnectionLostError.new("driver crashed: #{e.class}: #{e.message}"))
|
|
233
202
|
ensure
|
|
234
203
|
fatal_close(d, ShutdownError.new("driver owner stopped before shutdown completed")) if d.running
|
|
235
204
|
end
|
|
236
205
|
|
|
206
|
+
def process_event(d, event)
|
|
207
|
+
input_changed = false
|
|
208
|
+
|
|
209
|
+
case event
|
|
210
|
+
when :requests
|
|
211
|
+
d.request_event_pending = false
|
|
212
|
+
when :submission_finished
|
|
213
|
+
nil
|
|
214
|
+
when :readable
|
|
215
|
+
begin
|
|
216
|
+
read_available(d)
|
|
217
|
+
input_changed = true
|
|
218
|
+
ensure
|
|
219
|
+
d.reader_rearm.enqueue(:rearm) if d.running
|
|
220
|
+
end
|
|
221
|
+
when :writable
|
|
222
|
+
d.writer_armed = false
|
|
223
|
+
flush_output(d)
|
|
224
|
+
when :close
|
|
225
|
+
d.draining = true
|
|
226
|
+
when Array
|
|
227
|
+
tag, payload = event
|
|
228
|
+
if tag == :abort
|
|
229
|
+
fatal_close(d, payload)
|
|
230
|
+
return
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
drain_results(d) if input_changed
|
|
235
|
+
pump_requests(d) if d.running
|
|
236
|
+
finish_graceful_close(d) if d.running && d.draining && drained?(d)
|
|
237
|
+
end
|
|
238
|
+
|
|
237
239
|
def notify_requests(d)
|
|
238
240
|
return if d.request_event_pending
|
|
239
241
|
|
|
@@ -261,12 +263,12 @@ module PgPipeline
|
|
|
261
263
|
dispatched = true
|
|
262
264
|
end
|
|
263
265
|
|
|
264
|
-
flush_output(d) if dispatched
|
|
266
|
+
flush_output(d) if dispatched && !d.needs_flush
|
|
265
267
|
end
|
|
266
268
|
|
|
267
269
|
def send_unit(d, request)
|
|
268
270
|
begin
|
|
269
|
-
d.conn
|
|
271
|
+
send_command(d.conn, request)
|
|
270
272
|
rescue PG::UnableToSend => e
|
|
271
273
|
d.dispatching = nil
|
|
272
274
|
request.reject!(
|
|
@@ -281,9 +283,11 @@ module PgPipeline
|
|
|
281
283
|
NotDispatchedError.new("query was rejected before libpq accepted it: #{e.class}: #{e.message}")
|
|
282
284
|
)
|
|
283
285
|
raise ConnectionLostError, "dispatch failed: #{e.class}: #{e.message}"
|
|
286
|
+
rescue ProtocolError
|
|
287
|
+
raise
|
|
284
288
|
rescue StandardError => e
|
|
285
|
-
# ruby-pg prepares/encodes query parameters before calling
|
|
286
|
-
#
|
|
289
|
+
# ruby-pg prepares/encodes query parameters before calling PQsend*. A
|
|
290
|
+
# Ruby-side encoder/coercion exception therefore belongs only to this
|
|
287
291
|
# request and must not poison unrelated work already in the pipeline.
|
|
288
292
|
request.reject!(e)
|
|
289
293
|
return false
|
|
@@ -295,6 +299,23 @@ module PgPipeline
|
|
|
295
299
|
raise ConnectionLostError, "dispatch Sync failed: #{e.class}: #{e.message}"
|
|
296
300
|
end
|
|
297
301
|
|
|
302
|
+
def send_command(conn, request)
|
|
303
|
+
case request.operation
|
|
304
|
+
when :query
|
|
305
|
+
conn.send_query_params(request.sql, request.params)
|
|
306
|
+
when :prepare
|
|
307
|
+
if request.param_types.nil?
|
|
308
|
+
conn.send_prepare(request.statement_name, request.sql)
|
|
309
|
+
else
|
|
310
|
+
conn.send_prepare(request.statement_name, request.sql, request.param_types)
|
|
311
|
+
end
|
|
312
|
+
when :prepared_query
|
|
313
|
+
conn.send_query_prepared(request.statement_name, request.params)
|
|
314
|
+
else
|
|
315
|
+
raise ProtocolError, "unsupported request operation #{request.operation.inspect}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
298
319
|
def reusable_after_send_rejection?(d)
|
|
299
320
|
!d.conn.finished? &&
|
|
300
321
|
d.conn.status == PG::CONNECTION_OK &&
|
|
@@ -325,7 +346,6 @@ module PgPipeline
|
|
|
325
346
|
|
|
326
347
|
def read_available(d)
|
|
327
348
|
d.conn.consume_input
|
|
328
|
-
drain_results(d)
|
|
329
349
|
rescue PG::Error => e
|
|
330
350
|
raise ConnectionLostError, "read failed: #{e.class}: #{e.message}"
|
|
331
351
|
end
|
|
@@ -481,11 +501,17 @@ module PgPipeline
|
|
|
481
501
|
end
|
|
482
502
|
|
|
483
503
|
def stop_watchers(d)
|
|
484
|
-
d.reader_task
|
|
485
|
-
d.writer_task
|
|
504
|
+
reader = d.reader_task
|
|
505
|
+
writer = d.writer_task
|
|
486
506
|
d.reader_task = nil
|
|
487
507
|
d.writer_task = nil
|
|
488
|
-
|
|
508
|
+
|
|
509
|
+
[reader, writer].each do |task|
|
|
510
|
+
task&.stop
|
|
511
|
+
rescue Async::Cancel, StandardError
|
|
512
|
+
nil
|
|
513
|
+
end
|
|
514
|
+
|
|
489
515
|
nil
|
|
490
516
|
end
|
|
491
517
|
|
data/lib/pg_pipeline/errors.rb
CHANGED
data/lib/pg_pipeline/pool.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "async/notification"
|
|
|
7
7
|
|
|
8
8
|
require_relative "errors"
|
|
9
9
|
require_relative "connection_driver"
|
|
10
|
+
require_relative "prepared_statement"
|
|
10
11
|
require_relative "server_caps"
|
|
11
12
|
|
|
12
13
|
module PgPipeline
|
|
@@ -18,6 +19,7 @@ module PgPipeline
|
|
|
18
19
|
DEFAULT_HEALTH_INTERVAL = 10.0
|
|
19
20
|
DEFAULT_HEALTH_TIMEOUT = 5.0
|
|
20
21
|
DISCARD_SEQUENCES_SERVER_VERSION = 90_400
|
|
22
|
+
CANCEL_SIGNAL = Async::Cancel
|
|
21
23
|
|
|
22
24
|
attr_reader :reconnects, :pipeline_size
|
|
23
25
|
|
|
@@ -39,11 +41,11 @@ module PgPipeline
|
|
|
39
41
|
@max_pending = max_pending
|
|
40
42
|
@max_in_flight = max_in_flight
|
|
41
43
|
@reconnect = reconnect
|
|
42
|
-
@reconnect_interval =
|
|
43
|
-
@reconnect_backoff_max =
|
|
44
|
+
@reconnect_interval = PoolOps.finite_float!(reconnect_interval, :reconnect_interval)
|
|
45
|
+
@reconnect_backoff_max = PoolOps.finite_float!(reconnect_backoff_max, :reconnect_backoff_max)
|
|
44
46
|
@health_check = health_check
|
|
45
|
-
@health_interval =
|
|
46
|
-
@health_timeout =
|
|
47
|
+
@health_interval = PoolOps.finite_float!(health_interval, :health_interval, allow_zero: true)
|
|
48
|
+
@health_timeout = PoolOps.finite_float!(health_timeout, :health_timeout)
|
|
47
49
|
@cancel_pinned_on_abort = cancel_pinned_on_abort
|
|
48
50
|
|
|
49
51
|
@drivers = []
|
|
@@ -55,6 +57,11 @@ module PgPipeline
|
|
|
55
57
|
@health_failures = 0
|
|
56
58
|
@supervisor_error = nil
|
|
57
59
|
@supervisor = nil
|
|
60
|
+
@task_parent = nil
|
|
61
|
+
|
|
62
|
+
@prepared_statements = {}
|
|
63
|
+
@prepared_generation = 0
|
|
64
|
+
@statement_sequence = 0
|
|
58
65
|
|
|
59
66
|
@pinned_free = []
|
|
60
67
|
@pinned_in_use = {}
|
|
@@ -66,10 +73,17 @@ module PgPipeline
|
|
|
66
73
|
|
|
67
74
|
@started = false
|
|
68
75
|
@closing = false
|
|
76
|
+
@closed = false
|
|
69
77
|
end
|
|
70
78
|
|
|
71
|
-
def start(parent:
|
|
79
|
+
def start(parent: nil)
|
|
72
80
|
raise Error, "pool already started" if @started
|
|
81
|
+
if @closing || @closed
|
|
82
|
+
raise ShutdownError, "pool is closing or was closed and cannot be restarted; create a new Pool"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
parent ||= Async::Task.current
|
|
86
|
+
@task_parent = parent
|
|
73
87
|
|
|
74
88
|
begin
|
|
75
89
|
@pipeline_size.times { @drivers << start_pipeline_driver(parent) }
|
|
@@ -77,13 +91,13 @@ module PgPipeline
|
|
|
77
91
|
@driver_attempts = Array.new(@drivers.size, 0)
|
|
78
92
|
@driver_last_health = Array.new(@drivers.size, monotonic)
|
|
79
93
|
@pinned_gate = Async::Semaphore.new(@pinned_size) if @pinned_size.positive?
|
|
94
|
+
@started = true
|
|
95
|
+
@supervisor = parent.async { supervise } if @reconnect || @health_check
|
|
80
96
|
rescue Exception
|
|
81
97
|
cleanup_partial_start
|
|
82
98
|
raise
|
|
83
99
|
end
|
|
84
100
|
|
|
85
|
-
@started = true
|
|
86
|
-
@supervisor = parent.async { supervise } if @reconnect || @health_check
|
|
87
101
|
self
|
|
88
102
|
end
|
|
89
103
|
|
|
@@ -98,11 +112,72 @@ module PgPipeline
|
|
|
98
112
|
driver
|
|
99
113
|
end
|
|
100
114
|
|
|
115
|
+
def prepare_statement(client, name, sql, param_types)
|
|
116
|
+
ensure_available!
|
|
117
|
+
logical_name = PreparedStatementOps.snapshot_name(name)
|
|
118
|
+
if @prepared_statements.key?(logical_name)
|
|
119
|
+
raise Error, "prepared statement #{logical_name.inspect} already exists"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@statement_sequence += 1
|
|
123
|
+
statement = PreparedStatement.new(
|
|
124
|
+
client: client,
|
|
125
|
+
name: logical_name,
|
|
126
|
+
physical_name: "pgp_#{@statement_sequence.to_s(36)}",
|
|
127
|
+
sql: sql,
|
|
128
|
+
param_types: param_types
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@prepared_statements[logical_name] = statement
|
|
132
|
+
@prepared_generation += 1
|
|
133
|
+
requests = []
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
drivers = @drivers.select(&:available?)
|
|
137
|
+
if drivers.empty?
|
|
138
|
+
raise NotDispatchedError, "no live pipeline connections; statement was not prepared"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
requests = drivers.map do |driver|
|
|
142
|
+
Request.prepare(statement).tap { |request| driver.submit(request) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
first_error = nil
|
|
146
|
+
requests.each do |request|
|
|
147
|
+
begin
|
|
148
|
+
RequestOps.clear_result(request.wait)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
if first_error
|
|
151
|
+
e.clear_result! if e.respond_to?(:clear_result!)
|
|
152
|
+
else
|
|
153
|
+
first_error = e
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
raise first_error if first_error
|
|
158
|
+
|
|
159
|
+
statement
|
|
160
|
+
rescue Exception
|
|
161
|
+
if @prepared_statements.delete(logical_name)
|
|
162
|
+
@prepared_generation += 1
|
|
163
|
+
end
|
|
164
|
+
raise
|
|
165
|
+
ensure
|
|
166
|
+
requests.each { |request| request.cancel! unless request.settled? }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
101
170
|
def with_pinned
|
|
102
171
|
ensure_available!
|
|
103
172
|
raise @pinned_error if @pinned_error
|
|
104
173
|
raise Error, "pinned pool is disabled (pinned_size=0)" if @pinned_size.zero?
|
|
105
174
|
|
|
175
|
+
if @pinned_owners[Async::Task.current].positive?
|
|
176
|
+
raise RecursiveCheckoutError,
|
|
177
|
+
"nested Client#session/transaction on the same task is not allowed; " \
|
|
178
|
+
"use Transaction#savepoint for nested atomicity"
|
|
179
|
+
end
|
|
180
|
+
|
|
106
181
|
@pinned_gate.acquire do
|
|
107
182
|
raise @pinned_error if @pinned_error
|
|
108
183
|
raise ShutdownError, "pool is closing" if @closing
|
|
@@ -139,10 +214,12 @@ module PgPipeline
|
|
|
139
214
|
free: @pinned_free.size,
|
|
140
215
|
in_use: @pinned_in_use.size
|
|
141
216
|
},
|
|
217
|
+
prepared_statements: (@prepared_statements || {}).size,
|
|
142
218
|
reconnects: @reconnects,
|
|
143
219
|
health_failures: @health_failures,
|
|
144
220
|
supervisor_error: @supervisor_error&.message,
|
|
145
221
|
closing: @closing,
|
|
222
|
+
closed: @closed,
|
|
146
223
|
pinned_error: @pinned_error&.message
|
|
147
224
|
}
|
|
148
225
|
end
|
|
@@ -156,10 +233,24 @@ module PgPipeline
|
|
|
156
233
|
|
|
157
234
|
@closing = true
|
|
158
235
|
@started = false
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
236
|
+
first_error = nil
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
first_error = preferred_shutdown_error(first_error, stop_supervisor)
|
|
240
|
+
first_error = preferred_shutdown_error(first_error, close_all_drivers(:graceful_close))
|
|
241
|
+
|
|
242
|
+
begin
|
|
243
|
+
wait_for_pinned_idle
|
|
244
|
+
rescue CANCEL_SIGNAL, StandardError => e
|
|
245
|
+
first_error = preferred_shutdown_error(first_error, e)
|
|
246
|
+
end
|
|
247
|
+
ensure
|
|
248
|
+
close_free_pinned
|
|
249
|
+
@closed = true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
raise first_error if first_error
|
|
253
|
+
|
|
163
254
|
nil
|
|
164
255
|
end
|
|
165
256
|
|
|
@@ -168,31 +259,50 @@ module PgPipeline
|
|
|
168
259
|
|
|
169
260
|
@closing = true
|
|
170
261
|
@started = false
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
262
|
+
first_error = nil
|
|
263
|
+
|
|
264
|
+
begin
|
|
265
|
+
first_error = preferred_shutdown_error(first_error, stop_supervisor)
|
|
266
|
+
if @cancel_pinned_on_abort
|
|
267
|
+
first_error = preferred_shutdown_error(first_error, cancel_in_use_pinned)
|
|
268
|
+
end
|
|
269
|
+
first_error = preferred_shutdown_error(first_error, close_all_drivers(:abort!))
|
|
270
|
+
ensure
|
|
271
|
+
close_free_pinned
|
|
272
|
+
@closed = true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
raise first_error if first_error
|
|
276
|
+
|
|
175
277
|
nil
|
|
176
278
|
end
|
|
177
279
|
|
|
178
280
|
private
|
|
179
281
|
|
|
180
282
|
def supervise
|
|
181
|
-
parent = Async::Task.current
|
|
182
283
|
until @closing
|
|
183
284
|
begin
|
|
184
|
-
reap_and_replace(
|
|
285
|
+
reap_and_replace(@task_parent) if @reconnect
|
|
185
286
|
health_probe if @health_check
|
|
186
287
|
@supervisor_error = nil
|
|
187
288
|
rescue StandardError => e
|
|
188
|
-
# Keep the loop alive: one probe/reconnect failure must not permanently
|
|
189
|
-
# disable health checks and replacement for the worker lifetime.
|
|
190
289
|
@supervisor_error = e
|
|
191
290
|
end
|
|
192
|
-
|
|
291
|
+
|
|
292
|
+
break if @closing
|
|
293
|
+
|
|
294
|
+
sleep(supervisor_sleep_interval)
|
|
193
295
|
end
|
|
194
296
|
end
|
|
195
297
|
|
|
298
|
+
def supervisor_sleep_interval
|
|
299
|
+
candidates = []
|
|
300
|
+
candidates << @reconnect_interval if @reconnect
|
|
301
|
+
candidates << @health_interval if @health_check && @health_interval.positive?
|
|
302
|
+
interval = candidates.min || @reconnect_interval
|
|
303
|
+
interval.positive? ? interval : @reconnect_interval
|
|
304
|
+
end
|
|
305
|
+
|
|
196
306
|
def reap_and_replace(parent)
|
|
197
307
|
now = monotonic
|
|
198
308
|
ensure_driver_slots!
|
|
@@ -242,32 +352,68 @@ module PgPipeline
|
|
|
242
352
|
end
|
|
243
353
|
|
|
244
354
|
def next_backoff(attempts)
|
|
245
|
-
|
|
246
|
-
delay
|
|
355
|
+
exponent = [Integer(attempts) - 1, 0].max
|
|
356
|
+
delay = @reconnect_interval * (2.0**exponent)
|
|
357
|
+
delay.finite? && delay < @reconnect_backoff_max ? delay : @reconnect_backoff_max
|
|
247
358
|
end
|
|
248
359
|
|
|
249
360
|
def cancel_in_use_pinned
|
|
361
|
+
cancellation = nil
|
|
362
|
+
|
|
250
363
|
@pinned_in_use.keys.each do |conn|
|
|
251
364
|
conn.cancel if conn.respond_to?(:cancel)
|
|
365
|
+
rescue CANCEL_SIGNAL => e
|
|
366
|
+
cancellation ||= e
|
|
252
367
|
rescue StandardError
|
|
253
368
|
nil
|
|
254
369
|
end
|
|
370
|
+
|
|
371
|
+
cancellation
|
|
255
372
|
end
|
|
256
373
|
|
|
257
374
|
def stop_supervisor
|
|
258
|
-
@supervisor
|
|
375
|
+
supervisor = @supervisor
|
|
259
376
|
@supervisor = nil
|
|
377
|
+
supervisor&.stop
|
|
378
|
+
nil
|
|
379
|
+
rescue CANCEL_SIGNAL => e
|
|
380
|
+
e
|
|
260
381
|
rescue StandardError
|
|
261
382
|
nil
|
|
262
383
|
end
|
|
263
384
|
|
|
385
|
+
def close_all_drivers(method_name)
|
|
386
|
+
first_error = nil
|
|
387
|
+
cancellation = nil
|
|
388
|
+
|
|
389
|
+
@drivers.each do |driver|
|
|
390
|
+
begin
|
|
391
|
+
driver.public_send(method_name)
|
|
392
|
+
rescue CANCEL_SIGNAL => e
|
|
393
|
+
cancellation ||= e
|
|
394
|
+
rescue StandardError => e
|
|
395
|
+
first_error ||= e
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
cancellation || first_error
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def preferred_shutdown_error(current, candidate)
|
|
403
|
+
return current unless candidate
|
|
404
|
+
return candidate if candidate.is_a?(CANCEL_SIGNAL)
|
|
405
|
+
|
|
406
|
+
current || candidate
|
|
407
|
+
end
|
|
408
|
+
|
|
264
409
|
def ensure_available!
|
|
410
|
+
raise ShutdownError, "pool is closing or closed" if @closing || @closed
|
|
265
411
|
raise Error, "pool not started" unless @started
|
|
266
|
-
raise ShutdownError, "pool is closing" if @closing
|
|
267
412
|
end
|
|
268
413
|
|
|
269
414
|
def start_pipeline_driver(parent)
|
|
270
415
|
conn = PoolOps.new_connection(@connection_args)
|
|
416
|
+
prepare_registered_statements(conn)
|
|
271
417
|
driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight)
|
|
272
418
|
driver.start(parent: parent)
|
|
273
419
|
rescue Exception
|
|
@@ -275,6 +421,29 @@ module PgPipeline
|
|
|
275
421
|
raise
|
|
276
422
|
end
|
|
277
423
|
|
|
424
|
+
def prepare_registered_statements(conn)
|
|
425
|
+
prepared = {}
|
|
426
|
+
|
|
427
|
+
loop do
|
|
428
|
+
generation = @prepared_generation || 0
|
|
429
|
+
statements = (@prepared_statements || {}).values.dup
|
|
430
|
+
|
|
431
|
+
statements.each do |statement|
|
|
432
|
+
next if prepared.key?(statement.physical_name)
|
|
433
|
+
|
|
434
|
+
result = if statement.param_types.nil?
|
|
435
|
+
conn.prepare(statement.physical_name, statement.sql)
|
|
436
|
+
else
|
|
437
|
+
conn.prepare(statement.physical_name, statement.sql, statement.param_types)
|
|
438
|
+
end
|
|
439
|
+
RequestOps.clear_result(result)
|
|
440
|
+
prepared[statement.physical_name] = true
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
break if generation == (@prepared_generation || 0)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
278
447
|
def release_pinned(owner, conn)
|
|
279
448
|
begin
|
|
280
449
|
if conn
|
|
@@ -282,7 +451,14 @@ module PgPipeline
|
|
|
282
451
|
PoolOps.safe_close(conn)
|
|
283
452
|
else
|
|
284
453
|
recycled = recycle_pinned_connection(conn)
|
|
285
|
-
|
|
454
|
+
|
|
455
|
+
if recycled
|
|
456
|
+
if @closing
|
|
457
|
+
PoolOps.safe_close(recycled)
|
|
458
|
+
else
|
|
459
|
+
@pinned_free << recycled
|
|
460
|
+
end
|
|
461
|
+
end
|
|
286
462
|
end
|
|
287
463
|
end
|
|
288
464
|
ensure
|
|
@@ -301,6 +477,9 @@ module PgPipeline
|
|
|
301
477
|
|
|
302
478
|
PoolOps.sanitize_pinned_connection(conn)
|
|
303
479
|
conn
|
|
480
|
+
rescue CANCEL_SIGNAL
|
|
481
|
+
PoolOps.safe_close(conn)
|
|
482
|
+
raise
|
|
304
483
|
rescue StandardError => cleanup_error
|
|
305
484
|
PoolOps.safe_close(conn)
|
|
306
485
|
|
|
@@ -322,13 +501,20 @@ module PgPipeline
|
|
|
322
501
|
def cleanup_partial_start
|
|
323
502
|
@drivers.each do |driver|
|
|
324
503
|
driver.abort!
|
|
325
|
-
rescue StandardError
|
|
504
|
+
rescue CANCEL_SIGNAL, StandardError
|
|
326
505
|
nil
|
|
327
506
|
end
|
|
328
507
|
@drivers.clear
|
|
508
|
+
@driver_backoff.clear
|
|
509
|
+
@driver_attempts.clear
|
|
510
|
+
@driver_last_health.clear
|
|
329
511
|
close_free_pinned
|
|
512
|
+
@pinned_gate = nil
|
|
513
|
+
@supervisor = nil
|
|
514
|
+
@task_parent = nil
|
|
330
515
|
@started = false
|
|
331
516
|
@closing = false
|
|
517
|
+
@closed = false
|
|
332
518
|
end
|
|
333
519
|
|
|
334
520
|
def close_free_pinned
|
|
@@ -351,6 +537,17 @@ module PgPipeline
|
|
|
351
537
|
raise ArgumentError, "#{name} must be an integer >= 1"
|
|
352
538
|
end
|
|
353
539
|
|
|
540
|
+
def finite_float!(value, name, allow_zero: false)
|
|
541
|
+
number = Float(value)
|
|
542
|
+
bound_ok = allow_zero ? number >= 0 : number.positive?
|
|
543
|
+
raise ArgumentError unless number.finite? && bound_ok
|
|
544
|
+
|
|
545
|
+
number
|
|
546
|
+
rescue ArgumentError, TypeError
|
|
547
|
+
requirement = allow_zero ? "non-negative finite" : "positive finite"
|
|
548
|
+
raise ArgumentError, "#{name} must be a #{requirement} number (got #{value.inspect})"
|
|
549
|
+
end
|
|
550
|
+
|
|
354
551
|
def nonnegative_integer!(value, name)
|
|
355
552
|
integer = Integer(value)
|
|
356
553
|
raise ArgumentError, "#{name} must be >= 0" if integer.negative?
|
|
@@ -361,32 +558,27 @@ module PgPipeline
|
|
|
361
558
|
end
|
|
362
559
|
|
|
363
560
|
def select_driver(drivers, rr)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
561
|
+
size = drivers.length
|
|
562
|
+
return [nil, rr] if size.zero?
|
|
563
|
+
|
|
564
|
+
best = nil
|
|
565
|
+
best_index = nil
|
|
566
|
+
best_load = nil
|
|
567
|
+
|
|
568
|
+
size.times do |offset|
|
|
569
|
+
index = (rr + offset) % size
|
|
570
|
+
driver = drivers[index]
|
|
367
571
|
next unless driver.available?
|
|
368
572
|
|
|
369
573
|
load = driver.load
|
|
370
|
-
if
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
count += 1
|
|
574
|
+
if best.nil? || load < best_load
|
|
575
|
+
best = driver
|
|
576
|
+
best_index = index
|
|
577
|
+
best_load = load
|
|
375
578
|
end
|
|
376
579
|
end
|
|
377
|
-
return [nil, rr] if count.zero?
|
|
378
|
-
|
|
379
|
-
target = rr % count
|
|
380
|
-
index = 0
|
|
381
|
-
drivers.each do |driver|
|
|
382
|
-
next unless driver.available?
|
|
383
|
-
next unless driver.load == min_load
|
|
384
580
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
index += 1
|
|
388
|
-
end
|
|
389
|
-
[nil, rr]
|
|
581
|
+
best ? [best, (best_index + 1) % size] : [nil, rr]
|
|
390
582
|
end
|
|
391
583
|
|
|
392
584
|
def new_connection(connection_args)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "request"
|
|
5
|
+
|
|
6
|
+
module PgPipeline
|
|
7
|
+
class PreparedStatement
|
|
8
|
+
attr_reader :name, :sql, :param_types, :physical_name
|
|
9
|
+
|
|
10
|
+
def initialize(client:, name:, physical_name:, sql:, param_types: nil)
|
|
11
|
+
@client = client
|
|
12
|
+
@name = PreparedStatementOps.snapshot_name(name)
|
|
13
|
+
@physical_name = PreparedStatementOps.snapshot_name(physical_name)
|
|
14
|
+
@sql = RequestOps.snapshot_sql(sql)
|
|
15
|
+
@param_types = RequestOps.snapshot_param_types(param_types)
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def query(params = []) = PreparedStatementOps.query(self, params)
|
|
20
|
+
alias call query
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
"#<#{self.class} name=#{@name.inspect} sql=#{@sql.inspect}>"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :client
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module PreparedStatementOps
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def query(statement, params)
|
|
35
|
+
ClientOps.query_prepared(statement.__send__(:client), statement, params)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def snapshot_name(name)
|
|
39
|
+
value = name.to_s
|
|
40
|
+
raise ArgumentError, "prepared statement name must not be empty" if value.empty?
|
|
41
|
+
raise ArgumentError, "prepared statement name must not contain NUL" if value.include?("\0")
|
|
42
|
+
|
|
43
|
+
value.frozen? ? value : value.dup.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/pg_pipeline/request.rb
CHANGED
|
@@ -11,7 +11,7 @@ module PgPipeline
|
|
|
11
11
|
:result, :error
|
|
12
12
|
|
|
13
13
|
def initialize(sql:, params: nil)
|
|
14
|
-
@sql = sql
|
|
14
|
+
@sql = RequestOps.snapshot_sql(sql)
|
|
15
15
|
@params = RequestOps.snapshot_params(params)
|
|
16
16
|
@state = :new
|
|
17
17
|
@condition = Async::Notification.new
|
|
@@ -23,6 +23,14 @@ module PgPipeline
|
|
|
23
23
|
@error = nil
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def self.prepare(statement) = PrepareRequest.new(statement)
|
|
27
|
+
|
|
28
|
+
def self.prepared_query(statement, params: nil)
|
|
29
|
+
PreparedQueryRequest.new(statement, params: params)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def operation = :query
|
|
33
|
+
|
|
26
34
|
def queued! = RequestOps.transition!(self, :new, :queued)
|
|
27
35
|
def dispatched! = RequestOps.transition!(self, :queued, :dispatched)
|
|
28
36
|
def accept_result(result) = RequestOps.accept_result(self, result)
|
|
@@ -38,9 +46,37 @@ module PgPipeline
|
|
|
38
46
|
def settled? = @settled
|
|
39
47
|
end
|
|
40
48
|
|
|
49
|
+
class PrepareRequest < Request
|
|
50
|
+
attr_reader :statement_name, :param_types
|
|
51
|
+
|
|
52
|
+
def initialize(statement)
|
|
53
|
+
super(sql: statement.sql)
|
|
54
|
+
@statement_name = RequestOps.snapshot_name(statement.physical_name)
|
|
55
|
+
@param_types = RequestOps.snapshot_param_types(statement.param_types)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def operation = :prepare
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class PreparedQueryRequest < Request
|
|
62
|
+
attr_reader :statement_name
|
|
63
|
+
|
|
64
|
+
def initialize(statement, params: nil)
|
|
65
|
+
super(sql: statement.sql, params: params)
|
|
66
|
+
@statement_name = RequestOps.snapshot_name(statement.physical_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def operation = :prepared_query
|
|
70
|
+
end
|
|
71
|
+
|
|
41
72
|
module RequestOps
|
|
42
73
|
module_function
|
|
43
74
|
|
|
75
|
+
def snapshot_sql(sql)
|
|
76
|
+
value = sql.to_s
|
|
77
|
+
value.frozen? ? value : value.dup.freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
44
80
|
def snapshot_params(params)
|
|
45
81
|
values = params.nil? ? [] : params
|
|
46
82
|
raise ArgumentError, "params must be an Array" unless values.is_a?(Array)
|
|
@@ -51,16 +87,32 @@ module PgPipeline
|
|
|
51
87
|
def snapshot_value(value)
|
|
52
88
|
case value
|
|
53
89
|
when String
|
|
54
|
-
value.dup.freeze
|
|
90
|
+
value.frozen? ? value : value.dup.freeze
|
|
55
91
|
when Hash
|
|
56
92
|
value.each_with_object({}) do |(key, item), copy|
|
|
57
|
-
copy[key] = item.is_a?(String) ? item.dup.freeze : item
|
|
93
|
+
copy[key] = item.is_a?(String) && !item.frozen? ? item.dup.freeze : item
|
|
58
94
|
end.freeze
|
|
59
95
|
else
|
|
60
96
|
value
|
|
61
97
|
end
|
|
62
98
|
end
|
|
63
99
|
|
|
100
|
+
def snapshot_name(name)
|
|
101
|
+
value = name.to_s
|
|
102
|
+
raise ArgumentError, "statement_name must not be empty" if value.empty?
|
|
103
|
+
|
|
104
|
+
value.frozen? ? value : value.dup.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def snapshot_param_types(param_types)
|
|
108
|
+
return nil if param_types.nil?
|
|
109
|
+
raise ArgumentError, "param_types must be an Array or nil" unless param_types.is_a?(Array)
|
|
110
|
+
|
|
111
|
+
param_types.map { |oid| oid.nil? ? nil : Integer(oid) }.freeze
|
|
112
|
+
rescue ArgumentError, TypeError
|
|
113
|
+
raise ArgumentError, "param_types must contain only integer OIDs or nil"
|
|
114
|
+
end
|
|
115
|
+
|
|
64
116
|
def transition!(req, from, to)
|
|
65
117
|
unless req.state == from
|
|
66
118
|
raise ProtocolError, "invalid request transition #{req.state.inspect} -> #{to.inspect}"
|
data/lib/pg_pipeline/session.rb
CHANGED
|
@@ -13,7 +13,7 @@ module PgPipeline
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def query(sql, params = []) = SessionOps.query(self, sql, params)
|
|
16
|
-
def exec(sql) = SessionOps.exec(self, sql)
|
|
16
|
+
def exec(sql, params = nil) = SessionOps.exec(self, sql, params)
|
|
17
17
|
def prepare(name, sql, param_types = nil) = SessionOps.prepare(self, name, sql, param_types)
|
|
18
18
|
def exec_prepared(name, params = []) = SessionOps.exec_prepared(self, name, params)
|
|
19
19
|
def active? = @active
|
|
@@ -39,9 +39,13 @@ module PgPipeline
|
|
|
39
39
|
connection(session).exec_params(sql, params)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def exec(session, sql)
|
|
42
|
+
def exec(session, sql, params = nil)
|
|
43
43
|
ensure_active!(session)
|
|
44
|
-
|
|
44
|
+
if params.nil?
|
|
45
|
+
connection(session).exec(sql)
|
|
46
|
+
else
|
|
47
|
+
connection(session).exec_params(sql, params)
|
|
48
|
+
end
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
def prepare(session, name, sql, param_types)
|
|
@@ -30,7 +30,11 @@ module PgPipeline
|
|
|
30
30
|
}.freeze
|
|
31
31
|
|
|
32
32
|
def assert_multiplexable!(sql, mode: :default)
|
|
33
|
-
|
|
33
|
+
assert_multiplexable_normalized!(sql, mode: normalize_mode!(mode))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def assert_multiplexable_normalized!(sql, mode:)
|
|
37
|
+
reason = unsafe_reason_normalized(sql, mode: mode)
|
|
34
38
|
return true unless reason
|
|
35
39
|
|
|
36
40
|
raise UnsafeMultiplexError,
|
|
@@ -43,9 +47,12 @@ module PgPipeline
|
|
|
43
47
|
GUARD_CACHE_LIMIT = 2048
|
|
44
48
|
|
|
45
49
|
def unsafe_reason(sql, mode: :default)
|
|
46
|
-
mode
|
|
50
|
+
unsafe_reason_normalized(sql, mode: normalize_mode!(mode))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unsafe_reason_normalized(sql, mode:)
|
|
47
54
|
key = sql.to_s
|
|
48
|
-
cache = guard_cache
|
|
55
|
+
cache = guard_cache.fetch(mode)
|
|
49
56
|
return cache[key] if cache.key?(key)
|
|
50
57
|
|
|
51
58
|
reason = compute_unsafe_reason(key, mode)
|
|
@@ -6,8 +6,6 @@ require_relative "session"
|
|
|
6
6
|
|
|
7
7
|
module PgPipeline
|
|
8
8
|
class Transaction < Session
|
|
9
|
-
attr_accessor :open, :savepoint_seq
|
|
10
|
-
|
|
11
9
|
def initialize(conn)
|
|
12
10
|
super
|
|
13
11
|
@open = false
|
|
@@ -23,6 +21,10 @@ module PgPipeline
|
|
|
23
21
|
end
|
|
24
22
|
|
|
25
23
|
def open? = @open
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_accessor :open, :savepoint_seq
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
module TransactionOps
|
|
@@ -30,19 +32,17 @@ module PgPipeline
|
|
|
30
32
|
|
|
31
33
|
def run(tx)
|
|
32
34
|
SessionOps.ensure_active!(tx)
|
|
33
|
-
raise Error, "transaction is already open" if tx.open
|
|
35
|
+
raise Error, "transaction is already open" if tx.open?
|
|
34
36
|
|
|
35
37
|
conn = SessionOps.connection(tx)
|
|
36
38
|
conn.exec("BEGIN")
|
|
37
|
-
tx.open
|
|
39
|
+
tx.__send__(:open=, true)
|
|
38
40
|
begin
|
|
39
41
|
result = yield tx
|
|
40
42
|
conn.exec("COMMIT")
|
|
41
|
-
tx.open
|
|
43
|
+
tx.__send__(:open=, false)
|
|
42
44
|
result
|
|
43
45
|
rescue Exception
|
|
44
|
-
# Only roll back the transaction we opened. Early guard errors
|
|
45
|
-
# ("already open", inactive handle) must not hit this path.
|
|
46
46
|
rollback_quietly(tx)
|
|
47
47
|
raise
|
|
48
48
|
end
|
|
@@ -50,11 +50,12 @@ module PgPipeline
|
|
|
50
50
|
|
|
51
51
|
def savepoint(tx, name)
|
|
52
52
|
SessionOps.ensure_active!(tx)
|
|
53
|
-
raise Error, "savepoint requires an open transaction" unless tx.open
|
|
53
|
+
raise Error, "savepoint requires an open transaction" unless tx.open?
|
|
54
54
|
|
|
55
55
|
conn = SessionOps.connection(tx)
|
|
56
|
-
tx.savepoint_seq
|
|
57
|
-
|
|
56
|
+
seq = tx.__send__(:savepoint_seq) + 1
|
|
57
|
+
tx.__send__(:savepoint_seq=, seq)
|
|
58
|
+
point = name || "pgp_sp_#{seq}"
|
|
58
59
|
ident = conn.quote_ident(point)
|
|
59
60
|
|
|
60
61
|
conn.exec("SAVEPOINT #{ident}")
|
|
@@ -79,14 +80,14 @@ module PgPipeline
|
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
def rollback_quietly(tx)
|
|
82
|
-
return unless tx.open
|
|
83
|
+
return unless tx.open?
|
|
83
84
|
|
|
84
85
|
conn = SessionOps.connection(tx)
|
|
85
86
|
conn&.exec("ROLLBACK")
|
|
86
87
|
rescue PG::Error
|
|
87
88
|
nil
|
|
88
89
|
ensure
|
|
89
|
-
tx.open
|
|
90
|
+
tx.__send__(:open=, false)
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
end
|
data/lib/pg_pipeline/version.rb
CHANGED
data/lib/pg_pipeline.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "pg_pipeline/session_guard"
|
|
|
7
7
|
require_relative "pg_pipeline/session"
|
|
8
8
|
require_relative "pg_pipeline/transaction"
|
|
9
9
|
require_relative "pg_pipeline/request"
|
|
10
|
+
require_relative "pg_pipeline/prepared_statement"
|
|
10
11
|
require_relative "pg_pipeline/connection_driver"
|
|
11
12
|
require_relative "pg_pipeline/pool"
|
|
12
13
|
require_relative "pg_pipeline/client"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pg_pipeline
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Hajdarov
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- lib/pg_pipeline/connection_driver.rb
|
|
122
122
|
- lib/pg_pipeline/errors.rb
|
|
123
123
|
- lib/pg_pipeline/pool.rb
|
|
124
|
+
- lib/pg_pipeline/prepared_statement.rb
|
|
124
125
|
- lib/pg_pipeline/request.rb
|
|
125
126
|
- lib/pg_pipeline/server_caps.rb
|
|
126
127
|
- lib/pg_pipeline/session.rb
|