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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +78 -4
- data/Rakefile +6 -1
- data/app/models/pgbus/processed_event.rb +45 -0
- data/exe/pgbus-health +9 -0
- data/lib/generators/pgbus/add_processed_event_completion_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_processed_event_completion.rb.erb +19 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +3 -0
- data/lib/pgbus/configuration.rb +72 -0
- data/lib/pgbus/doctor.rb +12 -1
- data/lib/pgbus/event_bus/handler.rb +49 -7
- data/lib/pgbus/generators/migration_detector.rb +13 -0
- data/lib/pgbus/health_probe.rb +132 -0
- data/lib/pgbus/process/consumer.rb +4 -1
- data/lib/pgbus/process/readiness_snapshot.rb +30 -0
- data/lib/pgbus/process/supervisor.rb +64 -3
- data/lib/pgbus/process/worker.rb +14 -4
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/health_app.rb +23 -1
- data/lib/pgbus/web/streamer/failover_listener.rb +130 -0
- data/lib/pgbus/web/streamer/hub_client.rb +199 -0
- data/lib/pgbus/web/streamer/hub_protocol.rb +85 -0
- data/lib/pgbus/web/streamer/instance.rb +69 -20
- data/lib/pgbus/web/streamer/listener.rb +17 -2
- data/lib/pgbus/web/streamer/master_hub.rb +414 -0
- data/lib/pgbus/web/streamer/master_hub_boot.rb +149 -0
- data/lib/puma/plugin/pgbus_streams.rb +38 -2
- metadata +12 -1
|
@@ -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]
|
|
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.
|
|
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
|