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.
@@ -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
@@ -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
- start_notify_listener
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
- dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
408
- prefix = "#{config.queue_prefix}_"
409
-
410
- # Stream queues share the job namespace (pgbus_<name>) but must never
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
- prefix = "#{config.queue_prefix}_"
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
- channel.delete_prefix(NotifyListener::CHANNEL_PREFIX).delete_suffix(NotifyListener::CHANNEL_SUFFIX)
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
- @notify_listener&.stop
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.12.4"
4
+ VERSION = "0.13.0"
5
5
  end
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.12.4
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