pgbus 0.12.4 → 0.13.1

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.1"
5
5
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ module Streamer
6
+ # The worker-side seam between the two listening modes (issue #382):
7
+ # starts on the master hub (HubClient) and fails over — once, one-way —
8
+ # to a per-worker Listener when the hub transport dies (master gone,
9
+ # ack deadline, eviction). The Dispatcher/Instance consume the same
10
+ # ensure_listening/remove_listening/stop surface either way and never
11
+ # learn which mode is active.
12
+ #
13
+ # Fallback direction is settled on #382: per-worker listener, not
14
+ # poll-only — ephemeral broadcasts have no polling equivalent (their
15
+ # payload exists only in the NOTIFY), so an outage trades connections
16
+ # for unchanged semantics. Once fallen back, the worker stays local
17
+ # until it recycles; no flap-back.
18
+ #
19
+ # The subscription set is recorded here so failover can rebuild the
20
+ # exact LISTEN set on the fresh local connection before anything else
21
+ # relies on it. ensure_listening NEVER raises to the dispatcher: on a
22
+ # double failure (hub dead AND local build failing — e.g. DB down) it
23
+ # logs and returns nil, matching the Listener's own ack-timeout
24
+ # contract, which the dispatcher already tolerates.
25
+ class FailoverListener
26
+ def initialize(hub_client:, local_listener_factory:, logger: Pgbus.logger)
27
+ @hub_client = hub_client
28
+ @local_listener_factory = local_listener_factory
29
+ @logger = logger
30
+ # @state_mutex guards the cheap shared state (@subscriptions, @impl,
31
+ # @failed_over) and is only ever held for constant-time work — the
32
+ # dispatcher's ensure/remove path must never wait behind a failover
33
+ # build. @failover_mutex serializes the (blocking) build + replay:
34
+ # a fresh PG connect + N re-LISTEN acks can stall for seconds when
35
+ # the trigger IS a database problem (review on #384).
36
+ @state_mutex = Mutex.new
37
+ @failover_mutex = Mutex.new
38
+ @subscriptions = Set.new
39
+ @impl = hub_client
40
+ @failed_over = false
41
+ end
42
+
43
+ # Interface parity with Listener for Instance#start: the hub client
44
+ # connected at construction and the fallback starts itself on swap.
45
+ def start
46
+ self
47
+ end
48
+
49
+ def ensure_listening(queue)
50
+ @state_mutex.synchronize { @subscriptions.add(queue) }
51
+ current_impl.ensure_listening(queue)
52
+ rescue HubClient::HubUnavailableError
53
+ fail_over!
54
+ begin
55
+ current_impl.ensure_listening(queue)
56
+ rescue HubClient::HubUnavailableError
57
+ # fail_over! itself failed (factory raised) and @impl is still the
58
+ # dead client — reported there; honor the nil-on-timeout contract.
59
+ nil
60
+ end
61
+ end
62
+
63
+ def remove_listening(queue)
64
+ @state_mutex.synchronize { @subscriptions.delete(queue) }
65
+ current_impl.remove_listening(queue)
66
+ rescue HubClient::HubUnavailableError => e
67
+ @logger.debug do
68
+ "[Pgbus::Streamer::FailoverListener] remove_listening on a dead hub client " \
69
+ "(#{e.message}) — ignoring, unlisten GC is best-effort"
70
+ end
71
+ nil
72
+ end
73
+
74
+ # Idempotent, callable from the client's on_failure (reader thread)
75
+ # and from a synchronous ensure failure (dispatcher thread).
76
+ # @failover_mutex serializes concurrent callers — the second blocks
77
+ # until the first finishes and then no-ops, so a synchronous retry
78
+ # after fail_over! always lands on the swapped-in local listener.
79
+ # The blocking build + replay runs OUTSIDE @state_mutex so concurrent
80
+ # ensure/remove/stop calls never stall behind it.
81
+ def fail_over!
82
+ local = nil
83
+ @failover_mutex.synchronize do
84
+ return if @state_mutex.synchronize { @failed_over }
85
+
86
+ local = @local_listener_factory.call
87
+ @state_mutex.synchronize { @subscriptions.dup }.each { |q| local.ensure_listening(q) }
88
+ # Subscriptions recorded between the snapshot and this swap arrive
89
+ # via their own retried ensure_listening call on the new impl.
90
+ @state_mutex.synchronize do
91
+ @impl = local
92
+ @failed_over = true
93
+ end
94
+ # Ownership transferred to @impl — the rescue must not stop it.
95
+ local = nil
96
+ end
97
+ rescue StandardError => e
98
+ # A listener the factory STARTED but that never swapped in (the
99
+ # replay raised) would otherwise leak its thread and LISTEN
100
+ # connection alongside the dead hub client.
101
+ begin
102
+ local&.stop
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ # Hub dead AND the local listener can't be built (DB down, config
107
+ # broken). Mark failed-over so callers stop rebuilding; @impl stays
108
+ # on the dead client — every ensure_listening resolves nil and the
109
+ # dispatcher rides its existing timeout tolerance until the worker
110
+ # recycles.
111
+ @state_mutex.synchronize { @failed_over = true }
112
+ @logger.error do
113
+ "[Pgbus::Streamer::FailoverListener] fallback listener failed to build " \
114
+ "(#{e.class}: #{e.message}) — streams degraded until this worker recycles"
115
+ end
116
+ end
117
+
118
+ def stop
119
+ current_impl.stop
120
+ end
121
+
122
+ private
123
+
124
+ def current_impl
125
+ @state_mutex.synchronize { @impl }
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Pgbus
6
+ module Web
7
+ module Streamer
8
+ # Worker-side client for the MasterHub (issue #382). Presents the same
9
+ # surface the Dispatcher consumes from a Listener — synchronous
10
+ # `ensure_listening` (the no-lost-broadcast ack contract, now crossing
11
+ # the process boundary), async `remove_listening` — while wakes arrive
12
+ # as HubProtocol frames and are re-materialized into the worker's
13
+ # dispatch queue as WakeMessages.
14
+ #
15
+ # Failure model: this class never retries. Connect refusal, an ack
16
+ # deadline, or transport EOF (master died / eviction) marks the client
17
+ # dead, fails every pending sub, and fires +on_failure+ exactly once —
18
+ # the FailoverListener's cue to swap in a per-worker Listener. One-way:
19
+ # once a worker has fallen back it stays local until it recycles
20
+ # (settled on #382 — no flap-back complexity).
21
+ class HubClient
22
+ class HubUnavailableError < StandardError; end
23
+
24
+ # Optimistic before the first status broadcast, mirroring WakePipe /
25
+ # NotifyListener: a just-connected worker isn't treated as degraded
26
+ # before the hub has said anything.
27
+ def initialize(socket_path:, dispatch_queue:, ack_timeout: 2.0,
28
+ on_failure: nil, logger: Pgbus.logger)
29
+ @socket_path = socket_path
30
+ @dispatch_queue = dispatch_queue
31
+ @ack_timeout = ack_timeout
32
+ @on_failure = on_failure
33
+ @logger = logger
34
+ @write_mutex = Mutex.new
35
+ @ack_mutex = Mutex.new
36
+ @pending_acks = Hash.new { |h, k| h[k] = [] }
37
+ @hub_healthy = true
38
+ @dead = false
39
+ @stopping = false
40
+ @sock = nil
41
+ @reader = nil
42
+ end
43
+
44
+ def connect
45
+ @sock = UNIXSocket.new(@socket_path)
46
+ @reader = Thread.new { reader_loop }
47
+ self
48
+ rescue SystemCallError, IOError, ArgumentError, ThreadError => e
49
+ # ArgumentError: a socket path over the platform sun_path limit;
50
+ # IOError: a path that exists but is not a socket; ThreadError: the
51
+ # reader thread could not spawn. All must fall back exactly like a
52
+ # refused connect, never abort worker boot — and never leak the
53
+ # half-opened socket.
54
+ close_quietly(@sock)
55
+ @sock = nil
56
+ raise HubUnavailableError, "cannot reach master hub at #{@socket_path}: #{e.class}: #{e.message}"
57
+ end
58
+
59
+ def hub_healthy?
60
+ @hub_healthy
61
+ end
62
+
63
+ def dead?
64
+ @dead
65
+ end
66
+
67
+ # Synchronous, bounded: returns :done once the master has confirmed
68
+ # LISTEN is active for +queue+. Raises HubUnavailableError on a dead
69
+ # transport or an expired ack deadline (which also kills the
70
+ # transport — a hub that can't ack in time can't be trusted with the
71
+ # no-lost-broadcast contract either).
72
+ def ensure_listening(queue)
73
+ raise HubUnavailableError, "master hub transport is dead" if @dead
74
+
75
+ waiter = Queue.new
76
+ @ack_mutex.synchronize { @pending_acks[queue] << waiter }
77
+ write_frame({ "t" => "sub", "q" => queue })
78
+
79
+ result = waiter.pop(timeout: @ack_timeout)
80
+ if result.nil?
81
+ discard_waiter(queue, waiter)
82
+ mark_dead("sub ack for #{queue} not received within #{@ack_timeout}s")
83
+ raise HubUnavailableError, "master hub ack timeout for #{queue}"
84
+ end
85
+ raise HubUnavailableError, "master hub died while awaiting ack for #{queue}" if result == :dead
86
+
87
+ :done
88
+ end
89
+
90
+ # Lazy GC, fire-and-forget — no correctness path waits on UNLISTEN
91
+ # (mirrors Listener#remove_listening). A dead transport is a no-op:
92
+ # the master's EOF cleanup already released this worker's refs.
93
+ def remove_listening(queue)
94
+ return if @dead
95
+
96
+ write_frame({ "t" => "unsub", "q" => queue })
97
+ rescue HubUnavailableError
98
+ nil
99
+ end
100
+
101
+ def stop
102
+ @stopping = true
103
+ close_quietly(@sock)
104
+ @reader&.join(2)
105
+ @reader = nil
106
+ self
107
+ end
108
+
109
+ private
110
+
111
+ def reader_loop
112
+ loop do
113
+ frame = HubProtocol.read_frame(@sock)
114
+ break if frame.nil?
115
+
116
+ handle_frame(frame)
117
+ end
118
+ mark_dead("master hub closed the transport") unless @stopping
119
+ rescue HubProtocol::ProtocolError => e
120
+ mark_dead("master hub protocol error: #{e.message}") unless @stopping
121
+ rescue IOError, Errno::EBADF, Errno::ECONNRESET
122
+ mark_dead("master hub transport error") unless @stopping
123
+ rescue StandardError => e
124
+ # The reader thread is the ONLY detector of hub death — an
125
+ # unexpected error must not let it exit with the client still
126
+ # reporting healthy, or the worker goes silently deaf.
127
+ mark_dead("master hub reader crashed: #{e.class}: #{e.message}") unless @stopping
128
+ end
129
+
130
+ def handle_frame(frame)
131
+ case frame["t"]
132
+ when "wake"
133
+ @dispatch_queue << Listener::WakeMessage.new(queue_name: frame["q"], payload: frame["p"])
134
+ when "ack"
135
+ @ack_mutex.synchronize { @pending_acks[frame["q"]].shift }&.push(:ack)
136
+ when "status"
137
+ @hub_healthy = frame["healthy"]
138
+ else
139
+ @logger.warn { "[Pgbus::Streamer::HubClient] unknown frame from master: #{frame["t"].inspect}" }
140
+ end
141
+ end
142
+
143
+ # Frames must never interleave — all writes go through one mutex
144
+ # (writers: dispatcher thread via ensure/remove; no writer thread
145
+ # needed client-side, sub/unsub frames are tiny). Bounded: a master
146
+ # that stopped draining its input would otherwise block this write
147
+ # forever, and the ack deadline only starts ticking AFTER the write
148
+ # returns — so a stalled write is itself a failover trigger.
149
+ def write_frame(message)
150
+ data = HubProtocol.encode(message)
151
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @ack_timeout
152
+ @write_mutex.synchronize do
153
+ until data.empty?
154
+ begin
155
+ written = @sock.write_nonblock(data)
156
+ data = data.byteslice(written..)
157
+ rescue IO::WaitWritable
158
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
159
+ raise Errno::ETIMEDOUT, "write stalled" if remaining <= 0 || !@sock.wait_writable(remaining)
160
+ end
161
+ end
162
+ end
163
+ rescue IOError, Errno::EPIPE, Errno::EBADF, Errno::ECONNRESET, Errno::ETIMEDOUT => e
164
+ mark_dead("write to master hub failed: #{e.class}")
165
+ raise HubUnavailableError, "master hub transport is dead"
166
+ end
167
+
168
+ # Idempotent: first caller flips @dead, fails every waiter, fires
169
+ # on_failure once. Reachable from the reader (EOF/protocol error) and
170
+ # from ack timeouts / failed writes on caller threads.
171
+ def mark_dead(reason)
172
+ waiters = @ack_mutex.synchronize do
173
+ return if @dead
174
+
175
+ @dead = true
176
+ drained = @pending_acks.values.flatten
177
+ @pending_acks.clear
178
+ drained
179
+ end
180
+ @hub_healthy = false
181
+ waiters.each { |w| w << :dead }
182
+ close_quietly(@sock)
183
+ @logger.warn { "[Pgbus::Streamer::HubClient] #{reason} — falling back to a per-worker listener" }
184
+ @on_failure&.call
185
+ end
186
+
187
+ def discard_waiter(queue, waiter)
188
+ @ack_mutex.synchronize { @pending_acks[queue].delete(waiter) }
189
+ end
190
+
191
+ def close_quietly(io)
192
+ io.close if io && !io.closed?
193
+ rescue IOError, Errno::EBADF
194
+ nil
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end