pg_pipeline 0.2.1 → 0.2.2

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: 750bfa3d5740056066f5a3543e747f02701132ab941aabbd828d25ece368b1df
4
+ data.tar.gz: f822719ac13016e6a5e8fc373f6fcaf625cd97cc128cf9322a1231228d20ad40
5
5
  SHA512:
6
- metadata.gz: 70249da4404588815fd9b5122ad3bcd564ca8fb8deec8f9068788a27bad9427ebac189bb7ee3ae9f0e05c0461429a00c2a70001749d62f11199777a1d3394472
7
- data.tar.gz: b030ef64f18383b8d3fd15177f7375b7347fed99e7e2e2dc387133306a35fccbe775a79aa8545005fef3bffc7d8ed1ba9547a389212de9a3c8801c0558a0d24e
6
+ metadata.gz: d7ea51781dc5150133a1f04d8c65ac2b57bc39df6de98f34534e6e490359dcb1b4f19684c2a950f6a22e138ef2d1ca17c4e034df961140afbc4f37d1dab3c76a
7
+ data.tar.gz: 45c4e56417cfa97147db391f2150dba6c5dc0e01f957a373429ef5f6dd7c6ee4baa50973e0a93c179f480bbf3ad79c20d5a501b4e72e705a743cb16b921b4607
data/CHANGELOG.md CHANGED
@@ -1,6 +1,86 @@
1
1
  # Changelog
2
2
 
