pg_pipeline 0.2.4 → 0.3.0

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: e6537ec11ece29a487aa35449c264d2bae69089022a28c473f71523e52fd2acd
4
- data.tar.gz: 231b7285d0ddf23bb686836217ff35d1c89cc3872a5ac4ef20df28cea7e781bb
3
+ metadata.gz: 5ebb76f18598913316b77fdc0fd0ab4aa411fbd014cf1f3f10e427c1a5709967
4
+ data.tar.gz: cd51b3fc6b5f20f5896969e8eb3a4263d999f2544239606944194192f894e850
5
5
  SHA512:
6
- metadata.gz: 31ef9eeacef9208296c2df0d29f5a83e0a2ee8a2278715bd4b67d261fb1561a5b0e11771856b5dfb27d34a28cb21034e7b6cda5408757f6e13672a3db65c8d3d
7
- data.tar.gz: b686d8bd24fb976e8d9e6842b92db04988b1ae6f5981b297de57fc7d32b0add212d200f5b2906d5a7ea450b47bf46b9373a4404d5449ab24b702b189fb2a9131
6
+ metadata.gz: 6c1581a9ecc7f45b44342c49c529d12694c5d962808ef4ba7a0ec9d71aac5ae46852c99c242ec1b897c8c1b591d9ab66c55a24eacf0a4e6637de068624f36fc3
7
+ data.tar.gz: 3272e19424de3eed0f6c186b2bf14ac830ef226f9ce47207fd3c71fd38f622557a6542630c44934794cf9c650106a2e5a7e66ec475501220a685551799609111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-08-07
4
+
5
+ Scheduler-agnostic control plane: the gem no longer depends on the `async` gem
6
+ at runtime. Any `Fiber::Scheduler` host works (Async::Scheduler, Itsi::Scheduler,
7
+ or another `Fiber.set_scheduler` implementation).
8
+
9
+ This unlocks hosts that were previously impossible. On our stand, Itsi with
10
+ 0.3.0 serves ~26% more req/s than the 0.2.5 Falcon baseline (29350 vs 23254,
11
+ 4 workers, `oha -z 60s -c 1000`) — a configuration 0.2.5 could not run at all,
12
+ since it required an Async reactor. Falcon throughput itself is unchanged
13
+ (23007 vs 23254, inside run-to-run spread): this is a portability change, and
14
+ the speedup comes from being free to pick the host.
15
+
16
+ ### Changed
17
+
18
+ - New `PgPipeline::Runtime` primitives (`Notification`, `Queue`, `Semaphore`,
19
+ `Task`, `spawn`, `with_timeout`, `Cancel`) built on `Fiber.scheduler`.
20
+ - Driver/pool background work uses `Runtime.spawn` instead of `parent.async`.
21
+ - Pinned-connection ownership keys on `Fiber.current` (not `Async::Task.current`).
22
+ - Watcher shutdown closes queues + connection, then joins tasks with a timeout.
23
+ - Timeouts go through `Timeout.timeout` (correct `timeout_after` arity), never a
24
+ direct one-arg `scheduler.timeout_after` call.
25
+ - Closed `Runtime::Queue#enqueue` is a no-op; supervisor sleep errors are recorded
26
+ and `stats` exposes `supervisor_alive`.
27
+ - `Client#start` / `Pool#start` / `ConnectionDriver#start` no longer take
28
+ `parent:` (no Async task-tree ownership; use `Runtime.spawn` on the active
29
+ scheduler). Call sites that passed `start(parent: task)` should use `#start`.
30
+ - Runtime dependency: only `pg`. `async` is a development dependency for the
31
+ existing Async-based test harness.
32
+
33
+ ### Migration
34
+
35
+ Hosts must install a Fiber scheduler before `Client#start`. Under Async:
36
+
37
+ ```ruby
38
+ require "async"
39
+ Sync { client.start; ... }
40
+ ```
41
+
42
+ Under Itsi, the server installs `Itsi::Scheduler` for you — just `client.start`.
43
+
44
+ Apps that previously relied on `pg_pipeline` pulling in `async` transitively
45
+ should add `gem "async"` themselves if they still use Async.
46
+
47
+ ## [0.2.5] - 2026-08-03
48
+
49
+ Control-plane cleanup on the multiplexed query path: fewer per-query allocations,
50
+ a cheaper session-guard fast path, slightly tighter result drain, and driver
51
+ fill metrics. Wire behaviour and the public API are unchanged.
52
+
53
+ ### Performance
54
+
55
+ - `SessionGuard` skips comment/literal masking when the SQL has no quotes,
56
+ comments or dollar-quotes; skips the forbidden-pattern scan when the SQL has
57
+ neither `(` nor `into`; and uses a byte scan for multi-statement detection.
58
+ - `Request.build` / `PreparedQueryRequest.build` avoid keyword `Class#new` hash
59
+ allocation on the hot path.
60
+ - `RequestOps.snapshot_params` reuses a shared frozen empty array and skips
61
+ copying frozen immutable param arrays.
62
+ - Default query params use that empty frozen array instead of allocating `[]`
63
+ per call (`Client`, `Session`, `PreparedStatement`).
64
+ - `PoolOps.select_driver_into` writes the round-robin cursor into a pool-owned
65
+ slot instead of allocating a `[driver, cursor]` pair per selection.
66
+ - `drain_results` matches hot statuses (`TUPLES_OK`, `PIPELINE_SYNC`) first and
67
+ accumulates `results_read` once per drain.
68
+
69
+ ### Observability
70
+
71
+ - Driver stats add `fast_sync`, `units_per_readable`, `results_per_readable`,
72
+ `flush_calls`, `flush_incomplete`, `dispatches`, and `flush_calls_per_unit`.
73
+ - One process-level warning on libpq older than 17 when Sync is coupled to flush
74
+ (`PG_PIPELINE_SILENCE_WARNINGS=1` to suppress).
75
+
76
+ ### Reliability
77
+
78
+ - Round-robin cursor is normalised with `rr % size` when past the pool length.
79
+ - Driver metric readers default to zero if ivars are unset.
80
+
81
+ ### Tests
82
+
83
+ - Guard fast-path / masking / cache eviction coverage.
84
+ - `Request.build` equivalence and prepared-query build coverage.
85
+ - Randomised driver-selection vs reference algorithm.
86
+ - Drain counter write-back when the loop raises.
87
+
3
88
  ## [0.2.4] - 2026-07-31
4
89
 
5
90
  Request-completion allocation patch. It replaces the general-purpose
data/DESIGN.md CHANGED
@@ -240,6 +240,46 @@ that the request-specific one-shot waiter deliberately does not provide.
240
240
  > suite against the exact 2.42.0 floor so `Scheduler#block`/`#unblock` behavior is
241
241
  > not merely assumed from a broad pessimistic dependency range.
242
242
 
243
+ ## 10a. Guard evaluation shape
244
+
245
+ `SessionGuard` validates the session-neutral SQL contract of §6 before a query is
246
+ allowed onto the shared path, so its cost lands on the reactor thread and its
247
+ stalls are visible in every other fiber's latency, not just the caller's.
248
+
249
+ Evaluation is therefore staged so that the expensive stage runs only when it can
250
+ change the answer:
251
+
252
+ 1. **Masking is skipped when it is provably an identity transform.** `code_only`
253
+ blanks string literals, quoted identifiers, comments and dollar-quoted bodies.
254
+ If none of `'`, `"`, `--`, `/*` or a `$tag$` delimiter appears in the SQL,
255
+ there is nothing to blank and the raw SQL is validated directly.
256
+ 2. **The forbidden-pattern scan is skipped when no pattern can match.** Every
257
+ pattern in `FORBIDDEN_PATTERNS` and `STRICT_FORBIDDEN` is anchored on either a
258
+ parenthesis or the word `into`, so SQL containing neither cannot match any of
259
+ them.
260
+ 3. **Verdicts are cached by SQL string**, and the cache evicts a single oldest
261
+ entry at its limit. Clearing the whole cache would turn one insertion into a
262
+ full recompute for every subsequent statement.
263
+
264
+ Stage 1 and stage 2 are correctness-preserving only as long as their premises
265
+ hold. Both are asserted in `spec/pg_pipeline/session_guard_fast_path_spec.rb`;
266
+ **adding a pattern that is not anchored on `(` or `into` requires updating the
267
+ prefilter in the same change.**
268
+
269
+ Masking is deliberately monotone in the wrong direction to be reordered: blanking
270
+ a comment can *create* a match (`nextval/* c */('s')` becomes `nextval ('s')`),
271
+ so the raw SQL can never be used as a negative filter for stage 2. Only the
272
+ absence of maskable syntax justifies skipping stage 1.
273
+
274
+ ## 10b. Fill observability
275
+
276
+ Pipelining pays off only when one reactor wakeup is amortised over several units.
277
+ `ConnectionDriver#stats` therefore reports `units_per_readable` (and related
278
+ counters) alongside the queue depths. A value near 1.0 means each query costs a
279
+ full socket wait and a scheduler round trip, which bounds throughput
280
+ independently of control-plane work; values well above 1.0 mean the pipeline is
281
+ filling. Read this number before attributing throughput to Ruby-side cost.
282
+
243
283
  ## 11. Version policy
