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,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+
6
+ module Pgbus
7
+ module Web
8
+ module Streamer
9
+ # Master-process streams hub (issue #382): ONE LISTEN connection per web
10
+ # host instead of one per Puma worker. Runs in the Puma master (started
11
+ # by the pgbus_streams plugin), owns a single Web::Streamer::Listener on
12
+ # the refcounted union of every worker's stream channels, and fans wakes
13
+ # (including ephemeral payloads) out to workers over a Unix domain
14
+ # socket using HubProtocol frames.
15
+ #
16
+ # Workers are CLIENTS: they connect lazily to +socket_path+ on first SSE
17
+ # use (HubClient). Nothing is inherited across fork, so there is no FD
18
+ # hygiene for this transport, and a server that never starts a hub (no
19
+ # preload_app!, single mode, hub crash) simply has no socket — every
20
+ # worker falls back to its own per-worker Listener (FailoverListener),
21
+ # trading connections for unchanged semantics (settled on #382).
22
+ #
23
+ # The no-lost-wake ack contract, cross-process: a worker's sub is
24
+ # registered in the routing table BEFORE the hub executes LISTEN, and
25
+ # the ack is sent only AFTER ensure_listening returns — so from the
26
+ # moment LISTEN is active every wake reaches the subscribing worker.
27
+ # Over-delivery before the ack is harmless; under-delivery is the only
28
+ # failure mode that matters (same principle as Process::NotifyHub).
29
+ #
30
+ # Backpressure (per-worker outbound queue + writer thread):
31
+ # - durable wakes (payload nil) are droppable beyond durable_queue_limit
32
+ # — the next durable wake re-reads from the min cursor, so they
33
+ # self-heal (mirrors dispatch_queue_limit semantics);
34
+ # - ephemeral wakes are NEVER dropped: they push past the durable cap,
35
+ # and a worker whose queue exceeds hard_queue_limit is EVICTED
36
+ # (socket severed) — which triggers that worker's own fallback
37
+ # listener. A wedged worker degrades itself, never its siblings.
38
+ #
39
+ # Threading: accept thread + fanout thread + status thread, plus one
40
+ # reader and one writer thread per connected worker. The routing table
41
+ # is guarded by @table_mutex; each worker's outbox by its own mutex.
42
+ # All socket WRITES go through that worker's writer thread (frames must
43
+ # never interleave).
44
+ class MasterHub
45
+ DEFAULT_DURABLE_QUEUE_LIMIT = 256
46
+ DEFAULT_HARD_QUEUE_LIMIT = 1024
47
+ # Status is rebroadcast every REBROADCAST_TICKS status intervals even
48
+ # unchanged, so a worker that connected mid-outage converges.
49
+ REBROADCAST_TICKS = 5
50
+
51
+ attr_reader :socket_path
52
+
53
+ def initialize(config:, socket_path:, listener_factory: nil, status_interval: 1.0,
54
+ durable_queue_limit: DEFAULT_DURABLE_QUEUE_LIMIT,
55
+ hard_queue_limit: DEFAULT_HARD_QUEUE_LIMIT, logger: Pgbus.logger)
56
+ @config = config
57
+ @socket_path = socket_path
58
+ @status_interval = status_interval
59
+ @durable_queue_limit = durable_queue_limit
60
+ @hard_queue_limit = hard_queue_limit
61
+ @logger = logger
62
+ @listener_factory = listener_factory || default_listener_factory
63
+ @dispatch_queue = Queue.new
64
+ # Serializes the FULL start and stop sequences: a stop racing an
65
+ # in-progress start (blocked in the listener factory's PG connect)
66
+ # must WAIT for it and then tear everything down — otherwise stop
67
+ # returns having cleaned nothing and start finishes building a live
68
+ # hub afterwards. Loop threads never take this mutex (they read
69
+ # @running via @table_mutex), so holding it across the blocking
70
+ # startup cannot deadlock them.
71
+ @lifecycle_mutex = Mutex.new
72
+ @table_mutex = Mutex.new
73
+ @workers = {}
74
+ # Plain Hash, entries created ONLY at subscribe time — a default
75
+ # proc here would leak one empty Set per wake that arrives for an
76
+ # already-unsubscribed channel (in-flight NOTIFYs after the last
77
+ # unsub, per-record stream names → unbounded, review on #384).
78
+ @queue_refs = {}
79
+ @stop_signal = Queue.new
80
+ @next_id = 0
81
+ @dropped_durable_wakes = 0
82
+ @evicted_workers = 0
83
+ @running = false
84
+ end
85
+
86
+ def dropped_durable_wakes
87
+ @table_mutex.synchronize { @dropped_durable_wakes }
88
+ end
89
+
90
+ def evicted_workers
91
+ @table_mutex.synchronize { @evicted_workers }
92
+ end
93
+
94
+ # The factory must return a STARTED listener wired to +dispatch_queue+.
95
+ # If any step after the listener exists fails (bad socket path, chmod,
96
+ # thread spawn), the listener — and its dedicated LISTEN connection,
97
+ # the exact resource this hub conserves — is stopped before the error
98
+ # propagates; MasterHubBoot's rescue never sees a leaked connection.
99
+ def start
100
+ @lifecycle_mutex.synchronize { locked_start }
101
+ end
102
+
103
+ def stop
104
+ @lifecycle_mutex.synchronize { locked_stop }
105
+ end
106
+
107
+ private
108
+
109
+ def locked_start
110
+ @table_mutex.synchronize { @running = true }
111
+ @listener = @listener_factory.call(dispatch_queue: @dispatch_queue)
112
+ FileUtils.rm_f(@socket_path)
113
+ # Owner-only: the socket carries every stream wake including
114
+ # ephemeral HTML payloads, and there is no peer authentication —
115
+ # the filesystem mode IS the access control. The umask covers the
116
+ # bind itself so there is no window in which the socket exists with
117
+ # wider permissions; the chmod stays as the second guarantee.
118
+ # File.umask is process-wide, but this runs once at hub start in
119
+ # the Puma master and is restored in the ensure.
120
+ old_umask = File.umask(0o177)
121
+ begin
122
+ @server = UNIXServer.new(@socket_path)
123
+ ensure
124
+ File.umask(old_umask)
125
+ end
126
+ File.chmod(0o600, @socket_path)
127
+ @accept_thread = Thread.new { accept_loop }
128
+ @fanout_thread = Thread.new { fanout_loop }
129
+ @status_thread = Thread.new { status_loop }
130
+ self
131
+ rescue StandardError
132
+ @table_mutex.synchronize { @running = false }
133
+ close_quietly(@server)
134
+ @listener&.stop
135
+ @listener = nil
136
+ raise
137
+ end
138
+
139
+ def locked_stop
140
+ @table_mutex.synchronize do
141
+ return self unless @running
142
+
143
+ @running = false
144
+ end
145
+ @stop_signal << :stop
146
+ close_quietly(@server)
147
+ @dispatch_queue << :stop
148
+ worker_ids = @table_mutex.synchronize { @workers.keys }
149
+ worker_ids.each { |id| cleanup_worker(id) }
150
+ [@accept_thread, @fanout_thread, @status_thread].each { |t| t&.join(2) }
151
+ @listener&.stop
152
+ FileUtils.rm_f(@socket_path)
153
+ self
154
+ end
155
+
156
+ def default_listener_factory
157
+ lambda do |dispatch_queue:|
158
+ build_connection = -> { Pgbus::DedicatedConnection.connect(@config.streams_connection_options) }
159
+ conn = build_connection.call
160
+ Pgbus::Process::PrimaryValidator.validate_primary!(conn)
161
+ Listener.new(
162
+ pg_connection: conn,
163
+ dispatch_queue: dispatch_queue,
164
+ health_check_ms: @config.streams_listen_health_check_ms,
165
+ connection_factory: build_connection,
166
+ dispatch_queue_limit: @config.streams_dispatch_queue_limit,
167
+ logger: @logger
168
+ ).tap(&:start)
169
+ end
170
+ end
171
+
172
+ def running?
173
+ @table_mutex.synchronize { @running }
174
+ end
175
+
176
+ def accept_loop
177
+ loop do
178
+ begin
179
+ sock = @server.accept
180
+ rescue IOError, Errno::EBADF, Errno::EINVAL
181
+ # server closed during stop
182
+ break
183
+ end
184
+ begin
185
+ register_worker(sock)
186
+ rescue StandardError => e
187
+ # One bad connection must not stop the hub accepting others.
188
+ @logger.warn { "[Pgbus::Streamer::MasterHub] failed to register a worker: #{e.class}: #{e.message}" }
189
+ close_quietly(sock)
190
+ end
191
+ end
192
+ end
193
+
194
+ def register_worker(sock)
195
+ entry = {
196
+ sock: sock, subs: Set.new, outbox: [], durable_count: 0, open: true,
197
+ outbox_mutex: Mutex.new, outbox_cond: ConditionVariable.new
198
+ }
199
+ id = @table_mutex.synchronize do
200
+ @next_id += 1
201
+ @workers[@next_id] = entry
202
+ @next_id
203
+ end
204
+ entry[:writer] = Thread.new { writer_loop(id, entry) }
205
+ entry[:reader] = Thread.new { reader_loop(id, entry) }
206
+ id
207
+ end
208
+
209
+ def reader_loop(id, entry)
210
+ loop do
211
+ frame = HubProtocol.read_frame(entry[:sock])
212
+ break if frame.nil?
213
+
214
+ handle_frame(id, entry, frame)
215
+ end
216
+ rescue HubProtocol::ProtocolError => e
217
+ @logger.warn { "[Pgbus::Streamer::MasterHub] worker #{id} protocol error: #{e.message}" }
218
+ rescue IOError, Errno::EBADF, Errno::ECONNRESET
219
+ # severed by eviction or stop
220
+ rescue StandardError => e
221
+ # e.g. ensure_listening raising inside handle_sub — the ensure still
222
+ # severs this worker (its fallback takes over), but never silently.
223
+ @logger.warn { "[Pgbus::Streamer::MasterHub] reader for worker #{id} failed: #{e.class}: #{e.message}" }
224
+ ensure
225
+ cleanup_worker(id)
226
+ end
227
+
228
+ def handle_frame(id, entry, frame)
229
+ case frame["t"]
230
+ when "sub" then handle_sub(id, entry, frame["q"])
231
+ when "unsub" then handle_unsub(id, frame["q"])
232
+ else
233
+ @logger.warn { "[Pgbus::Streamer::MasterHub] worker #{id} sent unknown frame: #{frame["t"].inspect}" }
234
+ end
235
+ end
236
+
237
+ # Register FIRST, LISTEN second, ack LAST — the ordering the no-lost-
238
+ # wake contract rests on (see class comment). Runs on this worker's
239
+ # reader thread; ensure_listening blocks bounded by the listener's own
240
+ # ack budget.
241
+ def handle_sub(id, entry, queue)
242
+ @table_mutex.synchronize do
243
+ entry[:subs].add(queue)
244
+ (@queue_refs[queue] ||= Set.new).add(id)
245
+ end
246
+ @listener.ensure_listening(queue)
247
+ enqueue_frame(id, entry, { "t" => "ack", "q" => queue }, droppable: false)
248
+ end
249
+
250
+ def handle_unsub(id, queue)
251
+ release_queue_refs(id, [queue])
252
+ @table_mutex.synchronize { @workers[id]&.[](:subs)&.delete(queue) }
253
+ end
254
+
255
+ def fanout_loop
256
+ loop do
257
+ message = @dispatch_queue.pop
258
+ break if message == :stop
259
+
260
+ begin
261
+ deliver(message)
262
+ rescue StandardError => e
263
+ # One bad message must not stop wake delivery for the host.
264
+ @logger.error { "[Pgbus::Streamer::MasterHub] wake delivery failed: #{e.class}: #{e.message}" }
265
+ end
266
+ end
267
+ end
268
+
269
+ def deliver(message)
270
+ frame = { "t" => "wake", "q" => message.queue_name, "p" => message.payload }
271
+ droppable = message.payload.nil?
272
+ targets = @table_mutex.synchronize do
273
+ refs = @queue_refs[message.queue_name]
274
+ refs ? refs.filter_map { |id| [id, @workers[id]] if @workers[id] } : []
275
+ end
276
+ targets.each { |id, entry| enqueue_frame(id, entry, frame, droppable: droppable) }
277
+ end
278
+
279
+ # Non-blocking enqueue with the drop/evict policy. Never blocks the
280
+ # fanout thread on one slow worker (the head-of-line lesson from
281
+ # issue #315 item 3, applied cross-process).
282
+ def enqueue_frame(id, entry, frame, droppable:)
283
+ evict = false
284
+ entry[:outbox_mutex].synchronize do
285
+ return unless entry[:open]
286
+
287
+ if droppable && entry[:durable_count] >= @durable_queue_limit
288
+ @table_mutex.synchronize { @dropped_durable_wakes += 1 }
289
+ return
290
+ end
291
+
292
+ entry[:outbox] << [frame, droppable]
293
+ entry[:durable_count] += 1 if droppable
294
+ evict = entry[:outbox].size > @hard_queue_limit
295
+ entry[:outbox_cond].signal
296
+ end
297
+ evict_worker(id, entry) if evict
298
+ end
299
+
300
+ # Sever a worker that stopped draining. Closing the socket unblocks
301
+ # its writer (IOError) and its reader (EOF on the client side makes
302
+ # the worker's HubClient fail over to a local listener) — the wedged
303
+ # worker degrades itself, never its siblings.
304
+ def evict_worker(id, entry)
305
+ already = false
306
+ entry[:outbox_mutex].synchronize do
307
+ already = !entry[:open]
308
+ entry[:open] = false
309
+ entry[:outbox_cond].broadcast
310
+ end
311
+ return if already
312
+
313
+ @table_mutex.synchronize { @evicted_workers += 1 }
314
+ @logger.warn do
315
+ "[Pgbus::Streamer::MasterHub] evicting worker #{id}: outbound queue exceeded " \
316
+ "#{@hard_queue_limit} frames (worker not draining) — it falls back to its own listener"
317
+ end
318
+ close_quietly(entry[:sock])
319
+ end
320
+
321
+ def writer_loop(id, entry)
322
+ loop do
323
+ frame = nil
324
+ entry[:outbox_mutex].synchronize do
325
+ entry[:outbox_cond].wait(entry[:outbox_mutex]) while entry[:outbox].empty? && entry[:open]
326
+ return unless entry[:open]
327
+
328
+ frame, droppable = entry[:outbox].shift
329
+ entry[:durable_count] -= 1 if droppable
330
+ end
331
+ entry[:sock].write(HubProtocol.encode(frame))
332
+ end
333
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::EBADF
334
+ # severed / worker died
335
+ rescue StandardError => e
336
+ # e.g. a ProtocolError from encode — never die silently; sever this
337
+ # worker so its fallback takes over.
338
+ @logger.warn { "[Pgbus::Streamer::MasterHub] writer for worker #{id} failed: #{e.class}: #{e.message}" }
339
+ ensure
340
+ cleanup_worker(id)
341
+ end
342
+
343
+ def status_loop
344
+ last_status = nil
345
+ ticks_since_broadcast = 0
346
+ loop do
347
+ # A stop-signal wait instead of sleep, so #stop wakes the thread
348
+ # immediately even with a long status_interval.
349
+ break if @stop_signal.pop(timeout: @status_interval)
350
+ break unless running?
351
+
352
+ begin
353
+ healthy = listener_healthy?
354
+ ticks_since_broadcast += 1
355
+ next unless healthy != last_status || ticks_since_broadcast >= REBROADCAST_TICKS
356
+
357
+ broadcast_status(healthy)
358
+ last_status = healthy
359
+ ticks_since_broadcast = 0
360
+ rescue StandardError => e
361
+ @logger.warn { "[Pgbus::Streamer::MasterHub] status tick failed: #{e.class}: #{e.message}" }
362
+ end
363
+ end
364
+ end
365
+
366
+ def listener_healthy?
367
+ listener = @listener
368
+ !!(listener&.alive? && listener.connected?)
369
+ end
370
+
371
+ def broadcast_status(healthy)
372
+ frame = { "t" => "status", "healthy" => healthy }
373
+ entries = @table_mutex.synchronize { @workers.to_a }
374
+ entries.each { |id, entry| enqueue_frame(id, entry, frame, droppable: false) }
375
+ end
376
+
377
+ # Idempotent teardown for one worker — reachable from its reader's
378
+ # ensure, an eviction, and stop.
379
+ def cleanup_worker(id)
380
+ entry = @table_mutex.synchronize { @workers.delete(id) }
381
+ return unless entry
382
+
383
+ entry[:outbox_mutex].synchronize do
384
+ entry[:open] = false
385
+ entry[:outbox_cond].broadcast
386
+ end
387
+ close_quietly(entry[:sock])
388
+ release_queue_refs(id, entry[:subs].to_a)
389
+ end
390
+
391
+ # Decrement refcounts; UNLISTEN queues that hit zero (async — no
392
+ # correctness path waits on unlisten, mirroring remove_listening).
393
+ def release_queue_refs(id, queues)
394
+ released = @table_mutex.synchronize do
395
+ queues.select do |q|
396
+ refs = @queue_refs[q]
397
+ next false unless refs
398
+
399
+ refs.delete(id)
400
+ @queue_refs.delete(q) if refs.empty?
401
+ end
402
+ end
403
+ released.each { |q| @listener.remove_listening(q) }
404
+ end
405
+
406
+ def close_quietly(io)
407
+ io.close if io && !io.closed?
408
+ rescue IOError, Errno::EBADF
409
+ nil
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Pgbus
6
+ module Web
7
+ module Streamer
8
+ # Deferred MasterHub startup for the Puma master (issue #382). The
9
+ # pgbus_streams plugin's `start` runs BEFORE `preload_app!` loads the
10
+ # Rails app (and with it the pgbus initializer), so the hub cannot be
11
+ # built eagerly. This class splits the two halves:
12
+ #
13
+ # 1. The socket path is exported to ENV IMMEDIATELY — workers inherit
14
+ # it across fork and connect lazily on first SSE use.
15
+ # 2. A poller thread waits for Pgbus.configuration to become ready
16
+ # (the initializer has run — with preload_app!, before the first
17
+ # fork), then builds and starts the MasterHub. Workers that race a
18
+ # still-booting hub simply fail to connect and fall back to their
19
+ # own listener until they recycle — degraded footprint, never
20
+ # degraded semantics.
21
+ #
22
+ # Without preload_app! the master never loads the app, the deadline
23
+ # expires quietly, no socket is ever bound, and every worker keeps
24
+ # today's per-worker listener — :master scope effectively requires
25
+ # preload_app!, documented on the docs site.
26
+ class MasterHubBoot
27
+ def self.default_socket_path
28
+ File.join(Dir.tmpdir, "pgbus-streams-hub-#{::Process.pid}.sock")
29
+ end
30
+
31
+ def initialize(socket_path: self.class.default_socket_path, hub_factory: nil,
32
+ poll_interval: 1.0, deadline: 120, logger: nil)
33
+ @socket_path = socket_path
34
+ @hub_factory = hub_factory || lambda do |socket_path:|
35
+ MasterHub.new(config: Pgbus.configuration, socket_path: socket_path)
36
+ end
37
+ @poll_interval = poll_interval
38
+ @deadline = deadline
39
+ @logger = logger
40
+ # Guards @hub and @running: written by the caller thread
41
+ # (start/stop) and the background poller. A hub whose start
42
+ # outlives stop's join budget is stopped by whichever side sees
43
+ # the flag last, so teardown can never leave a live hub behind.
44
+ @state_mutex = Mutex.new
45
+ @hub = nil
46
+ @running = false
47
+ @thread = nil
48
+ end
49
+
50
+ def start
51
+ ENV["PGBUS_STREAMS_HUB_SOCKET"] = @socket_path
52
+ @state_mutex.synchronize { @running = true }
53
+ @thread = Thread.new { wait_and_start }
54
+ self
55
+ end
56
+
57
+ def stop
58
+ to_stop = @state_mutex.synchronize do
59
+ @running = false
60
+ hub = @hub
61
+ @hub = nil
62
+ hub
63
+ end
64
+ @thread&.join(2)
65
+ @thread = nil
66
+ to_stop&.stop
67
+ self
68
+ end
69
+
70
+ private
71
+
72
+ def running?
73
+ @state_mutex.synchronize { @running }
74
+ end
75
+
76
+ def wait_and_start
77
+ waited = 0.0
78
+ until configuration_ready?
79
+ return unless running?
80
+ return give_up if waited >= @deadline
81
+
82
+ sleep @poll_interval
83
+ waited += @poll_interval
84
+ end
85
+ return unless running? && master_scope?
86
+
87
+ hub = @hub_factory.call(socket_path: @socket_path)
88
+ hub.start
89
+ # Register-or-late-stop: if stop ran while the hub was building,
90
+ # this thread owns the teardown of the hub stop never saw.
91
+ late = @state_mutex.synchronize do
92
+ if @running
93
+ @hub = hub
94
+ nil
95
+ else
96
+ hub
97
+ end
98
+ end
99
+ late&.stop
100
+ return if late
101
+
102
+ log(:info) { "[Pgbus::Streamer::MasterHubBoot] master hub listening at #{@socket_path}" }
103
+ rescue StandardError => e
104
+ @state_mutex.synchronize { @hub = nil }
105
+ # The method-scoped hub may have STARTED before a later step raised
106
+ # (e.g. a failing logger after registration) — stop it here or its
107
+ # LISTEN connection outlives the boot failure. MasterHub#stop is
108
+ # idempotent and safe on a never-started hub.
109
+ begin
110
+ hub&.stop
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ log(:error) do
115
+ "[Pgbus::Streamer::MasterHubBoot] master hub failed to start " \
116
+ "(#{e.class}: #{e.message}) — workers fall back to per-worker listeners"
117
+ end
118
+ end
119
+
120
+ # Ready once the app's initializer has produced connection options a
121
+ # dedicated LISTEN connection can be built from (String URL or libpq
122
+ # Hash; the Proc fallback means "nothing configured yet").
123
+ def configuration_ready?
124
+ return false unless defined?(Pgbus) && Pgbus.configuration.streams_enabled
125
+
126
+ options = Pgbus.configuration.streams_connection_options
127
+ options.is_a?(String) || options.is_a?(Hash)
128
+ rescue StandardError
129
+ false
130
+ end
131
+
132
+ def master_scope?
133
+ Pgbus.configuration.streams_listen_scope == :master
134
+ end
135
+
136
+ def give_up
137
+ log(:info) do
138
+ "[Pgbus::Streamer::MasterHubBoot] configuration never became ready within #{@deadline}s " \
139
+ "(no preload_app!?) — no master hub; workers use per-worker listeners"
140
+ end
141
+ end
142
+
143
+ def log(level, &)
144
+ (@logger || Pgbus.logger).public_send(level, &)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -22,15 +22,51 @@ require "puma/plugin"
22
22
  # and a non-Rails use case). Explicit opt-in is safer.
23
23
  Puma::Plugin.create do
24
24
  def start(launcher)
25
+ # Master-side streams hub (issue #382): in cluster mode, ONE LISTEN
26
+ # connection in the master serves every worker over a Unix socket. The
27
+ # socket path is exported to ENV here (pre-fork, so workers inherit it);
28
+ # the hub itself starts once the preloaded app has configured Pgbus (see
29
+ # MasterHubBoot). Any failure means no socket — workers keep their own
30
+ # per-worker listeners, trading connections for unchanged semantics.
31
+ boot_master_hub(launcher)
32
+
25
33
  launcher.events.register(:after_stopped) do
34
+ teardown_master_hub(launcher)
26
35
  teardown_streamer(launcher)
27
36
  end
28
37
 
29
38
  launcher.events.register(:before_restart) do
39
+ teardown_master_hub(launcher)
30
40
  teardown_streamer(launcher)
31
41
  end
32
42
  end
33
43
 
44
+ def boot_master_hub(launcher)
45
+ return unless defined?(Pgbus::Web::Streamer::MasterHubBoot)
46
+ # Single mode: the master IS the (only) serving process — one listener
47
+ # per host already; a hub would just add a socket hop.
48
+ return unless cluster_mode?(launcher)
49
+
50
+ @master_hub_boot = Pgbus::Web::Streamer::MasterHubBoot.new
51
+ @master_hub_boot.start
52
+ rescue StandardError => e
53
+ @master_hub_boot = nil
54
+ log_error(launcher, e, "master hub boot")
55
+ end
56
+
57
+ def cluster_mode?(launcher)
58
+ launcher.respond_to?(:options) && launcher.options[:workers].to_i.positive?
59
+ rescue StandardError
60
+ false
61
+ end
62
+
63
+ def teardown_master_hub(launcher)
64
+ @master_hub_boot&.stop
65
+ @master_hub_boot = nil
66
+ rescue StandardError => e
67
+ log_error(launcher, e, "master hub teardown")
68
+ end
69
+
34
70
  def teardown_streamer(launcher)
