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
@@ -102,6 +102,27 @@ module Pgbus
102
102
  # Requires a matching entry in config/database.yml under the "pgbus" key.
103
103
  attr_accessor :connects_to
104
104
 
105
+ # Reject a job-pool connection that landed on a read-only replica
106
+ # (pg_is_in_recovery() => t). Default false — a correctly-configured
107
+ # single-primary deployment sees no change. Set true when a read/write
108
+ # splitting pooler (pgdog/pgcat) could route pgmq's VOLATILE read/archive
109
+ # to a replica, which otherwise makes workers silently read nothing.
110
+ # See issue #332.
111
+ attr_accessor :require_primary
112
+
113
+ # How pgbus forwards database.yml GUCs (`variables:` such as
114
+ # client_min_messages, and the read-timeout statement_timeout) onto its
115
+ # dedicated pgmq connections:
116
+ # :options (default) — bake them into the libpq `options` STARTUP param
117
+ # (-c key=value). Works everywhere EXCEPT a
118
+ # transaction-mode PgBouncer, which rejects the
119
+ # `options` startup param with a FATAL.
120
+ # :session — apply them via post-connect `SET` statements on a
121
+ # fresh connection instead, so a transaction-mode
122
+ # pooler accepts them. Dedicated-connection path only.
123
+ # See issue #332.
124
+ attr_reader :connection_guc_mode
125
+
105
126
  # Zombie message detection — logs a warning when a message is redelivered
106
127
  # (read_ct > 1) without any prior failure recorded in pgbus_failed_events.
107
128
  attr_accessor :zombie_detection
@@ -130,9 +151,14 @@ module Pgbus
130
151
  :streams_write_deadline_ms, :streams_falcon_streaming_body,
131
152
  :streams_stats_enabled, :streams_test_mode,
132
153
  :streams_orphan_sweep_interval, :streams_orphan_threshold,
133
- :streams_durable_patterns,
154
+ :streams_durable_patterns, :streams_broadcast_queue,
134
155
  :streams_presence_patterns, :streams_presence_member,
135
- :streams_host, :streams_port, :streams_database_url
156
+ :streams_host, :streams_port, :streams_database_url,
157
+ :streams_pool_size, :streams_pool_timeout,
158
+ :streams_fanout_write_deadline_ms, :streams_dispatch_queue_limit,
159
+ :streams_writer_threads, :streams_writer_buffer_limit,
160
+ :streams_pool_autoscale, :streams_pool_max,
161
+ :streams_pool_autoscale_interval, :streams_application_name
136
162
  attr_reader :streams_default_broadcast_mode # rubocop:disable Style/AccessorGrouping
137
163
 
138
164
  # NOTIFY-gated worker wakeups. When true, each Worker fork owns a
@@ -155,8 +181,8 @@ module Pgbus
155
181
  # a Pgbus::Metrics::Backend instance — a custom backend
156
182
  attr_accessor :metrics_backend, :statsd_host, :statsd_port
157
183
 
158
- # When true (default), Pgbus.configure and ConfigLoader.apply run
159
- # validate! after applying settings, so an invalid value fails loud at
184
+ # When true (default), Pgbus.configure runs validate! after the block
185
+ # yields, so an invalid value fails loud at
160
186
  # boot instead of dormant until a worker path consumes it. Set false to
161
187
  # opt out (exotic setups that intentionally hold a transiently-invalid
162
188
  # config); explicit validate! still works.
@@ -243,6 +269,8 @@ module Pgbus
243
269
  @stats_flush_interval = StatBuffer::DEFAULT_FLUSH_INTERVAL
244
270
 
245
271
  @connects_to = nil
272
+ @require_primary = false
273
+ @connection_guc_mode = :options
246
274
 
247
275
  @web_auth = nil
248
276
  @web_refresh_interval = 5000
@@ -263,6 +291,11 @@ module Pgbus
263
291
 
264
292
  @streams_enabled = true
265
293
  @streams_path = nil
294
+ # Retained for backward compatibility only — NO LONGER USED for queue
295
+ # naming or stream detection. Stream queues are named like job queues
296
+ # (`#{queue_prefix}_<name>`, see #queue_name) and are identified via the
297
+ # `pgbus_stream_queues` registry (Pgbus::StreamQueue), not by this prefix.
298
+ # Setting it has no effect. See issue #308.
266
299
  @streams_queue_prefix = "pgbus_stream"
267
300
  # Streamer-only connection overrides. The Streamer's Listener owns a
268
301
  # dedicated long-lived `wait_for_notify` PG connection that can't go
@@ -280,6 +313,42 @@ module Pgbus
280
313
  @streams_host = nil
281
314
  @streams_port = nil
282
315
  @streams_database_url = nil
316
+ # Dedicated DB connection pool for the streamer's publish + replay hot
317
+ # paths (Client#send_stream_message and read_after/stream_current_msg_id).
318
+ # Isolated from the job pool (`pool_size`) so a saturated worker pool
319
+ # can't delay broadcast INSERTs and the dispatcher's per-wake read_after
320
+ # reuses a persistent connection instead of a fresh PG.connect per call
321
+ # (issue #315). A small fixed default — streams fan out from one
322
+ # dispatcher thread per Puma worker, so a handful of connections is
323
+ # plenty; raise it only for very high broadcast concurrency. Ignored on
324
+ # the shared-ActiveRecord (Proc) connection path, where libpq isn't
325
+ # thread-safe and streams keep using the single serialized connection.
326
+ @streams_pool_size = 5
327
+ @streams_pool_timeout = 5
328
+ # Self-tuning streams-pool autoscaling (issue #323). Opt-in, default off.
329
+ # When true, a per-web-process control loop grows the dedicated streams
330
+ # pool into a FAIR SHARE of live Postgres connection headroom under
331
+ # saturation and shrinks it back to streams_pool_size (the baseline/floor)
332
+ # when idle — and emergency-shrinks immediately if the DB runs critically
333
+ # low on connections. No connection-count config: every threshold derives
334
+ # from live max_connections. streams_pool_max is an OPTIONAL hard ceiling
335
+ # (nil = the dynamic fair share is the effective cap).
336
+ @streams_pool_autoscale = false
337
+ @streams_pool_max = nil
338
+ # Slow maintenance cadence (seconds) — the autoscaler runs as a periodic
339
+ # check on the streamer's idle LISTEN connection (pghero-style), not a busy
340
+ # loop. 5 minutes: the interval itself debounces, so there is no per-sample
341
+ # hysteresis; a sustained burst converges over a few checks.
342
+ @streams_pool_autoscale_interval = 300.0
343
+ # Distinctive application_name prefix stamped on streams-pool connections
344
+ # so the autoscaler can count peer processes (DISTINCT application_name
345
+ # LIKE '<prefix>_%') from pg_stat_activity. Per-process suffix is appended
346
+ # at build time. Namespace it per deployment if several pgbus fleets share
347
+ # one database. NOTE: a transaction-mode PgBouncer strips application_name;
348
+ # peer inference then degrades to "assume alone" (the safety margin +
349
+ # emergency-shrink cushion this) — connect the streamer DIRECTLY for
350
+ # accurate autoscaling.
351
+ @streams_application_name = "pgbus_streams"
283
352
  @streams_signed_name_secret = nil
284
353
  @streams_default_retention = 5 * 60 # 5 minutes
285
354
  @streams_retention = {}
@@ -296,6 +365,49 @@ module Pgbus
296
365
  # PG keepalive interval.
297
366
  @streams_listen_health_check_ms = 250
298
367
  @streams_write_deadline_ms = 5_000
