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.
@@ -15,7 +15,12 @@ module Pgbus
15
15
  # only running `wait_for_notify` — all LISTEN/UNLISTEN SQL goes
16
16
  # through a command queue that the listener thread drains between
17
17
  # notifies
18
- # - #stop joins the thread cleanly
18
+ # - #stop clears @running and joins; it never touches the connection
19
+ # - the connection is SINGLE-OWNER: the listener thread is the only
20
+ # thread that may exec, wait, or close on it, from construction
21
+ # through teardown. PG::Connection is not thread-safe and #close is
22
+ # PQfinish — freeing the PGconn under a concurrent libpq call is a
23
+ # process-killing SEGV, not a rescuable PG::Error (issue #375)
19
24
  #
20
25
  # Health check: `wait_for_notify(timeout)` returns nil on timeout. When
21
26
  # it does, the listener runs `SELECT 1` as a TCP keepalive. If that
@@ -41,6 +46,10 @@ module Pgbus
41
46
 
42
47
  RECONNECT_BACKOFF_SECONDS = 0.5
43
48
 
49
+ # Grace added to one health-check cycle when #stop joins the listener
50
+ # thread. See #stop_join_timeout.
51
+ STOP_JOIN_GRACE_SECONDS = 5
52
+
44
53
  attr_reader :listening_to
45
54
 
46
55
  # @param connection_factory [#call] builds a FRESH PG connection on each
@@ -98,18 +107,16 @@ module Pgbus
98
107
 
99
108
  @running = false
100
109
  @commands << [:stop]
101
- # Interrupt the blocking wait_for_notify by closing the PG
102
- # connection. Without this, the listener thread would sit
103
- # inside wait_for_notify until a NOTIFY arrived, which may
104
- # never happen. Closing the socket raises PG::Error inside
105
- # wait_for_notify; our rescue clause sees @running == false
106
- # on the next iteration and exits cleanly.
107
- begin
108
- @conn.close if @conn.respond_to?(:close)
109
- rescue StandardError
110
- # best effort — connection may already be gone
111
- end
112
- @thread&.join(5)
110
+ # Deliberately does NOT touch @conn. PG::Connection is not
111
+ # thread-safe and #close is PQfinish: it frees the PGconn and its
112
+ # OpenSSL objects out from under whatever libpq call the listener
113
+ # thread is making on the same connection. That is a use-after-free —
114
+ # a process-killing SEGV, not a rescuable PG::Error (issue #375).
115
+ # The listener thread is the sole owner of @conn and closes it in
116
+ # run_loop's ensure; clearing @running above is the whole stop signal.
117
+ # It is observed within one wait_for_notify timeout (health_check_ms),
118
+ # which is what the join budget below is sized against.
119
+ @thread&.join(stop_join_timeout)
113
120
  @thread = nil
114
121
  self
115
122
  end
@@ -148,13 +155,18 @@ module Pgbus
148
155
 
149
156
  timeout_s = @health_check_ms / 1000.0
150
157
  begin
151
- @conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
158
+ got_notify = @conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
152
159
  handle_notify(channel, payload)
153
- end || run_health_check
160
+ end
161
+ # Skip the keepalive when a stop landed during the wait: the loop
162
+ # is about to exit and close this connection anyway, so the
163
+ # round-trip would only add latency to shutdown.
164
+ run_health_check if !got_notify && @running
154
165
  rescue IOError => e
155
- # #stop closes the PG connection to interrupt
156
- # wait_for_notify, which raises IOError ("stream closed
157
- # in another thread"). This is expected exit cleanly.
166
+ # #stop no longer closes the connection to interrupt the wait
167
+ # (issue #375), so an IOError here is a genuine socket failure,
168
+ # not the expected shutdown signal. Reconnect unless we're
169
+ # stopping, in which case exit cleanly.
158
170
  break unless @running
159
171
 
160
172
  @logger.warn { "[Pgbus::Streamer::Listener] IO error (#{e.class}: #{e.message}) — reconnecting" }
@@ -167,7 +179,15 @@ module Pgbus
167
179
  end
168
180
  end
169
181
  ensure
170
- safe_unlisten_all
182
+ # Bookkeeping only — no UNLISTEN round-trip. We close the connection
183
+ # on the next line and closing the session deregisters every LISTEN
184
+ # server-side, so the exec bought nothing while being the statement
185
+ # that raced #stop's PQfinish into a SEGV (issue #375).
186
+ @listening_to.clear
187
+ # The listener thread owns this connection, so the listener thread is
188
+ # the one that closes it — #stop only clears @running.
189
+ close_quietly(@conn)
190
+ @conn = nil
171
191
  end
172
192
 
173
193
  def drain_commands
@@ -204,6 +224,16 @@ module Pgbus
204
224
  (@health_check_ms / 1000.0) + 1.0
205
225
  end
206
226
 
227
+ # How long #stop waits for the listener thread to notice the cleared
228
+ # @running and finish teardown. The thread can be parked in
229
+ # wait_for_notify for one full health-check cycle before it re-checks
230
+ # the flag, so the budget is that cycle plus grace — a flat timeout
231
+ # would expire before a listener with a large health_check_ms had even
232
+ # one chance to observe the stop.
233
+ def stop_join_timeout
234
+ (@health_check_ms / 1000.0) + STOP_JOIN_GRACE_SECONDS
235
+ end
236
+
207
237
  def do_listen(queue_name)
208
238
  channel = channel_for(queue_name)
209
239
  return if @listening_to.include?(channel)
@@ -322,17 +352,6 @@ module Pgbus
322
352
  nil
323
353
  end
324
354
 
325
- def safe_unlisten_all
326
- @listening_to.each do |channel|
327
- # @conn may be nil if #stop interrupted the reconnect loop between
328
- # closing the old connection and publishing a new one.
329
- @conn&.exec(%(UNLISTEN "#{channel}"))
330
- rescue PG::Error
331
- # connection may be dead; nothing we can do
332
- end
333
- @listening_to.clear
334
- end
335
-
336
355
  def channel_for(queue_name)
337
356
  "#{CHANNEL_PREFIX}#{queue_name}#{CHANNEL_SUFFIX}"
338
357
  end
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.12.3
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -328,13 +328,16 @@ files:
328
328
  - lib/pgbus/process/heartbeat.rb
329
329
  - lib/pgbus/process/lifecycle.rb
330
330
  - lib/pgbus/process/memory_usage.rb
331
+ - lib/pgbus/process/notify_hub.rb
331
332
  - lib/pgbus/process/notify_listener.rb
332
333
  - lib/pgbus/process/notify_probe.rb
333
334
  - lib/pgbus/process/primary_validator.rb
334
335
  - lib/pgbus/process/queue_lock.rb
335
336
  - lib/pgbus/process/signal_handler.rb
336
337
  - lib/pgbus/process/supervisor.rb
338
+ - lib/pgbus/process/wake_pipe.rb
337
339
  - lib/pgbus/process/wake_signal.rb
340
+ - lib/pgbus/process/wildcard_queue_resolver.rb
338
341
  - lib/pgbus/process/worker.rb
339
342
  - lib/pgbus/queue_factory.rb
340
343
  - lib/pgbus/queue_name_validator.rb