pg_pipeline 0.2.5 → 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: 9bc5e0b7e4d8a274bc84e16fc7205774f0c20a0d4b2ee2ed9482a676eb15b986
4
- data.tar.gz: fd0f6c75af1815f9574b2133caff20c092db8f45f4ee04085bf975f51be231e0
3
+ metadata.gz: 5ebb76f18598913316b77fdc0fd0ab4aa411fbd014cf1f3f10e427c1a5709967
4
+ data.tar.gz: cd51b3fc6b5f20f5896969e8eb3a4263d999f2544239606944194192f894e850
5
5
  SHA512:
6
- metadata.gz: 3a284e77a674fc31d6e1b0263ff32cd254814f8f535e84060227e30d5e7ffe284b104dc764dfb9cd9062dc4f56b5319b088d9649ff6213e60498775806930637
7
- data.tar.gz: fa814de3830232f0f9136d3f582917bd91c426fc540f1d4cc19ded4872eb9eed4d45f936738d35f34c9377beb6057fe9e4c204b413f1654aa31b3d431e2c3266
6
+ metadata.gz: 6c1581a9ecc7f45b44342c49c529d12694c5d962808ef4ba7a0ec9d71aac5ae46852c99c242ec1b897c8c1b591d9ab66c55a24eacf0a4e6637de068624f36fc3
7
+ data.tar.gz: 3272e19424de3eed0f6c186b2bf14ac830ef226f9ce47207fd3c71fd38f622557a6542630c44934794cf9c650106a2e5a7e66ec475501220a685551799609111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
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
+
3
47
  ## [0.2.5] - 2026-08-03
4
48
 
5
49
  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
@@ -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)
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pg"
4
- require "async"
5
- require "async/queue"
6
4
 
7
5
  require_relative "errors"
6
+ require_relative "runtime"
8
7
  require_relative "bounded_queue"
9
8
  require_relative "server_caps"
10
9
  require_relative "request"
@@ -21,7 +20,7 @@ module PgPipeline
21
20
  :owner_task, :reader_task, :writer_task
22
21
 
23
22
  attr_writer :readable_events, :results_read, :units_completed, :flush_calls,
24
- :flush_incomplete, :dispatches
23
+ :flush_incomplete, :dispatches, :leaked_watchers
25
24
 
26
25
  def readable_events = @readable_events || 0
27
26
  def results_read = @results_read || 0
@@ -29,6 +28,7 @@ module PgPipeline
29
28
  def flush_calls = @flush_calls || 0
30
29
  def flush_incomplete = @flush_incomplete || 0
31
30
  def dispatches = @dispatches || 0
31
+ def leaked_watchers = @leaked_watchers || 0
32
32
 
33
33
  def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT)
34
34
  @max_pending = DriverOps.positive_integer!(max_pending, :max_pending)
@@ -40,35 +40,19 @@ module PgPipeline
40
40
  DriverOps.warn_flush_coupling_once(@caps)
41
41
 
42
42
  @requests = BoundedQueue.new(@max_pending)
43
- @events = Async::Queue.new
44
- @reader_rearm = Async::Queue.new
45
- @writer_commands = Async::Queue.new
43
+ @events = Runtime::Queue.new
44
+ @reader_rearm = Runtime::Queue.new
45
+ @writer_commands = Runtime::Queue.new
46
+
47
+ @accepting = @running = @draining = @needs_flush = @writer_armed = @request_event_pending = false
48
+ @readable_events = @results_read = @units_completed = @flush_calls =
49
+ @flush_incomplete = @dispatches = @leaked_watchers = @submitting = 0
50
+ @socket = @owner_task = @reader_task = @writer_task = @dispatching = nil
46
51
 
47
52
  @inflight = []
48
- @dispatching = nil
49
- @submitting = 0
50
-
51
- @accepting = false
52
- @running = false
53
- @draining = false
54
- @needs_flush = false
55
- @writer_armed = false
56
- @request_event_pending = false
57
-
58
- @readable_events = 0
59
- @results_read = 0
60
- @units_completed = 0
61
- @flush_calls = 0
62
- @flush_incomplete = 0
63
- @dispatches = 0
64
-
65
- @socket = nil
66
- @owner_task = nil
67
- @reader_task = nil
68
- @writer_task = nil
69
- end
70
-
71
- def start(parent: Async::Task.current) = DriverOps.start(self, parent)
53
+ end
54
+
55
+ def start = DriverOps.start(self)
72
56
  def submit(request) = DriverOps.submit(self, request)
73
57
  def load = @requests.size + @inflight.size + @submitting + (@dispatching ? 1 : 0)
74
58
  def available? = @accepting && @running
@@ -91,7 +75,8 @@ module PgPipeline
91
75
  dispatches: dispatches,
92
76
  units_per_readable: DriverOps.ratio(units_completed, readable_events),
93
77
  results_per_readable: DriverOps.ratio(results_read, readable_events),
94
- flush_calls_per_unit: DriverOps.ratio(flush_calls, units_completed)
78
+ flush_calls_per_unit: DriverOps.ratio(flush_calls, units_completed),
79
+ leaked_watchers: leaked_watchers
95
80
  }
96
81
  end
97
82
 
@@ -99,21 +84,19 @@ module PgPipeline
99
84
  return true unless available?
100
85
 
101
86
  probe = Request.build("SELECT 1", nil)
102
- begin
103
- submit(probe)
104
- Async::Task.current.with_timeout(timeout) { probe.wait }
105
- true
106
- rescue Async::TimeoutError
107
- DriverOps.abort_timed_out_health_probe(self, probe)
108
- rescue QueryError, PipelineAbortedError
109
- true
110
- rescue ShutdownError, NotDispatchedError
111
- true
112
- rescue ConnectionLostError, PG::Error
113
- false
114
- ensure
115
- probe.cancel! unless probe.settled?
116
- end
87
+ submit(probe)
88
+ Runtime.with_timeout(timeout) { probe.wait }
89
+ true
90
+ rescue Runtime::TimeoutError
91
+ DriverOps.abort_timed_out_health_probe(self, probe)
92
+ rescue QueryError, PipelineAbortedError
93
+ true
94
+ rescue ShutdownError, NotDispatchedError
95
+ true
96
+ rescue ConnectionLostError, PG::Error
97
+ false
98
+ ensure
99
+ probe.cancel! if probe && !probe.settled?
117
100
  end
118
101
 
119
102
  def graceful_close = DriverOps.graceful_close(self)
@@ -124,6 +107,9 @@ module PgPipeline
124
107
  end
125
108
 
126
109
  module DriverOps
110
+ WATCHER_JOIN_TIMEOUT = 2.0
111
+ OWNER_JOIN_TIMEOUT = 5.0
112
+
127
113
  module_function
128
114
 
129
115
  def ratio(numerator, denominator)
@@ -155,8 +141,9 @@ module PgPipeline
155
141
  raise ArgumentError, "#{name} must be an integer >= 1"
156
142
  end
157
143
 
158
- def start(d, parent)
144
+ def start(d)
159
145
  raise Error, "driver already started" if d.running
146
+ raise Error, "driver start requires an active Fiber scheduler" unless Fiber.scheduler
160
147
 
161
148
  d.conn.setnonblocking(true)
162
149
  d.conn.enter_pipeline_mode
@@ -165,15 +152,15 @@ module PgPipeline
165
152
  d.accepting = true
166
153
  d.running = true
167
154
 
168
- d.reader_task = parent.async { reader_watcher(d) }
169
- d.writer_task = parent.async { writer_watcher(d) }
170
- d.owner_task = parent.async { owner_loop(d) }
155
+ d.reader_task = Runtime.spawn(name: :reader) { reader_watcher(d) }
156
+ d.writer_task = Runtime.spawn(name: :writer) { writer_watcher(d) }
157
+ d.owner_task = Runtime.spawn(name: :owner) { owner_loop(d) }
171
158
  d
172
159
  rescue Exception
173
160
  d.accepting = false
174
161
  d.running = false
175
- stop_watchers(d)
176
- safe_close_conn(d)
162
+ teardown_watchers(d)
163
+ join_owner(d)
177
164
  raise
178
165
  end
179
166
 
@@ -200,7 +187,7 @@ module PgPipeline
200
187
  d.accepting = false
201
188
  d.requests.close(ShutdownError.new("driver is closing"))
202
189
  d.events.enqueue(:close)
203
- d.owner_task.wait
190
+ join_owner(d)
204
191
  nil
205
192
  end
206
193
 
@@ -210,7 +197,7 @@ module PgPipeline
210
197
  d.accepting = false
211
198
  d.requests.close(not_dispatched_error(error))
212
199
  d.events.enqueue([:abort, error])
213
- d.owner_task.wait unless Async::Task.current.equal?(d.owner_task)
200
+ join_owner(d)
214
201
  nil
215
202
  end
216
203
 
@@ -232,7 +219,12 @@ module PgPipeline
232
219
  end
233
220
 
234
221
  def owner_loop(d)
235
- process_event(d, d.events.dequeue) while d.running
222
+ while d.running
223
+ event = d.events.dequeue
224
+ break if event.nil?
225
+
226
+ process_event(d, event)
227
+ end
236
228
  rescue StandardError => e
237
229
  fatal_close(d, ConnectionLostError.new("driver crashed: #{e.class}: #{e.message}"))
238
230
  ensure
@@ -275,6 +267,7 @@ module PgPipeline
275
267
 
276
268
  def notify_requests(d)
277
269
  return if d.request_event_pending
270
+ return unless d.running
278
271
 
279
272
  d.request_event_pending = true
280
273
  d.events.enqueue(:requests)
@@ -377,6 +370,7 @@ module PgPipeline
377
370
 
378
371
  def arm_writer(d)
379
372
  return if d.writer_armed
373
+ return unless d.running
380
374
 
381
375
  d.writer_armed = true
382
376
  d.writer_commands.enqueue(:wait_writable)
@@ -466,14 +460,11 @@ module PgPipeline
466
460
  d.accepting = false
467
461
  d.running = false
468
462
 
469
- begin
470
- d.conn.exit_pipeline_mode
471
- rescue PG::Error => e
472
- fail_all(d, ConnectionLostError.new("failed to exit pipeline mode: #{e.message}"))
473
- ensure
474
- stop_watchers(d)
475
- safe_close_conn(d)
476
- end
463
+ d.conn.exit_pipeline_mode
464
+ rescue PG::Error => e
465
+ fail_all(d, ConnectionLostError.new("failed to exit pipeline mode: #{e.message}"))
466
+ ensure
467
+ teardown_watchers(d)
477
468
  end
478
469
 
479
470
  def fatal_close(d, error)
@@ -483,8 +474,8 @@ module PgPipeline
483
474
  d.running = false
484
475
  d.requests.close(not_dispatched_error(error))
485
476
  fail_all(d, error)
486
- stop_watchers(d)
487
- safe_close_conn(d)
477
+
478
+ teardown_watchers(d)
488
479
  end
489
480
 
490
481
  def fail_all(d, error)
@@ -549,21 +540,87 @@ module PgPipeline
549
540
  end
550
541
  end
551
542
 
552
- def stop_watchers(d)
553
- reader = d.reader_task
554
- writer = d.writer_task
543
+ def teardown_watchers(d)
544
+ tasks = release_watchers(d)
545
+ close_wait_points(d)
546
+ begin
547
+ d.socket&.close
548
+ rescue StandardError
549
+ nil
550
+ end
551
+ stop_watchers(tasks)
552
+ join_watchers(d, tasks)
553
+ d.socket = nil
554
+ safe_close_conn(d)
555
+ nil
556
+ end
557
+
558
+ def release_watchers(d)
559
+ tasks = [d.reader_task, d.writer_task].compact
555
560
  d.reader_task = nil
556
561
  d.writer_task = nil
562
+ tasks
563
+ end
564
+
565
+ def close_wait_points(d)
566
+ [d.reader_rearm, d.writer_commands, d.events].each do |queue|
567
+ queue&.close
568
+ rescue StandardError
569
+ nil
570
+ end
571
+ end
572
+
573
+ def stop_watchers(tasks)
574
+ Array(tasks).each do |task|
575
+ task.stop
576
+ rescue Runtime::Cancel, StandardError
577
+ nil
578
+ end
579
+
580
+ nil
581
+ end
557
582
 
558
- [reader, writer].each do |task|
559
- task&.stop
560
- rescue Async::Cancel, StandardError
583
+ def join_watchers(d, tasks)
584
+ Array(tasks).each do |task|
585
+ task.wait(WATCHER_JOIN_TIMEOUT)
586
+ rescue Runtime::TimeoutError
587
+ d.leaked_watchers += 1
588
+ warn_leaked_task(d, task, WATCHER_JOIN_TIMEOUT)
589
+ rescue Runtime::Cancel, StandardError
561
590
  nil
562
591
  end
563
592
 
564
593
  nil
565
594
  end
566
595
 
596
+ def join_owner(d)
597
+ owner = d.owner_task
598
+ return if owner.nil?
599
+ return if Fiber.current.equal?(owner.fiber)
600
+
601
+ begin
602
+ owner.wait(OWNER_JOIN_TIMEOUT)
603
+ rescue Runtime::TimeoutError
604
+ d.leaked_watchers += 1
605
+ warn_leaked_task(d, owner, OWNER_JOIN_TIMEOUT)
606
+ rescue Runtime::Cancel, StandardError
607
+ nil
608
+ end
609
+
610
+ nil
611
+ end
612
+
613
+ def warn_leaked_task(d, task, timeout)
614
+ return if ENV["PG_PIPELINE_SILENCE_WARNINGS"]
615
+
616
+ warn(
617
+ "pg_pipeline: task #{task.name.inspect} did not exit within " \
618
+ "#{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)."
621
+ )
622
+ end
623
+
567
624
  def safe_close_conn(d)
568
625
  d.conn.close unless d.conn.finished?
569
626
  rescue StandardError