368
+ # Deadline (ms) for a socket write made INSIDE the dispatcher's
369
+ # serial fanout loop (handle_durable_wake / handle_ephemeral_wake).
370
+ # Kept far below streams_write_deadline_ms (5s) because these writes
371
+ # run one after another on the single dispatcher thread: K
372
+ # slow-but-not-yet-dead clients stack the stall, so the whole-thread
373
+ # worst case is ~K * this_value before each is marked dead
374
+ # (issue #315 item 3). A slow-but-alive client that can't absorb a
375
+ # fanout frame in this window is marked dead and reconnects — its
376
+ # EventSource Last-Event-ID then replays the missed frames from the
377
+ # durable archive (or triggers a fresh re-render for ephemeral
378
+ # streams, which have no archive). Connect-replay writes keep the
379
+ # full streams_write_deadline_ms. Set this equal to
380
+ # streams_write_deadline_ms to restore the pre-#315 timing.
381
+ @streams_fanout_write_deadline_ms = 250
382
+ # Optional cap on dispatch-queue depth checked by the Listener before
383
+ # it pushes a durable WakeMessage. 0 (default) = unbounded — today's
384
+ # behavior. A positive value makes the Listener DROP a durable wake
385
+ # when the queue is at/over the cap; this is safe because the next
386
+ # durable wake for that stream re-reads from the min cursor. It
387
+ # bounds DISTINCT-STREAM backlog only — a single hot stream's wakes
388
+ # coalesce (drain_wakes_for) so its depth stays low regardless.
389
+ # Ephemeral wakes (which carry the only copy of their HTML) and
390
+ # Connect/Disconnect messages are NEVER dropped (issue #315 item 3).
391
+ @streams_dispatch_queue_limit = 0
392
+ # Number of writer threads that flush durable-stream fanout socket writes
393
+ # OFF the single dispatcher thread (issue #321). 0 (default) = inline —
394
+ # byte-for-byte the pre-#321 behavior, no pump, no extra allocation. A
395
+ # positive value spawns N writer threads; each durable broadcast fanout
396
+ # write is handed to a writer (partitioned by connection.id.hash % N, so
397
+ # a connection's frames stay ordered and its per-io mutex is respected),
398
+ # freeing the dispatcher to service the next wake/connect. Fast clients
399
+ # then stop waiting behind slow ones. EPHEMERAL fanout and connect-replay
400
+ # writes always stay inline regardless of this value: ephemerals have no
401
+ # archive to replay, so they must not risk an async drop (see #321 B1).
402
+ @streams_writer_threads = 0
403
+ # Per-connection cap on the writer's outbound buffer when
404
+ # streams_writer_threads > 0. 0 (default) = unbounded. A positive value
405
+ # drops the OLDEST buffered durable frame for a connection whose writer
406
+ # can't keep up — safe because durable frames live in the a_<stream>
407
+ # archive and are re-read on reconnect via Last-Event-ID; it's a memory
408
+ # guard against a pathologically slow-but-alive client, not a delivery
409
+ # guarantee. Ignored when streams_writer_threads == 0 (issue #321).
410
+ @streams_writer_buffer_limit = 0
299
411
  # EXPERIMENTAL — exempt from the 1.0 stability promise.
300
412
  @streams_falcon_streaming_body = false
301
413
  # Opt-in: when true, the Dispatcher writes one row to
@@ -311,6 +423,14 @@ module Pgbus
311
423
  @streams_orphan_sweep_interval = 3600 # 1 hour
312
424
  @streams_orphan_threshold = 86_400 # 24 hours
313
425
  @streams_durable_patterns = []
426
+ # Dedicated queue for turbo-rails' async broadcast jobs
427
+ # (Turbo::Streams::ActionBroadcastJob and siblings). nil (default) leaves
428
+ # them on the default queue, where a `broadcasts_to`/`broadcasts_refreshes`
429
+ # render+broadcast can wait behind long-running jobs before the browser
430
+ # sees the update. Set to a queue name (e.g. "realtime") and back it with
431
+ # a dedicated worker capsule to isolate broadcast latency from job
432
+ # throughput. Applied at engine boot when turbo-rails is loaded. See #311.
433
+ @streams_broadcast_queue = nil
314
434
  # EXPERIMENTAL (both presence settings) — exempt from the 1.0 stability
315
435
  # promise. Streams matching these patterns get connection-driven presence:
316
436
  # auto-join on SSE connect, auto-leave on disconnect, touch on the
@@ -413,6 +533,18 @@ module Pgbus
413
533
  @group_mode = coerced
414
534
  end
415
535
 
536
+ VALID_CONNECTION_GUC_MODES = %i[options session].freeze
537
+
538
+ def connection_guc_mode=(mode)
539
+ mode = mode.to_sym
540
+ unless VALID_CONNECTION_GUC_MODES.include?(mode)
541
+ raise Pgbus::ConfigurationError,
542
+ "Invalid connection_guc_mode: #{mode}. Must be one of: #{VALID_CONNECTION_GUC_MODES.join(", ")}"
543
+ end
544
+
545
+ @connection_guc_mode = mode
546
+ end
547
+
416
548
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
417
549
 
418
550
  def pgmq_schema_mode=(mode)
@@ -497,6 +629,8 @@ module Pgbus
497
629
  raise Pgbus::ConfigurationError, "insights_default_minutes must be a positive integer"
498
630
  end
499
631
 
632
+ raise Pgbus::ConfigurationError, "require_primary must be true or false" unless [true, false].include?(require_primary)
633
+
500
634
  validate_streams!
501
635
  validate_metrics_backend!
502
636
 
@@ -528,8 +662,43 @@ module Pgbus
528
662
  raise Pgbus::ConfigurationError, "streams_write_deadline_ms must be a positive integer"
529
663
  end
530
664
 
665
+ unless streams_fanout_write_deadline_ms.is_a?(Integer) && streams_fanout_write_deadline_ms.positive?
666
+ raise Pgbus::ConfigurationError, "streams_fanout_write_deadline_ms must be a positive integer"
667
+ end
668
+
669
+ # >= 0, NOT .positive? — 0 is the valid "unbounded" sentinel.
670
+ unless streams_dispatch_queue_limit.is_a?(Integer) && streams_dispatch_queue_limit >= 0
671
+ raise Pgbus::ConfigurationError, "streams_dispatch_queue_limit must be a non-negative integer (0 = unbounded)"
672
+ end
673
+
674
+ # >= 0, NOT .positive? — 0 is the valid "inline" sentinel.
675
+ unless streams_writer_threads.is_a?(Integer) && streams_writer_threads >= 0
676
+ raise Pgbus::ConfigurationError, "streams_writer_threads must be a non-negative integer (0 = inline)"
677
+ end
678
+
679
+ # >= 0, NOT .positive? — 0 is the valid "unbounded" sentinel.
680
+ unless streams_writer_buffer_limit.is_a?(Integer) && streams_writer_buffer_limit >= 0
681
+ raise Pgbus::ConfigurationError, "streams_writer_buffer_limit must be a non-negative integer (0 = unbounded)"
682
+ end
683
+
684
+ unless streams_pool_size.is_a?(Integer) && streams_pool_size.positive?
685
+ raise Pgbus::ConfigurationError, "streams_pool_size must be a positive integer"
686
+ end
687
+
688
+ unless streams_pool_timeout.is_a?(Numeric) && streams_pool_timeout.positive?
689
+ raise Pgbus::ConfigurationError, "streams_pool_timeout must be a positive number"
690
+ end
691
+
692
+ validate_streams_autoscale!
693
+
531
694
  raise Pgbus::ConfigurationError, "streams_retention must be a Hash" unless streams_retention.is_a?(Hash)
532
695
 
696
+ unless streams_broadcast_queue.nil? ||
697
+ (streams_broadcast_queue.is_a?(String) && !streams_broadcast_queue.empty?)
698
+ raise Pgbus::ConfigurationError,
699
+ "streams_broadcast_queue must be a non-empty String (a queue name) or nil to disable"
700
+ end
701
+
533
702
  if streams_orphan_sweep_interval && !(streams_orphan_sweep_interval.is_a?(Numeric) && streams_orphan_sweep_interval.positive?)
534
703
  raise Pgbus::ConfigurationError, "streams_orphan_sweep_interval must be a positive number or nil to disable"
535
704
  end
@@ -554,6 +723,26 @@ module Pgbus
554
723
  raise Pgbus::ConfigurationError, "streams_orphan_threshold must be a positive number or nil to disable"
555
724
  end
556
725
 
726
+ def validate_streams_autoscale!
727
+ raise Pgbus::ConfigurationError, "streams_pool_autoscale must be true or false" unless [true, false].include?(streams_pool_autoscale)
728
+
729
+ # nil = no hard cap (dynamic fair-share is the ceiling). A positive value
730
+ # below the baseline is an inverted ceiling — fail loud at boot.
731
+ unless streams_pool_max.nil? ||
732
+ (streams_pool_max.is_a?(Integer) && streams_pool_max >= streams_pool_size)
733
+ raise Pgbus::ConfigurationError,
734
+ "streams_pool_max must be an Integer >= streams_pool_size " \
735
+ "(#{streams_pool_size}) or nil to leave growth uncapped"
736
+ end
737
+
738
+ unless streams_pool_autoscale_interval.is_a?(Numeric) && streams_pool_autoscale_interval.positive?
739
+ raise Pgbus::ConfigurationError, "streams_pool_autoscale_interval must be a positive number"
740
+ end
741
+
742
+ raise Pgbus::ConfigurationError, "streams_application_name must be a non-empty String" \
743
+ unless streams_application_name.is_a?(String) && !streams_application_name.empty?
744
+ end
745
+
557
746
  def validate_metrics_backend!
558
747
  value = metrics_backend
559
748
  return if value.nil?
@@ -661,9 +850,9 @@ module Pgbus
661
850
  end
662
851
  end
663
852
 
664
- # Event consumer configs arrive with symbol keys (Ruby DSL) or string keys
665
- # (YAML via ConfigLoader). Normalize once here so every read site uses
666
- # symbol access only. nil passes through (no consumers configured).
853
+ # Event consumer configs arrive with symbol keys (Ruby DSL) or, from an
854
+ # explicit Array literal, string keys. Normalize once here so every read
855
+ # site uses symbol access only. nil passes through (no consumers configured).
667
856
  def event_consumers=(value)
668
857
  @event_consumers =
669
858
  case value
@@ -854,6 +1043,15 @@ module Pgbus
854
1043
  # timeouts under load). Setting pool_size explicitly is still supported
855
1044
  # for advanced cases where you need a tighter or looser pool than the
856
1045
  # default formula provides.
1046
+ #
1047
+ # NOTE: this covers the JOB pool only. On the dedicated-connection path
1048
+ # (database_url / connection_params) every Client also opens a separate
1049
+ # streams pool of +streams_pool_size+ (default 5) for durable-stream
1050
+ # publish + replay (issue #315). Both pools are lazy, so an idle process
1051
+ # holds few real connections, but when budgeting Postgres/PgBouncer
1052
+ # max_connections, size for +resolved_pool_size + streams_pool_size+ per
1053
+ # process — every forked worker/dispatcher/scheduler/consumer builds its
1054
+ # own streams pool even if it rarely touches streams.
857
1055
  POOL_SIZE_WARN_THRESHOLD = 50
858
1056
 
859
1057
  # Connections needed per async worker: one for the reactor's serial
@@ -878,7 +1076,17 @@ module Pgbus
878
1076
  if database_url
879
1077
  database_url
880
1078
  elsif connection_params
881
- connection_params
1079
+ # An explicit connection_params Hash may carry a database.yml-style
1080
+ # `:variables` block; forward it the same way the AR-extracted path does
1081
+ # so client_min_messages etc. actually reach pgmq's connections and a
1082
+ # non-libpq :variables key never reaches PG.connect (issue #332). A
1083
+ # non-Hash connection_params (e.g. a Proc returning a raw connection)
1084
+ # passes through untouched.
1085
+ if connection_params.is_a?(Hash)
1086
+ forward_connection_variables(connection_params.except(:variables), connection_params[:variables])
1087
+ else
1088
+ connection_params
1089
+ end
882
1090
  elsif defined?(ActiveRecord::Base)
883
1091
  # Extract connection config from ActiveRecord so pgmq-ruby creates its
884
1092
  # own dedicated PG connections. Sharing AR's raw_connection via a Proc
@@ -1057,8 +1265,8 @@ module Pgbus
1057
1265
  end
1058
1266
 
1059
1267
  # Symbolize the top-level keys of a worker/consumer config entry so every
1060
- # downstream read site can use symbol access only. YAML (via ConfigLoader)
1061
- # yields string keys; the Ruby DSL and +capsule+ builder yield symbols.
1268
+ # downstream read site can use symbol access only. An explicit Array literal
1269
+ # may carry string keys; the Ruby DSL and +capsule+ builder yield symbols.
1062
1270
  # Entries are flat hashes with array/scalar values — no deep recursion.
1063
1271
  def normalize_entry(entry, group:)
