pg_pipeline 0.3.0 → 0.3.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: 5ebb76f18598913316b77fdc0fd0ab4aa411fbd014cf1f3f10e427c1a5709967
4
- data.tar.gz: cd51b3fc6b5f20f5896969e8eb3a4263d999f2544239606944194192f894e850
3
+ metadata.gz: c7fdda2e5d806be070a3255e8cef4a63b72acaeac87d7a7777d580bfb0fc0d0a
4
+ data.tar.gz: 2b23bf8808241eb64396672959a86ae86c26af05bb0d72049de646907cc9e99d
5
5
  SHA512:
6
- metadata.gz: 6c1581a9ecc7f45b44342c49c529d12694c5d962808ef4ba7a0ec9d71aac5ae46852c99c242ec1b897c8c1b591d9ab66c55a24eacf0a4e6637de068624f36fc3
7
- data.tar.gz: 3272e19424de3eed0f6c186b2bf14ac830ef226f9ce47207fd3c71fd38f622557a6542630c44934794cf9c650106a2e5a7e66ec475501220a685551799609111
6
+ metadata.gz: 37a5b065506fb61a675a72023057b0d24254ea7fa411ea358c68e255e0ffabf53079ab3a7214be59b2d09b02063aa2c09875576868a213df8c91526f90834ebd
7
+ data.tar.gz: fb0e3b15f73e8de66b56d09c7791b8e357477c859b83293474ec48c15d41d12298f91c974bd8ebeb0ae2cc6a3437ed598c442e6eb26e8a13787e71a3d35856f7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2] - 2026-08-17
4
+
5
+ Runtime correctness. Public SQL, Sync-per-unit, failure model, and the
6
+ `Fiber::Scheduler` host contract are unchanged.
7
+
8
+ ### Fixed
9
+
10
+ - Bounded waits (`Notification#wait`, `Task#wait`) park through
11
+ `scheduler.block(blocker, remaining)` and a monotonic deadline. They no
12
+ longer wrap `park` in `Timeout.timeout`. A wait timeout is not a work
13
+ timeout: `Notification#wait` no longer rescues `TimeoutError`, so an
14
+ enclosing `Runtime.with_timeout` cannot be swallowed by
15
+ `BoundedQueue#wait_on` or `wait_for_pinned_idle`. `wait(0)` polls.
16
+ - `Runtime.with_timeout` (health probes and other work limits) calls
17
+ `scheduler.timeout_after(duration, Deadline, message)` when the hook
18
+ exists. `Deadline < Exception`, so `rescue => e` cannot eat it; the
19
+ method still raises `Runtime::TimeoutError` at its own boundary. A
20
+ scheduler without the hook falls back to stdlib `Timeout` and warns
21
+ once — `Thread#raise` can hit the wrong fiber.
22
+ - `Notification#signal` and `Semaphore#release` skip a waiter that
23
+ `Task#stop` already released, or a fiber that is no longer alive. On a
24
+ deferred `unblock` the previous `shift` + wake handed the signal to the
25
+ stopped waiter and left the live one parked; a dead semaphore waiter
26
+ burned a pinned-pool slot. `park` also raises `Cancel` after a stop-wake
27
+ so the wait is not mistaken for a successful signal.
28
+
29
+ ### Unchanged
30
+
31
+ - Per-fiber waiter-hash reuse, `Request` one-shot completion, watcher
32
+ `fiber_interrupt` / poll shutdown, and the host-installed scheduler
33
+ rule.
34
+
35
+ ## [0.3.1] - 2026-08-09
36
+
37
+ Reliability and correctness fixes on top of 0.3.0. No public API changes other
38
+ than the new error class below; no changes to wire behaviour.
39
+
40
+ ### Fixed
41
+
42
+ - `SessionGuard` dollar-quote masking now recognises tags containing non-ASCII
43
+ identifier bytes (e.g. `$тег$...$тег$`). Previously an unrecognised tag left
44
+ the quoted body unmasked, so its raw text was scanned by the forbidden-pattern
45
+ checks instead of being treated as an opaque literal.
46
+ - `Client#transaction` now distinguishes a lost `COMMIT` acknowledgement from an
47
+ ordinary server-side rejection. If `COMMIT` fails while the connection is
48
+ still healthy (`status == CONNECTION_OK`, not `finished?`), the original
49
+ `PG::Error` is raised unchanged -- the server gave a complete answer and the
50
+ transaction did not commit. If the connection itself is gone or broken
51
+ (`PG::ConnectionBad`, `finished?`, or a non-OK status), the outcome is
52
+ genuinely unknown, and this is now raised as the new
53
+ `PgPipeline::IndeterminateCommitError < IndeterminateResultError` instead of
54
+ a bare `PG::Error`, so callers can tell "definitely rolled back" apart from
55
+ "may have committed, do not retry blindly" without inspecting connection
56
+ internals themselves. See the updated "Failure model" table in the README.
57
+
58
+ ### Reverted
59
+
60
+ - The reader/writer watcher shutdown mechanism briefly introduced in this cycle
61
+ (self-pipe `Runtime::Wakeup` + `IO.select` on every socket wait) has been
62
+ removed before release. It fixed a real gap -- on schedulers with no way to
63
+ interrupt a fiber blocked in `wait_readable`/`wait_writable`, closing the
64
+ socket alone does not reliably wake it pre-Ruby-4.0 -- but at an unacceptable
65
+ cost: it moved the connection driver's hottest loop from `io_wait` (native,
66
+ zero extra threads) onto `IO.select`, and both reference schedulers implement
67
+ that hook expensively. `Async::Scheduler#io_select` spawns a new OS thread per
68
+ call; `Itsi::Scheduler#io_select` with more than one IO (our case: socket +
69
+ wakeup pipe) falls onto Itsi's bounded `blocking_operation_wait` worker pool
70
+ and holds a worker for the full duration of every idle wait, which can
71
+ exhaust that pool under a modest number of pipeline connections.
72
+ - Root-caused instead: `Async::Scheduler` and `Itsi::Scheduler` both already
73
+ implement `#fiber_interrupt` as an ordinary library method, independent of
74
+ Ruby core's own hook of the same name (Ruby >= 4.0). `Task#stop` already
75
+ tries `#fiber_interrupt` first, so on both of this gem's reference schedulers
76
+ a blocked watcher is woken immediately via `fiber.raise`, on the same
77
+ `wait_readable`/`wait_writable` fast path as 0.3.0 -- no self-pipe needed.
78
+ `ConnectionDriver` now only falls back to a bounded
79
+ `wait_readable(timeout)`/`wait_writable(timeout)` poll
80
+ (`DriverOps::WATCHER_POLL_INTERVAL`, 0.25s) when the active scheduler does
81
+ *not* respond to `#fiber_interrupt`, guaranteeing shutdown within one poll
82
+ interval instead of depending on socket-close propagation. Async and Itsi
83
+ never pay this poll; an unknown/minimal scheduler does, bounded and cheap.
84
+ - `teardown_watchers` also reverts to closing the socket/connection
85
+ unconditionally regardless of whether the watcher tasks joined in time. The
86
+ self-pipe version returned early when a watcher missed
87
+ `WATCHER_JOIN_TIMEOUT`, before closing the wakeup pipes, the socket, or the
88
+ `PG::Connection` -- turning a leaked *task* into a leaked live DB connection
89
+ and file descriptors. Resources are now always closed; only the task's own
90
+ fiber can still leak (tracked via `stats[:leaked_watchers]`, unchanged).
91
+
3
92
  ## [0.3.0] - 2026-08-07
