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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -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 +261 -4
- data/lib/pgbus/configuration.rb +244 -11
- data/lib/pgbus/doctor.rb +73 -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/notify_listener.rb +18 -2
- data/lib/pgbus/process/worker.rb +19 -3
- 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
|
@@ -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
|
|
159
|
-
#
|
|
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
|
|
665
|
-
#
|
|
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.
|
|
1061
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|