pgbus 0.12.3 → 0.13.0
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 +9 -0
- data/README.md +2 -1
- data/Rakefile +12 -1
- data/lib/pgbus/client.rb +15 -0
- data/lib/pgbus/configuration/capsule_dsl.rb +8 -0
- data/lib/pgbus/configuration.rb +36 -5
- data/lib/pgbus/dedicated_connection.rb +29 -2
- data/lib/pgbus/doctor.rb +36 -2
- data/lib/pgbus/event_bus/registry.rb +22 -0
- data/lib/pgbus/process/consumer.rb +57 -22
- data/lib/pgbus/process/notify_hub.rb +273 -0
- data/lib/pgbus/process/notify_listener.rb +103 -26
- data/lib/pgbus/process/supervisor.rb +146 -34
- data/lib/pgbus/process/wake_pipe.rb +129 -0
- data/lib/pgbus/process/wildcard_queue_resolver.rb +35 -0
- data/lib/pgbus/process/worker.rb +58 -35
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/streamer/listener.rb +49 -30
- metadata +4 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Supervisor-owned LISTEN hub (issue #381, worker_notify_scope:
|
|
6
|
+
# :supervisor). Owns ONE NotifyListener — one direct PG connection for the
|
|
7
|
+
# whole host — on the union of every capsule's and consumer's queue
|
|
8
|
+
# channels, and fans wakes out to worker/consumer forks over per-fork
|
|
9
|
+
# pipes (see WakePipe for the fork side and the W/H/P byte protocol).
|
|
10
|
+
#
|
|
11
|
+
# Union: explicit capsule queues from config.workers, wildcard capsules
|
|
12
|
+
# via the shared WildcardQueueResolver, consumer queues via
|
|
13
|
+
# Registry#queue_names_for_topics — each gated by config.role_enabled?.
|
|
14
|
+
# Refreshed every REFRESH_INTERVAL_SECONDS from the supervisor's monitor
|
|
15
|
+
# tick so wildcard churn and late registry subscriptions converge; a
|
|
16
|
+
# transient gap is bounded by the forks' NOTIFY poll ceiling (15s), so
|
|
17
|
+
# under-listening degrades latency, never liveness.
|
|
18
|
+
#
|
|
19
|
+
# Routing: a NOTIFY for queue Q wakes every fork whose registered set
|
|
20
|
+
# contains Q, plus every wildcard fork. Over-waking costs one empty read;
|
|
21
|
+
# under-waking is the only failure mode that matters, so wildcard routing
|
|
22
|
+
# is unconditional.
|
|
23
|
+
#
|
|
24
|
+
# Status: healthy? = listener running + connected + delivering. Broadcast
|
|
25
|
+
# to all pipes on change and every STATUS_REBROADCAST_SECONDS (idempotent
|
|
26
|
+
# 1-byte writes), so a lost byte or a freshly full pipe can't wedge a fork
|
|
27
|
+
# in the wrong mode.
|
|
28
|
+
#
|
|
29
|
+
# Threading: register/deregister/tick/stop run on the supervisor's main
|
|
30
|
+
# loop; the wake callback runs on the listener's thread. The fork table is
|
|
31
|
+
# the only shared state — guarded by @table_mutex. Pipe writes are 1-byte
|
|
32
|
+
# write_nonblock calls (atomic well under PIPE_BUF) and never block: a
|
|
33
|
+
# full pipe already guarantees a pending readable byte, so the wake is
|
|
34
|
+
# skipped, and status repair rides the periodic rebroadcast.
|
|
35
|
+
class NotifyHub
|
|
36
|
+
REFRESH_INTERVAL_SECONDS = 30
|
|
37
|
+
STATUS_REBROADCAST_SECONDS = 5
|
|
38
|
+
RETRY_BASE_SECONDS = 5
|
|
39
|
+
RETRY_MAX_SECONDS = 300
|
|
40
|
+
|
|
41
|
+
def initialize(config:, listener_factory: nil, clock: nil, logger: Pgbus.logger)
|
|
42
|
+
@config = config
|
|
43
|
+
@logger = logger
|
|
44
|
+
@clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
|
|
45
|
+
@listener_factory = listener_factory || default_listener_factory
|
|
46
|
+
@table_mutex = Mutex.new
|
|
47
|
+
@forks = {}
|
|
48
|
+
@listener = nil
|
|
49
|
+
@last_status = nil
|
|
50
|
+
@last_broadcast_at = nil
|
|
51
|
+
@last_refresh_at = nil
|
|
52
|
+
@retry_at = 0.0
|
|
53
|
+
@retry_backoff = RETRY_BASE_SECONDS
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def start
|
|
57
|
+
build_listener
|
|
58
|
+
@last_refresh_at = now
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop
|
|
63
|
+
stop_listener_quietly
|
|
64
|
+
@table_mutex.synchronize do
|
|
65
|
+
@forks.each_value { |entry| close_quietly(entry[:pipe]) }
|
|
66
|
+
@forks.clear
|
|
67
|
+
end
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Register a fork's wake pipe. +queues+ are PHYSICAL queue names (already
|
|
72
|
+
# prefixed); wildcard forks pass wildcard: true and are woken for every
|
|
73
|
+
# channel. The fork's current status byte is sent immediately so a
|
|
74
|
+
# freshly forked worker starts in the right polling mode.
|
|
75
|
+
def register_fork(pid:, queues:, pipe:, wildcard: false)
|
|
76
|
+
entry = { pipe: pipe, queues: Set.new(queues), wildcard: wildcard }
|
|
77
|
+
@table_mutex.synchronize { @forks[pid] = entry }
|
|
78
|
+
byte = status_byte
|
|
79
|
+
write_byte(entry, byte)
|
|
80
|
+
# The registration write is the broadcast baseline: without stamping
|
|
81
|
+
# it, the next tick would treat @last_status as unknown and re-send
|
|
82
|
+
# the same byte to every fork immediately.
|
|
83
|
+
@last_status = byte
|
|
84
|
+
@last_broadcast_at = now
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def deregister_fork(pid)
|
|
89
|
+
entry = @table_mutex.synchronize { @forks.delete(pid) }
|
|
90
|
+
close_quietly(entry[:pipe]) if entry
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Called ONLY inside a just-forked child: release the child's copies of
|
|
95
|
+
# the parent's hub resources — the LISTEN socket fd (without a libpq
|
|
96
|
+
# Terminate, see NotifyListener#close_inherited_socket!) and the fork
|
|
97
|
+
# table's pipe write ends. Never touches parent state (fork copied it).
|
|
98
|
+
def close_inherited!
|
|
99
|
+
@listener&.close_inherited_socket!
|
|
100
|
+
@listener = nil
|
|
101
|
+
@table_mutex.synchronize do
|
|
102
|
+
@forks.each_value { |entry| close_quietly(entry[:pipe]) }
|
|
103
|
+
@forks.clear
|
|
104
|
+
end
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One supervisor monitor-loop beat: self-heal the listener, refresh the
|
|
109
|
+
# LISTEN union, keep fork status fresh.
|
|
110
|
+
def tick
|
|
111
|
+
ensure_listener
|
|
112
|
+
refresh_union_if_due
|
|
113
|
+
broadcast_status
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def healthy?
|
|
118
|
+
listener = @listener
|
|
119
|
+
!!(listener&.running? && listener.connected? && listener.delivering?)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
attr_reader :config
|
|
125
|
+
|
|
126
|
+
def now
|
|
127
|
+
@clock.call
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def default_listener_factory
|
|
131
|
+
lambda do |physical_queues:, on_wake:|
|
|
132
|
+
NotifyListener.new(
|
|
133
|
+
physical_queues: physical_queues,
|
|
134
|
+
on_wake: on_wake,
|
|
135
|
+
connection_options: config.worker_notify_connection_options,
|
|
136
|
+
health_check_ms: (config.polling_interval * 1000).to_i.clamp(250, 5_000),
|
|
137
|
+
logger: @logger
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_listener
|
|
143
|
+
@listener = @listener_factory.call(
|
|
144
|
+
physical_queues: desired_physical_queues,
|
|
145
|
+
on_wake: method(:route)
|
|
146
|
+
)
|
|
147
|
+
@listener.start
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
@listener = nil
|
|
150
|
+
@logger.error { "[Pgbus::NotifyHub] listener failed to start: #{e.class}: #{e.message}" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Mirrors Worker#ensure_notify_listener: restart a dead listener thread
|
|
154
|
+
# on an exponential backoff so a persistent outage doesn't thrash a
|
|
155
|
+
# reconnect per supervisor tick.
|
|
156
|
+
def ensure_listener
|
|
157
|
+
return if @listener&.running?
|
|
158
|
+
return if now < @retry_at
|
|
159
|
+
|
|
160
|
+
stop_listener_quietly
|
|
161
|
+
build_listener
|
|
162
|
+
|
|
163
|
+
@retry_backoff = if @listener&.running?
|
|
164
|
+
RETRY_BASE_SECONDS
|
|
165
|
+
else
|
|
166
|
+
[@retry_backoff * 2, RETRY_MAX_SECONDS].min
|
|
167
|
+
end
|
|
168
|
+
@retry_at = now + @retry_backoff
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def stop_listener_quietly
|
|
172
|
+
@listener&.stop
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
@logger.warn { "[Pgbus::NotifyHub] failed to stop listener: #{e.class}: #{e.message}" }
|
|
175
|
+
ensure
|
|
176
|
+
@listener = nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def refresh_union_if_due
|
|
180
|
+
return unless @listener
|
|
181
|
+
return if @last_refresh_at && (now - @last_refresh_at) < REFRESH_INTERVAL_SECONDS
|
|
182
|
+
|
|
183
|
+
@last_refresh_at = now
|
|
184
|
+
desired = desired_physical_queues.to_set
|
|
185
|
+
current = @listener.listening_to.to_set { |channel| channel_to_physical(channel) }
|
|
186
|
+
(desired - current).each { |q| @listener.add_queue(q) }
|
|
187
|
+
(current - desired).each { |q| @listener.remove_queue(q) }
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
@logger.warn { "[Pgbus::NotifyHub] union refresh failed: #{e.class}: #{e.message}" }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def broadcast_status(status = status_byte)
|
|
193
|
+
rebroadcast_due = @last_broadcast_at.nil? || (now - @last_broadcast_at) >= STATUS_REBROADCAST_SECONDS
|
|
194
|
+
return if status == @last_status && !rebroadcast_due
|
|
195
|
+
|
|
196
|
+
entries = @table_mutex.synchronize { @forks.values.dup }
|
|
197
|
+
entries.each { |entry| write_byte(entry, status) }
|
|
198
|
+
@last_status = status
|
|
199
|
+
@last_broadcast_at = now
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def status_byte
|
|
203
|
+
healthy? ? WakePipe::HEALTHY : WakePipe::DEGRADED
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Runs on the LISTENER thread (NotifyListener on_wake callback).
|
|
207
|
+
def route(channel)
|
|
208
|
+
physical = channel_to_physical(channel)
|
|
209
|
+
entries = @table_mutex.synchronize { @forks.values.dup }
|
|
210
|
+
entries.each do |entry|
|
|
211
|
+
write_byte(entry, WakePipe::WAKE) if entry[:wildcard] || entry[:queues].include?(physical)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Never block, never raise: a full pipe already guarantees a pending
|
|
216
|
+
# readable byte (wake semantics are level-triggered), and a dead pipe's
|
|
217
|
+
# fork is on its way through reap → deregister_fork.
|
|
218
|
+
def write_byte(entry, byte)
|
|
219
|
+
entry[:pipe].write_nonblock(byte)
|
|
220
|
+
rescue IO::WaitWritable, Errno::EPIPE, IOError, Errno::EBADF
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def desired_physical_queues
|
|
225
|
+
names = []
|
|
226
|
+
names.concat(capsule_queue_names) if config.role_enabled?(:workers)
|
|
227
|
+
names.concat(consumer_queue_names) if config.role_enabled?(:consumers)
|
|
228
|
+
names.uniq.map { |q| config.queue_name(q) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def capsule_queue_names
|
|
232
|
+
names = []
|
|
233
|
+
wildcard = false
|
|
234
|
+
Array(config.workers).each do |worker_config|
|
|
235
|
+
queues = worker_config[:queues] || worker_config["queues"] || [config.default_queue]
|
|
236
|
+
Array(queues).each do |q|
|
|
237
|
+
q.to_s == "*" ? wildcard = true : names << q.to_s
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
names.concat(resolve_wildcard) if wildcard
|
|
241
|
+
names
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# A resolver failure (DB blip at boot) degrades to the explicit union;
|
|
245
|
+
# wildcard queues rejoin on the next successful refresh, and the forks'
|
|
246
|
+
# poll ceiling keeps them processing meanwhile.
|
|
247
|
+
def resolve_wildcard
|
|
248
|
+
WildcardQueueResolver.resolve(config: config)
|
|
249
|
+
rescue StandardError => e
|
|
250
|
+
@logger.warn { "[Pgbus::NotifyHub] wildcard resolution failed: #{e.class}: #{e.message}" }
|
|
251
|
+
[]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def consumer_queue_names
|
|
255
|
+
registry = Pgbus::EventBus::Registry.instance
|
|
256
|
+
Array(config.event_consumers).flat_map do |consumer_config|
|
|
257
|
+
topics = consumer_config[:topics] || consumer_config["topics"] || []
|
|
258
|
+
registry.queue_names_for_topics(Array(topics))
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def channel_to_physical(channel)
|
|
263
|
+
NotifyListener.physical_for(channel)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def close_quietly(pipe)
|
|
267
|
+
pipe.close if pipe && !pipe.closed?
|
|
268
|
+
rescue IOError, Errno::EBADF
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -30,12 +30,32 @@ module Pgbus
|
|
|
30
30
|
# blocking IO call where the mutex MUST NOT be held), so wait_once reads
|
|
31
31
|
# the connection out of the mutex first and operates on a local. Reconnect
|
|
32
32
|
# publishes the new connection + channel set under the mutex.
|
|
33
|
+
#
|
|
34
|
+
# The mutex only makes the ivar READ safe. PG::Connection itself is not
|
|
35
|
+
# thread-safe, so the connection is single-owner: the listener thread is
|
|
36
|
+
# the ONLY thread that may exec, wait, or close on it, from build through
|
|
37
|
+
# teardown. #stop signals by clearing @running and joining — it never
|
|
38
|
+
# touches the connection, because #close is PQfinish and freeing the
|
|
39
|
+
# PGconn under a concurrent libpq call is a process-killing SEGV, not a
|
|
40
|
+
# rescuable PG::Error (issue #375).
|
|
33
41
|
class NotifyListener
|
|
34
42
|
CHANNEL_PREFIX = "pgmq.q_"
|
|
35
43
|
CHANNEL_SUFFIX = ".INSERT"
|
|
36
44
|
|
|
45
|
+
# Inverse of #channel_for: map a NOTIFY channel back to the physical
|
|
46
|
+
# queue name. Class-level because the channel format is owned here —
|
|
47
|
+
# NotifyHub (wake routing + union refresh) and Worker (queue-set sync)
|
|
48
|
+
# both consume it.
|
|
49
|
+
def self.physical_for(channel)
|
|
50
|
+
channel.delete_prefix(CHANNEL_PREFIX).delete_suffix(CHANNEL_SUFFIX)
|
|
51
|
+
end
|
|
52
|
+
|
|
37
53
|
RECONNECT_BACKOFF_SECONDS = 0.5
|
|
38
54
|
|
|
55
|
+
# Grace added to one health-check cycle when #stop joins the listener
|
|
56
|
+
# thread. See #stop_join_timeout.
|
|
57
|
+
STOP_JOIN_GRACE_SECONDS = 5
|
|
58
|
+
|
|
39
59
|
def initialize(physical_queues:, on_wake:, connection_options:,
|
|
40
60
|
health_check_ms: 1000, logger: Pgbus.logger)
|
|
41
61
|
@physical_queues = Array(physical_queues)
|
|
@@ -58,6 +78,16 @@ module Pgbus
|
|
|
58
78
|
@state_mutex.synchronize { @listening_to.dup }
|
|
59
79
|
end
|
|
60
80
|
|
|
81
|
+
# Whether a live PG connection is currently published. running? stays
|
|
82
|
+
# true during a reconnect (the thread is alive, looping in reconnect!),
|
|
83
|
+
# so this is the signal that distinguishes "parked in wait_for_notify"
|
|
84
|
+
# from "between connections". The supervisor NotifyHub (issue #381)
|
|
85
|
+
# consults it to broadcast degraded status to forks the moment the
|
|
86
|
+
# shared connection drops, and healthy again once it is rebuilt.
|
|
87
|
+
def connected?
|
|
88
|
+
@state_mutex.synchronize { !@conn.nil? }
|
|
89
|
+
end
|
|
90
|
+
|
|
61
91
|
# Whether the start-time self-probe confirmed this connection can actually
|
|
62
92
|
# receive a NOTIFY. False when a transaction-mode pooler or replica
|
|
63
93
|
# silently drops LISTEN: the thread is still alive (running? == true) but
|
|
@@ -80,22 +110,20 @@ module Pgbus
|
|
|
80
110
|
end
|
|
81
111
|
|
|
82
112
|
def stop
|
|
83
|
-
conn_to_close = nil
|
|
84
113
|
@state_mutex.synchronize do
|
|
85
114
|
return self unless @running
|
|
86
115
|
|
|
87
116
|
@running = false
|
|
88
|
-
conn_to_close = @conn
|
|
89
117
|
end
|
|
90
118
|
@commands << [:stop]
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
@thread&.join(
|
|
119
|
+
# Deliberately does NOT touch @conn. PG::Connection is not thread-safe
|
|
120
|
+
# and #close is PQfinish: it frees the PGconn and its OpenSSL objects
|
|
121
|
+
# out from under whatever libpq call the listener thread is making on
|
|
122
|
+
# the same connection. That is a use-after-free — a process-killing
|
|
123
|
+
# SEGV, not a rescuable PG::Error (issue #375). The listener thread is
|
|
124
|
+
# the sole owner of @conn for its entire life and closes it in
|
|
125
|
+
# run_loop's ensure; clearing @running above is the whole stop signal.
|
|
126
|
+
@thread&.join(stop_join_timeout)
|
|
99
127
|
@thread = nil
|
|
100
128
|
self
|
|
101
129
|
end
|
|
@@ -115,6 +143,30 @@ module Pgbus
|
|
|
115
143
|
@state_mutex.synchronize { @running }
|
|
116
144
|
end
|
|
117
145
|
|
|
146
|
+
# Called ONLY inside a just-forked child (issue #381 hub hygiene): drop
|
|
147
|
+
# this process's copy of the LISTEN socket fd WITHOUT PQfinish — #close
|
|
148
|
+
# would send a libpq Terminate over the socket shared with the parent,
|
|
149
|
+
# killing the parent's LISTEN session. Closing the IO wrapper just
|
|
150
|
+
# closes the child's fd. The listener thread does not exist in the
|
|
151
|
+
# child (fork copies only the calling thread), so there is no
|
|
152
|
+
# concurrent owner and the single-owner rule (#375) does not apply.
|
|
153
|
+
def close_inherited_socket!
|
|
154
|
+
conn = @state_mutex.synchronize do
|
|
155
|
+
c = @conn
|
|
156
|
+
@conn = nil
|
|
157
|
+
@running = false
|
|
158
|
+
c
|
|
159
|
+
end
|
|
160
|
+
conn&.socket_io&.close
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
# Best-effort (a lingering fd copy is benign until the parent dies),
|
|
163
|
+
# but never silent: the child keeps booting either way.
|
|
164
|
+
@logger.warn do
|
|
165
|
+
"[Pgbus::NotifyListener] inherited socket cleanup failed: #{e.class}: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
118
170
|
private
|
|
119
171
|
|
|
120
172
|
def run_loop
|
|
@@ -153,9 +205,26 @@ module Pgbus
|
|
|
153
205
|
# Clear @running so #start can spawn a fresh thread after a fatal exit
|
|
154
206
|
# (e.g. build_connection raising at boot). Without this, the dead
|
|
155
207
|
# thread's @running stays true and #start returns early forever.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
208
|
+
#
|
|
209
|
+
# The LISTEN set is dropped as bookkeeping only — no UNLISTEN
|
|
210
|
+
# round-trip. We close on the next line and closing the session
|
|
211
|
+
# deregisters every LISTEN server-side, so the exec bought nothing
|
|
212
|
+
# while being the statement that raced #stop into a SEGV (issue #375).
|
|
213
|
+
#
|
|
214
|
+
# Capturing @conn in the SAME critical section that clears @running is
|
|
215
|
+
# what makes restart safe. #start is public and guarded only by
|
|
216
|
+
# @running, so a caller watching running? may spawn a fresh thread the
|
|
217
|
+
# instant it flips. If teardown read @conn in a later critical section
|
|
218
|
+
# it could pick up the NEW thread's connection and PQfinish it mid-use
|
|
219
|
+
# — the same use-after-free, reached through restart instead of #stop.
|
|
220
|
+
conn = @state_mutex.synchronize do
|
|
221
|
+
@running = false
|
|
222
|
+
@listening_to.clear
|
|
223
|
+
c = @conn
|
|
224
|
+
@conn = nil
|
|
225
|
+
c
|
|
226
|
+
end
|
|
227
|
+
close_quietly(conn)
|
|
159
228
|
end
|
|
160
229
|
|
|
161
230
|
def wait_once
|
|
@@ -163,10 +232,16 @@ module Pgbus
|
|
|
163
232
|
return reconnect! unless conn
|
|
164
233
|
|
|
165
234
|
timeout_s = @health_check_ms / 1000.0
|
|
166
|
-
got_notify = conn.wait_for_notify(timeout_s) do |
|
|
167
|
-
|
|
235
|
+
got_notify = conn.wait_for_notify(timeout_s) do |channel, _pid, _payload|
|
|
236
|
+
# The channel rides along so a hub caller (issue #381) can route the
|
|
237
|
+
# wake to the fork(s) reading that queue; fork-owned listeners take
|
|
238
|
+
# ->(_channel) and ignore it.
|
|
239
|
+
@on_wake.call(channel)
|
|
168
240
|
end
|
|
169
|
-
|
|
241
|
+
# Skip the keepalive when a stop landed during the wait: the loop is
|
|
242
|
+
# about to exit and close this connection anyway, so the round-trip
|
|
243
|
+
# would only add latency to shutdown.
|
|
244
|
+
run_health_check(conn) if !got_notify && running?
|
|
170
245
|
rescue IOError, PG::Error => e
|
|
171
246
|
return unless running?
|
|
172
247
|
|
|
@@ -270,14 +345,14 @@ module Pgbus
|
|
|
270
345
|
end
|
|
271
346
|
end
|
|
272
347
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
@
|
|
348
|
+
# How long #stop waits for the listener thread to notice the cleared
|
|
349
|
+
# @running and finish teardown. The thread can be parked in
|
|
350
|
+
# wait_for_notify for one full health-check cycle before it re-checks the
|
|
351
|
+
# flag, so the budget is that cycle plus grace — a flat timeout would
|
|
352
|
+
# expire before a listener with a large health_check_ms had even one
|
|
353
|
+
# chance to observe the stop.
|
|
354
|
+
def stop_join_timeout
|
|
355
|
+
(@health_check_ms / 1000.0) + STOP_JOIN_GRACE_SECONDS
|
|
281
356
|
end
|
|
282
357
|
|
|
283
358
|
def safe_close
|
|
@@ -289,8 +364,10 @@ module Pgbus
|
|
|
289
364
|
close_quietly(conn)
|
|
290
365
|
end
|
|
291
366
|
|
|
292
|
-
# Close
|
|
293
|
-
#
|
|
367
|
+
# Close a PG::Connection we are done with — a half-built reconnect
|
|
368
|
+
# attempt that never made it into @conn, or the connection captured out
|
|
369
|
+
# of @conn during teardown. Always called on the listener thread, which
|
|
370
|
+
# owns the connection. Best-effort.
|
|
294
371
|
def close_quietly(conn)
|
|
295
372
|
conn&.close if conn.respond_to?(:close)
|
|
296
373
|
rescue StandardError
|