4
93
 
5
94
  Scheduler-agnostic control plane: the gem no longer depends on the `async` gem
data/README.md CHANGED
@@ -265,8 +265,15 @@ If your workload mixes fast and slow queries, prefer one of:
265
265
  |---|---|---|
266
266
  | `NotDispatchedError` | query never reached the wire | ✅ yes |
267
267
  | `IndeterminateResultError` | query was sent, Sync not observed | ⚠️ only if idempotent |
268
+ | `IndeterminateCommitError` | `Client#transaction`'s `COMMIT` acknowledgement was lost while the connection itself was gone/broken | ⚠️ never — the transaction may have committed |
268
269
  | `UnsafeMultiplexError` | session-mutating SQL on multiplexed path | — fix the call site |
269
270
 
271
+ `IndeterminateCommitError < IndeterminateResultError`. It is raised only when the
272
+ pinned connection looks dead (`PG::ConnectionBad`, `finished?`, or a non-OK
273
+ status) at the moment `COMMIT` fails. If `COMMIT` fails while the connection is
274
+ still healthy, that's the server giving a complete, unambiguous answer — the
275
+ original `PG::Error` is raised as-is, and the transaction did not commit.
276
+
270
277
  Cancelling a fiber does **not** send `CancelRequest` to PostgreSQL — the query may
