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 +4 -4
- data/CHANGELOG.md +101 -0
- data/README.md +61 -1
- data/lib/pg_pipeline/bounded_queue.rb +10 -12
- data/lib/pg_pipeline/client.rb +12 -17
- data/lib/pg_pipeline/connection_driver.rb +156 -82
- data/lib/pg_pipeline/errors.rb +1 -0
- data/lib/pg_pipeline/pool.rb +82 -48
- data/lib/pg_pipeline/request.rb +5 -7
- data/lib/pg_pipeline/runtime/notification.rb +32 -0
- data/lib/pg_pipeline/runtime/queue.rb +31 -0
- data/lib/pg_pipeline/runtime/semaphore.rb +138 -0
- data/lib/pg_pipeline/runtime/task.rb +124 -0
- data/lib/pg_pipeline/runtime.rb +98 -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
- data/lib/pg_pipeline.rb +1 -0
- metadata +26 -19
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
73
|
+
notification = Runtime::Notification.new
|
|
76
74
|
list << notification
|
|
77
75
|
completed = false
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
data/lib/pg_pipeline/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
client.close
|
|
61
|
-
end
|
|
55
|
+
yield client
|
|
56
|
+
ensure
|
|
57
|
+
client.close if client
|
|
62
58
|
end
|
|
63
59
|
|
|
64
|
-
def start(client
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|