3
- ## [0.2.0] - unreleased
3
+ ## [0.2.2] - 2026-07-30
4
+
5
+ Lifecycle-hardening release. Fixes five pool/task-ownership defects surfaced by
6
+ review, adds input validation, and ships deterministic regression coverage for
7
+ each fix.
8
+
9
+ ### Fixed (pool & task lifecycle)
10
+
11
+ - Replacement pipeline drivers are now owned by the pool's stable parent task,
12
+ not the supervisor. Stopping the supervisor during shutdown can no longer
13
+ cascade-cancel a live replacement driver before it is gracefully drained.
14
+ - Returning a pinned connection re-checks the closing state after the yielding
15
+ recycle step (`ROLLBACK`/`DISCARD ALL`/reconnect); a connection recycled while
16
+ a concurrent `abort!` runs is closed instead of leaked into the free list.
17
+ - Task cancellation during pinned recycle (`Async::Cancel`/`Async::Stop` are
18
+ `Exception`s, not `StandardError`s) is caught solely to close the connection
19
+ and then re-raised, so cancellation semantics are preserved and the connection
20
+ is not leaked.
21
+ - Nested `Client#session`/`Client#transaction` on the same task is rejected with
22
+ `RecursiveCheckoutError` before the pinned semaphore is acquired, preventing a
23
+ self-deadlock (`pinned_size: 1`) or a silently different connection
24
+ (`pinned_size > 1`). Use `Transaction#savepoint` for nested atomicity.
25
+ - A closing or closed pool is terminal: `Pool#start` raises rather than
26
+ half-restarting onto stale driver state, including after an interrupted close.
27
+ Create a new pool instead.
28
+ - Partial startup now also cleans up if spawning the supervisor task fails after
29
+ drivers were created.
30
+ - Once shutdown begins, availability checks report `ShutdownError` before the
31
+ generic not-started state, preserving the definitely-not-dispatched failure
32
+ classification for concurrent submitters.
33
+
34
+ ### Changed (hardening)
35
+
36
+ - Timing options (`reconnect_interval`, `reconnect_backoff_max`,
37
+ `health_interval`, `health_timeout`) reject negative, `NaN`, and infinite
38
+ values; `health_interval` additionally allows `0` ("probe every cycle").
39
+ - The supervisor wakes on the shorter of the enabled reconnect/health cadences,
40
+ so a short `health_interval` is honored independently of a large
41
+ `reconnect_interval`, using scheduler-aware `Kernel#sleep` rather than the
42
+ deprecated `Async::Task#sleep` wrapper.
43
+ - Reconnect backoff uses floating-point exponentiation and caps non-finite or
44
+ over-limit delays, avoiding giant integers without prematurely flattening the
45
+ backoff for very small base intervals.
46
+ - `Transaction#open`/`#savepoint_seq` are no longer publicly writable; control
47
+ state is read via `#open?` and mutated only internally.
48
+ - `Session#exec` accepts optional bind parameters (`exec(sql, params)`),
49
+ matching the documented API; any explicitly supplied params value, including
50
+ an empty array, uses `exec_params`, while omitted params keep simple-query mode.
51
+ - Driver shutdown attempts every driver even if one close raises or the closing
52
+ task is cancelled, then re-raises cancellation after best-effort cleanup;
53
+ watcher cleanup still stops the writer when stopping the reader fails.
54
+ - `Client#close`/`#abort!` now clear client lifecycle state even when terminal pool
55
+ cleanup reports an error, while a rejected re-entrant close leaves the live
56
+ client started.
57
+
58
+ ### Compatibility
59
+
60
+ - Shutdown cleanup handles `Async::Cancel` explicitly. The supported dependency
61
+ floor (`async 2.42.0`) already defines that class, so no dead compatibility
62
+ fallback is required.
63
+ - CI now runs the unit suite against exact `async 2.42.0`, builds ruby-pg against
64
+ source-built client libpq 14.23/16.14/17.10 and runs each build against a live
65
+ PostgreSQL server, plus a separate server integration matrix for 14/16/17/18.
66
+
67
+ ## [0.2.1]
68
+
69
+ - SessionGuard: bounded per-SQL memo cache for unsafe_reason. Repeated SQL
70
+ (parameterized queries) now costs ~0 allocations instead of ~330 objects/call
71
+ (measured); eliminates the code_only/byteslice line that showed up in profiles.
72
+ - Client#query: drop redundant sql dup (Request owns the one immutable copy).
73
+ - PoolOps.select_driver: allocation-free least-loaded scan (was 2-3 arrays/call);
74
+ behavior verified identical across 20k random cases.
75
+ - bench_kit/metrics.rb + rake bench:metrics: full picture in one run —
76
+ allocations/query + GC pressure, RubyProf process_time (CPU) and allocations,
77
+ RubyProf wall (sanity), and StackProf cpu/wall sampling. Asserts run outside
78
+ every profiler; warmup keeps connection setup out of frame.
79
+ - profile_ci_scenario.rb: correctness asserts moved out of the profiled block;
80
+ GC.start before profiling.
81
+ - Dev dependency: stackprof (~> 0.2).
82
+
83
+ ## [0.2.0]
4
84
 
5
85
  Initial experimental implementation.
6
86
 
@@ -103,7 +183,7 @@ Initial experimental implementation.
103
183
  even when the losing Request was already settled.
104
184
  - Strict guard precise denylist (no pg_typeof/setup/xact-advisory false positives).
105
185
  - CI separates server-major coverage from source-built client-libpq 14/16 coverage.
106
- - benchmarks/ (fiber-storm latency, multi-worker connection-count smoke).
186
+ - `bench_kit/` (fiber-storm latency, multi-worker connection-count smoke).
107
187
  - Pool driver slot arrays self-heal size (reconnect/health safe under partial init).
108
188
  - Unit coverage: failover, health_probe, pinned cancel on abort.
109
189
 
@@ -138,19 +218,3 @@ Initial experimental implementation.
138
218
  boundary: cancellation during rollback/sanitize/reconnect closes the physical
139
219
  connection instead of orphaning it outside the pool. Graceful close also closes
140
220
  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
@@ -254,10 +254,10 @@ chunked-rows, non-blocking cancel), never a floor.
254
254
  - `SessionGuard` is policy/ergonomics, not a security boundary. `:strict` adds a
255
255
  precise denylist (`nextval`/`setval`/`pg_export_snapshot`, …) without the old
256
256
  broad `pg_*(` / `set*(` false positives; UDFs can still mutate session state.