271
278
  still execute. For mutations, do not retry blindly after a timeout.
272
279
 
@@ -38,7 +38,6 @@ module PgPipeline
38
38
  @caps = ServerCaps.from_connection(conn)
39
39
  @caps.assert_supported!
40
40
  DriverOps.warn_flush_coupling_once(@caps)
41
-
42
41
  @requests = BoundedQueue.new(@max_pending)
43
42
  @events = Runtime::Queue.new
44
43
  @reader_rearm = Runtime::Queue.new
@@ -48,7 +47,6 @@ module PgPipeline
48
47
  @readable_events = @results_read = @units_completed = @flush_calls =
49
48
  @flush_incomplete = @dispatches = @leaked_watchers = @submitting = 0
50
49
  @socket = @owner_task = @reader_task = @writer_task = @dispatching = nil
51
-
52
50
  @inflight = []
53
51
  end
54
52
 
@@ -109,6 +107,7 @@ module PgPipeline
109
107
  module DriverOps
110
108
  WATCHER_JOIN_TIMEOUT = 2.0
111
109
  OWNER_JOIN_TIMEOUT = 5.0
110
+ WATCHER_POLL_INTERVAL = 0.25
112
111
 
113
112
  module_function
114
113
 
@@ -147,7 +146,6 @@ module PgPipeline
147
146
 
148
147
  d.conn.setnonblocking(true)
149
148
  d.conn.enter_pipeline_mode
150
-
151
149
  d.socket = d.conn.socket_io
152
150
  d.accepting = true
153
151
  d.running = true
@@ -233,7 +231,6 @@ module PgPipeline
233
231
 
234
232
  def process_event(d, event)
235
233
  input_changed = false
236
-
237
234
  case event
238
235
  when :requests
239
236
  d.request_event_pending = false
@@ -275,7 +272,6 @@ module PgPipeline
275
272
 
276
273
  def pump_requests(d)
277
274
  dispatched = false
278
-
279
275
  while d.inflight.size < d.max_in_flight && !d.requests.empty?
280
276
  request = d.requests.dequeue
281
277
  break unless request
@@ -356,7 +352,6 @@ module PgPipeline
356
352
  return unless d.running
357
353
 
358
354
  d.flush_calls += 1
359
-
360
355
  if d.conn.sync_flush
361
356
  d.needs_flush = false
362
357
  else
@@ -398,7 +393,6 @@ module PgPipeline
398
393
  end
399
394
 
400
395
  status = result.result_status
401
-
402
396
  case status
403
397
  when PG::PGRES_TUPLES_OK
404
398
  ensure_before_query_boundary!(request, status)
@@ -459,7 +453,6 @@ module PgPipeline
459
453
  def finish_graceful_close(d)
460
454
  d.accepting = false
461
455
  d.running = false
462
-
463
456
  d.conn.exit_pipeline_mode
464
457
  rescue PG::Error => e
465
458
  fail_all(d, ConnectionLostError.new("failed to exit pipeline mode: #{e.message}"))
@@ -511,10 +504,32 @@ module PgPipeline
511
504
  )
512
505
  end
513
506
 
