pgbus 0.12.4 → 0.13.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 +8 -0
- data/README.md +2 -1
- data/Rakefile +12 -1
- data/lib/pgbus/client.rb +15 -0
- data/lib/pgbus/configuration/capsule_dsl.rb +8 -0
- data/lib/pgbus/configuration.rb +36 -5
- data/lib/pgbus/dedicated_connection.rb +29 -2
- data/lib/pgbus/doctor.rb +36 -2
- data/lib/pgbus/event_bus/registry.rb +22 -0
- data/lib/pgbus/process/consumer.rb +57 -22
- data/lib/pgbus/process/notify_hub.rb +273 -0
- data/lib/pgbus/process/notify_listener.rb +47 -2
- data/lib/pgbus/process/supervisor.rb +146 -34
- data/lib/pgbus/process/wake_pipe.rb +129 -0
- data/lib/pgbus/process/wildcard_queue_resolver.rb +35 -0
- data/lib/pgbus/process/worker.rb +58 -35
- data/lib/pgbus/version.rb +1 -1
- metadata +4 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Fork-side receiver for supervisor-mediated wake-ups (issue #381,
|
|
6
|
+
# worker_notify_scope: :supervisor). The supervisor's NotifyHub holds the
|
|
7
|
+
# write end of a per-fork pipe; this class owns the inherited read end and
|
|
8
|
+
# a single watcher thread that translates the byte protocol into the
|
|
9
|
+
# fork's existing primitives:
|
|
10
|
+
#
|
|
11
|
+
# W — a NOTIFY arrived for a queue this fork reads → wake_signal.notify!
|
|
12
|
+
# H — the shared listener is healthy (connected + delivering) → the
|
|
13
|
+
# fork may sleep up to the NOTIFY poll ceiling
|
|
14
|
+
# P — the shared listener is degraded (dead thread, mid-reconnect,
|
|
15
|
+
# pooler-deaf) → the fork falls back to fast polling
|
|
16
|
+
#
|
|
17
|
+
# Status starts optimistic (mirrors NotifyListener's @delivering default)
|
|
18
|
+
# so a just-forked worker isn't pinned to fast polling before the hub's
|
|
19
|
+
# first broadcast. EOF on the pipe means the supervisor is gone: mark
|
|
20
|
+
# not-delivering and let the fork run on plain polling.
|
|
21
|
+
#
|
|
22
|
+
# A nil reader (scope :fork, or the supervisor never armed the pipe)
|
|
23
|
+
# yields an inert instance: start is a no-op and delivering? is false, so
|
|
24
|
+
# wake_timeout math treats it exactly like an absent listener.
|
|
25
|
+
class WakePipe
|
|
26
|
+
WAKE = "W"
|
|
27
|
+
HEALTHY = "H"
|
|
28
|
+
DEGRADED = "P"
|
|
29
|
+
|
|
30
|
+
def initialize(reader, wake_signal:, logger: Pgbus.logger)
|
|
31
|
+
@reader = reader
|
|
32
|
+
@wake_signal = wake_signal
|
|
33
|
+
@logger = logger
|
|
34
|
+
@state_mutex = Mutex.new
|
|
35
|
+
@running = false
|
|
36
|
+
@thread = nil
|
|
37
|
+
@delivering = !reader.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delivering?
|
|
41
|
+
@state_mutex.synchronize { @delivering }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def running?
|
|
45
|
+
@state_mutex.synchronize { @running }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def start
|
|
49
|
+
return self if @reader.nil?
|
|
50
|
+
|
|
51
|
+
@state_mutex.synchronize do
|
|
52
|
+
return self if @running
|
|
53
|
+
|
|
54
|
+
@running = true
|
|
55
|
+
end
|
|
56
|
+
@thread = Thread.new { run_loop }
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stop
|
|
61
|
+
@state_mutex.synchronize do
|
|
62
|
+
return self unless @running
|
|
63
|
+
|
|
64
|
+
@running = false
|
|
65
|
+
end
|
|
66
|
+
# Closing the pipe FD interrupts the watcher's blocking readpartial —
|
|
67
|
+
# Ruby raises IOError in the blocked thread at the interpreter level.
|
|
68
|
+
# (Safe for a plain IO, unlike PG::Connection#close, which is PQfinish
|
|
69
|
+
# under a concurrent libpq call — issue #375. That constraint is about
|
|
70
|
+
# libpq, not IO.)
|
|
71
|
+
close_reader_quietly
|
|
72
|
+
@thread&.join(2)
|
|
73
|
+
@thread = nil
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def run_loop
|
|
80
|
+
loop do
|
|
81
|
+
break unless running?
|
|
82
|
+
|
|
83
|
+
handle_bytes(@reader.readpartial(4096))
|
|
84
|
+
end
|
|
85
|
+
rescue EOFError
|
|
86
|
+
# Supervisor exited: no more wakes will ever arrive on this pipe.
|
|
87
|
+
mark_not_delivering("supervisor wake pipe reached EOF — falling back to polling")
|
|
88
|
+
rescue IOError, Errno::EBADF
|
|
89
|
+
# Reader closed under us. Expected during #stop (running? already
|
|
90
|
+
# false); anything else degrades to polling like EOF.
|
|
91
|
+
mark_not_delivering("supervisor wake pipe closed — falling back to polling") if running?
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
mark_not_delivering("supervisor wake pipe failed (#{e.class}: #{e.message}) — falling back to polling")
|
|
94
|
+
ensure
|
|
95
|
+
@state_mutex.synchronize { @running = false }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# One coalesced read may carry several bytes. Status transitions apply
|
|
99
|
+
# in order; any number of W bytes collapses into one notify! (WakeSignal
|
|
100
|
+
# coalesces concurrent notifies anyway).
|
|
101
|
+
def handle_bytes(data)
|
|
102
|
+
wake = false
|
|
103
|
+
data.each_char do |byte|
|
|
104
|
+
case byte
|
|
105
|
+
when WAKE then wake = true
|
|
106
|
+
when HEALTHY then update_delivering(true)
|
|
107
|
+
when DEGRADED then update_delivering(false)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
@wake_signal.notify! if wake
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def update_delivering(value)
|
|
114
|
+
@state_mutex.synchronize { @delivering = value }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def mark_not_delivering(message)
|
|
118
|
+
update_delivering(false)
|
|
119
|
+
@logger.warn { "[Pgbus::WakePipe] #{message}" }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def close_reader_quietly
|
|
123
|
+
@reader.close if @reader && !@reader.closed?
|
|
124
|
+
rescue IOError, Errno::EBADF
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Resolves a wildcard ("*") queue list to the concrete logical queue names
|
|
6
|
+
# a job worker may adopt: every pgmq.meta queue minus dead-letter queues,
|
|
7
|
+
# stream queues (issue #309/#366 — a job worker adopting one would claim
|
|
8
|
+
# durable broadcasts and DLQ-move them out of replay history), and
|
|
9
|
+
# event-subscriber queues (issue #333 — event payloads, not ActiveJob
|
|
10
|
+
# jobs), with the configured prefix stripped.
|
|
11
|
+
#
|
|
12
|
+
# Shared by Worker#resolve_wildcard_queues (per-fork adoption) and the
|
|
13
|
+
# supervisor-owned NotifyHub (issue #381 — the LISTEN union for wildcard
|
|
14
|
+
# capsules), so both sides of the wake path agree on what "*" means.
|
|
15
|
+
module WildcardQueueResolver
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def resolve(config: Pgbus.configuration)
|
|
19
|
+
prefix = "#{config.queue_prefix}_"
|
|
20
|
+
|
|
21
|
+
# Reset first so a stream created since the last resolve is excluded.
|
|
22
|
+
Pgbus::StreamQueue.reset_cache!
|
|
23
|
+
stream_names = Pgbus::StreamQueue.known_names
|
|
24
|
+
event_names = Pgbus::EventBus::Registry.instance.event_queue_names
|
|
25
|
+
|
|
26
|
+
conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
27
|
+
conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
28
|
+
.reject { |q| q.end_with?(Pgbus::DEAD_LETTER_SUFFIX) }
|
|
29
|
+
.reject { |q| stream_names.include?(q) }
|
|
30
|
+
.reject { |q| event_names.include?(q) }
|
|
31
|
+
.map { |q| q.delete_prefix(prefix) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -19,6 +19,11 @@ module Pgbus
|
|
|
19
19
|
# between calls, and simulate start_notify_listener assigning the listener
|
|
20
20
|
# from inside a stub (production mutates all three in the run loop).
|
|
21
21
|
attr_accessor :notify_listener, :notify_retry_at, :notify_retry_backoff
|
|
22
|
+
# Supervisor-mediated wake source (issue #381, worker_notify_scope:
|
|
23
|
+
# :supervisor). Non-nil iff the supervisor forked us with a wake pipe;
|
|
24
|
+
# its presence switches the whole notify lifecycle from a fork-local
|
|
25
|
+
# NotifyListener to the shared hub. Readable as a test seam.
|
|
26
|
+
attr_reader :wake_pipe
|
|
22
27
|
|
|
23
28
|
# The collaborators below (rate_counter, wake_signal, stat_buffer) and the
|
|
24
29
|
# recycle clock (started_at_monotonic) accept injected seeds so tests can
|
|
@@ -30,7 +35,7 @@ module Pgbus
|
|
|
30
35
|
rate_counter: nil, wake_signal: nil, stat_buffer: :default,
|
|
31
36
|
notify_listener: nil, notify_retry_at: 0.0,
|
|
32
37
|
notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
|
|
33
|
-
started_at_monotonic: nil)
|
|
38
|
+
started_at_monotonic: nil, wake_pipe: nil)
|
|
34
39
|
@queues = Array(queues)
|
|
35
40
|
@initial_queues = @queues.dup.freeze
|
|
36
41
|
@wildcard = @queues.include?("*")
|
|
@@ -98,6 +103,10 @@ module Pgbus
|
|
|
98
103
|
# so the watchdog can detect a wedged worker even when the database (and
|
|
99
104
|
# thus the Heartbeat's loop_tick_at) is unavailable.
|
|
100
105
|
@liveness_pipe = liveness_pipe
|
|
106
|
+
# Supervisor wake pipe (read end, inherited across fork). When present
|
|
107
|
+
# the fork owns NO LISTEN connection: wakes and listener-health status
|
|
108
|
+
# arrive as bytes from the supervisor's NotifyHub.
|
|
109
|
+
@wake_pipe = wake_pipe ? WakePipe.new(wake_pipe, wake_signal: @wake_signal) : nil
|
|
101
110
|
end
|
|
102
111
|
|
|
103
112
|
def stats
|
|
@@ -150,7 +159,7 @@ module Pgbus
|
|
|
150
159
|
setup_signals
|
|
151
160
|
start_heartbeat
|
|
152
161
|
resolve_wildcard_queues
|
|
153
|
-
|
|
162
|
+
start_wake_source
|
|
154
163
|
@lifecycle.transition_to!(:running)
|
|
155
164
|
Pgbus.logger.info do
|
|
156
165
|
"[Pgbus] Worker started: queues=#{queues.join(",")} threads=#{threads} " \
|
|
@@ -404,34 +413,10 @@ module Pgbus
|
|
|
404
413
|
def resolve_wildcard_queues
|
|
405
414
|
return unless @wildcard
|
|
406
415
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
# be adopted by a wildcard worker: a worker would claim durable
|
|
412
|
-
# broadcasts, fail to deserialize them, and DLQ-move them out of the
|
|
413
|
-
# stream's replay history. known_names includes fingerprint-matched
|
|
414
|
-
# dormant pre-registry streams (issue #366) so they are excluded even
|
|
415
|
-
# before backfill. Reset first so a stream created since the last
|
|
416
|
-
# resolve is excluded.
|
|
417
|
-
Pgbus::StreamQueue.reset_cache!
|
|
418
|
-
stream_names = Pgbus::StreamQueue.known_names
|
|
419
|
-
|
|
420
|
-
# Event-bus subscriber queues also share the job namespace (pgbus_<handler>)
|
|
421
|
-
# but carry event payloads, not ActiveJob jobs. A wildcard worker that
|
|
422
|
-
# adopts one hands the event to the executor, which fails to deserialize
|
|
423
|
-
# it and DLQ-moves it out of the consumer's reach — so an app running the
|
|
424
|
-
# event bus had to hand-maintain an explicit queue list. Exclude them,
|
|
425
|
-
# like stream queues (issue #333).
|
|
426
|
-
event_names = Pgbus::EventBus::Registry.instance.event_queue_names
|
|
427
|
-
|
|
428
|
-
conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
|
|
429
|
-
all_queues = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
430
|
-
resolved = all_queues
|
|
431
|
-
.reject { |q| q.end_with?(dlq_suffix) }
|
|
432
|
-
.reject { |q| stream_names.include?(q) }
|
|
433
|
-
.reject { |q| event_names.include?(q) }
|
|
434
|
-
.map { |q| q.delete_prefix(prefix) }
|
|
416
|
+
# Exclusion rationale (streams, event queues, DLQs) lives with the
|
|
417
|
+
# shared resolver — the NotifyHub uses the same one, so the LISTEN
|
|
418
|
+
# union and the fork's adopted set can't drift (issue #381).
|
|
419
|
+
resolved = WildcardQueueResolver.resolve(config: config)
|
|
435
420
|
|
|
436
421
|
if resolved.empty?
|
|
437
422
|
Pgbus.logger.warn { "[Pgbus] Wildcard queue '*' resolved to no queues — falling back to default" }
|
|
@@ -627,6 +612,10 @@ module Pgbus
|
|
|
627
612
|
end
|
|
628
613
|
|
|
629
614
|
def listener_delivering?
|
|
615
|
+
# Supervisor scope: the hub's H/P broadcasts (via WakePipe) stand in
|
|
616
|
+
# for the local listener's health — same fast-poll fallback rules.
|
|
617
|
+
return @wake_pipe.delivering? if @wake_pipe
|
|
618
|
+
|
|
630
619
|
@notify_listener&.running? && @notify_listener.delivering?
|
|
631
620
|
end
|
|
632
621
|
|
|
@@ -642,12 +631,35 @@ module Pgbus
|
|
|
642
631
|
config.polling_interval
|
|
643
632
|
end
|
|
644
633
|
|
|
634
|
+
# :supervisor scope: the fork opens NO LISTEN connection; the WakePipe
|
|
635
|
+
# watcher is the wake source. A missing pipe under that scope means the
|
|
636
|
+
# hub failed to start — plain polling, NEVER a local listener, or a hub
|
|
637
|
+
# outage would balloon the host back to one direct connection per fork
|
|
638
|
+
# (the exact budget the scope exists to protect). :fork scope: the
|
|
639
|
+
# fork-local NotifyListener, exactly as before.
|
|
640
|
+
def start_wake_source
|
|
641
|
+
return @wake_pipe.start if @wake_pipe
|
|
642
|
+
|
|
643
|
+
start_notify_listener if local_listener_scope?
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Local NotifyListener lifecycle (start + self-heal) is allowed only
|
|
647
|
+
# under :fork scope.
|
|
648
|
+
def local_listener_scope?
|
|
649
|
+
config.worker_notify_scope == :fork
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def stop_wake_source
|
|
653
|
+
@wake_pipe&.stop
|
|
654
|
+
@notify_listener&.stop
|
|
655
|
+
end
|
|
656
|
+
|
|
645
657
|
def start_notify_listener
|
|
646
658
|
return unless notify_wakeup?
|
|
647
659
|
|
|
648
660
|
@notify_listener = NotifyListener.new(
|
|
649
661
|
physical_queues: physical_queue_names,
|
|
650
|
-
on_wake: -> { @wake_signal.notify! },
|
|
662
|
+
on_wake: ->(_channel) { @wake_signal.notify! },
|
|
651
663
|
connection_options: config.worker_notify_connection_options,
|
|
652
664
|
health_check_ms: (config.polling_interval * 1000).to_i.clamp(250, 5_000),
|
|
653
665
|
logger: Pgbus.logger
|
|
@@ -666,6 +678,12 @@ module Pgbus
|
|
|
666
678
|
# its queue subscription reconciled (wildcard workers) and the backoff
|
|
667
679
|
# reset; a still-failing restart doubles the backoff up to the cap.
|
|
668
680
|
def ensure_notify_listener
|
|
681
|
+
# Supervisor scope: listener self-healing is the hub's job (it runs
|
|
682
|
+
# once per host in the supervisor's monitor tick), and a pipe-less
|
|
683
|
+
# fork under that scope must stay on plain polling — self-healing a
|
|
684
|
+
# listener it was never allowed to start would leak a connection.
|
|
685
|
+
return if @wake_pipe
|
|
686
|
+
return unless local_listener_scope?
|
|
669
687
|
return unless notify_wakeup?
|
|
670
688
|
return if @notify_listener&.running?
|
|
671
689
|
return if monotonic_now < @notify_retry_at
|
|
@@ -706,13 +724,18 @@ module Pgbus
|
|
|
706
724
|
Pgbus.logger.warn { "[Pgbus] NotifyListener queue sync failed: #{e.class}: #{e.message}" }
|
|
707
725
|
end
|
|
708
726
|
|
|
727
|
+
# Through config.queue_name (not raw concatenation) so a logical name
|
|
728
|
+
# needing normalization (e.g. "orders-handler" → "orders_handler")
|
|
729
|
+
# yields the SAME physical name the queue table was created with — the
|
|
730
|
+
# NOTIFY channel derives from the table name, so a raw concat would
|
|
731
|
+
# LISTEN on a channel that never fires. Keeps the fork-local (:fork
|
|
732
|
+
# scope) channels identical to the NotifyHub's (issue #381 review).
|
|
709
733
|
def physical_queue_names
|
|
710
|
-
|
|
711
|
-
queues.map { |q| "#{prefix}#{q}" }
|
|
734
|
+
queues.map { |q| config.queue_name(q) }
|
|
712
735
|
end
|
|
713
736
|
|
|
714
737
|
def channel_to_physical(channel)
|
|
715
|
-
|
|
738
|
+
NotifyListener.physical_for(channel)
|
|
716
739
|
end
|
|
717
740
|
|
|
718
741
|
def start_heartbeat
|
|
@@ -767,7 +790,7 @@ module Pgbus
|
|
|
767
790
|
|
|
768
791
|
def shutdown
|
|
769
792
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
770
|
-
|
|
793
|
+
stop_wake_source
|
|
771
794
|
@pool.shutdown
|
|
772
795
|
@pool.wait_for_termination(30)
|
|
773
796
|
@stat_buffer&.stop
|
data/lib/pgbus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgbus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -328,13 +328,16 @@ files:
|
|
|
328
328
|
- lib/pgbus/process/heartbeat.rb
|
|
329
329
|
- lib/pgbus/process/lifecycle.rb
|
|
330
330
|
- lib/pgbus/process/memory_usage.rb
|
|
331
|
+
- lib/pgbus/process/notify_hub.rb
|
|
331
332
|
- lib/pgbus/process/notify_listener.rb
|
|
332
333
|
- lib/pgbus/process/notify_probe.rb
|
|
333
334
|
- lib/pgbus/process/primary_validator.rb
|
|
334
335
|
- lib/pgbus/process/queue_lock.rb
|
|
335
336
|
- lib/pgbus/process/signal_handler.rb
|
|
336
337
|
- lib/pgbus/process/supervisor.rb
|
|
338
|
+
- lib/pgbus/process/wake_pipe.rb
|
|
337
339
|
- lib/pgbus/process/wake_signal.rb
|
|
340
|
+
- lib/pgbus/process/wildcard_queue_resolver.rb
|
|
338
341
|
- lib/pgbus/process/worker.rb
|
|
339
342
|
- lib/pgbus/queue_factory.rb
|
|
340
343
|
- lib/pgbus/queue_name_validator.rb
|