257
- - Live integration covers **server** PG 14/16/17. CI prints the linked **client**
258
- libpq version, but does not yet matrix distinct client-libpq majors (one
259
- runner libpq across all server jobs). Pipeline capability is client-side, so
260
- a dedicated libpq 14/16/17 client matrix remains future work.
257
+ - Live integration covers **server** PG 14/16/17/18. A separate CI matrix
258
+ builds ruby-pg against source-built **client** libpq 14/16/17, asserts the
259
+ linked major, and runs both unit and live integration suites because pipeline
260
+ capability is determined by client libpq rather than server version.
261
261
  - On libpq 14–16, `place_sync` uses `PQpipelineSync` / ruby-pg `sync_pipeline_sync`
262
262
  (flush coupled); libpq 17+ uses `send_pipeline_sync` + explicit `sync_flush`.
263
263
  - Pinned recycle still runs full `DISCARD ALL` (no lighter reset profile / recycle
@@ -284,15 +284,15 @@ Implemented:
284
284
  live sibling; the failed Request object itself is never re-used.
285
285
  - `abort!` CancelRequest on in-use pinned connections.
286
286
  - Falcon per-worker contract + sizing formula; reactor-local Client fail-fast.
287
- - Packaging: gemspec metadata, CI (unit + PG server 14/16/17), docker-compose,
288
- examples, `benchmarks/pipeline_bench.rb` + `benchmarks/multiworker_smoke.rb`.
287
+ - Packaging: gemspec metadata, CI (unit + PG server 14/16/17/18 + client-libpq
288
+ 14/16/17 + exact async floor), docker-compose,
289
+ examples, `bench_kit/pipeline_throughput.rb` + `bench_kit/multiworker_smoke.rb`.
289
290
  - Live integration suite (multiplex, isolation, cancel-drain, savepoints, stats,
290
291
  backpressure, pinned concurrency, indeterminate on abort, reconnect, backend
291
292
  terminate recovery, pinned abort cancel).
292
293
 
293
294
  Still open:
294
295
 
295
- - Dedicated client-libpq 14/16/17 CI matrix (not only server majors).
296
296
  - Lighter pinned reset / recycle-latency metrics under high-TPS tx.
297
297
  - Force socket close if pinned cancel is ignored.
298
298
  - Published bench numbers in README; tighten multiworker smoke to peak occupancy.
data/README.md CHANGED
@@ -123,7 +123,7 @@ db.stats
123
123
  # }
124
124
  ```
125
125
 
126
- `load` (pending + in_flight) per driver is the head-of-line signal.
126
+ `load` (pending + in-flight + submitting + dispatching) per driver is the routing/head-of-line signal.
127
127
 
128
128
  ## Failure model
129
129
 
@@ -144,16 +144,22 @@ module PgPipeline
144
144
  return unless started?(client)
145
145
 
146
146
  ensure_context!(client)
147
- pool(client).graceful_close
148
- client.__send__(:started=, false)
147
+ begin
148
+ pool(client).graceful_close
149
+ ensure
150
+ client.__send__(:started=, false) if pool(client).closing?
151
+ end
149
152
  end
150
153
 
151
154
  def abort!(client)
152
155
  return unless started?(client)
153
156
 
154
157
  ensure_context!(client)
155
- pool(client).abort!
156
- client.__send__(:started=, false)
158
+ begin
159
+ pool(client).abort!
160
+ ensure
161
+ client.__send__(:started=, false) if pool(client).closing?
162
+ end
157
163
  end
158
164
 
159
165
  def pool(client)
@@ -481,11 +481,17 @@ module PgPipeline
481
481
  end
482
482
 
483
483
  def stop_watchers(d)
484
- d.reader_task&.stop
485
- d.writer_task&.stop
484
+ reader = d.reader_task
485
+ writer = d.writer_task
486
486
  d.reader_task = nil
487
487
  d.writer_task = nil
488
- rescue StandardError
488
+
489
+ [reader, writer].each do |task|
490
+ task&.stop
491
+ rescue Async::Cancel, StandardError
492
+ nil
493
+ end
494
+
489
495
  nil
490
496
  end
491
497
 
@@ -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
@@ -18,6 +18,7 @@ module PgPipeline
18
18
  DEFAULT_HEALTH_INTERVAL = 10.0
19
19
  DEFAULT_HEALTH_TIMEOUT = 5.0
20
20
  DISCARD_SEQUENCES_SERVER_VERSION = 90_400
21
+ CANCEL_SIGNAL = Async::Cancel
21
22
 
22
23
  attr_reader :reconnects, :pipeline_size
23
24
 
@@ -39,11 +40,11 @@ module PgPipeline
39
40
  @max_pending = max_pending
40
41
  @max_in_flight = max_in_flight
41
42
  @reconnect = reconnect
42
- @reconnect_interval = Float(reconnect_interval)
43
- @reconnect_backoff_max = Float(reconnect_backoff_max)
43
+ @reconnect_interval = PoolOps.finite_float!(reconnect_interval, :reconnect_interval)
44
+ @reconnect_backoff_max = PoolOps.finite_float!(reconnect_backoff_max, :reconnect_backoff_max)
44
45
  @health_check = health_check
45
- @health_interval = Float(health_interval)
46
- @health_timeout = Float(health_timeout)
46
+ @health_interval = PoolOps.finite_float!(health_interval, :health_interval, allow_zero: true)
47
+ @health_timeout = PoolOps.finite_float!(health_timeout, :health_timeout)
47
48
  @cancel_pinned_on_abort = cancel_pinned_on_abort
48
49
 
49
50
  @drivers = []
@@ -55,6 +56,7 @@ module PgPipeline
55
56
  @health_failures = 0
56
57
  @supervisor_error = nil
57
58
  @supervisor = nil
59
+ @task_parent = nil
58
60
 
59
61
  @pinned_free = []
60
62
  @pinned_in_use = {}
@@ -66,10 +68,17 @@ module PgPipeline
66
68
 
67
69
  @started = false
68
70
  @closing = false
71
+ @closed = false
69
72
  end
70
73
 
71
- def start(parent: Async::Task.current)
74
+ def start(parent: nil)
72
75
  raise Error, "pool already started" if @started
76
+ if @closing || @closed
77
+ raise ShutdownError, "pool is closing or was closed and cannot be restarted; create a new Pool"
78
+ end
79
+
80
+ parent ||= Async::Task.current
81
+ @task_parent = parent
73
82
 
74
83
  begin
75
84
  @pipeline_size.times { @drivers << start_pipeline_driver(parent) }
@@ -77,13 +86,13 @@ module PgPipeline
77
86
  @driver_attempts = Array.new(@drivers.size, 0)
78
87
  @driver_last_health = Array.new(@drivers.size, monotonic)
79
88
  @pinned_gate = Async::Semaphore.new(@pinned_size) if @pinned_size.positive?
89
+ @started = true
90
+ @supervisor = parent.async { supervise } if @reconnect || @health_check
80
91
  rescue Exception
81
92
  cleanup_partial_start
82
93
  raise
83
94
  end
84
95
 
85
- @started = true
86
- @supervisor = parent.async { supervise } if @reconnect || @health_check
87
96
  self
88
97
  end
89
98
 
@@ -103,6 +112,12 @@ module PgPipeline
103
112
  raise @pinned_error if @pinned_error
104
113
  raise Error, "pinned pool is disabled (pinned_size=0)" if @pinned_size.zero?
105
114
 
115
+ if @pinned_owners[Async::Task.current].positive?
116
+ raise RecursiveCheckoutError,
117
+ "nested Client#session/transaction on the same task is not allowed; " \
118
+ "use Transaction#savepoint for nested atomicity"
119
+ end
120
+
106
121
  @pinned_gate.acquire do
107
122
  raise @pinned_error if @pinned_error
108
123
  raise ShutdownError, "pool is closing" if @closing
@@ -143,6 +158,7 @@ module PgPipeline
143
158
  health_failures: @health_failures,
144
159
  supervisor_error: @supervisor_error&.message,
145
160
  closing: @closing,
161
+ closed: @closed,
146
162
  pinned_error: @pinned_error&.message
147
163
  }
148
164
  end
@@ -156,10 +172,24 @@ module PgPipeline
156
172
 
157
173
  @closing = true
158
174
  @started = false
159
- stop_supervisor
160
- @drivers.each(&:graceful_close)
161
- wait_for_pinned_idle
162
- close_free_pinned
175
+ first_error = nil
176
+
177
+ begin
178
+ first_error = preferred_shutdown_error(first_error, stop_supervisor)
179
+ first_error = preferred_shutdown_error(first_error, close_all_drivers(:graceful_close))
180
+
181
+ begin
182
+ wait_for_pinned_idle
183
+ rescue CANCEL_SIGNAL, StandardError => e
184
+ first_error = preferred_shutdown_error(first_error, e)
185
+ end
186
+ ensure
187
+ close_free_pinned
188
+ @closed = true
189
+ end
190
+
191
+ raise first_error if first_error
192
+
163
193
  nil
164
194
  end
165
195
 
@@ -168,31 +198,50 @@ module PgPipeline
168
198
 
169
199
  @closing = true
170
200
  @started = false
171
- stop_supervisor
172
- cancel_in_use_pinned if @cancel_pinned_on_abort
173
- @drivers.each(&:abort!)
174
- close_free_pinned
201
+ first_error = nil
202
+
203
+ begin
204
+ first_error = preferred_shutdown_error(first_error, stop_supervisor)
205
+ if @cancel_pinned_on_abort
206
+ first_error = preferred_shutdown_error(first_error, cancel_in_use_pinned)
207
+ end
208
+ first_error = preferred_shutdown_error(first_error, close_all_drivers(:abort!))
209
+ ensure
210
+ close_free_pinned
211
+ @closed = true
212
+ end
213
+
214
+ raise first_error if first_error
215
+
175
216
  nil
176
217
  end
177
218
 
178
219
  private
179
220
 
180
221
  def supervise
181
- parent = Async::Task.current
182
222
  until @closing
183
223
  begin
184
- reap_and_replace(parent) if @reconnect
224
+ reap_and_replace(@task_parent) if @reconnect
185
225
  health_probe if @health_check
186
226
  @supervisor_error = nil
187
227
  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
228
  @supervisor_error = e
191
229
  end
192
- parent.sleep(@reconnect_interval)
230
+
231
+ break if @closing
232
+
233
+ sleep(supervisor_sleep_interval)
193
234
  end
194
235
  end
195
236
 
237
+ def supervisor_sleep_interval
238
+ candidates = []
239
+ candidates << @reconnect_interval if @reconnect
240
+ candidates << @health_interval if @health_check && @health_interval.positive?
241
+ interval = candidates.min || @reconnect_interval
242
+ interval.positive? ? interval : @reconnect_interval
243
+ end
244
+
196
245
  def reap_and_replace(parent)
197
246
  now = monotonic
198
247
  ensure_driver_slots!
@@ -242,28 +291,63 @@ module PgPipeline
242
291
  end
243
292
 
244
293
  def next_backoff(attempts)
245
- delay = @reconnect_interval * (2**(attempts - 1))
246
- delay < @reconnect_backoff_max ? delay : @reconnect_backoff_max
294
+ exponent = [Integer(attempts) - 1, 0].max
295
+ delay = @reconnect_interval * (2.0**exponent)
296
+ delay.finite? && delay < @reconnect_backoff_max ? delay : @reconnect_backoff_max
247
297
  end
248
298
 
249
299
  def cancel_in_use_pinned
300
+ cancellation = nil
301
+
250
302
  @pinned_in_use.keys.each do |conn|
251
303
  conn.cancel if conn.respond_to?(:cancel)
304
+ rescue CANCEL_SIGNAL => e
305
+ cancellation ||= e
252
306
  rescue StandardError
253
307
  nil
254
308
  end
309
+
310
+ cancellation
255
311
  end
256
312
 
257
313
  def stop_supervisor
258
- @supervisor&.stop
314
+ supervisor = @supervisor
259
315
  @supervisor = nil
316
+ supervisor&.stop
317
+ nil
318
+ rescue CANCEL_SIGNAL => e
319
+ e
260
320
  rescue StandardError
261
321
  nil
262
322
  end
263
323
 
324
+ def close_all_drivers(method_name)
325
+ first_error = nil
326
+ cancellation = nil
327
+
328
+ @drivers.each do |driver|
329
+ begin
330
+ driver.public_send(method_name)
331
+ rescue CANCEL_SIGNAL => e
332
+ cancellation ||= e
333
+ rescue StandardError => e
334
+ first_error ||= e
335
+ end
336
+ end
337
+
338
+ cancellation || first_error
339
+ end
340
+
341
+ def preferred_shutdown_error(current, candidate)
342
+ return current unless candidate
343
+ return candidate if candidate.is_a?(CANCEL_SIGNAL)
344
+
345
+ current || candidate
346
+ end
347
+
264
348
  def ensure_available!
349
+ raise ShutdownError, "pool is closing or closed" if @closing || @closed
265
350
  raise Error, "pool not started" unless @started
266
- raise ShutdownError, "pool is closing" if @closing
267
351
  end
268
352
 
269
353
  def start_pipeline_driver(parent)
@@ -282,7 +366,14 @@ module PgPipeline
282
366
  PoolOps.safe_close(conn)
283
367
  else
284
368
  recycled = recycle_pinned_connection(conn)
285
- @pinned_free << recycled if recycled
369
+
370
+ if recycled
371
+ if @closing
372
+ PoolOps.safe_close(recycled)
373
+ else
374
+ @pinned_free << recycled
375
+ end
376
+ end
286
377
  end
287
378
  end
288
379
  ensure
@@ -301,6 +392,9 @@ module PgPipeline
301
392
 
302
393
  PoolOps.sanitize_pinned_connection(conn)
303
394
  conn
395
+ rescue CANCEL_SIGNAL
396
+ PoolOps.safe_close(conn)
397
+ raise
304
398
  rescue StandardError => cleanup_error
305
399
  PoolOps.safe_close(conn)
306
400
 
@@ -322,13 +416,20 @@ module PgPipeline
322
416
  def cleanup_partial_start
323
417
  @drivers.each do |driver|
324
418
  driver.abort!
325
- rescue StandardError
419
+ rescue CANCEL_SIGNAL, StandardError
326
420
  nil
327
421
  end
328
422
  @drivers.clear
423
+ @driver_backoff.clear
424
+ @driver_attempts.clear
425
+ @driver_last_health.clear
329
426
  close_free_pinned
427
+ @pinned_gate = nil
428
+ @supervisor = nil
429
+ @task_parent = nil
330
430
  @started = false
331
431
  @closing = false
432
+ @closed = false
332
433
  end
333
434
 
334
435
  def close_free_pinned
@@ -351,6 +452,17 @@ module PgPipeline
351
452
  raise ArgumentError, "#{name} must be an integer >= 1"
352
453
  end
353
454
 
455
+ def finite_float!(value, name, allow_zero: false)
456
+ number = Float(value)
457
+ bound_ok = allow_zero ? number >= 0 : number.positive?
458
+ raise ArgumentError unless number.finite? && bound_ok
459
+
460
+ number
461
+ rescue ArgumentError, TypeError
462
+ requirement = allow_zero ? "non-negative finite" : "positive finite"
463
+ raise ArgumentError, "#{name} must be a #{requirement} number (got #{value.inspect})"
464
+ end
465
+
354
466
  def nonnegative_integer!(value, name)
355
467
  integer = Integer(value)
356
468
  raise ArgumentError, "#{name} must be >= 0" if integer.negative?
@@ -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)
@@ -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.2"
5
5
  end
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov