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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -7
- data/README.md +34 -7
- data/Rakefile +22 -1
- data/app/models/pgbus/stream_queue.rb +87 -0
- data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
- data/lib/generators/pgbus/install_generator.rb +3 -3
- data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
- data/lib/generators/pgbus/update_generator.rb +6 -66
- data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
- data/lib/pgbus/client/read_after.rb +7 -6
- data/lib/pgbus/client/resizable_pool.rb +160 -0
- data/lib/pgbus/client.rb +200 -1
- data/lib/pgbus/configuration.rb +170 -9
- data/lib/pgbus/doctor.rb +49 -2
- data/lib/pgbus/engine.rb +30 -3
- data/lib/pgbus/generators/migration_detector.rb +7 -0
- data/lib/pgbus/process/dispatcher.rb +89 -19
- data/lib/pgbus/process/worker.rb +9 -0
- data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
- data/lib/pgbus/streams/pool_trigger.rb +124 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
- data/lib/pgbus/streams.rb +2 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/streamer/connection.rb +22 -6
- data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
- data/lib/pgbus/web/streamer/instance.rb +75 -5
- data/lib/pgbus/web/streamer/io_writer.rb +11 -5
- data/lib/pgbus/web/streamer/listener.rb +61 -1
- data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
- metadata +9 -4
- data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
- data/lib/pgbus/config_loader.rb +0 -73
- data/lib/pgbus/generators/config_converter.rb +0 -345
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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
|
|
159
|
-
#
|
|
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
|
|
665
|
-
#
|
|
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.
|
|
1061
|
-
#
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
@@ -37,6 +37,16 @@ module Pgbus
|
|
|
37
37
|
MAINTENANCE_BACKOFF_BASE = 30 # seconds; first backoff window
|
|
38
38
|
MAINTENANCE_BACKOFF_MAX = 600 # seconds; window ceiling (10 minutes)
|
|
39
39
|
|
|
40
|
+
# A pooled backend the server terminated while the dispatcher sat idle
|
|
41
|
+
# between cycles surfaces with this exact PostgreSQL text on the next
|
|
42
|
+
# query. It is expected and self-healing — release_maintenance_connections
|
|
43
|
+
# returns the dead socket to the pool and the next cycle reconnects — so a
|
|
44
|
+
# task that hits it logs a calm INFO rather than an alarming WARN. Matched
|
|
45
|
+
# case-insensitively as a substring of the exception message. Common in
|
|
46
|
+
# local development (Postgres restart, `rails db:migrate` terminating idle
|
|
47
|
+
# backends, foreman/overmind restarting the DB), rare in production.
|
|
48
|
+
TERMINATED_CONNECTION_SIGNAL = "terminating connection due to administrator command"
|
|
49
|
+
|
|
40
50
|
# The per-task monotonic timestamps run_maintenance consults via run_if_due.
|
|
41
51
|
# Enumerated so set_maintenance_timestamp can validate its argument.
|
|
42
52
|
MAINTENANCE_TIMESTAMPS = %i[
|
|
@@ -166,6 +176,51 @@ module Pgbus
|
|
|
166
176
|
|
|
167
177
|
results = run_maintenance_tasks(now)
|
|
168
178
|
update_maintenance_backoff(now, results)
|
|
179
|
+
ensure
|
|
180
|
+
release_maintenance_connections
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Return every AR connection this cycle leased back to the pool. The
|
|
184
|
+
# dispatcher is a long-lived loop that queries the pool on coarse
|
|
185
|
+
# intervals (concurrency cleanup every 300s, most tasks hourly). If it
|
|
186
|
+
# kept its pooled connection leased across the idle sleep, a backend the
|
|
187
|
+
# server terminates while it sits idle — routine in local development
|
|
188
|
+
# (a Postgres restart, `rails db:migrate` calling pg_terminate_backend,
|
|
189
|
+
# foreman/overmind restarting the DB) — would be reused dead on the next
|
|
190
|
+
# cycle, and every task would raise PG::ConnectionBad
|
|
191
|
+
# ("terminating connection due to administrator command"). Releasing here
|
|
192
|
+
# means the next cycle checks out afresh, and AR's verify!-on-checkout
|
|
193
|
+
# transparently discards a dead socket and reconnects. Best-effort: a
|
|
194
|
+
# failure to release must never crash the loop (mirrors the Streamer's
|
|
195
|
+
# release_ar_connections).
|
|
196
|
+
def release_maintenance_connections
|
|
197
|
+
return unless defined?(::ActiveRecord::Base)
|
|
198
|
+
|
|
199
|
+
Pgbus::BusRecord.connection_handler.clear_active_connections!
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
Pgbus.logger.debug { "[Pgbus] Maintenance connection release failed: #{e.class}: #{e.message}" }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# True when the error is a pooled backend the server terminated while the
|
|
205
|
+
# dispatcher was idle between cycles. Self-healing (see
|
|
206
|
+
# TERMINATED_CONNECTION_SIGNAL / release_maintenance_connections), so
|
|
207
|
+
# callers downgrade the log from WARN to INFO.
|
|
208
|
+
def terminated_connection_error?(error)
|
|
209
|
+
error.message.to_s.downcase.include?(TERMINATED_CONNECTION_SIGNAL)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Log a maintenance task failure at the right volume: a self-healing
|
|
213
|
+
# terminated-idle-connection is a calm INFO ("will reconnect next cycle"),
|
|
214
|
+
# anything else stays a WARN operators should look at.
|
|
215
|
+
def log_maintenance_failure(label, error)
|
|
216
|
+
if terminated_connection_error?(error)
|
|
217
|
+
Pgbus.logger.info do
|
|
218
|
+
"[Pgbus] #{label} skipped: database connection was terminated while idle; " \
|
|
219
|
+
"reconnecting on the next cycle (#{error.message.lines.first&.strip})"
|
|
220
|
+
end
|
|
221
|
+
else
|
|
222
|
+
Pgbus.logger.warn { "[Pgbus] #{label} failed: #{error.message}" }
|
|
223
|
+
end
|
|
169
224
|
end
|
|
170
225
|
|
|
171
226
|
# Runs every due maintenance task and returns their results. Each entry
|
|
@@ -284,7 +339,7 @@ module Pgbus
|
|
|
284
339
|
deleted = ProcessedEvent.expired(Time.current - ttl).delete_all
|
|
285
340
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} expired processed events" } if deleted.positive?
|
|
286
341
|
rescue StandardError => e
|
|
287
|
-
|
|
342
|
+
log_maintenance_failure("Idempotency cleanup", e)
|
|
288
343
|
e
|
|
289
344
|
end
|
|
290
345
|
|
|
@@ -293,7 +348,7 @@ module Pgbus
|
|
|
293
348
|
deleted = ProcessEntry.stale(Time.current - threshold).delete_all
|
|
294
349
|
Pgbus.logger.info { "[Pgbus] Reaped #{deleted} stale processes" } if deleted.positive?
|
|
295
350
|
rescue StandardError => e
|
|
296
|
-
|
|
351
|
+
log_maintenance_failure("Stale process reaping", e)
|
|
297
352
|
e
|
|
298
353
|
end
|
|
299
354
|
|
|
@@ -306,7 +361,7 @@ module Pgbus
|
|
|
306
361
|
orphaned = Concurrency::BlockedExecution.expire_stale
|
|
307
362
|
Pgbus.logger.debug { "[Pgbus] Expired #{orphaned} orphaned blocked executions" } if orphaned.positive?
|
|
308
363
|
rescue StandardError => e
|
|
309
|
-
|
|
364
|
+
log_maintenance_failure("Concurrency cleanup", e)
|
|
310
365
|
e
|
|
311
366
|
end
|
|
312
367
|
|
|
@@ -314,14 +369,14 @@ module Pgbus
|
|
|
314
369
|
promoted = Concurrency::BlockedExecution.promote_next(key, client: Pgbus.client)
|
|
315
370
|
Pgbus.logger.debug { "[Pgbus] Released blocked execution for key: #{key}" } if promoted
|
|
316
371
|
rescue StandardError => e
|
|
317
|
-
|
|
372
|
+
log_maintenance_failure("Releasing blocked execution for #{key}", e)
|
|
318
373
|
end
|
|
319
374
|
|
|
320
375
|
def cleanup_batches
|
|
321
376
|
deleted = Batch.cleanup(older_than: Time.current - (7 * 24 * 3600)) # 7 days
|
|
322
377
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished batches" } if deleted.positive?
|
|
323
378
|
rescue StandardError => e
|
|
324
|
-
|
|
379
|
+
log_maintenance_failure("Batch cleanup", e)
|
|
325
380
|
e
|
|
326
381
|
end
|
|
327
382
|
|
|
@@ -353,7 +408,7 @@ module Pgbus
|
|
|
353
408
|
)
|
|
354
409
|
Pgbus.logger.info { "[Pgbus] Table maintenance completed: #{maintained} table(s) vacuumed" } if maintained.positive?
|
|
355
410
|
rescue StandardError => e
|
|
356
|
-
|
|
411
|
+
log_maintenance_failure("Table maintenance", e)
|
|
357
412
|
e
|
|
358
413
|
end
|
|
359
414
|
|
|
@@ -378,7 +433,7 @@ module Pgbus
|
|
|
378
433
|
reaped = reap_orphaned_uniqueness_keys
|
|
379
434
|
Pgbus.logger.info { "[Pgbus] Reaped #{reaped} orphaned uniqueness keys" } if reaped.positive?
|
|
380
435
|
rescue StandardError => e
|
|
381
|
-
|
|
436
|
+
log_maintenance_failure("Job lock cleanup", e)
|
|
382
437
|
e
|
|
383
438
|
end
|
|
384
439
|
|
|
@@ -434,7 +489,7 @@ module Pgbus
|
|
|
434
489
|
deleted = OutboxEntry.published_before(Time.current - retention).delete_all
|
|
435
490
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} published outbox entries" } if deleted.positive?
|
|
436
491
|
rescue StandardError => e
|
|
437
|
-
|
|
492
|
+
log_maintenance_failure("Outbox cleanup", e)
|
|
438
493
|
e
|
|
439
494
|
end
|
|
440
495
|
|
|
@@ -445,6 +500,10 @@ module Pgbus
|
|
|
445
500
|
cutoff = Time.current - retention
|
|
446
501
|
batch_size = ARCHIVE_COMPACTION_BATCH_SIZE
|
|
447
502
|
prefix = config.queue_prefix
|
|
503
|
+
# Stream queues share the job prefix but are pruned separately by
|
|
504
|
+
# prune_stream_archives (per-stream retention). Skip them here so a
|
|
505
|
+
# stream archive is compacted by exactly one path, not both.
|
|
506
|
+
stream_names = current_stream_queue_names
|
|
448
507
|
|
|
449
508
|
conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
450
509
|
queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
@@ -453,6 +512,7 @@ module Pgbus
|
|
|
453
512
|
queue_names.each_with_index do |full_name, index|
|
|
454
513
|
break if interrupted?("Archive compaction", index, queue_names.size)
|
|
455
514
|
next unless full_name.start_with?("#{prefix}_")
|
|
515
|
+
next if stream_names.include?(full_name)
|
|
456
516
|
|
|
457
517
|
stripped = full_name.delete_prefix("#{prefix}_")
|
|
458
518
|
deleted = Pgbus.client.purge_archive(stripped, older_than: cutoff, batch_size: batch_size)
|
|
@@ -464,7 +524,7 @@ module Pgbus
|
|
|
464
524
|
end
|
|
465
525
|
outcome.result
|
|
466
526
|
rescue StandardError => e
|
|
467
|
-
|
|
527
|
+
log_maintenance_failure("Archive compaction", e)
|
|
468
528
|
e
|
|
469
529
|
end
|
|
470
530
|
|
|
@@ -475,8 +535,8 @@ module Pgbus
|
|
|
475
535
|
# Lookup order for a given queue: exact string match, then regex
|
|
476
536
|
# match, then streams_default_retention (5 minutes by default).
|
|
477
537
|
def prune_stream_archives
|
|
478
|
-
|
|
479
|
-
return if
|
|
538
|
+
stream_names = current_stream_queue_names
|
|
539
|
+
return if stream_names.empty?
|
|
480
540
|
|
|
481
541
|
batch_size = ARCHIVE_COMPACTION_BATCH_SIZE
|
|
482
542
|
conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
@@ -485,7 +545,7 @@ module Pgbus
|
|
|
485
545
|
outcome = LoopOutcome.new
|
|
486
546
|
queue_names.each_with_index do |full_name, index|
|
|
487
547
|
break if interrupted?("Stream archive compaction", index, queue_names.size)
|
|
488
|
-
next unless
|
|
548
|
+
next unless stream_names.include?(full_name)
|
|
489
549
|
|
|
490
550
|
retention = retention_for_stream_queue(full_name)
|
|
491
551
|
next unless retention.positive?
|
|
@@ -505,7 +565,7 @@ module Pgbus
|
|
|
505
565
|
end
|
|
506
566
|
outcome.result
|
|
507
567
|
rescue StandardError => e
|
|
508
|
-
|
|
568
|
+
log_maintenance_failure("Stream archive compaction", e)
|
|
509
569
|
e
|
|
510
570
|
end
|
|
511
571
|
|
|
@@ -524,12 +584,12 @@ module Pgbus
|
|
|
524
584
|
end
|
|
525
585
|
|
|
526
586
|
def sweep_orphan_streams
|
|
527
|
-
prefix = config.streams_queue_prefix
|
|
528
|
-
return if prefix.nil? || prefix.empty?
|
|
529
|
-
|
|
530
587
|
threshold = config.streams_orphan_threshold
|
|
531
588
|
return unless threshold
|
|
532
589
|
|
|
590
|
+
stream_names = current_stream_queue_names
|
|
591
|
+
return if stream_names.empty?
|
|
592
|
+
|
|
533
593
|
conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
534
594
|
queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
535
595
|
|
|
@@ -537,7 +597,7 @@ module Pgbus
|
|
|
537
597
|
outcome = LoopOutcome.new
|
|
538
598
|
queue_names.each_with_index do |full_name, index|
|
|
539
599
|
break if interrupted?("Orphan stream sweep", index, queue_names.size)
|
|
540
|
-
next unless
|
|
600
|
+
next unless stream_names.include?(full_name)
|
|
541
601
|
|
|
542
602
|
safe_name = QueueNameValidator.sanitize!(full_name)
|
|
543
603
|
row = conn.select_one(<<~SQL, "Pgbus Orphan Check")
|
|
@@ -571,10 +631,20 @@ module Pgbus
|
|
|
571
631
|
Pgbus.logger.debug { "[Pgbus] Orphan stream sweep complete: dropped #{dropped} queue(s)" } if dropped.positive?
|
|
572
632
|
outcome.result
|
|
573
633
|
rescue StandardError => e
|
|
574
|
-
|
|
634
|
+
log_maintenance_failure("Orphan stream sweep", e)
|
|
575
635
|
e
|
|
576
636
|
end
|
|
577
637
|
|
|
638
|
+
# Fresh set of physical stream-queue names for this maintenance pass.
|
|
639
|
+
# Reset first so a stream created since the last hourly pass is picked
|
|
640
|
+
# up without a process restart. Empty on unmigrated installs (registry
|
|
641
|
+
# table absent) — callers then treat every queue as a job queue, which
|
|
642
|
+
# is the pre-registry behavior.
|
|
643
|
+
def current_stream_queue_names
|
|
644
|
+
Pgbus::StreamQueue.reset_cache!
|
|
645
|
+
Pgbus::StreamQueue.all_names
|
|
646
|
+
end
|
|
647
|
+
|
|
578
648
|
def cleanup_recurring_executions
|
|
579
649
|
retention = config.recurring_execution_retention
|
|
580
650
|
return unless retention&.positive?
|
|
@@ -582,7 +652,7 @@ module Pgbus
|
|
|
582
652
|
deleted = RecurringExecution.older_than(Time.current - retention).delete_all
|
|
583
653
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old recurring executions" } if deleted.positive?
|
|
584
654
|
rescue StandardError => e
|
|
585
|
-
|
|
655
|
+
log_maintenance_failure("Recurring execution cleanup", e)
|
|
586
656
|
e
|
|
587
657
|
end
|
|
588
658
|
|