1064
1272
  raise Pgbus::ConfigurationError, "#{group} entry must be a Hash, got #{entry.class}: #{entry.inspect}" unless entry.is_a?(Hash)
@@ -1165,13 +1373,15 @@ module Pgbus
1165
1373
  # Rails 7.1+ db_config.configuration_hash returns the full config
1166
1374
  config_hash = db_config.configuration_hash
1167
1375
 
1168
- {
1376
+ base = {
1169
1377
  host: config_hash[:host] || "localhost",
1170
1378
  port: (config_hash[:port] || 5432).to_i,
1171
1379
  dbname: config_hash[:database],
1172
1380
  user: config_hash[:username],
1173
1381
  password: config_hash[:password]
1174
1382
  }.compact
1383
+
1384
+ forward_connection_variables(base, config_hash[:variables])
1175
1385
  rescue StandardError => e
1176
1386
  # Fallback to Proc path if AR config extraction fails (e.g., adapter
1177
1387
  # doesn't expose standard config keys). Log a warning since this path
@@ -1187,5 +1397,28 @@ module Pgbus
1187
1397
  -> { ActiveRecord::Base.connection.raw_connection }
1188
1398
  end
1189
1399
  end
1400
+
1401
+ # database.yml's `variables:` block (e.g. client_min_messages) is a Rails
1402
+ # convention, not a libpq keyword — pgmq-ruby passes the hash straight to
1403
+ # PG.connect, which would ignore/reject a `:variables` key. So carry the GUCs
1404
+ # forward by the mechanism the operator's pooler tolerates:
1405
+ # :options — bake them into the libpq `options` STARTUP param
1406
+ # (-c key=value), appended to any existing options. A
1407
+ # transaction-mode PgBouncer rejects this param.
1408
+ # :session — leave the raw `:variables` hash on the returned options so the
1409
+ # Client applies them via post-connect `SET` (pooler-safe).
1410
+ # Empty/absent variables is a no-op on both paths. See issue #332.
1411
+ def forward_connection_variables(base, variables)
1412
+ return base if variables.nil? || variables.empty?
1413
+
1414
+ case connection_guc_mode
1415
+ when :session
1416
+ base.merge(variables: variables)
1417
+ else
1418
+ gucs = variables.map { |k, v| "-c #{k}=#{v}" }.join(" ")
1419
+ options = [base[:options], gucs].compact.reject(&:empty?).join(" ")
1420
+ base.merge(options: options)
1421
+ end
1422
+ end
1190
1423
  end
1191
1424
  end
data/lib/pgbus/doctor.rb CHANGED
@@ -5,7 +5,7 @@ require "pgbus/mcp/health_analyzer"
5
5
 
6
6
  module Pgbus
7
7
  # Preflight diagnostics for a pgbus deployment — the single command that
8
- # answers "is this environment healthy enough to run?". Runs seven checks and
8
+ # answers "is this environment healthy enough to run?". Runs nine checks and
9
9
  # returns a machine-readable result plus a human report, so `pgbus doctor`
10
10
  # and `rake pgbus:doctor` can gate a deploy or CI run (exit 0 on success,
11
11
  # 1 on any failure).
@@ -41,7 +41,9 @@ module Pgbus
41
41
  check_queues,
42
42
  check_notify,
43
43
  check_processes,
44
- check_allowed_global_id_models
44
+ check_allowed_global_id_models,
45
+ check_broadcast_queue,
46
+ check_primary
45
47
  ].map(&:to_h)
46
48
  end
47
49
 
@@ -210,6 +212,75 @@ module Pgbus
210
212
  Check.new(name: "GlobalID allowlist", status: :fail, detail: "#{e.class}: #{e.message}")
211
213
  end
212
214
 