507
+ def watcher_wait_timeout
508
+ scheduler = Fiber.scheduler
509
+ return nil if scheduler&.respond_to?(:fiber_interrupt)
510
+
511
+ WATCHER_POLL_INTERVAL
512
+ end
513
+
514
+ def wait_socket_readable(d, timeout)
515
+ loop do
516
+ return false unless d.running
517
+ return true if d.socket.wait_readable(timeout)
518
+ end
519
+ end
520
+
521
+ def wait_socket_writable(d, timeout)
522
+ loop do
523
+ return false unless d.running
524
+ return true if d.socket.wait_writable(timeout)
525
+ end
526
+ end
527
+
514
528
  def reader_watcher(d)
529
+ timeout = watcher_wait_timeout
530
+
515
531
  while d.running
516
- d.socket.wait_readable
517
- break unless d.running
532
+ break unless wait_socket_readable(d, timeout)
518
533
 
519
534
  d.events.enqueue(:readable)
520
535
  command = d.reader_rearm.dequeue
@@ -527,11 +542,13 @@ module PgPipeline
527
542
  end
528
543
 
529
544
  def writer_watcher(d)
545
+ timeout = watcher_wait_timeout
546
+
530
547
  while d.running
531
548
  command = d.writer_commands.dequeue
532
549
  break unless command == :wait_writable && d.running
550
+ break unless wait_socket_writable(d, timeout)
533
551
 
534
- d.socket.wait_writable
535
552
  d.events.enqueue(:writable) if d.running
536
553
  end
537
554
  rescue StandardError => e
@@ -616,8 +633,8 @@ module PgPipeline
616
633
  warn(
617
634
  "pg_pipeline: task #{task.name.inspect} did not exit within " \
618
635
  "#{timeout}s and has been leaked (total #{d.leaked_watchers}). " \
619
- "Closing the duplexed socket_io / connection did not release the fiber " \
620
- "(often parked in wait_readable/wait_writable)."
636
+ "Closing the socket and, on schedulers without #fiber_interrupt, the " \
637
+ "#{WATCHER_POLL_INTERVAL}s watcher poll did not release the fiber in time."
621
638
  )
622
639
  end
623
640
 
@@ -8,6 +8,7 @@ module PgPipeline
8
8
  class ConnectionLostError < Error; end
9
9
  class NotDispatchedError < ConnectionLostError; end
10
10
  class IndeterminateResultError < ConnectionLostError; end
11
+ class IndeterminateCommitError < IndeterminateResultError; end
11
12
  class ShutdownError < Error; end
12
13
  class ProtocolError < Error; end
13
14
  class RecursiveCheckoutError < Error; end
@@ -7,19 +7,24 @@ module PgPipeline
7
7
  @waiters = []
8
8
  end
9
9
 
10
+ def waiting = @waiters.size
11
+
10
12
  def wait(timeout = nil)
13
+ wait_until(Runtime.deadline_for(timeout))
14
+ end
15
+
16
+ def wait_until(deadline = nil)
11
17
  Runtime.with_waiter(self, @waiters) do |waiter|
12
- Runtime.with_timeout(timeout) { Runtime.park(self, waiter) { false } }
13
- true
14
- rescue TimeoutError
15
- false
18
+ Runtime.park(self, waiter, deadline) { false }
16
19
  end
17
20
  end
18
21
 
19
22
  def signal
20
- waiter = @waiters.shift or return false
21
- Runtime.wake_dequeued(waiter, self)
22
- true
23
+ while (waiter = @waiters.shift)
24
+ return true if Runtime.wake_dequeued(waiter, self)
25
+ end
26
+
27
+ false
23
28
  end
24
29
 
25
30
  def signal_all
@@ -23,12 +23,12 @@ module PgPipeline
23
23
  end
24
24
 
25
25
  def release
26
- if (node = @waiting.shift)
26
+ while (node = @waiting.shift)
27
27
  node.grant!
28
- node.resume
29
- else
30
- @available += 1
28
+ return @available if node.resume
31
29
  end
30
+
31
+ @available += 1
32
32
  @available
33
33
  end
34
34
 
@@ -126,9 +126,10 @@ module PgPipeline
126
126
 
