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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b73820c432250a5622916f522cc7df96e9be218120372d90d30447b2ea4364f
4
- data.tar.gz: af6ac07e07a30e7812846c58b56cfb3fcf0722b6fcac99119be4529249a92a0f
3
+ metadata.gz: 7135257ec52d199f4a7e0d3666909eebd8effa277f7efe80d27ca8ff00717f93
4
+ data.tar.gz: 8f3b976cc4b9f8400439a1504bbad9a019865a88a7f41e6556907ba649587c80
5
5
  SHA512:
6
- metadata.gz: 70249da4404588815fd9b5122ad3bcd564ca8fb8deec8f9068788a27bad9427ebac189bb7ee3ae9f0e05c0461429a00c2a70001749d62f11199777a1d3394472
7
- data.tar.gz: b030ef64f18383b8d3fd15177f7375b7347fed99e7e2e2dc387133306a35fccbe775a79aa8545005fef3bffc7d8ed1ba9547a389212de9a3c8801c0558a0d24e
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.0] - unreleased
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
- - benchmarks/ (fiber-storm latency, multi-worker connection-count smoke).
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 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.
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. 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.
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), docker-compose,
288
- examples, `benchmarks/pipeline_bench.rb` + `benchmarks/multiworker_smoke.rb`.
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 + in_flight) per driver is the head-of-line signal.
156
+ `load` (pending + in-flight + submitting + dispatching) per driver is the routing/head-of-line signal.
127
157
 
128
158
  ## Failure model
129
159
 
@@ -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.to_s
75
- SessionGuard.assert_multiplexable!(sql, mode: client.guard)
76
+ sql = RequestOps.snapshot_sql(sql)
77
+ SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
76
78
 
77
- request = submit_with_failover(client, sql, params)
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, sql, params)
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 = Request.new(sql: sql, params: params)
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
- pool(client).graceful_close
148
- client.__send__(:started=, false)
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
- pool(client).abort!
156
- client.__send__(:started=, false)
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 || d.needs_flush
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.send_query_params(request.sql, request.params)
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 PQsendQueryParams.
286
- # A Ruby-side encoder/coercion exception therefore belongs only to this
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&.stop
485
- d.writer_task&.stop
504
+ reader = d.reader_task
505
+ writer = d.writer_task
486
506
  d.reader_task = nil
487
507
  d.writer_task = nil
488
- rescue StandardError
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
 
@@ -10,6 +10,7 @@ module PgPipeline
10
10
  class IndeterminateResultError < ConnectionLostError; end
11
11
  class ShutdownError < Error; end
12
12
  class ProtocolError < Error; end
13
+ class RecursiveCheckoutError < Error; end
13
14
 
14
15
  class QueryError < Error
15
16
  attr_reader :cause_result
@@ -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 = Float(reconnect_interval)
43
- @reconnect_backoff_max = Float(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 = Float(health_interval)
46
- @health_timeout = Float(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: Async::Task.current)
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
- stop_supervisor
160
- @drivers.each(&:graceful_close)
161
- wait_for_pinned_idle
162
- close_free_pinned
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
- stop_supervisor
172
- cancel_in_use_pinned if @cancel_pinned_on_abort
173
- @drivers.each(&:abort!)
174
- close_free_pinned
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(parent) if @reconnect
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
- parent.sleep(@reconnect_interval)
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
- delay = @reconnect_interval * (2**(attempts - 1))
246
- delay < @reconnect_backoff_max ? delay : @reconnect_backoff_max
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&.stop
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
- @pinned_free << recycled if recycled
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
- min_load = nil
365
- count = 0
366
- drivers.each do |driver|
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 min_load.nil? || load < min_load
371
- min_load = load
372
- count = 1
373
- elsif load == min_load
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
- return [driver, rr + 1] if index == target
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
@@ -11,7 +11,7 @@ module PgPipeline
11
11
  :result, :error
12
12
 
13
13
  def initialize(sql:, params: nil)
14
- @sql = sql.to_s.dup.freeze
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}"
@@ -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
- connection(session).exec(sql)
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
- reason = unsafe_reason(sql, mode: mode)
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 = normalize_mode!(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[mode]
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 = true
39
+ tx.__send__(:open=, true)
38
40
  begin
39
41
  result = yield tx
40
42
  conn.exec("COMMIT")
41
- tx.open = false
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 += 1
57
- point = name || "pgp_sp_#{tx.savepoint_seq}"
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 = false
90
+ tx.__send__(:open=, false)
90
91
  end
91
92
  end
92
93
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgPipeline
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.3"
5
5
  end
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.1
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