215
+ # 8. Broadcast-queue isolation — latency + correctness. With turbo-rails
216
+ # loaded, the default broadcasts_to/broadcasts_refreshes path enqueues
217
+ # render+broadcast ActiveJobs on the DEFAULT queue, so a browser SSE update
218
+ # can wait behind long-running jobs (#311). Two failure modes:
219
+ #
220
+ # (a) streams_broadcast_queue is nil — broadcasts share the default queue
221
+ # and can wait behind long jobs. A latency concern, so warn only in
222
+ # production (where the blast radius is real).
223
+ # (b) streams_broadcast_queue is set but NO worker capsule drains it —
224
+ # broadcasts route there and pile up unread, so the browser never
225
+ # updates. A correctness bug, so warn everywhere, not just production.
226
+ #
227
+ # Both are warnings, never failures. Only relevant when Turbo is loaded.
228
+ def check_broadcast_queue
229
+ broadcast_queue = @config.streams_broadcast_queue
230
+ turbo = @config.streams_enabled && defined?(::Turbo::Broadcastable)
231
+
232
+ if turbo && broadcast_queue && !worker_drains?(broadcast_queue)
233
+ return Check.new(name: "Broadcast queue", status: :warn,
234
+ detail: "streams_broadcast_queue is \"#{broadcast_queue}\" but no worker capsule " \
235
+ "drains it — broadcasts will pile up unread and never reach the browser; " \
236
+ "add a capsule for the \"#{broadcast_queue}\" queue (or a wildcard worker)")
237
+ end
238
+
239
+ if turbo && production? && broadcast_queue.nil?
240
+ return Check.new(name: "Broadcast queue", status: :warn,
241
+ detail: "streams_broadcast_queue is nil — turbo-rails broadcast jobs share the " \
242
+ "default queue and can wait behind long-running jobs; set a dedicated queue " \
243
+ "(e.g. \"realtime\") and back it with a worker capsule")
244
+ end
245
+
246
+ Check.new(name: "Broadcast queue", status: :ok,
247
+ detail: broadcast_queue ? "dedicated: #{broadcast_queue}" : "n/a")
248
+ rescue StandardError => e
249
+ Check.new(name: "Broadcast queue", status: :fail, detail: "#{e.class}: #{e.message}")
250
+ end
251
+
252
+ # 9. Primary affinity — pooler safety (issue #332). A read/write-splitting
253
+ # pooler (pgdog/pgcat) can route pgmq's VOLATILE read/archive to a read
254
+ # replica, where workers read nothing and jobs stop with a healthy
255
+ # heartbeat. If the job connection currently lands on a replica
256
+ # (pg_is_in_recovery() => t), warn with the direct-port remediation. A
257
+ # warning, never a failure: a deliberate replica-read setup is rare but
258
+ # possible, and require_primary is the enforcement knob for those who want a
259
+ # hard stop.
260
+ def check_primary
261
+ if @client.in_recovery?
262
+ return Check.new(name: "Primary affinity", status: :warn,
263
+ detail: "job connection is on a read-only replica (pg_is_in_recovery() => t) — " \
264
+ "a read/write-splitting pooler may be routing pgmq reads to a standby, " \
265
+ "silently stalling jobs. Point the connection at the DIRECT primary port " \
266
+ "(worker_notify_* / streams_* overrides) and set require_primary to reject " \
267
+ "a replica at boot")
268
+ end
269
+
270
+ Check.new(name: "Primary affinity", status: :ok, detail: "on primary")
271
+ rescue StandardError => e
272
+ Check.new(name: "Primary affinity", status: :warn, detail: "could not determine (#{e.class}: #{e.message})")
273
+ end
274
+
275
+ # True when some configured worker capsule drains the given queue — either
276
+ # by naming it explicitly or via a "*" wildcard (which drains every queue).
277
+ def worker_drains?(queue)
278
+ Array(@config.workers).any? do |w|
279
+ queues = w[:queues] || w["queues"] || []
280
+ queues.include?("*") || queues.include?(queue)
281
+ end
282
+ end
283
+
213
284
  # Physical queue names known to PGMQ. list_queues rows may be Hashes (with a
214
285
  # :queue_name key) or value objects responding to #queue_name.
215
286
  def existing_queue_names
data/lib/pgbus/engine.rb CHANGED
@@ -12,9 +12,30 @@ module Pgbus
12
12
  Pgbus.teardown_models_loader!
13
13
  end
14
14
 
