pgbus 0.9.10 → 0.9.11

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -7
  3. data/README.md +34 -7
  4. data/Rakefile +22 -1
  5. data/app/models/pgbus/stream_queue.rb +87 -0
  6. data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
  7. data/lib/generators/pgbus/install_generator.rb +3 -3
  8. data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
  9. data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
  10. data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
  11. data/lib/generators/pgbus/update_generator.rb +6 -66
  12. data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
  13. data/lib/pgbus/client/read_after.rb +7 -6
  14. data/lib/pgbus/client/resizable_pool.rb +160 -0
  15. data/lib/pgbus/client.rb +200 -1
  16. data/lib/pgbus/configuration.rb +170 -9
  17. data/lib/pgbus/doctor.rb +49 -2
  18. data/lib/pgbus/engine.rb +30 -3
  19. data/lib/pgbus/generators/migration_detector.rb +7 -0
  20. data/lib/pgbus/process/dispatcher.rb +89 -19
  21. data/lib/pgbus/process/worker.rb +9 -0
  22. data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
  23. data/lib/pgbus/streams/pool_trigger.rb +124 -0
  24. data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
  25. data/lib/pgbus/streams.rb +2 -2
  26. data/lib/pgbus/version.rb +1 -1
  27. data/lib/pgbus/web/streamer/connection.rb +22 -6
  28. data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
  29. data/lib/pgbus/web/streamer/instance.rb +75 -5
  30. data/lib/pgbus/web/streamer/io_writer.rb +11 -5
  31. data/lib/pgbus/web/streamer/listener.rb +61 -1
  32. data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
  33. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
  34. metadata +9 -4
  35. data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
  36. data/lib/pgbus/config_loader.rb +0 -73
  37. data/lib/pgbus/generators/config_converter.rb +0 -345
@@ -1,50 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
- require "pgbus/generators/config_converter"
5
4
  require "pgbus/generators/migration_detector"
6
5
  require "pgbus/generators/database_target_detector"
7
6
 
8
7
  module Pgbus
9
8
  module Generators
10
- # Upgrade command with two independent jobs:
11
- #
12
- # 1. Config conversion: if config/pgbus.yml exists, convert it to
13
- # config/initializers/pgbus.rb using the modern Ruby DSL. Skip
14
- # silently if the initializer already exists or the YAML is
15
- # absent — safe to re-run.
16
- #
17
- # 2. Migration detection: inspect the live database and add any
18
- # missing pgbus migrations to db/migrate (or db/pgbus_migrate
19
- # if a separate database is configured). Invokes each matching
20
- # sub-generator in-process via Thor's invoke, so this mirrors
21
- # what the user would get running each generator by hand.
9
+ # Upgrade command: inspect the live database and add any missing pgbus
10
+ # migrations to db/migrate (or db/pgbus_migrate if a separate database is
11
+ # configured). Invokes each matching sub-generator in-process via Thor's
12
+ # invoke, so this mirrors what the user would get running each generator
13
+ # by hand.
22
14
  #
23
15
  # Usage:
24
16
  #
25
17
  # bin/rails generate pgbus:update
26
18
  # bin/rails generate pgbus:update --dry-run
27
- # bin/rails generate pgbus:update --skip-config
28
19
  # bin/rails generate pgbus:update --skip-migrations
29
20
  # bin/rails generate pgbus:update --database=pgbus
30
21
  # bin/rails generate pgbus:update --quiet
31
22
  class UpdateGenerator < Rails::Generators::Base
32
- desc "Upgrade pgbus: convert YAML config + add any missing migrations"
33
-
34
- class_option :source,
35
- type: :string,
36
- default: "config/pgbus.yml",
37
- desc: "Path to an existing YAML config to convert (default: config/pgbus.yml)"
38
-
39
- class_option :destination,
40
- type: :string,
41
- default: "config/initializers/pgbus.rb",
42
- desc: "Path to the generated initializer (default: config/initializers/pgbus.rb)"
43
-
44
- class_option :skip_config,
45
- type: :boolean,
46
- default: false,
47
- desc: "Skip the YAML → Ruby initializer conversion step"
23
+ desc "Upgrade pgbus: add any missing migrations"
48
24
 
