pgbus 0.9.9 → 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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -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/mcp/rack_app.rb +18 -6
  21. data/lib/pgbus/process/dispatcher.rb +105 -23
  22. data/lib/pgbus/process/worker.rb +9 -0
  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
@@ -130,9 +130,14 @@ module Pgbus
130
130
  :streams_write_deadline_ms, :streams_falcon_streaming_body,
131
131
  :streams_stats_enabled, :streams_test_mode,
132
132
  :streams_orphan_sweep_interval, :streams_orphan_threshold,
133
- :streams_durable_patterns,
133
+ :streams_durable_patterns, :streams_broadcast_queue,
134
134
  :streams_presence_patterns, :streams_presence_member,
135
- :streams_host, :streams_port, :streams_database_url
135
+ :streams_host, :streams_port, :streams_database_url,
136
+ :streams_pool_size, :streams_pool_timeout,
137
+ :streams_fanout_write_deadline_ms, :streams_dispatch_queue_limit,
138
+ :streams_writer_threads, :streams_writer_buffer_limit,
139
+ :streams_pool_autoscale, :streams_pool_max,
140
+ :streams_pool_autoscale_interval, :streams_application_name
136
141
  attr_reader :streams_default_broadcast_mode # rubocop:disable Style/AccessorGrouping
137
142
 
138
143
  # NOTIFY-gated worker wakeups. When true, each Worker fork owns a
@@ -155,8 +160,8 @@ module Pgbus
155
160
  # a Pgbus::Metrics::Backend instance — a custom backend
156
161
  attr_accessor :metrics_backend, :statsd_host, :statsd_port
157
162
 
158
- # When true (default), Pgbus.configure and ConfigLoader.apply run
159
- # validate! after applying settings, so an invalid value fails loud at
163
+ # When true (default), Pgbus.configure runs validate! after the block
164
+ # yields, so an invalid value fails loud at
160
165
  # boot instead of dormant until a worker path consumes it. Set false to
161
166
  # opt out (exotic setups that intentionally hold a transiently-invalid
162
167
  # config); explicit validate! still works.
@@ -263,6 +268,11 @@ module Pgbus
263
268
 
264
269
  @streams_enabled = true
265
270
  @streams_path = nil
271
+ # Retained for backward compatibility only — NO LONGER USED for queue
272
+ # naming or stream detection. Stream queues are named like job queues
273
+ # (`#{queue_prefix}_<name>`, see #queue_name) and are identified via the
274
+ # `pgbus_stream_queues` registry (Pgbus::StreamQueue), not by this prefix.
275
+ # Setting it has no effect. See issue #308.
266
276
  @streams_queue_prefix = "pgbus_stream"
267
277
  # Streamer-only connection overrides. The Streamer's Listener owns a
268
278
  # dedicated long-lived `wait_for_notify` PG connection that can't go
@@ -280,6 +290,42 @@ module Pgbus
280
290
  @streams_host = nil
281
291
  @streams_port = nil
282
292
  @streams_database_url = nil
293
+ # Dedicated DB connection pool for the streamer's publish + replay hot
294
+ # paths (Client#send_stream_message and read_after/stream_current_msg_id).
295
+ # Isolated from the job pool (`pool_size`) so a saturated worker pool
296
+ # can't delay broadcast INSERTs and the dispatcher's per-wake read_after
297
+ # reuses a persistent connection instead of a fresh PG.connect per call
298
+ # (issue #315). A small fixed default — streams fan out from one
299
+ # dispatcher thread per Puma worker, so a handful of connections is
300
+ # plenty; raise it only for very high broadcast concurrency. Ignored on
301
+ # the shared-ActiveRecord (Proc) connection path, where libpq isn't
302
+ # thread-safe and streams keep using the single serialized connection.
303
+ @streams_pool_size = 5
304
+ @streams_pool_timeout = 5
305
+ # Self-tuning streams-pool autoscaling (issue #323). Opt-in, default off.
306
+ # When true, a per-web-process control loop grows the dedicated streams
307
+ # pool into a FAIR SHARE of live Postgres connection headroom under
308
+ # saturation and shrinks it back to streams_pool_size (the baseline/floor)
309
+ # when idle — and emergency-shrinks immediately if the DB runs critically
310
+ # low on connections. No connection-count config: every threshold derives
311
+ # from live max_connections. streams_pool_max is an OPTIONAL hard ceiling
312
+ # (nil = the dynamic fair share is the effective cap).
313
+ @streams_pool_autoscale = false
314
+ @streams_pool_max = nil
315
+ # Slow maintenance cadence (seconds) — the autoscaler runs as a periodic
316
+ # check on the streamer's idle LISTEN connection (pghero-style), not a busy
317
+ # loop. 5 minutes: the interval itself debounces, so there is no per-sample
318
+ # hysteresis; a sustained burst converges over a few checks.
319
+ @streams_pool_autoscale_interval = 300.0
320
+ # Distinctive application_name prefix stamped on streams-pool connections
321
+ # so the autoscaler can count peer processes (DISTINCT application_name
322
+ # LIKE '<prefix>_%') from pg_stat_activity. Per-process suffix is appended
323
+ # at build time. Namespace it per deployment if several pgbus fleets share
324
+ # one database. NOTE: a transaction-mode PgBouncer strips application_name;
325
+ # peer inference then degrades to "assume alone" (the safety margin +
326
+ # emergency-shrink cushion this) — connect the streamer DIRECTLY for
327
+ # accurate autoscaling.
328
+ @streams_application_name = "pgbus_streams"
283
329
  @streams_signed_name_secret = nil
284
330
  @streams_default_retention = 5 * 60 # 5 minutes
285
331
  @streams_retention = {}
@@ -296,6 +342,49 @@ module Pgbus
296
342
  # PG keepalive interval.
297
343
  @streams_listen_health_check_ms = 250
298
344
  @streams_write_deadline_ms = 5_000
345
+ # Deadline (ms) for a socket write made INSIDE the dispatcher's
346
+ # serial fanout loop (handle_durable_wake / handle_ephemeral_wake).
347
+ # Kept far below streams_write_deadline_ms (5s) because these writes
348
+ # run one after another on the single dispatcher thread: K
349
+ # slow-but-not-yet-dead clients stack the stall, so the whole-thread
350
+ # worst case is ~K * this_value before each is marked dead
351
+ # (issue #315 item 3). A slow-but-alive client that can't absorb a
352
+ # fanout frame in this window is marked dead and reconnects — its
353
+ # EventSource Last-Event-ID then replays the missed frames from the
354
+ # durable archive (or triggers a fresh re-render for ephemeral
355
+ # streams, which have no archive). Connect-replay writes keep the
356
+ # full streams_write_deadline_ms. Set this equal to
357
+ # streams_write_deadline_ms to restore the pre-#315 timing.
358
+ @streams_fanout_write_deadline_ms = 250
359
+ # Optional cap on dispatch-queue depth checked by the Listener before
360
+ # it pushes a durable WakeMessage. 0 (default) = unbounded — today's
361
+ # behavior. A positive value makes the Listener DROP a durable wake
362
+ # when the queue is at/over the cap; this is safe because the next
363
+ # durable wake for that stream re-reads from the min cursor. It
364
+ # bounds DISTINCT-STREAM backlog only — a single hot stream's wakes
365
+ # coalesce (drain_wakes_for) so its depth stays low regardless.
366
+ # Ephemeral wakes (which carry the only copy of their HTML) and
367
+ # Connect/Disconnect messages are NEVER dropped (issue #315 item 3).
368
+ @streams_dispatch_queue_limit = 0
369
+ # Number of writer threads that flush durable-stream fanout socket writes
370
+ # OFF the single dispatcher thread (issue #321). 0 (default) = inline —
371
+ # byte-for-byte the pre-#321 behavior, no pump, no extra allocation. A
372
+ # positive value spawns N writer threads; each durable broadcast fanout
373
+ # write is handed to a writer (partitioned by connection.id.hash % N, so
374
+ # a connection's frames stay ordered and its per-io mutex is respected),
375
+ # freeing the dispatcher to service the next wake/connect. Fast clients
376
+ # then stop waiting behind slow ones. EPHEMERAL fanout and connect-replay
377
+ # writes always stay inline regardless of this value: ephemerals have no
378
+ # archive to replay, so they must not risk an async drop (see #321 B1).
379
+ @streams_writer_threads = 0
380
+ # Per-connection cap on the writer's outbound buffer when
381
+ # streams_writer_threads > 0. 0 (default) = unbounded. A positive value
382
+ # drops the OLDEST buffered durable frame for a connection whose writer
383
+ # can't keep up — safe because durable frames live in the a_<stream>
384
+ # archive and are re-read on reconnect via Last-Event-ID; it's a memory
385
+ # guard against a pathologically slow-but-alive client, not a delivery
386
+ # guarantee. Ignored when streams_writer_threads == 0 (issue #321).
387
+ @streams_writer_buffer_limit = 0
299
388
  # EXPERIMENTAL — exempt from the 1.0 stability promise.
300
389
  @streams_falcon_streaming_body = false
301
390
  # Opt-in: when true, the Dispatcher writes one row to
@@ -311,6 +400,14 @@ module Pgbus
311
400
  @streams_orphan_sweep_interval = 3600 # 1 hour
312
401
  @streams_orphan_threshold = 86_400 # 24 hours
313
402
  @streams_durable_patterns = []
403
+ # Dedicated queue for turbo-rails' async broadcast jobs
404
+ # (Turbo::Streams::ActionBroadcastJob and siblings). nil (default) leaves
405
+ # them on the default queue, where a `broadcasts_to`/`broadcasts_refreshes`
406
+ # render+broadcast can wait behind long-running jobs before the browser
407
+ # sees the update. Set to a queue name (e.g. "realtime") and back it with
408
+ # a dedicated worker capsule to isolate broadcast latency from job
409
+ # throughput. Applied at engine boot when turbo-rails is loaded. See #311.
410
+ @streams_broadcast_queue = nil
314
411
  # EXPERIMENTAL (both presence settings) — exempt from the 1.0 stability
315
412
  # promise. Streams matching these patterns get connection-driven presence:
316
413
  # auto-join on SSE connect, auto-leave on disconnect, touch on the
@@ -528,8 +625,43 @@ module Pgbus
528
625
  raise Pgbus::ConfigurationError, "streams_write_deadline_ms must be a positive integer"
529
626
  end
530
627
 
628
+ unless streams_fanout_write_deadline_ms.is_a?(Integer) && streams_fanout_write_deadline_ms.positive?
629
+ raise Pgbus::ConfigurationError, "streams_fanout_write_deadline_ms must be a positive integer"
630
+ end
631
+
632
+ # >= 0, NOT .positive? — 0 is the valid "unbounded" sentinel.
633
+ unless streams_dispatch_queue_limit.is_a?(Integer) && streams_dispatch_queue_limit >= 0
634
+ raise Pgbus::ConfigurationError, "streams_dispatch_queue_limit must be a non-negative integer (0 = unbounded)"
635
+ end
636
+
637
+ # >= 0, NOT .positive? — 0 is the valid "inline" sentinel.
638
+ unless streams_writer_threads.is_a?(Integer) && streams_writer_threads >= 0
639
+ raise Pgbus::ConfigurationError, "streams_writer_threads must be a non-negative integer (0 = inline)"
640
+ end
641
+
642
+ # >= 0, NOT .positive? — 0 is the valid "unbounded" sentinel.
643
+ unless streams_writer_buffer_limit.is_a?(Integer) && streams_writer_buffer_limit >= 0
644
+ raise Pgbus::ConfigurationError, "streams_writer_buffer_limit must be a non-negative integer (0 = unbounded)"
645
+ end
646
+
647
+ unless streams_pool_size.is_a?(Integer) && streams_pool_size.positive?
648
+ raise Pgbus::ConfigurationError, "streams_pool_size must be a positive integer"
649
+ end
650
+
651
+ unless streams_pool_timeout.is_a?(Numeric) && streams_pool_timeout.positive?
652
+ raise Pgbus::ConfigurationError, "streams_pool_timeout must be a positive number"
653
+ end
654
+
655
+ validate_streams_autoscale!
656
+
531
657
  raise Pgbus::ConfigurationError, "streams_retention must be a Hash" unless streams_retention.is_a?(Hash)
532
658
 
659
+ unless streams_broadcast_queue.nil? ||
660
+ (streams_broadcast_queue.is_a?(String) && !streams_broadcast_queue.empty?)
661
+ raise Pgbus::ConfigurationError,
662
+ "streams_broadcast_queue must be a non-empty String (a queue name) or nil to disable"
663
+ end
664
+
533
665
  if streams_orphan_sweep_interval && !(streams_orphan_sweep_interval.is_a?(Numeric) && streams_orphan_sweep_interval.positive?)
534
666
  raise Pgbus::ConfigurationError, "streams_orphan_sweep_interval must be a positive number or nil to disable"
535
667
  end
@@ -554,6 +686,26 @@ module Pgbus
554
686
  raise Pgbus::ConfigurationError, "streams_orphan_threshold must be a positive number or nil to disable"
555
687
  end
556
688
 