244
284
 
245
285
  | Component | Minimum | Reason |
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
@@ -40,6 +93,14 @@ gem "pg_pipeline"
40
93
 
41
94
  Requires Ruby ≥ 3.3, `async ~> 2.42`, `pg ≥ 1.5`, and **libpq ≥ 14** at runtime.
42
95
 
96
+ **libpq ≥ 17 is recommended for maximum local throughput.** Below 17 there is no
97
+ `PQsendPipelineSync`, so `PQpipelineSync` couples Sync with a flush and the driver
98
+ cannot batch writes across a dispatch burst. Correctness is identical and RTT
99
+ amortisation — the main reason to pipeline — still works on libpq 14; only local
100
+ throughput is capped. The driver detects this at connect time and warns once per
101
+ process; set `PG_PIPELINE_SILENCE_WARNINGS=1` to suppress it, and check
102
+ `db.stats[:pipeline][:drivers].first[:fast_sync]` to see which path is active.
103
+
43
104
  ## Usage
44
105
 
45
106
  ### Multiplexed queries
@@ -155,6 +216,49 @@ db.stats
155
216
 
156
217
  `load` (pending + in-flight + submitting + dispatching) per driver is the routing/head-of-line signal.
157
218
 
219
+ Each driver also reports how well the pipeline is filling:
220
+
221
+ ```ruby
222
+ db.stats[:pipeline][:drivers].first
223
+ # => { ..., fast_sync: true,
224
+ # units_per_readable: 7.9, results_per_readable: 22.2,
225
+ # flush_calls_per_unit: 0.17, flush_incomplete: 0 }
226
+ ```
227
+
228
+ `units_per_readable` is the number to watch. Around `1.0` means every query pays a
229
+ full socket wait plus scheduler round trip and the pipeline is not filling — that is
230
+ the expected shape for a single fiber issuing one query at a time, and no amount of
231
+ Ruby-side optimisation will change it. Values well above `1.0` mean one reactor
232
+ wakeup is amortised over many queries, which is the regime pipelining is for. Check
233
+ this before attributing a throughput number to control-plane cost.
234
+
235
+ The numbers above are from a saturated HTTP benchmark (4 workers, 4 connections
236
+ each, ~20k requests/second against a local server): roughly eight queries per
237
+ reactor wakeup and one flush per six queries. `results_per_readable` runs at
238
+ three times `units_per_readable` because a completed unit yields three protocol
239
+ results -- the data, the query boundary and the Sync.
240
+
241
+ ## Head-of-line blocking
242
+
243
+ Results on a pipelined connection arrive in FIFO order, and PostgreSQL offers no
244
+ safe way to cancel one request out of a multiplexed pipeline (see `DESIGN.md` §7).
245
+ Two consequences worth designing around:
246
+
247
+ - **A slow query delays everything behind it on the same connection.** With the
248
+ default `max_in_flight: 64`, one multi-second query can hold up to 63 unrelated
249
+ queries on that driver. Driver selection balances by queue depth, not by expected
250
+ cost, so a slow query counts the same as a fast one.
251
+ - **A timeout is not a cancellation.** Wrapping `db.query` in `with_timeout` returns
252
+ control to your fiber, but the unit stays in the pipeline until the server answers
253
+ it, and the requests behind it still wait.
254
+
255
+ If your workload mixes fast and slow queries, prefer one of:
256
+
257
+ - lower `max_in_flight` so a stall cannot capture a deep queue;
258
+ - a second `Client` with its own connections for the slow queries;
259
+ - `Client#session` for anything long-running, which uses an exclusive pinned
260
+ connection and cannot block multiplexed traffic.
261
+
158
262
  ## Failure model
159
263
 
160
264
  | Error | Meaning | Retry safe? |
@@ -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,19 +13,19 @@ 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)
30
- def query(sql, params = []) = ClientOps.query(self, sql, params)
27
+ def start = ClientOps.start(self)
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)
33
31
 
@@ -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)
@@ -77,7 +74,7 @@ module PgPipeline
77
74
  SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
78
75
 
79
76
  wait_for_request do
80
- submit_with_failover(client) { Request.new(sql: sql, params: params) }
77
+ submit_with_failover(client) { Request.build(sql, params) }
81
78
  end
82
79
  end
83
80
 
@@ -98,15 +95,11 @@ 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
- # NotDispatchedError is safe to retry on a fresh Request by contract.
109
- # ShutdownError is retried only while the current Request is still pre-dispatch.
110
103
  def submit_with_failover(client)
111
104
  attempts = 0
112
105
  limit = [pool(client).pipeline_size, 1].max