49
25
  class_option :skip_migrations,
50
26
  type: :boolean,
@@ -67,30 +43,6 @@ module Pgbus
67
43
  default: false,
68
44
  desc: "Suppress verbose per-step output"
69
45
 
70
- def convert_yaml_if_present
71
- return if options[:skip_config]
72
-
73
- source_path = File.expand_path(options[:source], destination_root)
74
- destination_path = File.expand_path(options[:destination], destination_root)
75
-
76
- unless File.exist?(source_path)
77
- log "YAML config not found at #{options[:source]}; skipping config conversion."
78
- return
79
- end
80
-
81
- if File.exist?(destination_path)
82
- log "Initializer already exists at #{options[:destination]}; skipping config conversion."
83
- return
84
- end
85
-
86
- ruby_source = load_and_convert(source_path)
87
- if options[:dry_run]
88
- log_change "[dry-run] would create #{options[:destination]}"
89
- else
90
- create_file destination_path, ruby_source
91
- end
92
- end
93
-
94
46
  def detect_and_install_missing_migrations
95
47
  return if options[:skip_migrations]
96
48
 
@@ -172,18 +124,6 @@ module Pgbus
172
124
 
173
125
  private
174
126
 
175
- # Wrap converter and YAML errors as Thor::Error so the generator
176
- # surfaces them with the standard "in red, no backtrace, exit 1"
177
- # behavior. Catches:
178
- # - ConfigConverter::Error (validation, missing file race)
179
- # - Psych::Exception (malformed YAML, disallowed types)
180
- # - Errno::ENOENT / Errno::EACCES (file disappeared / not readable)
181
- def load_and_convert(source_path)
182
- ConfigConverter.from_yaml(source_path)
183
- rescue ConfigConverter::Error, Psych::Exception, Errno::ENOENT, Errno::EACCES => e
184
- raise Thor::Error, "Failed to convert #{options[:source]}: #{e.message}"
185
- end
186
-
187
127
  def active_record_available?
188
128
  defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.respond_to?(:connection)
189
129
  end
@@ -22,7 +22,14 @@ module Pgbus
22
22
  full_name = config.queue_name(stream_name)
23
23
 
24
24
  with_stale_connection_retry do
25
- ensure_queue(stream_name)
25
+ # Create the BARE queue directly. ensure_queue would fan out through
26
+ # the priority strategy to _p0.._pN under priority_levels>1, leaving
27
+ # the bare queue — the one the streamer NOTIFYs on and read_after
28
+ # peeks — uncreated, and the enable_notify_if_needed below would then
29
+ # raise on the missing bare table. Streams never use priority
30
+ # sub-queues (issue #310).
31
+ ensure_pgmq_schema
32
+ ensure_single_queue(full_name)
26
33
 
27
34
  # PGMQ's default NOTIFY throttle is 250ms — meant to coalesce
28
35
  # high-frequency worker queue inserts. Streams are latency-
@@ -38,6 +45,8 @@ module Pgbus
38
45
  # requires a roundtrip and a brief ACCESS SHARE lock on the archive
39
46
  # table. Broadcast-per-after_commit loops can hit this 1000x/sec on
40
47
  # the same stream, so memoize per-process after the first success.
48
+ # The StreamQueue registration shares this memo — both are one-time
49
+ # per stream per process and must both survive a first-broadcast.
41
50
  return if @stream_indexes_created[stream_name]
42
51
 
43
52
  sanitized = QueueNameValidator.sanitize!(full_name)
@@ -52,6 +61,11 @@ module Pgbus
52
61
  end
53
62
  end
54
63
 
64
+ # Record the physical queue name so maintenance (stream-archive prune,
65
+ # orphan sweep, compact_archives) and wildcard workers can tell this
66
+ # queue apart from a job queue. No-ops on unmigrated installs.
67
+ Pgbus::StreamQueue.record!(full_name)
68
+
55
69
  @stream_indexes_created[stream_name] = true
56
70
  end
57
71
  end
@@ -18,7 +18,7 @@ module Pgbus
18
18
  sql = build_read_after_sql(sanitized)
19
19
 
20
20
  rows = synchronized do
21
- with_raw_connection do |conn|
21
+ with_streams_connection do |conn|
22
22
  conn.exec_params(sql, [after_id.to_i, limit.to_i]).to_a
23
23
  end
24
24
  end
@@ -34,7 +34,7 @@ module Pgbus
34
34
  sanitized = sanitized_queue(stream_name)
35
35
  sql = "SELECT COALESCE(MAX(msg_id), 0) AS max FROM pgmq.q_#{sanitized}"
36
36
  synchronized do
37
- with_raw_connection do |conn|
37
+ with_streams_connection do |conn|
38
38
  conn.exec(sql).first.fetch("max").to_i
39
39
  end
40
40
  end
@@ -53,7 +53,7 @@ module Pgbus
53
53
  ) AS least
54
54
  SQL
55
55
  synchronized do
56
- with_raw_connection do |conn|
56
+ with_streams_connection do |conn|
57
57
  value = conn.exec(sql).first.fetch("least")
58
58
  value&.to_i
59
59
  end
@@ -84,9 +84,10 @@ module Pgbus
84
84
  # See issues #101 and #104. The comparison is case-insensitive because
85
85
  # Postgres downcases unquoted identifiers in its error output, while
86
86
  # `sanitized` can contain uppercase characters for GlobalID-keyed streams
87
- # (e.g. `pgbus_stream_Z2lkOi8vY29zbW9zL1VzZXIvMQ` from
88
- # `pgbus_stream_from Current.user`). A case-sensitive substring match
89
- # would miss the downcased relation name and let the exception escape.
87
+ # (e.g. `pgbus_Z2lkOi8vY29zbW9zL1VzZXIvMQ` from
88
+ # `pgbus_stream_from Current.user` stream queues share the job prefix,
89
+ # see Configuration#queue_name). A case-sensitive substring match would
90
+ # miss the downcased relation name and let the exception escape.
90
91
  #
91
92
  # The regex uses `\b` word boundaries so `pgmq.q_<needle>` doesn't
92
93
  # accidentally match longer related identifiers like
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ class Client
7
+ # Owns the current streams PGMQ::Client behind a Concurrent::AtomicReference
8
+ # and resizes it by BUILDING A NEW pool, atomically swapping the reference,
9
+ # then bounded-draining + shutting down the old one (issue #323 spike).
10
+ #
11
+ # connection_pool 2.x cannot resize in place (@size is frozen at
12
+ # construction; only shutdown/reload/available exist), so growing REQUIRES a
13
+ # new PGMQ::Client + a reference swap.
14
+ #
15
+ # DRAIN CORRECTNESS — the load-bearing detail. We do NOT trust
16
+ # connection_pool's `available == size` as "drained": pgmq-ruby's
17
+ # #with_connection retries a lost connection (checkout → checkin → checkout
18
+ # again), so between the two checkouts the connection sits idle in the queue
19
+ # and `available` momentarily equals `size` while a produce/read is still
20
+ # mid-flight. Closing the pool in that window makes the retry's checkout raise
21
+ # PoolShuttingDownError → a genuinely LOST durable broadcast (the
22
+ # PgBouncer/failover mid-INSERT case). Instead each reader op is bracketed by
23
+ # an in-flight AtomicFixnum on the CAPTURED pool, and drain waits until THAT
24
+ # hits zero. This is immune to the internal retry and independent of the
25
+ # connection_pool version.
26
+ #
27
+ # DURABLE BROADCASTS are loss-safe by construction: #produce captures the
28
+ # Pool once, runs its whole retrying checkout→INSERT→COMMIT→checkin on that
29
+ # pool, then decrements. Both old and new pools connect to the same Postgres,
30
+ # so any INSERT commits; and close waits for the captured pool's inflight to
31
+ # reach zero, so a pool is never shut while a produce is in flight on it.
32
+ class ResizablePool
33
+ # The current pool: a PGMQ::Client plus its own in-flight checkout counter.
34
+ Pool = Data.define(:pgmq, :inflight)
35
+
36
+ SwapStats = Data.define(:swap_count, :last_drain_seconds, :last_conns_closed,
37
+ :last_from_size, :last_to_size, :last_drained)
38
+
39
+ DRAIN_POLL = 0.01 # 10ms
40
+
41
+ # `shared:` is accepted for symmetry with the Client's two connection paths
42
+ # but is not stored — the shared-AR alias guard lives in #close_current via
43
+ # the `job_pool:` identity check, and #swap is simply never called on the
44
+ # shared path (Client#resize_streams_pool short-circuits there).
45
+ def initialize(pgmq, shared:, drain_timeout:, logger:, clock: ::Process) # rubocop:disable Lint/UnusedMethodArgument
46
+ @ref = Concurrent::AtomicReference.new(new_pool(pgmq))
47
+ @drain_timeout = drain_timeout
48
+ @logger = logger
49
+ @clock = clock
50
+ @swap_mutex = Mutex.new # serializes swap AND close_current; reads never take it
51
+ @swap_count = 0
52
+ @last = nil
53
+ end
54
+
55
+ # Hot path: one volatile load, no lock.
56
+ def current = @ref.get
57
+
58
+ # Bracketed reader ops — inflight decrements only after the WHOLE retrying
59
+ # call returns (so drain can't close under an in-flight produce/read).
60
+ def produce(*, **)
61
+ pool = @ref.get
62
+ pool.inflight.increment
63
+ begin
64
+ pool.pgmq.produce(*, **)
65
+ ensure
66
+ pool.inflight.decrement
67
+ end
68
+ end
69
+
70
+ def with_connection(&)
71
+ pool = @ref.get
72
+ pool.inflight.increment
73
+ begin
74
+ pool.pgmq.with_connection(&)
75
+ ensure
76
+ pool.inflight.decrement
77
+ end
78
+ end
79
+
80
+ def stats = @ref.get.pgmq.stats
81
+
82
+ # Swap in a freshly-built PGMQ::Client, then drain + close the old Pool.
83
+ def swap(new_pgmq, from_size:, to_size:)
84
+ @swap_mutex.synchronize do
85
+ old = @ref.get
86
+ @ref.set(new_pool(new_pgmq)) # new checkouts now resolve to the new pool
87
+ drained, secs, closed = drain_and_close(old)
88
+ @swap_count += 1
89
+ @last = SwapStats.new(swap_count: @swap_count, last_drain_seconds: secs,
90
+ last_conns_closed: closed, last_from_size: from_size,
91
+ last_to_size: to_size, last_drained: drained)
92
+ end
93
+ end
94
+
95
+ # Teardown of the current pool (from Client#close), race-free vs swap.
96
+ # Skips the close when the current pool aliases the job pool (shared-AR path).
97
+ def close_current(job_pool:)
98
+ @swap_mutex.synchronize do
99
+ pool = @ref.get
100
+ pool.pgmq.close unless pool.pgmq.equal?(job_pool)
101
+ end
102
+ end
103
+
104
+ def stats_snapshot
105
+ @last || SwapStats.new(swap_count: 0, last_drain_seconds: 0.0, last_conns_closed: 0,
106
+ last_from_size: nil, last_to_size: nil, last_drained: nil)
107
+ end
108
+
109
+ private
110
+
111
+ def new_pool(pgmq)
112
+ Pool.new(pgmq: pgmq, inflight: Concurrent::AtomicFixnum.new(0))
113
+ end
114
+
115
+ # Bounded drain on the OLD pool's inflight counter, then shutdown. Mirrors
116
+ # OutboundPump#stop: fixed budget, log-and-abandon, NEVER Thread#kill /
117
+ # Timeout.timeout. Returns [drained?, wall_secs, conns_closed].
118
+ def drain_and_close(old)
119
+ t0 = mono
120
+ drained = wait_until_drained(old.inflight)
121
+ # Count connections about to be torn down BEFORE close (after close it's ~0).
122
+ conns = conns_open(old.pgmq)
123
+ unless drained
124
+ @logger.warn do
125
+ "[Pgbus::ResizablePool] streams pool did not fully drain in " \
126
+ "#{@drain_timeout}s (inflight=#{old.inflight.value}); shutting down anyway — " \
127
+ "in-flight connections self-close on checkin"
128
+ end
129
+ end
130
+ old.pgmq.close
131
+ [drained, mono - t0, conns]
132
+ rescue StandardError => e
133
+ @logger.error { "[Pgbus::ResizablePool] old pool teardown failed: #{e.class}: #{e.message}" }
134
+ [false, mono - t0, 0]
135
+ end
136
+
137
+ def wait_until_drained(inflight)
138
+ deadline = mono + @drain_timeout
139
+ loop do
140
+ return true if inflight.value <= 0
141
+ return false if mono >= deadline
142
+
143
+ sleep(DRAIN_POLL) # NOT Timeout.timeout — Pgbus/NoRubyTimeout cop
144
+ end
145
+ end
146
+
147
+ # size - available = connections currently open (created & not idle-in-queue).
148
+ def conns_open(pgmq)
149
+ stats = pgmq.stats
150
+ [stats[:size] - stats[:available], 0].max
151
+ rescue StandardError
152
+ 0
153
+ end
154
+
155
+ # Qualify ::Process — Pgbus::Process (the worker/supervisor namespace)
156
+ # shadows the top-level constant here.
157
+ def mono = @clock.clock_gettime(::Process::CLOCK_MONOTONIC)
158
+ end
159
+ end
160
+ end
data/lib/pgbus/client.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "client/read_after"
7
7
  require_relative "client/ensure_stream_queue"
8
8
  require_relative "client/notify_stream"
9
9
  require_relative "client/connection_health"
10
+ require_relative "client/resizable_pool"
10
11
 
11
12
  module Pgbus
12
13
  class Client
@@ -59,6 +60,12 @@ module Pgbus
59
60
  # operations through a mutex.
60
61
  @pgmq = PGMQ::Client.new(conn_opts, pool_size: 1, pool_timeout: config.pool_timeout)
61
62
  @pgmq_mutex = Mutex.new
63
+ # No dedicated streams pool on this path: a second PGMQ::Client would
64
+ # still funnel through the same non-thread-safe AR raw_connection.
65
+ # Stream publish + replay share the single serialized connection —
66
+ # @streams_pgmq aliases @pgmq so the code paths are uniform, and
67
+ # #with_streams_connection falls back to with_raw_connection.
68
+ @streams_pgmq = @pgmq
62
69
  else
63
70
  # With a String URL or Hash params, pgmq-ruby creates its own dedicated
64
71
  # PG::Connection per pool slot — no shared state with ActiveRecord.
@@ -75,10 +82,44 @@ module Pgbus
75
82
  conn_opts = apply_connection_bounds(conn_opts)
76
83
  @pgmq = PGMQ::Client.new(conn_opts, pool_size: config.resolved_pool_size, pool_timeout: config.pool_timeout)
77
84
  @pgmq_mutex = nil
85
+ # Dedicated streams pool (issue #315): isolates the durable-stream
86
+ # publish INSERT (#send_stream_message) and the dispatcher's per-wake
87
+ # replay reads (#read_after) from the job pool, so a saturated worker
88
+ # pool can't delay a broadcast on pool checkout, and each wake reuses a
89
+ # persistent connection instead of a fresh PG.connect per call. Its own
90
+ # PGMQ::Client → its own connection_pool, sized independently of worker
91
+ # thread counts.
92
+ # Build the streams pool from streams_connection_options (which defaults
93
+ # to connection_options but honors streams_database_url/host/port for a
94
+ # separate/direct streams DB — issue #315), bounds-applied, and tagged
95
+ # with a per-process application_name so the autoscaler can count peer
96
+ # processes from pg_stat_activity (issue #323 P1/P2). Snapshot it so a
97
+ # hot-swap rebuilds a byte-identical pool at a new size.
98
+ @streams_conn_opts = tag_application_name(
99
+ apply_connection_bounds(config.streams_connection_options)
100
+ )
101
+ @streams_pgmq = PGMQ::Client.new(@streams_conn_opts, pool_size: config.streams_pool_size,
102
+ pool_timeout: config.streams_pool_timeout)
78
103
  end
79
104
 
105
+ # Wrap the streams pool so its live reference can be atomically hot-swapped
106
+ # to a new size under load without losing broadcasts or leaking connections
107
+ # (issue #323 spike; #resize_streams_pool). All streams-pool access goes
108
+ # through this — see #streams_pool. Default behavior with no swap is
109
+ # byte-identical (one AtomicReference read + a counter bump per op).
110
+ @streams_pool = ResizablePool.new(
111
+ @streams_pgmq,
112
+ shared: @shared_connection,
113
+ drain_timeout: config.streams_pool_timeout + 1.0,
114
+ logger: Pgbus.logger
115
+ )
116
+
80
117
  @queues_created = Concurrent::Map.new
81
118
  @stream_indexes_created = Concurrent::Map.new
119
+ # Guards the one-time build of the publisher autoscale trigger (issue #323).
120
+ # NOT @pgmq_mutex — that is nil on the dedicated path (the only path the
121
+ # trigger exists on), so it wouldn't serialize concurrent first-publishers.
122
+ @streams_trigger_mutex = Mutex.new
82
123
  @queue_strategy = QueueFactory.for(config)
83
124
  @schema_ensured = schema_ensured
84
125
  @connection_health = ConnectionHealth.new(
@@ -240,6 +281,38 @@ module Pgbus
240
281
  end
241
282
  end
242
283
 
284
+ # Durable stream broadcast. Unlike #send_message, this ALWAYS targets the
285
+ # bare queue (config.queue_name) and never the priority strategy's
286
+ # _p0.._pN sub-queues: streams are delivered by a non-consuming peek
287
+ # (read_after) on the bare queue, and the streamer LISTENs on the bare
288
+ # channel, so a broadcast routed to _p1 would never reach the browser
289
+ # (issue #310). ensure_stream_queue creates the bare queue + NOTIFY
290
+ # trigger + archive index, mirroring this bare-name write path.
291
+ def send_stream_message(stream_name, payload, headers: nil, delay: 0)
292
+ target = config.queue_name(stream_name)
293
+ # Capture the produced msg_id — it is this method's return value (callers
294
+ # like Stream#broadcast rely on it), so the autoscale trigger below must NOT
295
+ # become the last expression.
296
+ msg_id = Instrumentation.instrument("pgbus.client.send_message", queue: target) do
297
+ with_stale_connection_retry do
298
+ ensure_stream_queue(stream_name)
299
+ # Publish through the dedicated streams pool (issue #315) so a
300
+ # saturated job pool can't block a broadcast on pool checkout. On the
301
+ # shared-AR path @streams_pgmq aliases @pgmq and synchronized still
302
+ # serializes on the mutex.
303
+ synchronized do
304
+ streams_pool.produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay)
305
+ end
306
+ end
307
+ end
308
+ # Opportunistically autoscale the streams pool from the publish path so a
309
+ # pure-publisher process (no streamer) still grows under a broadcast storm
310
+ # (issue #323 follow-up). Throttled + fail-soft — never delays or breaks the
311
+ # broadcast; nil (a no-op) unless autoscale is on and the pool is dedicated.
312
+ streams_pool_trigger&.maybe_check
313
+ msg_id
314
+ end
315
+
243
316
  def send_batch(queue_name, payloads, headers: nil, delay: 0)
244
317
  full_name = config.queue_name(queue_name)
245
318
  serialized, serialized_headers = serialize_batch(payloads, headers)
@@ -446,6 +519,16 @@ module Pgbus
446
519
  {}
447
520
  end
448
521
 
522
+ # Same shape as #pool_stats but for the dedicated streams pool (issue #315).
523
+ # On the shared-AR path @streams_pgmq aliases @pgmq, so this reports the job
524
+ # pool's counters — accurate, since streams share that connection there.
525
+ def streams_pool_stats
526
+ streams_pool.stats.merge(pool_timeout: config.streams_pool_timeout)
527
+ rescue StandardError => e
528
+ Pgbus.logger.debug { "[Pgbus::Client] streams_pool_stats unavailable: #{e.class}: #{e.message}" }
529
+ {}
530
+ end
531
+
449
532
  def list_queues
450
533
  with_stale_connection_retry do
451
534
  synchronized { @pgmq.list_queues }
@@ -642,7 +725,47 @@ module Pgbus
642
725
  end
643
726
 
644
727
  def close
645
- synchronized { @pgmq.close }
728
+ # Stop the publisher autoscale executor (if one was ever built) so its
729
+ # background thread doesn't leak (issue #323). Outside `synchronized` — it
730
+ # takes no pool lock and shutdown waits on a possibly-running check.
731
+ @streams_pool_trigger.shutdown if defined?(@streams_pool_trigger) && @streams_pool_trigger
732
+ synchronized do
733
+ @pgmq.close
734
+ # Close the CURRENT streams pool too (issue #315) so its connections
735
+ # don't leak. close_current reads the live (possibly hot-swapped, #323)
736
+ # pool once under the swap mutex and skips it when it aliases @pgmq (the
737
+ # shared-AR path) so we don't double-close the same pool.
738
+ @streams_pool.close_current(job_pool: @pgmq)
739
+ end
740
+ end
741
+
742
+ # Opt-in hot-swap of the dedicated streams pool to a new size (issue #323
743
+ # spike). Builds a fresh PGMQ::Client at new_size with the SAME bounds-applied
744
+ # connection options, atomically swaps the live reference, then drains +
745
+ # closes the old pool (bounded, never Thread#kill). NOT called automatically —
746
+ # there is no control loop here; a caller triggers it explicitly.
747
+ #
748
+ # No-op on the shared-AR (Proc) path (the streams pool aliases the job pool,
749
+ # which is non-thread-safe and forced to pool_size 1 — swapping it would
750
+ # corrupt the job pool), and no-op when the size is unchanged.
751
+ #
752
+ # @return [ResizablePool::SwapStats] on a swap, or {swapped: false, reason:}
753
+ def resize_streams_pool(new_size)
754
+ raise ArgumentError, "new_size must be a positive integer" unless new_size.is_a?(Integer) && new_size.positive?
755
+ return { swapped: false, reason: :shared_connection } if @shared_connection
756
+ return { swapped: false, reason: :unchanged } if streams_pool.stats[:size] == new_size
757
+
758
+ from_size = streams_pool.stats[:size]
759
+ new_pgmq = PGMQ::Client.new(
760
+ @streams_conn_opts, pool_size: new_size, pool_timeout: config.streams_pool_timeout
761
+ )
762
+ @streams_pool.swap(new_pgmq, from_size: from_size, to_size: new_size)
763
+ end
764
+
765
+ # Accumulated streams-pool swap telemetry (issue #323) — for the bench and a
766
+ # future control loop. Zero-valued before any swap.
767
+ def streams_swap_stats
768
+ @streams_pool.stats_snapshot
646
769
  end
647
770
 
648
771
  private
@@ -776,6 +899,22 @@ module Pgbus
776
899
  conn&.close if owned
777
900
  end
778
901
 
902
+ # Yields a PG connection from the dedicated streams pool for the streamer's
903
+ # replay reads (read_after / stream_current_msg_id / stream_oldest_msg_id).
904
+ # On the dedicated (String/Hash) path this checks out a persistent pooled
905
+ # connection — no fresh PG.connect per call (issue #315). On the shared-AR
906
+ # (Proc) path there is no separate pool (@streams_pgmq aliases @pgmq and
907
+ # points at the non-thread-safe AR raw_connection), so we fall back to
908
+ # with_raw_connection, which reuses that same shared connection. Callers
909
+ # already wrap this in `synchronized`, so the shared path stays serialized.
910
+ def with_streams_connection(&)
911
+ if @shared_connection
912
+ with_raw_connection(&)
913
+ else
914
+ streams_pool.with_connection(&)
915
+ end
916
+ end
917
+
779
918
  def ensure_single_queue(full_name)