127
127
  def resume
128
128
  fiber = @fiber
129
- return unless fiber.alive?
129
+ return false unless fiber.alive?
130
130
 
131
131
  @scheduler.unblock(@blocker, fiber)
132
+ true
132
133
  end
133
134
  end
134
135
 
@@ -22,7 +22,10 @@ module PgPipeline
22
22
  end
23
23
 
24
24
  def wait(timeout = nil)
25
- Runtime.with_timeout(timeout) { join }
25
+ unless @done
26
+ deadline = Runtime.deadline_for(timeout)
27
+ raise TimeoutError, "task did not finish in time" unless join(deadline)
28
+ end
26
29
  raise @error if @error
27
30
 
28
31
  @result
@@ -94,8 +97,8 @@ module PgPipeline
94
97
 
95
98
  def release_blocker
96
99
  waiter = @blocker or return false
97
- Runtime.wake(waiter, waiter[:blocker])
98
- waiter[:fiber].alive?
100
+
101
+ Runtime.wake(waiter, waiter[:blocker]) && waiter[:fiber].alive?
99
102
  end
100
103
 
101
104
  def complete(result, error)
@@ -107,11 +110,11 @@ module PgPipeline
107
110
  wake_waiters
108
111
  end
109
112
 
110
- def join
111
- return if @done
113
+ def join(deadline)
114
+ return true if @done
112
115
 
113
116
  Runtime.with_waiter(self, @waiters) do |waiter|
114
- Runtime.park(self, waiter) { @done }
117
+ Runtime.park(self, waiter, deadline) { @done }
115
118
  end
116
119
  end
117
120
 
@@ -7,12 +7,25 @@ require_relative "errors"
7
7
  module PgPipeline
8
8
  module Runtime
9
9
  class Cancel < Exception; end
10
+ class Deadline < Exception; end
10
11
  class TimeoutError < Error; end
11
12
 
12
13
  module_function
13
14
 
14
15
  CURRENT_TASK_KEY = :pg_pipeline_current_task
15
16
  WAITER_KEY = :pg_pipeline_waiter
17
+ DEADLINE_MESSAGE = "execution expired"
18
+
19
+ MISSING_TIMEOUT_HOOK_WARNING = <<~MESSAGE
20
+ pg_pipeline: %s does not implement #timeout_after.
21
+
22
+ Falling back to stdlib Timeout, which uses Thread#raise and can deliver
23
+ the timeout to an unrelated fiber. Use a scheduler that implements
24
+ #timeout_after (async, itsi-scheduler) or avoid Runtime.with_timeout
25
+ on this host.
26
+ MESSAGE
27
+
28
+ @warned_schedulers = {}
16
29
 
17
30
  def spawn(name: nil, &block)
18
31
  Task.spawn(name: name, &block)
@@ -22,6 +35,10 @@ module PgPipeline
22
35
  Fiber.scheduler or raise Error, "operation requires an active Fiber scheduler"
23
36
  end
24
37
 
38
+ def native_timeouts?(target = Fiber.scheduler)
39
+ !target.nil? && target.respond_to?(:timeout_after)
40
+ end
41
+
25
42
  def current_task
26
43
  Thread.current[CURRENT_TASK_KEY]
27
44
  end
@@ -44,16 +61,20 @@ module PgPipeline
44
61
  waiter
45
62
  end
46
63
 
47
- def park(blocker, waiter)
64
+ def park(blocker, waiter, deadline = nil)
48
65
  scheduler = waiter[:scheduler]
49
66
  task = current_task
50
67
  task&.enter_block(waiter)
51
68
 
52
69
  until waiter[:ready] || yield
53
- scheduler.block(blocker, nil)
70
+ remaining = deadline ? (deadline - monotonic_now) : nil
71
+ return false if deadline && remaining <= 0
72
+
73
+ scheduler.block(blocker, remaining)
54
74
  task&.raise_if_cancelled!
55
75
  end
56
- nil
76
+ task&.raise_if_cancelled!
77
+ true
57
78
  ensure
58
79
  waiter[:blocker] = nil
59
80
  task&.exit_block
@@ -65,7 +86,10 @@ module PgPipeline
65
86
  waiters << waiter
66
87
  yield waiter
67
88
  ensure
68
- waiters.delete(waiter) if waiter[:queued]
89
+ if waiter[:queued]
90
+ waiters.delete(waiter)
91
+ waiter[:queued] = false
92
+ end
69
93
  end
70
94
 
71
95
  def wake_dequeued(waiter, blocker)
@@ -73,21 +97,59 @@ module PgPipeline
73
97
  wake(waiter, blocker)
74
98
  end
75
99
 
100
+ def wake(waiter, blocker)
101
+ return false if waiter[:ready]
102
+
103
+ waiter[:ready] = true
104
+ fiber = waiter[:fiber]
105
+ return false unless fiber.alive?
106
+
107
+ waiter[:scheduler].unblock(blocker, fiber)
108
+ true
109
+ end
110
+
111
+ def monotonic_now
112
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+
115
+ def deadline_for(timeout)
116
+ return nil if timeout.nil?
117
+
118
+ seconds = Float(timeout)
119
+ raise ArgumentError, "timeout must be non-negative and finite" unless seconds.finite? && seconds >= 0
120
+
121
+ monotonic_now + seconds
122
+ end
123
+
76
124
  def with_timeout(duration)
77
125
  return yield if duration.nil?
78
126
 
79
- timeout = Float(duration)
80
- raise ArgumentError, "timeout must be non-negative and finite" unless timeout.finite? && timeout >= 0
127
+ seconds = Float(duration)
128
+ raise ArgumentError, "timeout must be non-negative and finite" unless seconds.finite? && seconds >= 0
81
129
 
82
- ::Timeout.timeout(timeout, TimeoutError) { yield }
130
+ begin
131
+ arm_deadline(seconds) { yield }
132
+ rescue Deadline => e
133
+ raise TimeoutError, e.message
134
+ end
83
135
  end
84
136
 
85
- def wake(waiter, blocker)
86
- waiter[:ready] = true
87
- fiber = waiter[:fiber]
88
- return unless fiber.alive?
137
+ def arm_deadline(seconds, &block)
138
+ target = Fiber.scheduler
139
+ if native_timeouts?(target)
140
+ return target.timeout_after(seconds, Deadline, DEADLINE_MESSAGE, &block)
141
+ end
89
142
 
90
- waiter[:scheduler].unblock(blocker, fiber)
143
+ warn_missing_timeout_hook(target) if target
144
+ ::Timeout.timeout(seconds, Deadline, DEADLINE_MESSAGE, &block)
145
+ end
146
+
147
+ def warn_missing_timeout_hook(target)
148
+ key = target.class
149
+ return if @warned_schedulers[key]
150
+
151
+ @warned_schedulers[key] = true
152
+ warn(format(MISSING_TIMEOUT_HOOK_WARNING, key))
91
153
  end
92
154
  end
93
155
  end
@@ -18,7 +18,6 @@ module PgPipeline
18
18
  "select-into-temp" => /\binto\s+(?:(?:global|local)\s+)?temp(?:orary)?\b/i,
19
19
  "select-into-pg-temp" => /\binto\s+(?:table\s+)?pg_temp(?:_\d+)?\s*\./i
20
20
  }.freeze