35
71
  return unless defined?(Pgbus::Web::Streamer)
36
72
 
@@ -43,8 +79,8 @@ Puma::Plugin.create do
43
79
  log_error(launcher, e)
44
80
  end
45
81
 
46
- def log_error(launcher, error)
47
- message = "[Pgbus::Puma::Plugin] streamer teardown raised: #{error.class}: #{error.message}"
82
+ def log_error(launcher, error, operation = "streamer teardown")
83
+ message = "[Pgbus::Puma::Plugin] #{operation} raised: #{error.class}: #{error.message}"
48
84
  if launcher.respond_to?(:log_writer)
49
85
  launcher.log_writer.log(message)
50
86
  elsif defined?(Pgbus) && Pgbus.respond_to?(:logger)
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.13.0
4
+ version: 0.13.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -114,6 +114,7 @@ email:
114
114
  - mikael@mhenrixon.com
115
115
  executables:
116
116
  - pgbus
117
+ - pgbus-health
117
118
  extensions: []
118
119
  extra_rdoc_files: []
119
120
  files:
@@ -207,6 +208,7 @@ files:
207
208
  - config/locales/sv.yml
208
209
  - config/routes.rb
209
210
  - exe/pgbus
211
+ - exe/pgbus-health
210
212
  - lib/active_job/queue_adapters/pgbus_adapter.rb
