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.
@@ -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
- # Interrupt the blocking wait by closing the socket; the rescue in
92
- # wait_once sees @running == false and exits cleanly.
93
- begin
94
- conn_to_close&.close if conn_to_close.respond_to?(:close)
95
- rescue StandardError
96
- nil
97
- end
98
- @thread&.join(5)
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
- @state_mutex.synchronize { @running = false }
157
- safe_unlisten_all
158
- safe_close
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 |_channel, _pid, _payload|
167
- @on_wake.call
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
- run_health_check(conn) unless got_notify
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
- def safe_unlisten_all
274
- channels, conn = @state_mutex.synchronize { [@listening_to.to_a, @conn] }
275
- channels.each do |channel|
276
- conn&.exec(%(UNLISTEN "#{channel}"))
277
- rescue PG::Error
278
- nil
279
- end
280
- @state_mutex.synchronize { @listening_to.clear }
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 an unpublished PG::Connection (one that never made it into @conn,
293
- # e.g. a half-built reconnect attempt where LISTEN raised). Best-effort.
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