21
-
22
21
  STRICT_FORBIDDEN = {
23
22
  "nextval" => /\bnextval\s*\(/i,
24
23
  "setval" => /\bsetval\s*\(/i,
@@ -28,9 +27,9 @@ module PgPipeline
28
27
  "session-advisory-unlock" => /\bpg_advisory_unlock(?:_shared|_all)?\s*\(/i,
29
28
  "pg_export_snapshot" => /\bpg_export_snapshot\s*\(/i
30
29
  }.freeze
31
-
32
30
  PATTERN_PREFILTER = /\binto\b/i
33
- NEEDS_MASK = /['"]|--|\/\*|\$[A-Za-z_0-9]*\$/
31
+ DOLLAR_QUOTE_TAG = /\A\$(?:(?:[A-Za-z_]|[^\x00-\x7F])(?:[A-Za-z0-9_]|[^\x00-\x7F])*)?\$/
32
+ NEEDS_MASK = /['"]|--|\/\*|\$(?:(?:[A-Za-z_]|[^\x00-\x7F])(?:[A-Za-z0-9_]|[^\x00-\x7F])*)?\$/
34
33
  LEADING_KEYWORD = /\A\s*([a-zA-Z_]+)/
35
34
  WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
36
35
 
@@ -84,7 +83,6 @@ module PgPipeline
84
83
  FORBIDDEN_PATTERNS.each do |name, pattern|
85
84
  return name if code.match?(pattern)
86
85
  end
87
-
88
86
  if mode == :strict
89
87
  STRICT_FORBIDDEN.each do |name, pattern|
90
88
  return "strict:#{name}" if code.match?(pattern)
@@ -111,7 +109,6 @@ module PgPipeline
111
109
  while index < size
112
110
  byte = source.getbyte(index)
113
111
  nxt = index + 1 < size ? source.getbyte(index + 1) : nil
114
-
115
112
  if block_depth.positive?
116
113
  if byte == 47 && nxt == 42
117
114
  block_depth += 1
@@ -159,7 +156,7 @@ module PgPipeline
159
156
 
160
157
  if byte == 36
161
158
  remainder = source.byteslice(index, size - index)
162
- tag = remainder.match(/\A\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)&.[](0)
159
+ tag = DOLLAR_QUOTE_TAG.match(remainder)&.[](0)
163
160
  if tag
164
161
  closing = source.index(tag, index + tag.bytesize)
165
162
  finish = closing ? closing + tag.bytesize : size
@@ -182,7 +179,6 @@ module PgPipeline
182
179
 
183
180
  while index < source.bytesize
184
181
  byte = source.getbyte(index)
185
-
186
182
  if escape_backslash && byte == 92
187
183
  output << " "
188
184
  index += 1
@@ -203,7 +199,6 @@ module PgPipeline
203
199
  index += 1
204
200
  end
205
201
  end
206
-
207
202
  index
208
203
  end
209
204
  private_class_method :mask_quoted
@@ -39,7 +39,7 @@ module PgPipeline
39
39
  tx.__send__(:open=, true)
40
40
  begin
41
41
  result = yield tx
42
- conn.exec("COMMIT")
42
+ commit(conn)
43
43
  tx.__send__(:open=, false)
44
44
  result
45
45
  rescue Exception
@@ -48,6 +48,28 @@ module PgPipeline
48
48
  end
49
49
  end
50
50
 
51
+ def commit(conn)
52
+ conn.exec("COMMIT")
53
+ rescue PG::Error => error
54
+ raise unless indeterminate_commit_failure?(conn, error)
55
+
56
+ raise IndeterminateCommitError.new(
57
+ "COMMIT acknowledgement was not received; the transaction may have committed " \
58
+ "and must not be retried blindly (#{error.class}: #{error.message})"
59
+ ), cause: error
60
+ end
61
+
62
+ def indeterminate_commit_failure?(conn, error)
63
+ return true if defined?(PG::ConnectionBad) && error.is_a?(PG::ConnectionBad)
64
+ return true if conn.finished?
65
+
66
+ conn.status != PG::CONNECTION_OK
67
+ rescue PG::Error
68
+ true
69
+ rescue NoMethodError
70
+ false
71
+ end
72
+
51
73
  def savepoint(tx, name)
52
74
  SessionOps.ensure_active!(tx)
53
75
  raise Error, "savepoint requires an open transaction" unless tx.open?
@@ -57,7 +79,6 @@ module PgPipeline
57
79
  tx.__send__(:savepoint_seq=, seq)
58
80
  point = name || "pgp_sp_#{seq}"
59
81
  ident = conn.quote_ident(point)
60
-
61
82
  conn.exec("SAVEPOINT #{ident}")
62
83
  begin
63
84
  result = yield tx
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgPipeline
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.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.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov