pg_pipeline 0.2.5 → 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: 9bc5e0b7e4d8a274bc84e16fc7205774f0c20a0d4b2ee2ed9482a676eb15b986
4
- data.tar.gz: fd0f6c75af1815f9574b2133caff20c092db8f45f4ee04085bf975f51be231e0
3
+ metadata.gz: ca8a6402d9ee49ae0393f519c135f4174248df64aa27332f0773e095eb3179c8
4
+ data.tar.gz: 3c01ed0c9be6950a3b4d264bc613e5fef94c56dc81cb8916c791902ffeac61b0
5
5
  SHA512:
6
- metadata.gz: 3a284e77a674fc31d6e1b0263ff32cd254814f8f535e84060227e30d5e7ffe284b104dc764dfb9cd9062dc4f56b5319b088d9649ff6213e60498775806930637
7
- data.tar.gz: fa814de3830232f0f9136d3f582917bd91c426fc540f1d4cc19ded4872eb9eed4d45f936738d35f34c9377beb6057fe9e4c204b413f1654aa31b3d431e2c3266
6
+ metadata.gz: dc55189c229e3958e40781e30577ac82c3645f70fa55c2bce894baf9e0a423caa67d3ba533f77d5fce627943b424e0c7de2cea5f99a2ea25b0903022f438b3df
7
+ data.tar.gz: 508c35a99f8a9f5f3fa3665b741adaf58f593b6cc6c95fbfb86b48ded121adb010d4203fce6d9063274e510171e9f401ed0295b8691f2211ce273395cd7dcb9d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,106 @@
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
+
60
+ ## [0.3.0] - 2026-08-07
61
+
62
+ Scheduler-agnostic control plane: the gem no longer depends on the `async` gem
63
+ at runtime. Any `Fiber::Scheduler` host works (Async::Scheduler, Itsi::Scheduler,
64
+ or another `Fiber.set_scheduler` implementation).
65
+
66
+ This unlocks hosts that were previously impossible. On our stand, Itsi with
67
+ 0.3.0 serves ~26% more req/s than the 0.2.5 Falcon baseline (29350 vs 23254,
68
+ 4 workers, `oha -z 60s -c 1000`) — a configuration 0.2.5 could not run at all,
69
+ since it required an Async reactor. Falcon throughput itself is unchanged
70
+ (23007 vs 23254, inside run-to-run spread): this is a portability change, and
71
+ the speedup comes from being free to pick the host.
72
+
73
+ ### Changed
74
+
75
+ - New `PgPipeline::Runtime` primitives (`Notification`, `Queue`, `Semaphore`,
76
+ `Task`, `spawn`, `with_timeout`, `Cancel`) built on `Fiber.scheduler`.
77
+ - Driver/pool background work uses `Runtime.spawn` instead of `parent.async`.
78
+ - Pinned-connection ownership keys on `Fiber.current` (not `Async::Task.current`).
79
+ - Watcher shutdown closes queues + connection, then joins tasks with a timeout.
80
+ - Timeouts go through `Timeout.timeout` (correct `timeout_after` arity), never a
81
+ direct one-arg `scheduler.timeout_after` call.
82
+ - Closed `Runtime::Queue#enqueue` is a no-op; supervisor sleep errors are recorded
83
+ and `stats` exposes `supervisor_alive`.
84
+ - `Client#start` / `Pool#start` / `ConnectionDriver#start` no longer take
85
+ `parent:` (no Async task-tree ownership; use `Runtime.spawn` on the active
86
+ scheduler). Call sites that passed `start(parent: task)` should use `#start`.
87
+ - Runtime dependency: only `pg`. `async` is a development dependency for the
88
+ existing Async-based test harness.
89
+
90
+ ### Migration
91
+
92
+ Hosts must install a Fiber scheduler before `Client#start`. Under Async:
93
+
94
+ ```ruby
95
+ require "async"
96
+ Sync { client.start; ... }
97
+ ```
98
+
99
+ Under Itsi, the server installs `Itsi::Scheduler` for you — just `client.start`.
100
+
101
+ Apps that previously relied on `pg_pipeline` pulling in `async` transitively
102
+ should add `gem "async"` themselves if they still use Async.
103
+
3
104
  ## [0.2.5] - 2026-08-03
4
105
 
5
106
  Control-plane cleanup on the multiplexed query path: fewer per-query allocations,
data/README.md CHANGED
@@ -24,13 +24,66 @@ At 10 ms RTT a naïve pool does ~100 queries/s per connection.
24
24
  A pipelined connection can do thousands — the wire stays full instead of sitting idle.
25
25
 
26
26
  `pg_pipeline` is the Ruby control plane that does exactly this: it multiplexes
27
- independent queries from many Async fibers onto a small pool of libpq connections,
27
+ independent queries from many fibers onto a small pool of libpq connections,
28
28
  routes FIFO results back to the right fiber, and keeps transactional/session work
29
29
  on separate pinned connections. All wire protocol work stays in libpq; zero C code here.
30
30
 
31
31
  The single-owner connection model, FIFO result ownership, and lifecycle approach
32
32
  are directly inspired by tokio-postgres.
33
33
 
34
+ ## Any Fiber scheduler, not just Async
35
+
36
+ Up to 0.2.x the control plane was built on the `async` gem: `Async::Task`,
37
+ `Async::Queue`, `Async::Semaphore`, and a task tree rooted in Async's reactor.
38
+ That made Falcon the only realistic host.
39
+
40
+ 0.3.0 removes that. The control plane is built on Ruby's `Fiber::Scheduler`
41
+ interface — `Fiber.schedule` plus the scheduler's `block` / `unblock` — and on
42
+ nothing else. The host installs whichever scheduler it likes; the gem never
43
+ installs one and never calls a scheduler hook directly. `async` is now a
44
+ development dependency only, and the sole runtime dependency is `pg`.
45
+
46
+ ```ruby
47
+ # Falcon / any Async host — unchanged, still works
48
+ Async do
49
+ client = PgPipeline::Client.open(ENV["DATABASE_URL"])
50
+ client.query("SELECT * FROM users WHERE id = $1", [id]).first
51
+ end
52
+
53
+ # Itsi, with its own scheduler — no Async anywhere
54
+ # Itsi.rb:
55
+ # fiber_scheduler "Itsi::Scheduler"
56
+ client = PgPipeline::Client.open(ENV["DATABASE_URL"])
57
+ client.query("SELECT * FROM users WHERE id = $1", [id]).first
58
+ ```
59
+
60
+ The call site does not change: `query` blocks the *fiber*, not the thread, so
61
+ application code reads synchronously with no `await` and no coloured functions.
62
+ The only hard requirement is that some scheduler is installed on the current
63
+ thread — under a web server running requests in `Fiber.schedule` that is free,
64
+ while a plain script or rake task must set one up itself.
65
+
66
+ ### What it buys
67
+
68
+ On our benchmark stand (4 workers, `oha -z 60s -c 1000`, single-row lookup by
69
+ primary key, local PostgreSQL 16):
70
+
71
+ | gem | server | scheduler | req/s |
72
+ |---|---|---|---:|
73
+ | 0.2.5 | Falcon | Async | 23254 |
74
+ | 0.3.0 | Falcon | Async | 23007 |
75
+ | 0.3.0 | Itsi | Async | 29206 |
76
+ | 0.3.0 | Itsi | Itsi::Scheduler | 29350 |
77
+
78
+ Falcon throughput is unchanged — this was a portability change, not a
79
+ Falcon optimisation. The gain comes from being *able* to move: an Itsi host is
80
+ roughly **26% faster than the 0.2.5 Falcon baseline**, and that configuration
81
+ simply could not run before, because 0.2.5 required an Async reactor.
82
+
83
+ Numbers from one stand on one machine; treat them as a direction, not a
84
+ guarantee. Your own ratio depends on payload size, RTT, and how much of the
85
+ request is spent outside the database.
86
+
34
87
  ## Installation
35
88
 
36
89
  ```ruby
@@ -212,8 +265,15 @@ If your workload mixes fast and slow queries, prefer one of:
212
265
  |---|---|---|
213
266
  | `NotDispatchedError` | query never reached the wire | ✅ yes |
214
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 |
215
269
  | `UnsafeMultiplexError` | session-mutating SQL on multiplexed path | — fix the call site |
216
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
+
217
277
  Cancelling a fiber does **not** send `CancelRequest` to PostgreSQL — the query may
218
278
  still execute. For mutations, do not retry blindly after a timeout.
219
279
 
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "async/notification"
4
-
5
3
  require_relative "errors"
4
+ require_relative "runtime"
6
5
 
7
6
  module PgPipeline
8
7
  class BoundedQueue
@@ -12,15 +11,14 @@ module PgPipeline
12
11
  rescue ArgumentError, TypeError
13
12
  raise ArgumentError, "limit must be an integer >= 1"
14
13
  else
15
- @items = []
16
- @consumers = []
17
- @producers = []
14
+ @items, @consumers, @producers = [], [], []
15
+
18
16
  @closed = false
19
17
  @close_error = nil
20
18
  end
21
19
 
22
20
  def enqueue(item)
23
- loop do
21
+ while true
24
22
  raise_close_error if @closed
25
23
 
26
24
  if @items.size < @limit
@@ -34,7 +32,7 @@ module PgPipeline
34
32
  end
35
33
 
36
34
  def dequeue
37
- loop do
35
+ while true
38
36
  unless @items.empty?
39
37
  item = @items.shift
40
38
  wake_one(@producers)
@@ -72,13 +70,13 @@ module PgPipeline
72
70
  private
73
71
 
74
72
  def wait_on(list)
75
- notification = Async::Notification.new
73
+ notification = Runtime::Notification.new
76
74
  list << notification
77
75
  completed = false
78
- begin
79
- notification.wait
80
- completed = true
81
- ensure
76
+ notification.wait
77
+ completed = true
78
+ ensure
79
+ if notification
82
80
  still_queued = list.delete(notification)
83
81
  wake_one(list) if !completed && still_queued.nil? && !@closed
84
82
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "async"
4
-
5
3
  require_relative "errors"
6
4
  require_relative "pool"
7
5
  require_relative "request"
@@ -15,18 +13,18 @@ module PgPipeline
15
13
  attr_reader :guard
16
14
 
17
15
  def initialize(connection_args = nil, guard: :default, **pool_opts)
16
+ @owner_thread, @scheduler = nil, nil
17
+
18
18
  @guard = SessionGuard.normalize_mode!(guard)
19
19
  @pool = Pool.new(connection_args, **pool_opts)
20
20
  @started = false
21
- @owner_thread = nil
22
- @scheduler = nil
23
21
  end
24
22
 
25
23
  def self.open(connection_args = nil, **opts, &block)
26
24
  ClientOps.open(connection_args, opts, &block)
27
25
  end
28
26
 
29
- def start(parent: Async::Task.current) = ClientOps.start(self, parent)
27
+ def start = ClientOps.start(self)
30
28
  def query(sql, params = RequestOps::EMPTY_PARAMS) = ClientOps.query(self, sql, params)
31
29
  def prepare(name, sql, param_types = nil) = ClientOps.prepare(self, name, sql, param_types)
32
30
  def stats = ClientOps.stats(self)
@@ -54,17 +52,16 @@ module PgPipeline
54
52
 
55
53
  def open(connection_args, opts)
56
54
  client = Client.new(connection_args, **opts).start
57
- begin
58
- yield client
59
- ensure
60
- client.close
61
- end
55
+ yield client
56
+ ensure
57
+ client.close if client
62
58
  end
63
59
 
64
- def start(client, parent)
60
+ def start(client)
65
61
  raise Error, "client already started" if started?(client)
62
+ raise Error, "client start requires an active Fiber scheduler" unless Fiber.scheduler
66
63
 
67
- pool(client).start(parent: parent)
64
+ pool(client).start
68
65
  client.__send__(:owner_thread=, Thread.current)
69
66
  client.__send__(:scheduler=, Fiber.scheduler)
70
67
  client.__send__(:started=, true)
@@ -98,11 +95,9 @@ module PgPipeline
98
95
 
99
96
  def wait_for_request
100
97
  request = yield
101
- begin
102
- request.wait
103
- ensure
104
- request.cancel! unless request.settled?
105
- end
98
+ request.wait
99
+ ensure
100
+ request.cancel! if request && !request.settled?
106
101
  end
107
102
 
108
103
  def submit_with_failover(client)