pg_pipeline 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ebb76f18598913316b77fdc0fd0ab4aa411fbd014cf1f3f10e427c1a5709967
4
- data.tar.gz: cd51b3fc6b5f20f5896969e8eb3a4263d999f2544239606944194192f894e850
3
+ metadata.gz: ca8a6402d9ee49ae0393f519c135f4174248df64aa27332f0773e095eb3179c8
4
+ data.tar.gz: 3c01ed0c9be6950a3b4d264bc613e5fef94c56dc81cb8916c791902ffeac61b0
5
5
  SHA512:
6
- metadata.gz: 6c1581a9ecc7f45b44342c49c529d12694c5d962808ef4ba7a0ec9d71aac5ae46852c99c242ec1b897c8c1b591d9ab66c55a24eacf0a4e6637de068624f36fc3
7
- data.tar.gz: 3272e19424de3eed0f6c186b2bf14ac830ef226f9ce47207fd3c71fd38f622557a6542630c44934794cf9c650106a2e5a7e66ec475501220a685551799609111
6
+ metadata.gz: dc55189c229e3958e40781e30577ac82c3645f70fa55c2bce894baf9e0a423caa67d3ba533f77d5fce627943b424e0c7de2cea5f99a2ea25b0903022f438b3df
7
+ data.tar.gz: 508c35a99f8a9f5f3fa3665b741adaf58f593b6cc6c95fbfb86b48ded121adb010d4203fce6d9063274e510171e9f401ed0295b8691f2211ce273395cd7dcb9d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-08-09
4
+
5
+ Reliability and correctness fixes on top of 0.3.0. No public API changes other
6
+ than the new error class below; no changes to wire behaviour.
7
+
8
+ ### Fixed
9
+
10
+ - `SessionGuard` dollar-quote masking now recognises tags containing non-ASCII
11
+ identifier bytes (e.g. `$тег$...$тег$`). Previously an unrecognised tag left
12
+ the quoted body unmasked, so its raw text was scanned by the forbidden-pattern
13
+ checks instead of being treated as an opaque literal.
14
+ - `Client#transaction` now distinguishes a lost `COMMIT` acknowledgement from an
15
+ ordinary server-side rejection. If `COMMIT` fails while the connection is
16
+ still healthy (`status == CONNECTION_OK`, not `finished?`), the original
17
+ `PG::Error` is raised unchanged -- the server gave a complete answer and the
18
+ transaction did not commit. If the connection itself is gone or broken
19
+ (`PG::ConnectionBad`, `finished?`, or a non-OK status), the outcome is
20
+ genuinely unknown, and this is now raised as the new
21
+ `PgPipeline::IndeterminateCommitError < IndeterminateResultError` instead of
22
+ a bare `PG::Error`, so callers can tell "definitely rolled back" apart from
23
+ "may have committed, do not retry blindly" without inspecting connection
24
+ internals themselves. See the updated "Failure model" table in the README.
25
+
26
+ ### Reverted
27
+
28
+ - The reader/writer watcher shutdown mechanism briefly introduced in this cycle
29
+ (self-pipe `Runtime::Wakeup` + `IO.select` on every socket wait) has been
30
+ removed before release. It fixed a real gap -- on schedulers with no way to
31
+ interrupt a fiber blocked in `wait_readable`/`wait_writable`, closing the
32
+ socket alone does not reliably wake it pre-Ruby-4.0 -- but at an unacceptable
33
+ cost: it moved the connection driver's hottest loop from `io_wait` (native,
34
+ zero extra threads) onto `IO.select`, and both reference schedulers implement
35
+ that hook expensively. `Async::Scheduler#io_select` spawns a new OS thread per
36
+ call; `Itsi::Scheduler#io_select` with more than one IO (our case: socket +
37
+ wakeup pipe) falls onto Itsi's bounded `blocking_operation_wait` worker pool
38
+ and holds a worker for the full duration of every idle wait, which can
39
+ exhaust that pool under a modest number of pipeline connections.
40
+ - Root-caused instead: `Async::Scheduler` and `Itsi::Scheduler` both already
41
+ implement `#fiber_interrupt` as an ordinary library method, independent of
42
+ Ruby core's own hook of the same name (Ruby >= 4.0). `Task#stop` already
43
+ tries `#fiber_interrupt` first, so on both of this gem's reference schedulers
44
+ a blocked watcher is woken immediately via `fiber.raise`, on the same
45
+ `wait_readable`/`wait_writable` fast path as 0.3.0 -- no self-pipe needed.
46
+ `ConnectionDriver` now only falls back to a bounded
47
+ `wait_readable(timeout)`/`wait_writable(timeout)` poll
48
+ (`DriverOps::WATCHER_POLL_INTERVAL`, 0.25s) when the active scheduler does
49
+ *not* respond to `#fiber_interrupt`, guaranteeing shutdown within one poll
50
+ interval instead of depending on socket-close propagation. Async and Itsi
51
+ never pay this poll; an unknown/minimal scheduler does, bounded and cheap.
52
+ - `teardown_watchers` also reverts to closing the socket/connection
53
+ unconditionally regardless of whether the watcher tasks joined in time. The
54
+ self-pipe version returned early when a watcher missed
55
+ `WATCHER_JOIN_TIMEOUT`, before closing the wakeup pipes, the socket, or the
56
+ `PG::Connection` -- turning a leaked *task* into a leaked live DB connection
57
+ and file descriptors. Resources are now always closed; only the task's own
58
+ fiber can still leak (tracked via `stats[:leaked_watchers]`, unchanged).
59
+
3
60
  ## [0.3.0] - 2026-08-07
4
61
 
5
62
  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
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov