pgbus 0.9.10 → 0.10.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -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 +261 -4
  16. data/lib/pgbus/configuration.rb +244 -11
  17. data/lib/pgbus/doctor.rb +73 -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/notify_listener.rb +18 -2
  22. data/lib/pgbus/process/worker.rb +19 -3
  23. data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
  24. data/lib/pgbus/streams/pool_trigger.rb +124 -0
  25. data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
  26. data/lib/pgbus/streams.rb +2 -2
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/streamer/connection.rb +22 -6
  29. data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
  30. data/lib/pgbus/web/streamer/instance.rb +75 -5
  31. data/lib/pgbus/web/streamer/io_writer.rb +11 -5
  32. data/lib/pgbus/web/streamer/listener.rb +61 -1
  33. data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
  35. metadata +9 -4
  36. data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
  37. data/lib/pgbus/config_loader.rb +0 -73
  38. data/lib/pgbus/generators/config_converter.rb +0 -345
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.
@@ -72,13 +79,49 @@ module Pgbus
72
79
  # Both raise clean PG errors — no Ruby Timeout, no Thread#raise. Only
73
80
  # safe on this dedicated-connection branch — never on the shared-AR Proc
74
81
  # path, where statement_timeout would leak into application queries.
75
- conn_opts = apply_connection_bounds(conn_opts)
82
+ conn_opts = wrap_session_gucs(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 = wrap_session_gucs(
99
+ tag_application_name(
100
+ apply_connection_bounds(config.streams_connection_options)
101
+ )
102
+ )
103
+ @streams_pgmq = PGMQ::Client.new(@streams_conn_opts, pool_size: config.streams_pool_size,
104
+ pool_timeout: config.streams_pool_timeout)
78
105
  end
79
106
 
107
+ # Wrap the streams pool so its live reference can be atomically hot-swapped
108
+ # to a new size under load without losing broadcasts or leaking connections
109
+ # (issue #323 spike; #resize_streams_pool). All streams-pool access goes
110
+ # through this — see #streams_pool. Default behavior with no swap is
111
+ # byte-identical (one AtomicReference read + a counter bump per op).
112
+ @streams_pool = ResizablePool.new(
113
+ @streams_pgmq,
114
+ shared: @shared_connection,
115
+ drain_timeout: config.streams_pool_timeout + 1.0,
116
+ logger: Pgbus.logger
117
+ )
118
+
80
119
  @queues_created = Concurrent::Map.new
81
120
  @stream_indexes_created = Concurrent::Map.new
121
+ # Guards the one-time build of the publisher autoscale trigger (issue #323).
122
+ # NOT @pgmq_mutex — that is nil on the dedicated path (the only path the
123
+ # trigger exists on), so it wouldn't serialize concurrent first-publishers.
124
+ @streams_trigger_mutex = Mutex.new
82
125
  @queue_strategy = QueueFactory.for(config)
83
126
  @schema_ensured = schema_ensured
84
127
  @connection_health = ConnectionHealth.new(
@@ -122,9 +165,22 @@ module Pgbus
122
165
  # carries the underlying error plus which config source was in use.
123
166
  def verify_connection!
124
167
  synchronized do
125
- @pgmq.with_connection { |conn| conn.exec("SELECT 1") }
168
+ @pgmq.with_connection do |conn|
169
+ conn.exec("SELECT 1")
170
+ # When require_primary is set, reject a connection that landed on a
171
+ # read-only replica at boot rather than letting a read/write-splitting
172
+ # pooler silently route pgmq's VOLATILE read/archive to a standby,
173
+ # where workers read nothing and jobs stop with a healthy heartbeat
174
+ # (issue #332). Off by default, so a single-primary deployment is
175
+ # unaffected.
176
+ Process::PrimaryValidator.validate_primary!(conn) if config.require_primary
177
+ end
126
178
  end
127
179
  true
180
+ rescue Process::ReplicaConnectionError => e
181
+ raise ConfigurationError,
182
+ "Database connection via #{connection_source} landed on a read-only replica " \
183
+ "(require_primary is set): #{e.message}"
128
184
  rescue PGMQ::Errors::ConnectionError, PG::Error => e
129
185
  raise ConfigurationError, "Database connection failed via #{connection_source}: #{e.message}"
130
186
  end
@@ -141,6 +197,17 @@ module Pgbus
141
197
  true
142
198
  end
143
199
 
200
+ # Whether the job connection currently lands on a read-only replica
201
+ # (pg_is_in_recovery() => t). Used by the doctor to warn about a
202
+ # read/write-splitting pooler that could route pgmq's VOLATILE read/archive
203
+ # to a standby, silently stalling job processing (issue #332). Raw PG error
204
+ # propagates so the caller can render the reason.
205
+ def in_recovery?
206
+ with_raw_connection do |conn|
207
+ conn.exec(Process::PrimaryValidator::RECOVERY_QUERY).getvalue(0, 0) == "t"
208
+ end
209
+ end
210
+
144
211
  # The logical queue names pgbus expects to exist based on the configuration
145
212
  # (default queue + worker capsules + recurring tasks). Public wrapper around
146
213
  # collect_configured_queues so the doctor can diff configured-vs-existing
@@ -240,6 +307,38 @@ module Pgbus
240
307
  end
241
308
  end
242
309
 
310
+ # Durable stream broadcast. Unlike #send_message, this ALWAYS targets the
311
+ # bare queue (config.queue_name) and never the priority strategy's
312
+ # _p0.._pN sub-queues: streams are delivered by a non-consuming peek
313
+ # (read_after) on the bare queue, and the streamer LISTENs on the bare
314
+ # channel, so a broadcast routed to _p1 would never reach the browser
315
+ # (issue #310). ensure_stream_queue creates the bare queue + NOTIFY
316
+ # trigger + archive index, mirroring this bare-name write path.
317
+ def send_stream_message(stream_name, payload, headers: nil, delay: 0)
318
+ target = config.queue_name(stream_name)
319
+ # Capture the produced msg_id — it is this method's return value (callers
320
+ # like Stream#broadcast rely on it), so the autoscale trigger below must NOT
321
+ # become the last expression.
322
+ msg_id = Instrumentation.instrument("pgbus.client.send_message", queue: target) do
323
+ with_stale_connection_retry do
324
+ ensure_stream_queue(stream_name)
325
+ # Publish through the dedicated streams pool (issue #315) so a
326
+ # saturated job pool can't block a broadcast on pool checkout. On the
327
+ # shared-AR path @streams_pgmq aliases @pgmq and synchronized still
328
+ # serializes on the mutex.
329
+ synchronized do
330
+ streams_pool.produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay)
331
+ end
332
+ end
333
+ end
334
+ # Opportunistically autoscale the streams pool from the publish path so a
335
+ # pure-publisher process (no streamer) still grows under a broadcast storm
336
+ # (issue #323 follow-up). Throttled + fail-soft — never delays or breaks the
337
+ # broadcast; nil (a no-op) unless autoscale is on and the pool is dedicated.
338
+ streams_pool_trigger&.maybe_check
339
+ msg_id
340
+ end
341
+
243
342
  def send_batch(queue_name, payloads, headers: nil, delay: 0)
244
343
  full_name = config.queue_name(queue_name)
245
344
  serialized, serialized_headers = serialize_batch(payloads, headers)
@@ -446,6 +545,16 @@ module Pgbus
446
545
  {}
447
546
  end
448
547
 
548
+ # Same shape as #pool_stats but for the dedicated streams pool (issue #315).
549
+ # On the shared-AR path @streams_pgmq aliases @pgmq, so this reports the job
550
+ # pool's counters — accurate, since streams share that connection there.
551
+ def streams_pool_stats
552
+ streams_pool.stats.merge(pool_timeout: config.streams_pool_timeout)
553
+ rescue StandardError => e
554
+ Pgbus.logger.debug { "[Pgbus::Client] streams_pool_stats unavailable: #{e.class}: #{e.message}" }
555
+ {}
556
+ end
557
+
449
558
  def list_queues
450
559
  with_stale_connection_retry do
451
560
  synchronized { @pgmq.list_queues }
@@ -642,7 +751,47 @@ module Pgbus
642
751
  end
643
752
 
644
753
  def close
645
- synchronized { @pgmq.close }
754
+ # Stop the publisher autoscale executor (if one was ever built) so its
755
+ # background thread doesn't leak (issue #323). Outside `synchronized` — it
756
+ # takes no pool lock and shutdown waits on a possibly-running check.
757
+ @streams_pool_trigger.shutdown if defined?(@streams_pool_trigger) && @streams_pool_trigger
758
+ synchronized do
759
+ @pgmq.close
760
+ # Close the CURRENT streams pool too (issue #315) so its connections
761
+ # don't leak. close_current reads the live (possibly hot-swapped, #323)
762
+ # pool once under the swap mutex and skips it when it aliases @pgmq (the
763
+ # shared-AR path) so we don't double-close the same pool.
764
+ @streams_pool.close_current(job_pool: @pgmq)
765
+ end
766
+ end
767
+
768
+ # Opt-in hot-swap of the dedicated streams pool to a new size (issue #323
769
+ # spike). Builds a fresh PGMQ::Client at new_size with the SAME bounds-applied
770
+ # connection options, atomically swaps the live reference, then drains +
771
+ # closes the old pool (bounded, never Thread#kill). NOT called automatically —
772
+ # there is no control loop here; a caller triggers it explicitly.
773
+ #
774
+ # No-op on the shared-AR (Proc) path (the streams pool aliases the job pool,
775
+ # which is non-thread-safe and forced to pool_size 1 — swapping it would
776
+ # corrupt the job pool), and no-op when the size is unchanged.
777
+ #
778
+ # @return [ResizablePool::SwapStats] on a swap, or {swapped: false, reason:}
779
+ def resize_streams_pool(new_size)
780
+ raise ArgumentError, "new_size must be a positive integer" unless new_size.is_a?(Integer) && new_size.positive?
781
+ return { swapped: false, reason: :shared_connection } if @shared_connection
782
+ return { swapped: false, reason: :unchanged } if streams_pool.stats[:size] == new_size
783
+
784
+ from_size = streams_pool.stats[:size]
785
+ new_pgmq = PGMQ::Client.new(
786
+ @streams_conn_opts, pool_size: new_size, pool_timeout: config.streams_pool_timeout
787
+ )
788
+ @streams_pool.swap(new_pgmq, from_size: from_size, to_size: new_size)
789
+ end
790
+
791
+ # Accumulated streams-pool swap telemetry (issue #323) — for the bench and a
792
+ # future control loop. Zero-valued before any swap.
793
+ def streams_swap_stats
794
+ @streams_pool.stats_snapshot
646
795
  end
647
796
 
648
797
  private
@@ -767,7 +916,14 @@ module Pgbus
767
916
  PG.connect(opts)
768
917
  when Hash
769
918
  owned = true
770
- PG.connect(**opts)
919
+ # :variables is a database.yml convention, not a libpq keyword —
920
+ # strip it before PG.connect and apply the GUCs via SET so this
921
+ # raw bootstrap/DDL connection matches the pooled connections
922
+ # (issue #332). Empty/absent variables is a plain connect.
923
+ variables = opts[:variables]
924
+ conn = PG.connect(**opts.except(:variables))
925
+ variables&.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
926
+ conn
771
927
  else
772
928
  raise ConfigurationError, "Cannot resolve raw PG connection from #{opts.class}"
773
929
  end
@@ -776,6 +932,22 @@ module Pgbus
776
932
  conn&.close if owned
777
933
  end
778
934
 
935
+ # Yields a PG connection from the dedicated streams pool for the streamer's
936
+ # replay reads (read_after / stream_current_msg_id / stream_oldest_msg_id).
937
+ # On the dedicated (String/Hash) path this checks out a persistent pooled
938
+ # connection — no fresh PG.connect per call (issue #315). On the shared-AR
939
+ # (Proc) path there is no separate pool (@streams_pgmq aliases @pgmq and
940
+ # points at the non-thread-safe AR raw_connection), so we fall back to
941
+ # with_raw_connection, which reuses that same shared connection. Callers
942
+ # already wrap this in `synchronized`, so the shared path stays serialized.
943
+ def with_streams_connection(&)
944
+ if @shared_connection
945
+ with_raw_connection(&)
946
+ else
947
+ streams_pool.with_connection(&)
948
+ end
949
+ end
950
+
779
951
  def ensure_single_queue(full_name)
780
952
  return if @queues_created[full_name]
781
953
 
@@ -871,6 +1043,40 @@ module Pgbus
871
1043
  end
872
1044
  end
873
1045
 
1046
+ # The ResizablePool wrapping the streams pool. All streams-pool reads
1047
+ # (produce / with_connection / stats) go through it so the underlying
1048
+ # PGMQ::Client can be atomically hot-swapped to a new size (issue #323).
1049
+ attr_reader :streams_pool
1050
+
1051
+ # Lazily-built publisher-side autoscale trigger (issue #323). nil (a no-op)
1052
+ # unless streams_pool_autoscale is on AND this is the dedicated-connection
1053
+ # path (resize is a no-op on the shared-AR path). Built once — on the first
1054
+ # publish, then reused. Runs its headroom query through the job pool (@pgmq),
1055
+ # so it never competes with the streams pool it resizes.
1056
+ #
1057
+ # Double-checked under @streams_trigger_mutex so concurrent first-publishers
1058
+ # (the integration spec fires 8) can't each build their own trigger — that
1059
+ # would give each thread a trigger with its own throttle, defeating the
1060
+ # once-per-interval throttle on the first window. Steady-state publishes hit
1061
+ # the fast `defined?` path with no lock.
1062
+ def streams_pool_trigger
1063
+ return @streams_pool_trigger if defined?(@streams_pool_trigger)
1064
+
1065
+ @streams_trigger_mutex.synchronize do
1066
+ return @streams_pool_trigger if defined?(@streams_pool_trigger)
1067
+
1068
+ @streams_pool_trigger =
1069
+ if config.streams_pool_autoscale && !@shared_connection
1070
+ autoscaler = Streams::PoolAutoscaler.new(client: self, config: config)
1071
+ Streams::PoolTrigger.new(
1072
+ autoscaler: autoscaler, job_pool: @pgmq,
1073
+ interval: config.streams_pool_autoscale_interval,
1074
+ application_name_prefix: config.streams_application_name
1075
+ )
1076
+ end
1077
+ end
1078
+ end
1079
+
874
1080
  # Substrings that indicate the pooled PG::Connection was already dead
875
1081
  # *before* pgmq-ruby tried to use it — typically killed by a connection
876
1082
  # pooler (PgBouncer server_idle_timeout / client_idle_timeout), an admin
@@ -1100,6 +1306,57 @@ module Pgbus
1100
1306
  # read_timeout is already failing — and keeps a single server-side mechanism.
1101
1307
  #
1102
1308
  # Returns conn_opts unchanged when read_timeout is nil (bounding disabled).
1309
+ # Stamp a per-process application_name on the streams-pool connection options
1310
+ # so the autoscaler can count peer processes via
1311
+ # `pg_stat_activity.application_name LIKE '<prefix>_%'` (issue #323 P1). The
1312
+ # suffix is the pid so DISTINCT application_name is an exact process count.
1313
+ # application_name is a cosmetic session GUC — appending it can't break the
1314
+ # connection. The Proc (shared-AR) path never reaches here.
1315
+ def tag_application_name(conn_opts)
1316
+ name = "#{config.streams_application_name}_#{::Process.pid}"
1317
+ case conn_opts
1318
+ when Hash
1319
+ conn_opts.merge(application_name: name)
1320
+ when String
1321
+ # Two libpq string forms (mirrors #append_connection_bounds): URI form
1322
+ # carries params as `?key=value&…` query pairs; key=value conninfo form
1323
+ # is space-separated. Appending wins over any earlier application_name.
1324
+ if conn_opts.start_with?("postgres://", "postgresql://")
1325
+ separator = conn_opts.include?("?") ? "&" : "?"
1326
+ "#{conn_opts}#{separator}application_name=#{name}"
1327
+ else
1328
+ "#{conn_opts} application_name=#{name}"
1329
+ end
1330
+ else
1331
+ conn_opts
1332
+ end
1333
+ end
1334
+
1335
+ # In :session GUC mode, a Hash conn_opts carrying database.yml `:variables`
1336
+ # must apply those GUCs via post-connect `SET` rather than the libpq
1337
+ # `options` STARTUP param (which a transaction-mode PgBouncer rejects). We
1338
+ # can't pass `:variables` to PG.connect (not a libpq keyword), so wrap the
1339
+ # opts in a fresh-connect factory Proc: pgmq-ruby natively accepts a callable
1340
+ # per pool slot (pgmq connection.rb), and it must return a UNIQUE
1341
+ # PG::Connection each call (pgmq guards against a shared object). Applies to
1342
+ # a Hash with `:variables` only — a String URL or no variables passes through
1343
+ # unchanged, as does :options mode (where forward_connection_variables
1344
+ # already baked the GUCs into `options`). See issue #332.
1345
+ def wrap_session_gucs(conn_opts)
1346
+ return conn_opts unless config.connection_guc_mode == :session
1347
+ return conn_opts unless conn_opts.is_a?(Hash)
1348
+
1349
+ variables = conn_opts[:variables]
1350
+ return conn_opts if variables.nil? || variables.empty?
1351
+
1352
+ pg_opts = conn_opts.except(:variables)
1353
+ lambda do
1354
+ conn = PG.connect(pg_opts)
1355
+ variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
1356
+ conn
1357
+ end
1358
+ end
1359
+
1103
1360
  def apply_connection_bounds(conn_opts)
1104
1361
  timeout = config.read_timeout
1105
1362
  return conn_opts unless timeout&.positive?