211
213
  - lib/generators/pgbus/add_failed_events_index_generator.rb
212
214
  - lib/generators/pgbus/add_job_stats_generator.rb
@@ -214,6 +216,7 @@ files:
214
216
  - lib/generators/pgbus/add_job_stats_queue_index_generator.rb
215
217
  - lib/generators/pgbus/add_outbox_generator.rb
216
218
  - lib/generators/pgbus/add_presence_generator.rb
219
+ - lib/generators/pgbus/add_processed_event_completion_generator.rb
217
220
  - lib/generators/pgbus/add_queue_states_generator.rb
218
221
  - lib/generators/pgbus/add_recurring_generator.rb
219
222
  - lib/generators/pgbus/add_stream_queues_generator.rb
@@ -228,6 +231,7 @@ files:
228
231
  - lib/generators/pgbus/templates/add_job_stats_queue_index.rb.erb
229
232
  - lib/generators/pgbus/templates/add_outbox.rb.erb
230
233
  - lib/generators/pgbus/templates/add_presence.rb.erb
234
+ - lib/generators/pgbus/templates/add_processed_event_completion.rb.erb
231
235
  - lib/generators/pgbus/templates/add_queue_states.rb.erb
232
236
  - lib/generators/pgbus/templates/add_recurring_tables.rb.erb