780
919
  return if @queues_created[full_name]
781
920
 
@@ -871,6 +1010,40 @@ module Pgbus
871
1010
  end
872
1011
  end
873
1012
 
1013
+ # The ResizablePool wrapping the streams pool. All streams-pool reads
1014
+ # (produce / with_connection / stats) go through it so the underlying
1015
+ # PGMQ::Client can be atomically hot-swapped to a new size (issue #323).
1016
+ attr_reader :streams_pool
1017
+
1018
+ # Lazily-built publisher-side autoscale trigger (issue #323). nil (a no-op)
1019
+ # unless streams_pool_autoscale is on AND this is the dedicated-connection
1020
+ # path (resize is a no-op on the shared-AR path). Built once — on the first
1021
+ # publish, then reused. Runs its headroom query through the job pool (@pgmq),
1022
+ # so it never competes with the streams pool it resizes.
1023
+ #
1024
+ # Double-checked under @streams_trigger_mutex so concurrent first-publishers
1025
+ # (the integration spec fires 8) can't each build their own trigger — that
1026
+ # would give each thread a trigger with its own throttle, defeating the
1027
+ # once-per-interval throttle on the first window. Steady-state publishes hit
1028
+ # the fast `defined?` path with no lock.
1029
+ def streams_pool_trigger
1030
+ return @streams_pool_trigger if defined?(@streams_pool_trigger)
1031
+
1032
+ @streams_trigger_mutex.synchronize do
1033
+ return @streams_pool_trigger if defined?(@streams_pool_trigger)
1034
+
1035
+ @streams_pool_trigger =
1036
+ if config.streams_pool_autoscale && !@shared_connection
1037
+ autoscaler = Streams::PoolAutoscaler.new(client: self, config: config)
1038
+ Streams::PoolTrigger.new(
1039
+ autoscaler: autoscaler, job_pool: @pgmq,
1040
+ interval: config.streams_pool_autoscale_interval,
1041
+ application_name_prefix: config.streams_application_name
1042
+ )
1043
+ end
1044
+ end
1045
+ end
1046
+
874
1047
  # Substrings that indicate the pooled PG::Connection was already dead
875
1048
  # *before* pgmq-ruby tried to use it — typically killed by a connection
876
1049
  # pooler (PgBouncer server_idle_timeout / client_idle_timeout), an admin
@@ -1100,6 +1273,32 @@ module Pgbus
1100
1273
  # read_timeout is already failing — and keeps a single server-side mechanism.
1101
1274
  #
1102
1275
  # Returns conn_opts unchanged when read_timeout is nil (bounding disabled).
1276
+ # Stamp a per-process application_name on the streams-pool connection options
1277
+ # so the autoscaler can count peer processes via
1278
+ # `pg_stat_activity.application_name LIKE '<prefix>_%'` (issue #323 P1). The
1279
+ # suffix is the pid so DISTINCT application_name is an exact process count.
1280
+ # application_name is a cosmetic session GUC — appending it can't break the
1281
+ # connection. The Proc (shared-AR) path never reaches here.
1282
+ def tag_application_name(conn_opts)
1283
+ name = "#{config.streams_application_name}_#{::Process.pid}"
1284
+ case conn_opts
1285
+ when Hash
1286
+ conn_opts.merge(application_name: name)
1287
+ when String
1288
+ # Two libpq string forms (mirrors #append_connection_bounds): URI form
1289
+ # carries params as `?key=value&…` query pairs; key=value conninfo form
1290
+ # is space-separated. Appending wins over any earlier application_name.
1291
+ if conn_opts.start_with?("postgres://", "postgresql://")
1292
+ separator = conn_opts.include?("?") ? "&" : "?"
1293
+ "#{conn_opts}#{separator}application_name=#{name}"
1294
+ else
1295
+ "#{conn_opts} application_name=#{name}"
1296
+ end
1297
+ else
1298
+ conn_opts
1299
+ end
1300
+ end
1301
+
1103
1302
  def apply_connection_bounds(conn_opts)
1104
1303
  timeout = config.read_timeout
1105
1304
  return conn_opts unless timeout&.positive?