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,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 =
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|