233
237
  - lib/generators/pgbus/templates/add_stream_queues.rb.erb
@@ -282,6 +286,7 @@ files:
282
286
  - lib/pgbus/failed_event_recorder.rb
283
287
  - lib/pgbus/generators/database_target_detector.rb
284
288
  - lib/pgbus/generators/migration_detector.rb
289
+ - lib/pgbus/health_probe.rb
285
290
  - lib/pgbus/instrumentation.rb
286
291
  - lib/pgbus/integrations/appsignal.rb
287
292
  - lib/pgbus/integrations/appsignal/dashboard.json
@@ -333,6 +338,7 @@ files:
333
338
  - lib/pgbus/process/notify_probe.rb
334
339
  - lib/pgbus/process/primary_validator.rb
335
340
  - lib/pgbus/process/queue_lock.rb
341
+ - lib/pgbus/process/readiness_snapshot.rb
336
342
  - lib/pgbus/process/signal_handler.rb
337
343
  - lib/pgbus/process/supervisor.rb
338
344
  - lib/pgbus/process/wake_pipe.rb
@@ -385,11 +391,16 @@ files:
385
391
  - lib/pgbus/web/stream_app.rb
386
392
  - lib/pgbus/web/streamer.rb
387
393
  - lib/pgbus/web/streamer/connection.rb
394
+ - lib/pgbus/web/streamer/failover_listener.rb
388
395
  - lib/pgbus/web/streamer/falcon_connection.rb
389
396
  - lib/pgbus/web/streamer/heartbeat.rb
397
+ - lib/pgbus/web/streamer/hub_client.rb
398
+ - lib/pgbus/web/streamer/hub_protocol.rb
390
399
  - lib/pgbus/web/streamer/instance.rb
391
400
  - lib/pgbus/web/streamer/io_writer.rb
392
401
  - lib/pgbus/web/streamer/listener.rb
402
+ - lib/pgbus/web/streamer/master_hub.rb
403
+ - lib/pgbus/web/streamer/master_hub_boot.rb
393
404
  - lib/pgbus/web/streamer/outbound_pump.rb
394
405
  - lib/pgbus/web/streamer/registry.rb
395
406
  - lib/pgbus/web/streamer/stream_counter.rb