689
+ def validate_streams_autoscale!
690
+ raise Pgbus::ConfigurationError, "streams_pool_autoscale must be true or false" unless [true, false].include?(streams_pool_autoscale)
691
+
692
+ # nil = no hard cap (dynamic fair-share is the ceiling). A positive value
693
+ # below the baseline is an inverted ceiling — fail loud at boot.
694
+ unless streams_pool_max.nil? ||
695
+ (streams_pool_max.is_a?(Integer) && streams_pool_max >= streams_pool_size)
696
+ raise Pgbus::ConfigurationError,
697
+ "streams_pool_max must be an Integer >= streams_pool_size " \
698
+ "(#{streams_pool_size}) or nil to leave growth uncapped"
699
+ end
700
+
701
+ unless streams_pool_autoscale_interval.is_a?(Numeric) && streams_pool_autoscale_interval.positive?
702
+ raise Pgbus::ConfigurationError, "streams_pool_autoscale_interval must be a positive number"
703
+ end
704
+
705
+ raise Pgbus::ConfigurationError, "streams_application_name must be a non-empty String" \
706
+ unless streams_application_name.is_a?(String) && !streams_application_name.empty?
707
+ end
708
+
557
709
  def validate_metrics_backend!
558
710
  value = metrics_backend
559
711
  return if value.nil?
@@ -661,9 +813,9 @@ module Pgbus
661
813
  end
662
814
  end
663
815
 
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).
816
+ # Event consumer configs arrive with symbol keys (Ruby DSL) or, from an
817
+ # explicit Array literal, string keys. Normalize once here so every read
818
+ # site uses symbol access only. nil passes through (no consumers configured).
667
819
  def event_consumers=(value)
668
820
  @event_consumers =
669
821
  case value
@@ -854,6 +1006,15 @@ module Pgbus
854
1006
  # timeouts under load). Setting pool_size explicitly is still supported
855
1007
  # for advanced cases where you need a tighter or looser pool than the
856
1008
  # default formula provides.
1009
+ #
1010
+ # NOTE: this covers the JOB pool only. On the dedicated-connection path
1011
+ # (database_url / connection_params) every Client also opens a separate
1012
+ # streams pool of +streams_pool_size+ (default 5) for durable-stream
1013
+ # publish + replay (issue #315). Both pools are lazy, so an idle process
1014
+ # holds few real connections, but when budgeting Postgres/PgBouncer
1015
+ # max_connections, size for +resolved_pool_size + streams_pool_size+ per
1016
+ # process — every forked worker/dispatcher/scheduler/consumer builds its
1017
+ # own streams pool even if it rarely touches streams.
857
1018
  POOL_SIZE_WARN_THRESHOLD = 50
858
1019
 
859
1020
  # Connections needed per async worker: one for the reactor's serial
@@ -1057,8 +1218,8 @@ module Pgbus
1057
1218
  end
1058
1219
 
1059
1220
  # 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.
1221
+ # downstream read site can use symbol access only. An explicit Array literal
1222
+ # may carry string keys; the Ruby DSL and +capsule+ builder yield symbols.
1062
1223
  # Entries are flat hashes with array/scalar values — no deep recursion.
1063
1224
  def normalize_entry(entry, group:)
1064
1225
  raise Pgbus::ConfigurationError, "#{group} entry must be a Hash, got #{entry.class}: #{entry.inspect}" unless entry.is_a?(Hash)
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 eight 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,8 @@ 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
45
46
  ].map(&:to_h)
46
47
  end
47
48
 
@@ -210,6 +211,52 @@ module Pgbus
210
211
  Check.new(name: "GlobalID allowlist", status: :fail, detail: "#{e.class}: #{e.message}")
211
212
  end
212
213
 
