pgbus 0.13.0 → 0.13.2

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,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
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ module Web
7
+ module Streamer
8
+ # Framing for the master-hub Unix socket (issue #382): 4-byte big-endian
9
+ # payload length + UTF-8 JSON. Unlike the job-side wake pipes (1-byte,
10
+ # lossy-by-design — Process::WakePipe), stream frames can carry an
11
+ # ephemeral broadcast's ONLY copy of its HTML, so the transport is
12
+ # length-prefixed and lossless; drop decisions are made per-message by
13
+ # the MasterHub, never by the wire format.
14
+ #
15
+ # Message shapes (JSON objects; "t" is the discriminator):
16
+ # worker → master: {t:"sub", q:} subscribe, synchronous — master acks
17
+ # {t:"unsub", q:} unsubscribe, fire-and-forget
18
+ # master → worker: {t:"ack", q:} sub acknowledged (LISTEN active)
19
+ # {t:"wake", q:, p: <String|nil>} durable (p:nil) or ephemeral wake
20
+ # {t:"status", healthy: <bool>} listener health broadcast
21
+ #
22
+ # Reads are blocking (each side owns a dedicated reader thread); a short
23
+ # read means the peer died mid-frame and is reported as EOF (nil), never
24
+ # as a truncated message.
25
+ module HubProtocol
26
+ class ProtocolError < StandardError; end
27
+
28
+ HEADER_BYTES = 4
29
+ # Generous ceiling for ephemeral HTML payloads; a frame announcing
30
+ # more than this is a corrupt stream or a runaway producer — sever
31
+ # rather than allocate.
32
+ MAX_FRAME_BYTES = 4 * 1024 * 1024
33
+
34
+ module_function
35
+
36
+ def encode(message)
37
+ json = JSON.generate(message)
38
+ bytes = json.b
39
+ raise ProtocolError, "frame too large: #{bytes.bytesize} bytes (max #{MAX_FRAME_BYTES})" if
40
+ bytes.bytesize > MAX_FRAME_BYTES
41
+
42
+ [bytes.bytesize].pack("N") + bytes
43
+ end
44
+
45
+ # Returns the decoded Hash, or nil on EOF — clean close, peer death
46
+ # mid-frame, OR a connection reset: an abrupt close can surface as
47
+ # ECONNRESET instead of orderly EOF depending on unread data and
48
+ # platform (Ruby 4.0 reports it deterministically where 3.x saw EOF),
49
+ # and both mean the same thing here: the peer is gone. Raises
50
+ # ProtocolError on an oversized announcement or malformed JSON.
51
+ def read_frame(io)
52
+ header = read_exactly(io, HEADER_BYTES)
53
+ return nil unless header
54
+
55
+ length = header.unpack1("N")
56
+ raise ProtocolError, "frame too large: #{length} bytes (max #{MAX_FRAME_BYTES})" if length > MAX_FRAME_BYTES
57
+
58
+ body = read_exactly(io, length)
59
+ return nil unless body
60
+
61
+ body = body.force_encoding(Encoding::UTF_8)
62
+ raise ProtocolError, "malformed frame: invalid UTF-8" unless body.valid_encoding?
63
+
64
+ decoded = JSON.parse(body)
65
+ raise ProtocolError, "malformed frame: expected a JSON object, got #{decoded.class}" unless decoded.is_a?(Hash)
66
+
67
+ decoded
68
+ rescue JSON::ParserError => e
69
+ raise ProtocolError, "malformed frame: #{e.message}"
70
+ rescue Errno::ECONNRESET
71
+ nil
72
+ end
73
+
74
+ # Blocking read of exactly +count+ bytes; nil on EOF (including EOF
75
+ # partway through — IO#read returns the short tail once, then nil).
76
+ def read_exactly(io, count)
77
+ data = io.read(count)
78
+ return nil if data.nil? || data.bytesize < count
79
+
80
+ data
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -39,7 +39,6 @@ module Pgbus
39
39
  @dispatch_queue = dispatch_queue || Queue.new
40
40
 
41
41
  @stream_counter = StreamCounter.new
42
- @pg_connection = pg_connection || build_pg_connection
43
42
  # Self-tuning streams-pool autoscaler (issue #323). Opt-in; nil unless
44
43
  # enabled AND on the dedicated connection path (the shared-AR streams
45
44
  # pool aliases the non-thread-safe job pool and resize is a no-op there).
@@ -50,25 +49,7 @@ module Pgbus
50
49
  if @config.streams_pool_autoscale && !@client.shared_connection?
51
50
  Pgbus::Streams::PoolAutoscaler.new(client: @client, config: @config, logger: @logger)
52
51
  end
53
- @listener = Listener.new(
54
- pg_connection: @pg_connection,
55
- dispatch_queue: @dispatch_queue,
56
- health_check_ms: @config.streams_listen_health_check_ms,
57
- # Opt-in dispatch-queue backpressure (issue #315 item 3). 0 =
58
- # unbounded (default). The queue itself stays an unbounded
59
- # Queue.new so the request-thread Connect push and the dispatcher's
60
- # own prune_dead self-post never block.
61
- dispatch_queue_limit: @config.streams_dispatch_queue_limit,
62
- maintenance: build_autoscale_maintenance,
63
- logger: @logger,
64
- # On reconnect the Listener rebuilds its OWN connection via this
65
- # factory (fresh connect re-resolves DNS, converges on the promoted
66
- # primary after a failover) instead of resetting a possibly-dead
67
- # socket. Always provided — even when an initial pg_connection: is
68
- # injected, the reconnect path builds a fresh raw connection. A test
69
- # can inject its own factory to avoid touching real configuration.
70
- connection_factory: connection_factory || -> { build_raw_pg_connection }
71
- )
52
+ @listener = build_listener(pg_connection, connection_factory)
72
53
  # Off-thread durable fanout writer (issue #321). Built only when
