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 +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +7 -0
- data/lib/pg_pipeline/connection_driver.rb +30 -13
- data/lib/pg_pipeline/errors.rb +1 -0
- data/lib/pg_pipeline/session_guard.rb +3 -8
- data/lib/pg_pipeline/transaction.rb +23 -2
- data/lib/pg_pipeline/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca8a6402d9ee49ae0393f519c135f4174248df64aa27332f0773e095eb3179c8
|
|
4
|
+
data.tar.gz: 3c01ed0c9be6950a3b4d264bc613e5fef94c56dc81cb8916c791902ffeac61b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
620
|
-
"
|
|
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
|
|
data/lib/pg_pipeline/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
data/lib/pg_pipeline/version.rb
CHANGED