214
+ # 8. Broadcast-queue isolation — latency + correctness. With turbo-rails
215
+ # loaded, the default broadcasts_to/broadcasts_refreshes path enqueues
216
+ # render+broadcast ActiveJobs on the DEFAULT queue, so a browser SSE update
217
+ # can wait behind long-running jobs (#311). Two failure modes:
218
+ #
219
+ # (a) streams_broadcast_queue is nil — broadcasts share the default queue
220
+ # and can wait behind long jobs. A latency concern, so warn only in
221
+ # production (where the blast radius is real).
222
+ # (b) streams_broadcast_queue is set but NO worker capsule drains it —
223
+ # broadcasts route there and pile up unread, so the browser never
224
+ # updates. A correctness bug, so warn everywhere, not just production.
225
+ #
226
+ # Both are warnings, never failures. Only relevant when Turbo is loaded.
227
+ def check_broadcast_queue
228
+ broadcast_queue = @config.streams_broadcast_queue
229
+ turbo = @config.streams_enabled && defined?(::Turbo::Broadcastable)
230
+
231
+ if turbo && broadcast_queue && !worker_drains?(broadcast_queue)
232
+ return Check.new(name: "Broadcast queue", status: :warn,
233
+ detail: "streams_broadcast_queue is \"#{broadcast_queue}\" but no worker capsule " \
234
+ "drains it — broadcasts will pile up unread and never reach the browser; " \
235
+ "add a capsule for the \"#{broadcast_queue}\" queue (or a wildcard worker)")
236
+ end
237
+
238
+ if turbo && production? && broadcast_queue.nil?
239
+ return Check.new(name: "Broadcast queue", status: :warn,
240
+ detail: "streams_broadcast_queue is nil — turbo-rails broadcast jobs share the " \
241
+ "default queue and can wait behind long-running jobs; set a dedicated queue " \
242
+ "(e.g. \"realtime\") and back it with a worker capsule")
243
+ end
244
+
245
+ Check.new(name: "Broadcast queue", status: :ok,
246
+ detail: broadcast_queue ? "dedicated: #{broadcast_queue}" : "n/a")
247
+ rescue StandardError => e
248
+ Check.new(name: "Broadcast queue", status: :fail, detail: "#{e.class}: #{e.message}")
249
+ end
250
+
251
+ # True when some configured worker capsule drains the given queue — either
252
+ # by naming it explicitly or via a "*" wildcard (which drains every queue).
253
+ def worker_drains?(queue)
254
+ Array(@config.workers).any? do |w|
255
+ queues = w[:queues] || w["queues"] || []
256
+ queues.include?("*") || queues.include?(queue)
257
+ end
258
+ end
259
+
213
260
  # Physical queue names known to PGMQ. list_queues rows may be Hashes (with a
214
261
  # :queue_name key) or value objects responding to #queue_name.
215
262
  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
@@ -22,11 +22,12 @@ module Pgbus
22
22
  # / behind your VPN, never internet-exposed.
23
23
  class RackApp
24
24
  BEARER_PREFIX = "Bearer "
25
- UNAUTHORIZED = [
26
- 401,
27
- { "Content-Type" => "application/json", "WWW-Authenticate" => "Bearer" },
28
- [{ jsonrpc: "2.0", id: nil, error: { code: -32_001, message: "Unauthorized" } }.to_json]
29
- ].freeze
25
+ # Only the JSON body string is frozen and reused. The outer response triple
26
+ # and its headers hash MUST be built fresh per call (#unauthorized) so
27
+ # downstream Rack middleware can mutate them — Rack::TempfileReaper assigns
28
+ # response[2] and Rack::ETag adds headers. Returning a frozen array/hash
29
+ # raised FrozenError → 500 instead of 401 (issue #304).
30
+ UNAUTHORIZED_BODY = { jsonrpc: "2.0", id: nil, error: { code: -32_001, message: "Unauthorized" } }.to_json.freeze
30
31
 
31
32
  # @param data_source [Pgbus::Web::DataSource] read layer the tools query.
32
33
  # Built once and shared: DataSource acquires Pgbus::BusRecord.connection
@@ -55,13 +56,24 @@ module Pgbus
55
56
  # the gem's transport has no auth of its own.
56
57
  def call(env)
57
58
  request = Rack::Request.new(env)
58
- return UNAUTHORIZED unless authorized?(request)
59
+ return unauthorized unless authorized?(request)
59
60
 
60
61
  @transport.handle_request(request)
61
62
  end
62
63
 
63
64
  private
64
65
 
66
+ # A fresh, mutable 401 response triple each call. See UNAUTHORIZED_BODY.
67
+ # Header keys are lowercase per the Rack 3 SPEC (matches stream_app.rb and
68
+ # prometheus_exporter.rb); Rack::Headers looks them up case-insensitively.
69
+ def unauthorized
70
+ [
71
+ 401,
72
+ { "content-type" => "application/json", "www-authenticate" => "Bearer" },
73
+ [UNAUTHORIZED_BODY]
74
+ ]
75
+ end
76
+
65
77
  # An explicit auth callable wins; otherwise fall back to the bearer token;
66
78
  # otherwise (neither configured) allow — the constructor already warned.
67
79
  def authorized?(request)