73
54
  # streams_writer_threads > 0; nil means fanout writes stay inline on
74
55
  # the dispatcher thread (the default, pre-#321 behavior). The pump
@@ -169,6 +150,74 @@ module Pgbus
169
150
 
170
151
  private
171
152
 
153
+ # Selects the wake source by streams_listen_scope (issue #382).
154
+ # :master with a reachable hub socket → FailoverListener over a
155
+ # HubClient (NO per-worker LISTEN connection is opened). Anything
156
+ # else — scope :process, no socket exported (single mode,
157
+ # non-preforking server, hub failed to start), or a refused connect —
158
+ # keeps today's per-worker Listener.
159
+ def build_listener(pg_connection, connection_factory)
160
+ hub = build_hub_listener(connection_factory)
161
+ return hub if hub
162
+
163
+ build_local_listener(pg_connection || build_pg_connection, connection_factory)
164
+ end
165
+
166
+ def build_hub_listener(connection_factory)
167
+ return nil unless @config.streams_listen_scope == :master
168
+
169
+ socket_path = ENV.fetch("PGBUS_STREAMS_HUB_SOCKET", nil)
170
+ return nil if socket_path.nil? || socket_path.empty?
171
+
172
+ # The worker's ack deadline must exceed the master's own internal
173
+ # ensure_listening budget (its listener's health-check cycle + 1s).
174
+ failover = nil
175
+ client = HubClient.new(
176
+ socket_path: socket_path,
177
+ dispatch_queue: @dispatch_queue,
178
+ ack_timeout: (@config.streams_listen_health_check_ms / 1000.0) + 2.0,
179
+ # failover is assigned right below; a transport death in the gap
180
+ # is caught by the FailoverListener's synchronous ensure path.
181
+ on_failure: -> { failover&.fail_over! },
182
+ logger: @logger
183
+ )
184
+ client.connect
185
+ failover = FailoverListener.new(
186
+ hub_client: client,
187
+ local_listener_factory: lambda do
188
+ build_local_listener(build_pg_connection, connection_factory).tap(&:start)
189
+ end,
190
+ logger: @logger
191
+ )
192
+ rescue HubClient::HubUnavailableError => e
193
+ @logger.info do
194
+ "[Pgbus::Streamer] master hub not reachable (#{e.message}) — using a per-worker listener"
195
+ end
196
+ nil
197
+ end
198
+
199
+ def build_local_listener(pg_connection, connection_factory)
200
+ Listener.new(
201
+ pg_connection: pg_connection,
202
+ dispatch_queue: @dispatch_queue,
203
+ health_check_ms: @config.streams_listen_health_check_ms,
204
+ # Opt-in dispatch-queue backpressure (issue #315 item 3). 0 =
205
+ # unbounded (default). The queue itself stays an unbounded
206
+ # Queue.new so the request-thread Connect push and the dispatcher's
207
+ # own prune_dead self-post never block.
208
+ dispatch_queue_limit: @config.streams_dispatch_queue_limit,
209
+ maintenance: build_autoscale_maintenance,
210
+ logger: @logger,
211
+ # On reconnect the Listener rebuilds its OWN connection via this
212
+ # factory (fresh connect re-resolves DNS, converges on the promoted
213
+ # primary after a failover) instead of resetting a possibly-dead
214
+ # socket. Always provided — even when an initial pg_connection: is
215
+ # injected, the reconnect path builds a fresh raw connection. A test
216
+ # can inject its own factory to avoid touching real configuration.
217
+ connection_factory: connection_factory || -> { build_raw_pg_connection }
218
+ )
219
+ end
220
+
172
221
  def safely
173
222
  yield
174
223
  rescue StandardError => e
@@ -41,8 +41,11 @@ module Pgbus
41
41
  end
42
42
  end
43
43
 
44
- CHANNEL_PREFIX = "pgmq.q_"
45
- CHANNEL_SUFFIX = ".INSERT"
44
+ # Single-sourced from NotifyListener, which owns the pgmq channel
45
+ # format (issue #381 review — the two copies had already drifted apart
46
+ # once in spirit if not in bytes).
47
+ CHANNEL_PREFIX = Pgbus::Process::NotifyListener::CHANNEL_PREFIX
48
+ CHANNEL_SUFFIX = Pgbus::Process::NotifyListener::CHANNEL_SUFFIX
46
49
 
47
50
  RECONNECT_BACKOFF_SECONDS = 0.5
48
51
 
@@ -102,6 +105,18 @@ module Pgbus
102
105
  self
103
106
  end
104
107
 
108
+ # Health signals for the MasterHub's status broadcasts (issue #382).
109
+ # Read cross-thread without synchronization: ivar assignment is atomic
110
+ # in MRI and a momentarily stale value only delays one status tick —
111
+ # these must never touch the connection itself (single-owner, #375).
112
+ def alive?
113
+ !!@thread&.alive?
114
+ end
115
+
116
+ def connected?
117
+ !@conn.nil?
118
+ end
119
+
105
120
  def stop
106
121
  return unless @running
107
122