15
- initializer "pgbus.configure" do |app|
16
- config_path = app.root.join("config", "pgbus.yml")
17
- Pgbus::ConfigLoader.load(config_path) if config_path.exist?
15
+ # config/pgbus.yml is no longer loaded (removed in 1.0). If an app still
16
+ # ships one, it's now silently inert — warn once at boot so the leftover
17
+ # file doesn't quietly diverge from the real (Ruby initializer) config.
18
+ initializer "pgbus.legacy_yaml_warning" do |app|
19
+ ActiveSupport.on_load(:after_initialize) do
20
+ Pgbus::Engine.warn_if_legacy_yaml_config(app.root, Pgbus.logger)
21
+ end
22
+ end
23
+
24
+ # Emits the legacy-YAML deprecation warning when +root+/config/pgbus.yml
25
+ # exists. Extracted so it can be unit-tested without booting Rails. A nil
26
+ # logger is a no-op (logger may be unset in a bare/embedded boot).
27
+ def self.warn_if_legacy_yaml_config(root, logger)
28
+ return if logger.nil?
29
+
30
+ config_path = root.join("config", "pgbus.yml")
31
+ return unless config_path.exist?
32
+
33
+ logger.warn do
34
+ "[Pgbus] config/pgbus.yml is no longer loaded (YAML config was removed in 1.0). " \
35
+ "Port its settings into config/initializers/pgbus.rb as a Pgbus.configure block " \
36
+ "and delete the YAML. See https://pgbus.zoolutions.llc/docs/configuration " \
37
+ "(the pgbus MCP server can also help port it)."
38
+ end
18
39
  end
19
40
 
20
41
  initializer "pgbus.recurring" do |app|
@@ -154,6 +175,12 @@ module Pgbus
154
175
  if defined?(::Turbo::Broadcastable)
155
176
  _autoload_trigger_broadcastable = Pgbus::Streams::BroadcastableOverride
156
177
  Pgbus::Streams::BroadcastableOverride.install!(::Turbo::Broadcastable)
178
+
179
+ # Route turbo-rails' async broadcast jobs to a dedicated queue when
180
+ # configured, so a broadcasts_to render+broadcast doesn't wait
181
+ # behind long-running jobs on the default queue (#311). No-op when
182
+ # streams_broadcast_queue is nil.
183
+ Pgbus::Streams.install_broadcast_queue!(Pgbus.configuration.streams_broadcast_queue)
157
184
  end
158
185
 
159
186
  # Subscribe-side patch: override turbo_stream_from to render
@@ -71,6 +71,7 @@ module Pgbus
71
71
  add_job_stats_latency: "pgbus:add_job_stats_latency",
72
72
  add_job_stats_queue_index: "pgbus:add_job_stats_queue_index",
73
73
  add_stream_stats: "pgbus:add_stream_stats",
74
+ add_stream_queues: "pgbus:add_stream_queues",
74
75
  add_presence: "pgbus:add_presence",
75
76
  add_queue_states: "pgbus:add_queue_states",
76
77
  add_outbox: "pgbus:add_outbox",
@@ -88,6 +89,7 @@ module Pgbus
88
89
  add_job_stats_latency: "job stats latency columns (enqueue_latency_ms, retry_count)",
89
90
  add_job_stats_queue_index: "job stats (queue_name, created_at) index",
90
91
  add_stream_stats: "stream stats table (opt-in real-time Insights)",
92
+ add_stream_queues: "stream queue registry (per-stream retention, orphan sweep, wildcard-worker safety)",
91
93
  add_presence: "presence members table (Turbo Streams presence)",
92
94
  add_queue_states: "queue states table (pause/resume)",
93
95
  add_outbox: "outbox entries table (transactional outbox)",
@@ -112,6 +114,7 @@ module Pgbus
112
114
  *uniqueness_key_migrations,
113
115
  *job_stats_migrations,
114
116
  *stream_stats_migrations,
117
+ *stream_queues_migrations,
115
118
  *presence_migrations,
116
119
  *queue_states_migrations,
117
120
  *outbox_migrations,
@@ -166,6 +169,10 @@ module Pgbus
166
169
  table_exists?("pgbus_stream_stats") ? [] : [:add_stream_stats]
167
170
  end
168
171
 
172
+ def stream_queues_migrations
173
+ table_exists?("pgbus_stream_queues") ? [] : [:add_stream_queues]
174
+ end
175
+
169
176
  def presence_migrations
170
177
  table_exists?("pgbus_presence_members") ? [] : [:add_presence]
171
178
  end