pgbus 0.21.4 → 0.23.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +37 -15
  4. data/Rakefile +15 -2
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +17 -3
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +4 -4
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
  8. data/app/controllers/pgbus/dead_letter_controller.rb +3 -1
  9. data/app/controllers/pgbus/jobs_controller.rb +2 -1
  10. data/app/models/pgbus/blocked_execution.rb +3 -2
  11. data/app/models/pgbus/queue_state.rb +6 -0
  12. data/app/models/pgbus/uniqueness_key.rb +11 -0
  13. data/config/locales/da.yml +1 -0
  14. data/config/locales/de.yml +1 -0
  15. data/config/locales/en.yml +1 -0
  16. data/config/locales/es.yml +1 -0
  17. data/config/locales/fi.yml +1 -0
  18. data/config/locales/fr.yml +1 -0
  19. data/config/locales/it.yml +1 -0
  20. data/config/locales/ja.yml +1 -0
  21. data/config/locales/nb.yml +1 -0
  22. data/config/locales/nl.yml +1 -0
  23. data/config/locales/pt.yml +1 -0
  24. data/config/locales/sv.yml +1 -0
  25. data/lib/generators/pgbus/fold_queue_name_case_generator.rb +57 -0
  26. data/lib/generators/pgbus/templates/fold_queue_name_case.rb.erb +12 -0
  27. data/lib/generators/pgbus/templates/tune_autovacuum.rb.erb +2 -2
  28. data/lib/generators/pgbus/templates/tune_fillfactor.rb.erb +1 -1
  29. data/lib/pgbus/active_job/adapter.rb +3 -2
  30. data/lib/pgbus/active_job/executor.rb +197 -46
  31. data/lib/pgbus/active_job/quiet_logs.rb +57 -0
  32. data/lib/pgbus/autovacuum_tuning.rb +2 -2
  33. data/lib/pgbus/batch/sweep.rb +2 -0
  34. data/lib/pgbus/batch.rb +4 -1
  35. data/lib/pgbus/cli/dlq.rb +2 -1
  36. data/lib/pgbus/client/async_commit.rb +149 -0
  37. data/lib/pgbus/client/native_statements.rb +432 -0
  38. data/lib/pgbus/client.rb +156 -20
  39. data/lib/pgbus/concurrency/blocked_execution.rb +10 -1
  40. data/lib/pgbus/configuration.rb +158 -6
  41. data/lib/pgbus/dead_letter_header.rb +3 -0
  42. data/lib/pgbus/doctor.rb +55 -2
  43. data/lib/pgbus/event_bus/handler.rb +2 -0
  44. data/lib/pgbus/failed_event_recorder.rb +5 -2
  45. data/lib/pgbus/generators/migration_detector.rb +29 -2
  46. data/lib/pgbus/instrumentation.rb +16 -1
  47. data/lib/pgbus/metrics/subscriber.rb +27 -1
  48. data/lib/pgbus/process/completer.rb +189 -0
  49. data/lib/pgbus/process/consumer.rb +15 -1
  50. data/lib/pgbus/process/worker.rb +57 -4
  51. data/lib/pgbus/queue_name_fold.rb +145 -0
  52. data/lib/pgbus/queue_name_validator.rb +20 -2
  53. data/lib/pgbus/recurring/schedule.rb +22 -13
  54. data/lib/pgbus/serializer.rb +1 -0
  55. data/lib/pgbus/stat_buffer.rb +67 -26
  56. data/lib/pgbus/streams/cursor.rb +17 -6
  57. data/lib/pgbus/streams/envelope.rb +16 -0
  58. data/lib/pgbus/streams/key.rb +1 -1
  59. data/lib/pgbus/table_maintenance.rb +1 -1
  60. data/lib/pgbus/uniqueness.rb +43 -16
  61. data/lib/pgbus/version.rb +1 -1
  62. data/lib/pgbus/visibility_heartbeat.rb +16 -12
  63. data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
  64. data/lib/pgbus/web/data_source/resend.rb +185 -0
  65. data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
  66. data/lib/pgbus/web/data_source.rb +55 -37
  67. data/lib/pgbus/web/dead_letter_reason.rb +6 -0
  68. data/lib/pgbus/web/streamer/connection.rb +36 -29
  69. data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
  70. data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
  71. data/lib/pgbus/web/streamer/instance.rb +8 -5
  72. data/lib/pgbus/web/streamer/listener.rb +239 -62
  73. data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
  74. data/lib/pgbus/web/streamer/registry.rb +55 -29
  75. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
  76. data/lib/tasks/pgbus_queues.rake +27 -0
  77. data/sig/pgbus/active_job/adapter.rbs +1 -1
  78. data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
  79. data/sig/pgbus/client.rbs +7 -5
  80. data/sig/pgbus/configuration.rbs +12 -0
  81. data/sig/pgbus/process/completer.rbs +26 -0
  82. metadata +14 -3
@@ -49,20 +49,20 @@ module Pgbus
49
49
  # (so the Dispatcher's safe_enqueue can call either class) but ignored:
50
50
  # write_to_body is non-blocking under Falcon's fiber reactor, so there
51
51
  # is no head-of-line blocking to bound here (issue #315 item 3).
52
- def enqueue(envelopes, deadline_ms: @write_deadline_ms) # rubocop:disable Lint/UnusedMethodArgument
52
+ # frames: as Connection#enqueue (issue #621). Ephemeral envelopes
53
+ # (negative msg_id) bypass the cursor and never advance it, as there.
54
+ def enqueue(envelopes, deadline_ms: @write_deadline_ms, frames: nil) # rubocop:disable Lint/UnusedMethodArgument
53
55
  written = []
54
56
  envelopes.each do |envelope|
55
- next if envelope.msg_id <= @last_msg_id_sent.get
57
+ msg_id = envelope.msg_id
58
+ ephemeral = msg_id.negative?
59
+ next if !ephemeral && msg_id <= @last_msg_id_sent.get
56
60
 
57
- bytes = Pgbus::Streams::Envelope.message(
58
- id: envelope.msg_id,
59
- event: sse_event_for(envelope),
60
- data: envelope.payload
61
- )
61
+ bytes = frames&.[](msg_id) || frame_for(envelope)
62
62
 
63
63
  result = write_to_body(bytes)
64
64
  if result == :ok
65
- @last_msg_id_sent.set(envelope.msg_id)
65
+ @last_msg_id_sent.set(msg_id) unless ephemeral
66
66
  @last_write_at = monotonic
67
67
  written << envelope
68
68
  else
@@ -74,16 +74,28 @@ module Pgbus
74
74
  end
75
75
 
76
76
  def write_comment(text)
77
- write_frame(Pgbus::Streams::Envelope.comment(text))
77
+ write_raw(Pgbus::Streams::Envelope.comment(text))
78
78
  end
79
79
 
80
80
  # See Connection#write_connected (issue #521).
81
81
  def write_connected
82
- write_frame(Pgbus::Streams::Envelope.connected(id: @id))
82
+ write_raw(Pgbus::Streams::Envelope.connected(id: @id))
83
+ end
84
+
85
+ # See Connection#write_raw (issue #621). deadline_ms is ignored, as in
86
+ # #enqueue: the body write does not block.
87
+ def write_raw(bytes, deadline_ms: @write_deadline_ms) # rubocop:disable Lint/UnusedMethodArgument
88
+ result = write_to_body(bytes)
89
+ if result == :ok
90
+ @last_write_at = monotonic
91
+ else
92
+ mark_dead!
93
+ end
94
+ result
83
95
  end
84
96
 
85
97
  def write_sentinel(bytes)
86
- write_to_body(bytes)
98
+ write_raw(bytes)
87
99
  end
88
100
 
89
101
  def close
@@ -112,20 +124,12 @@ module Pgbus
112
124
 
113
125
  private
114
126
 
115
- # See Connection#sse_event_for. (issue #170)
116
- def sse_event_for(envelope)
117
- event = envelope.respond_to?(:event) ? envelope.event : nil
118
- event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
119
- end
120
-
121
- def write_frame(bytes)
122
- result = write_to_body(bytes)
123
- if result == :ok
124
- @last_write_at = monotonic
125
- else
126
- mark_dead!
127
- end
128
- result
127
+ def frame_for(envelope)
128
+ Pgbus::Streams::Envelope.message(
129
+ id: envelope.msg_id,
130
+ event: Pgbus::Streams::Envelope.sse_event_for(envelope),
131
+ data: envelope.payload
132
+ )
129
133
  end
130
134
 
131
135
  def write_to_body(bytes)
@@ -10,7 +10,15 @@ module Pgbus
10
10
  # connection. This keeps proxies and load balancers from timing
11
11
  # out idle HTTP responses; most reverse proxies close HTTP
12
12
  # responses that sit idle for 30-60s, which would silently drop
13
- # SSE clients.
13
+ # SSE clients. The frame is built once per tick and shared by
14
+ # every connection, and each write runs under the short fan-out
15
+ # deadline (streams_fanout_write_deadline_ms, issue #621): the
16
+ # writes are serial on this one thread, and each socket write is
17
+ # bounded by 250 ms once it holds the connection's mutex, not by
18
+ # streams_write_deadline_ms (a concurrent fan-out or replay write
19
+ # on the same connection can still delay its turn). A client that
20
+ # cannot take the heartbeat comment in that window is marked dead
21
+ # and reconnects (durable streams replay from Last-Event-ID).
14
22
  #
15
23
  # 2. Mark connections that have been idle longer than the
16
24
  # configured idle_timeout as dead. The StreamEventDispatcher's next pass
@@ -30,8 +38,10 @@ module Pgbus
30
38
  # presence touch every tick, even with no presence connection here,
31
39
  # so the dispatcher can expire seats left by other processes.
32
40
  def initialize(registry:, dispatch_queue:, interval:, idle_timeout:, logger: Pgbus.logger, clock: nil,
33
- presence_expiry: false)
41
+ presence_expiry: false,
42
+ fanout_write_deadline_ms: Pgbus.configuration.streams_fanout_write_deadline_ms)
34
43
  @registry = registry
44
+ @fanout_write_deadline_ms = fanout_write_deadline_ms
35
45
  @queue = dispatch_queue
36
46
  @interval = interval
37
47
  @idle_timeout = idle_timeout
@@ -76,6 +86,7 @@ module Pgbus
76
86
  # code goes through the background thread.
77
87
  def tick
78
88
  now = @clock.call
89
+ frame = Pgbus::Streams::Envelope.comment("heartbeat #{now.to_i}").freeze
79
90
  presence_conns = []
80
91
  @registry.each_connection do |connection|
81
92
  if connection.dead?
@@ -91,7 +102,7 @@ module Pgbus
91
102
  next
92
103
  end
93
104
 
94
- result = connection.write_comment("heartbeat #{now.to_i}")
105
+ result = connection.write_raw(frame, deadline_ms: @fanout_write_deadline_ms)
95
106
  if connection.dead? || result != :ok
96
107
  enqueue_disconnect(connection)
97
108
  next
@@ -76,7 +76,8 @@ module Pgbus
76
76
  interval: @config.streams_heartbeat_interval,
77
77
  idle_timeout: @config.streams_idle_timeout,
78
78
  logger: @logger,
79
- presence_expiry: Array(@config.streams_presence_patterns).any?
79
+ presence_expiry: Array(@config.streams_presence_patterns).any?,
80
+ fanout_write_deadline_ms: @config.streams_fanout_write_deadline_ms
80
81
  )
81
82
  @peer_close_watcher = build_peer_close_watcher
82
83
  @started = false
@@ -243,8 +244,10 @@ module Pgbus
243
244
  @logger.warn { "[Pgbus::Streamer::Instance] component stop raised: #{e.class}: #{e.message}" }
244
245
  end
245
246
 
246
- # Captured BEFORE the stops: every component nils its thread
247
- # reference in #stop whether or not the join succeeded.
247
+ # Captured BEFORE the stops: the other components nil their thread
248
+ # reference in #stop whether or not the join succeeded (the Listener
249
+ # keeps a thread that outlived its budget, so it cannot be restarted
250
+ # beside it).
248
251
  def component_threads
249
252
  {
250
253
  "heartbeat" => @heartbeat.threads,
@@ -340,8 +343,8 @@ module Pgbus
340
343
  when Proc
341
344
  # The Proc branch in connection_options typically returns
342
345
  # ActiveRecord::Base.connection.raw_connection — a pooled
343
- # AR connection. That's fatal for the streamer: LISTEN and
344
- # wait_for_notify bind the session to the connection object,
346
+ # AR connection. That's fatal for the streamer: LISTEN and the
347
+ # listener's wait on its socket bind the session to the connection object,
345
348
  # so if AR's pool hands the same raw_connection to another
346
349
  # thread mid-wait, we get concurrent libpq calls → segfault
347
350
  # or result corruption. Bail out with a clear error instead
@@ -11,24 +11,31 @@ module Pgbus
11
11
  # Threading:
12
12
  # - #start spawns ONE listener thread
13
13
  # - ensure_listening / remove_listening are called from the
14
- # dispatcher thread, which means the listener thread itself is
15
- # only running `wait_for_notify` — all LISTEN/UNLISTEN SQL goes
16
- # through a command queue that the listener thread drains between
17
- # notifies
18
- # - #stop clears @running and joins; it never touches the connection
14
+ # dispatcher thread (the master hub's reader threads for a hub).
15
+ # All LISTEN/UNLISTEN SQL goes through a command queue that the
16
+ # listener thread drains; the caller then writes a byte to a wake
17
+ # pipe, so the thread, parked in IO.select on the LISTEN socket and
18
+ # the pipe, drains the command at once instead of at the end of its
19
+ # current wait (issue #630)
20
+ # - #stop clears @running, wakes the thread through the pipe and joins;
21
+ # it never touches the connection, and the thread closes the pipe
22
+ # itself on its way out
19
23
  # - the connection is SINGLE-OWNER: the listener thread is the only
20
24
  # thread that may exec, wait, or close on it, from construction
21
25
  # through teardown. PG::Connection is not thread-safe and #close is
22
26
  # PQfinish — freeing the PGconn under a concurrent libpq call is a
23
- # process-killing SEGV, not a rescuable PG::Error (issue #375)
27
+ # process-killing SEGV, not a rescuable PG::Error (issue #375). Only
28
+ # the pipe is touched from other threads
24
29
  #
25
- # Health check: `wait_for_notify(timeout)` returns nil on timeout. When
26
- # it does, the listener runs `SELECT 1` as a TCP keepalive. If that
27
- # raises, the listener rebuilds a FRESH connection via connection_factory
28
- # and re-LISTENs every channel in `@listening_to`. This is the fix for
29
- # design doc §11 #1 (silently dropped LISTEN connections from NAT / PG
30
- # restart / network blips) — a fresh connect also re-resolves DNS and
31
- # converges on the promoted primary after a failover.
30
+ # Health check: once per health_check_ms the listener runs `SELECT 1` as
31
+ # a TCP keepalive, on a deadline: an idle connection sees exactly one per
32
+ # cycle, and a stream of wakes or notifications cannot postpone it. If
33
+ # it raises, the listener rebuilds a FRESH connection via
34
+ # connection_factory and re-LISTENs every channel in `@listening_to`.
35
+ # This is the fix for design doc §11 #1 (silently dropped LISTEN
36
+ # connections from NAT / PG restart / network blips) — a fresh connect
37
+ # also re-resolves DNS and converges on the promoted primary after a
38
+ # failover.
32
39
  #
33
40
  # NOTIFY channel naming (from pgmq_v1.11.0.sql:1634):
34
41
  # PG_NOTIFY('pgmq.' || TG_TABLE_NAME || '.' || TG_OP, NULL)
@@ -95,7 +102,18 @@ module Pgbus
95
102
  @listening_to = Set.new
96
103
  @commands = Queue.new
97
104
  @running = false
105
+ # Set by #stop: no thread will serve a command again (a listener is
106
+ # never restarted in production), so ensure_listening answers at once.
107
+ @stopped = false
98
108
  @thread = nil
109
+ @health_check_due_at = nil
110
+ # ensure_listening acks held back while the reconnect that re-LISTENs
111
+ # their channel runs. Listener thread only.
112
+ @acks_after_reconnect = []
113
+ # The wake pipe is created by #start, so a listener that never
114
+ # starts holds no descriptors.
115
+ @wake_reader = nil
116
+ @wake_writer = nil
99
117
  end
100
118
 
101
119
  # Count of durable wakes dropped due to the dispatch-queue cap. Read by
@@ -106,10 +124,20 @@ module Pgbus
106
124
  def start
107
125
  return if @running
108
126
 
127
+ # A previous thread that outlived #stop's join still owns the
128
+ # connection; a second loop on it is the cross-thread use #375 is
129
+ # about.
130
+ if @thread&.alive?
131
+ @logger.warn { "[Pgbus::Streamer::Listener] previous listener thread still running; not starting another" }
132
+ return
133
+ end
134
+
135
+ @wake_reader, @wake_writer = IO.pipe if @wake_reader.nil? || @wake_reader.closed? || @wake_writer.closed?
136
+ @stopped = false
109
137
  @running = true
110
138
  # The LISTEN thread, owned by this object: Instance#start starts it
111
- # once (under Streamer's @current_mutex); #stop clears @running and
112
- # joins it.
139
+ # once (under Streamer's @current_mutex); #stop clears @running,
140
+ # wakes it and joins it.
113
141
  @thread = Thread.new { run_loop } # rubocop:disable ThreadSafety/NewThread
114
142
  self
115
143
  end
@@ -127,28 +155,48 @@ module Pgbus
127
155
  end
128
156
 
129
157
  # Snapshot of this component's live thread(s). Instance#shutdown! captures
130
- # it BEFORE calling #stop so a join that timed out is still observable
131
- # after #stop has cleared the reference (issue #443).
158
+ # it BEFORE calling #stop (issue #443). #stop clears the reference only
159
+ # when its join returned; a thread past its budget stays listed.
132
160
  def threads
133
161
  [@thread].compact
134
162
  end
135
163
 
136
164
  def stop
137
- return unless @running
165
+ unless @running
166
+ # Never started (or already stopped): nothing to join, but no
167
+ # thread will serve a command either.
168
+ @stopped = true
169
+ return
170
+ end
138
171
 
172
+ # @stopped before @running: the thread exits (and releases what is
173
+ # queued) only after it sees @running false, so by then @stopped is
174
+ # visible to ensure_listening's second check (MRI's GVL orders these
175
+ # plain ivar writes and reads).
176
+ @stopped = true
139
177
  @running = false
140
- @commands << [:stop]
141
178
  # Deliberately does NOT touch @conn. PG::Connection is not
142
179
  # thread-safe and #close is PQfinish: it frees the PGconn and its
143
180
  # OpenSSL objects out from under whatever libpq call the listener
144
181
  # thread is making on the same connection. That is a use-after-free —
145
182
  # a process-killing SEGV, not a rescuable PG::Error (issue #375).
146
183
  # The listener thread is the sole owner of @conn and closes it in
147
- # run_loop's ensure; clearing @running above is the whole stop signal.
148
- # It is observed within one wait_for_notify timeout (health_check_ms),
149
- # which is what the join budget below is sized against.
150
- @thread&.join(stop_join_timeout)
151
- @thread = nil
184
+ # run_loop's ensure; clearing @running above is the whole stop signal,
185
+ # and the wake makes the thread see it at once. The join budget still
186
+ # covers a full health-check cycle: the thread may be inside a
187
+ # keepalive or a reconnect rather than the select.
188
+ wake
189
+ joined = @thread.nil? || @thread.join(stop_join_timeout)
190
+ # A thread past its budget stays referenced, so #start refuses to
191
+ # run a second loop beside it; it closes its own pipe when it exits.
192
+ if joined
193
+ @thread = nil
194
+ else
195
+ @logger.warn do
196
+ "[Pgbus::Streamer::Listener] listener thread did not stop within #{stop_join_timeout}s; " \
197
+ "it closes its wake pipe when it exits"
198
+ end
199
+ end
152
200
  self
153
201
  end
154
202
 
@@ -159,12 +207,23 @@ module Pgbus
159
207
  # otherwise a broadcast committed in the gap would be neither
160
208
  # in the read_after result nor delivered as a WakeMessage.
161
209
  #
162
- # The wait is bounded by the next wait_for_notify timeout
163
- # (streams_listen_health_check_ms, default 250ms) plus a small
164
- # margin so a stuck listener can't hang the dispatcher forever.
210
+ # The wake pipe makes the typical wait a thread hop (issue #630). The
211
+ # wait is bounded by one health-check cycle
212
+ # (streams_listen_health_check_ms, default 250ms) plus a small margin,
213
+ # so a stuck listener can't hang the dispatcher forever.
165
214
  def ensure_listening(queue_name)
215
+ # A connect the dispatcher drains during shutdown (Instance#shutdown!
216
+ # stops the listener first) must not wait out the ack budget.
217
+ return nil if @stopped
218
+
166
219
  ack = Queue.new
167
220
  @commands << [:listen, queue_name, ack]
221
+ wake
222
+ # A stop that landed between the check above and the push may have
223
+ # let the thread release its queue and exit before the push: no ack
224
+ # would ever come. Pushed before that release, the command is acked.
225
+ return nil if @stopped
226
+
168
227
  ack.pop(timeout: ack_timeout)
169
228
  end
170
229
 
@@ -173,31 +232,28 @@ module Pgbus
173
232
  # completing before the caller proceeds, so we don't block.
174
233
  def remove_listening(queue_name)
175
234
  @commands << [:unlisten, queue_name]
235
+ wake
176
236
  end
177
237
 
178
238
  private
179
239
 
180
240
  def run_loop
241
+ schedule_health_check
181
242
  loop do
182
243
  break unless @running
183
244
 
184
- drain_commands
185
- break unless @running
186
-
187
- timeout_s = @health_check_ms / 1000.0
188
245
  begin
189
- got_notify = @conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
190
- handle_notify(channel, payload)
191
- end
192
- # Skip the keepalive when a stop landed during the wait: the loop
193
- # is about to exit and close this connection anyway, so the
194
- # round-trip would only add latency to shutdown.
195
- run_health_check if !got_notify && @running
196
- rescue IOError => e
197
- # #stop no longer closes the connection to interrupt the wait
198
- # (issue #375), so an IOError here is a genuine socket failure,
199
- # not the expected shutdown signal. Reconnect unless we're
200
- # stopping, in which case exit cleanly.
246
+ # Inside the rescue: a LISTEN that raises (the connection died
247
+ # under it) reconnects instead of killing this thread.
248
+ drain_commands
249
+ break unless @running
250
+
251
+ wait_once
252
+ rescue IOError, Errno::EBADF => e
253
+ # #stop never closes the connection to interrupt the wait (issue
254
+ # #375), so an IOError here — a select on a socket closed under
255
+ # it — is a genuine socket failure, not the shutdown signal.
256
+ # Reconnect unless we're stopping, in which case exit cleanly.
201
257
  break unless @running
202
258
 
203
259
  @logger.warn { "[Pgbus::Streamer::Listener] IO error (#{e.class}: #{e.message}) — reconnecting" }
@@ -210,6 +266,12 @@ module Pgbus
210
266
  end
211
267
  end
212
268
  ensure
269
+ release_held_acks
270
+ release_pending_acks
271
+ # The thread that selects on the pipe is the one that closes it, so
272
+ # no IO is ever closed under a live select (the cross-thread
273
+ # teardown #375 removed), whether #stop's join returned or not.
274
+ close_wake_pipe
213
275
  # Bookkeeping only — no UNLISTEN round-trip. We close the connection
214
276
  # on the next line and closing the session deregisters every LISTEN
215
277
  # server-side, so the exec bought nothing while being the statement
@@ -221,24 +283,125 @@ module Pgbus
221
283
  @conn = nil
222
284
  end
223
285
 
286
+ # One pass of the wait: dispatch what libpq has buffered, then park in
287
+ # IO.select on the LISTEN socket and the wake pipe until either is
288
+ # readable or the next keepalive is due. A wake returns to run_loop,
289
+ # which drains the commands. This keeps pg's own wait_for_notify order:
290
+ # buffered notifications first, then the socket, consume_input, and the
291
+ # buffer again.
292
+ def wait_once
293
+ # An exec (LISTEN, SELECT 1) may have buffered a notification while
294
+ # reading its reply, leaving nothing on the socket to wake the select.
295
+ drain_notifies
296
+ timeout = @health_check_due_at - @clock.call
297
+ # Never a zero or negative select timeout: zero is a non-blocking
298
+ # poll and would spin.
299
+ return run_health_check unless timeout.positive?
300
+
301
+ socket = @conn.socket_io
302
+ ready, = IO.select([socket, @wake_reader], nil, nil, timeout)
303
+ # Idle for a full cycle. Skip the keepalive when a stop landed during
304
+ # the wait: the loop is about to exit and close this connection
305
+ # anyway, so the round-trip would only add latency to shutdown.
306
+ return (run_health_check if @running) if ready.nil?
307
+
308
+ drain_wake if ready.include?(@wake_reader)
309
+ read_socket if ready.include?(socket)
310
+ end
311
+
312
+ def read_socket
313
+ @conn.consume_input
314
+ # A peer-closed socket stays readable (EOF) whether or not
315
+ # consume_input raised: check the status, or the loop would spin on
316
+ # it with the keepalive no longer the thing that notices.
317
+ raise PG::ConnectionBad, "LISTEN connection lost" unless @conn.status == PG::CONNECTION_OK
318
+
319
+ drain_notifies
320
+ end
321
+
322
+ # PG::Connection#notifies reports a NOTIFY without a payload as
323
+ # extra: "" (wait_for_notify yields nil for it). A durable PGMQ wake
324
+ # has no payload and must reach handle_notify as nil, or it would be
325
+ # routed as an ephemeral one.
326
+ def drain_notifies
327
+ while (notification = @conn.notifies)
328
+ extra = notification[:extra]
329
+ handle_notify(notification[:relname], extra.nil? || extra.empty? ? nil : extra)
330
+ end
331
+ end
332
+
333
+ # Before #start there is no pipe: the command waits in the queue and
334
+ # the thread drains it on its first pass.
335
+ def wake
336
+ @wake_writer&.write_nonblock(".", exception: false)
337
+ rescue IOError, Errno::EPIPE
338
+ # Closed by #stop (IOError), or its reader closed first (EPIPE):
339
+ # nothing is left to wake.
340
+ nil
341
+ end
342
+
343
+ # One read empties the pipe of every wake written so far (each is
344
+ # one byte), so a burst of commands costs one extra select return.
345
+ def drain_wake
346
+ @wake_reader.read_nonblock(4096, exception: false)
347
+ end
348
+
349
+ def close_wake_pipe
350
+ # Writer first: a late wake then meets a closed writer (IOError), not
351
+ # a writer whose reader is gone (EPIPE). Both are rescued in #wake.
352
+ @wake_writer.close unless @wake_writer.closed?
353
+ @wake_reader.close unless @wake_reader.closed?
354
+ end
355
+
356
+ # Acks held back by a LISTEN that raised: the reconnect that
357
+ # re-LISTENed their channel is done (or the thread is exiting).
358
+ def release_held_acks
359
+ @acks_after_reconnect.each { |ack| ack.push(:done) }
360
+ @acks_after_reconnect.clear
361
+ end
362
+
363
+ # Commands #stop left in the queue: release every ensure_listening
364
+ # caller now rather than at its ack timeout. Nothing is LISTENed: the
365
+ # connection closes right after this.
366
+ def release_pending_acks
367
+ loop do
368
+ cmd = @commands.pop(true)
369
+ cmd[2]&.push(:done) if cmd[0] == :listen
370
+ end
371
+ rescue ThreadError
372
+ nil
373
+ end
374
+
375
+ def schedule_health_check
376
+ @health_check_due_at = @clock.call + (@health_check_ms / 1000.0)
377
+ end
378
+
224
379
  def drain_commands
225
380
  loop do
381
+ # #stop cleared the flag: run_loop is about to exit and close the
382
+ # connection, so the rest of the queue is moot.
383
+ return unless @running
384
+
385
+ # A backlog that never empties must not postpone the keepalive.
386
+ run_health_check unless (@health_check_due_at - @clock.call).positive?
226
387
  cmd = @commands.pop(true)
227
388
  case cmd[0]
228
389
  when :listen
229
390
  ack = cmd[2]
230
391
  begin
231
392
  do_listen(cmd[1])
393
+ rescue StandardError
394
+ # The connection failed under the LISTEN. run_loop
395
+ # reconnects, and reconnect! re-LISTENs this channel with the
396
+ # rest; the caller is acked once that is done, so its
397
+ # read_after never runs ahead of an active LISTEN.
398
+ @acks_after_reconnect << ack if ack
399
+ ack = nil
400
+ raise
232
401
  ensure
233
- # Always ack so the caller is never left blocked, even
234
- # if do_listen raised. The caller can still detect
235
- # failure by checking @listening_to or by other means;
236
- # we just promise not to deadlock the dispatcher.
237
402
  ack&.push(:done)
238
403
  end
239
404
  when :unlisten then do_unlisten(cmd[1])
240
- when :stop then @running = false
241
- return
242
405
  end
243
406
  rescue ThreadError
244
407
  # empty queue
@@ -246,39 +409,45 @@ module Pgbus
246
409
  end
247
410
  end
248
411
 
249
- # Caller wait budget for ensure_listening's ack. The listener
250
- # thread will process the command at most one wait_for_notify
251
- # cycle from now (bounded by health_check_ms); add a small
252
- # safety margin so the dispatcher fails loud rather than
253
- # hanging if the listener thread is dead.
412
+ # Caller wait budget for ensure_listening's ack. The wake pipe
413
+ # makes the usual wait a thread hop; a thread busy in a keepalive
414
+ # or a reconnect gets one health-check cycle plus a margin, so the
415
+ # dispatcher fails loud rather than hanging if the thread is dead.
254
416
  def ack_timeout
255
417
  (@health_check_ms / 1000.0) + 1.0
256
418
  end
257
419
 
258
420
  # How long #stop waits for the listener thread to notice the cleared
259
- # @running and finish teardown. The thread can be parked in
260
- # wait_for_notify for one full health-check cycle before it re-checks
261
- # the flag, so the budget is that cycle plus grace — a flat timeout
262
- # would expire before a listener with a large health_check_ms had even
263
- # one chance to observe the stop.
421
+ # @running and finish teardown. The wake pipe interrupts its select at
422
+ # once, but it may be inside a keepalive or a reconnect attempt, so the
423
+ # budget stays one health-check cycle plus grace.
264
424
  def stop_join_timeout
265
425
  (@health_check_ms / 1000.0) + STOP_JOIN_GRACE_SECONDS
266
426
  end
267
427
 
428
+ # The channel joins @listening_to BEFORE the exec: if the LISTEN
429
+ # raises, run_loop reconnects, reconnect! re-LISTENs it with the rest,
430
+ # and only then is the caller acked (drain_commands), so an acked
431
+ # ensure_listening means an active LISTEN (the dispatcher's
432
+ # no-second-wait rule rests on that, issue #630). The exception is a
433
+ # thread on its way out (stop, or an unexpected error): run_loop's
434
+ # ensure releases every waiting caller with nothing LISTENed.
268
435
  def do_listen(queue_name)
269
436
  channel = channel_for(queue_name)
270
437
  return if @listening_to.include?(channel)
271
438
 
272
- @conn.exec(%(LISTEN "#{channel}"))
273
439
  @listening_to.add(channel)
440
+ @conn.exec(%(LISTEN "#{channel}"))
274
441
  end
275
442
 
443
+ # The channel leaves @listening_to BEFORE the exec, so a reconnect
444
+ # after a failed UNLISTEN does not LISTEN it again.
276
445
  def do_unlisten(queue_name)
277
446
  channel = channel_for(queue_name)
278
447
  return unless @listening_to.include?(channel)
279
448
 
280
- @conn.exec(%(UNLISTEN "#{channel}"))
281
449
  @listening_to.delete(channel)
450
+ @conn.exec(%(UNLISTEN "#{channel}"))
282
451
  end
283
452
 
284
453
  def handle_notify(channel, payload = nil)
@@ -308,15 +477,21 @@ module Pgbus
308
477
  @dispatch_queue << WakeMessage.new(queue_name: queue_name, payload: payload)
309
478
  end
310
479
 
480
+ # The next keepalive is due one cycle after this one ENDS: scheduled
481
+ # from its start, a keepalive slower than a cycle would leave every
482
+ # pass overdue and the loop running SELECT 1 back to back without ever
483
+ # selecting on the socket or the pipe.
311
484
  def run_health_check
312
485
  @conn.exec("SELECT 1")
313
486
  run_maintenance
487
+ ensure
488
+ schedule_health_check
314
489
  end
315
490
 
316
491
  # Periodic maintenance on the idle LISTEN connection, throttled to
317
492
  # @maintenance.interval (issue #323). Runs on the listener thread, so it
318
493
  # is the only place the non-thread-safe @conn may be queried outside
319
- # wait_for_notify. Fail-soft: a raising maintenance callback is logged and
494
+ # the listen loop itself. Fail-soft: a raising maintenance callback is logged and
320
495
  # never disturbs the listen loop.
321
496
  def run_maintenance
322
497
  return unless @maintenance
@@ -373,6 +548,8 @@ module Pgbus
373
548
  @listening_to = Set.new(channels)
374
549
  return
375
550
  end
551
+ ensure
552
+ release_held_acks
376
553
  end
377
554
 
378
555
  # Close a PG connection we're discarding (the old conn at the top of a
@@ -36,7 +36,7 @@ module Pgbus
36
36
  # ArgumentError on a negative msg_id as a defense-in-depth guard against a
37
37
  # future refactor accidentally offloading one.
38
38
  class OutboundPump
39
- WriteJob = Data.define(:connection, :envelopes, :batch_max, :deadline_ms)
39
+ WriteJob = Data.define(:connection, :envelopes, :batch_max, :deadline_ms, :frames)
40
40
  private_constant :WriteJob
41
41
 
42
42
  DRAIN = :__drain__
@@ -85,7 +85,9 @@ module Pgbus
85
85
 
86
86
  # Hand a durable fanout write to the pump. Returns immediately — the
87
87
  # dispatcher does not block. Raises on a negative (ephemeral) msg_id.
88
- def post(connection, envelopes, batch_max, deadline_ms:)
88
+ # frames: the wake's prebuilt frames, handed to Connection#enqueue
89
+ # (issue #621).
90
+ def post(connection, envelopes, batch_max, deadline_ms:, frames: nil)
89
91
  if envelopes.any? { |e| e.msg_id.negative? }
90
92
  raise ArgumentError,
91
93
  "OutboundPump received an ephemeral (negative msg_id) envelope; " \
@@ -94,7 +96,7 @@ module Pgbus
94
96
 
95
97
  partition_for(connection).push(
96
98
  WriteJob.new(connection: connection, envelopes: envelopes,
97
- batch_max: batch_max, deadline_ms: deadline_ms)
99
+ batch_max: batch_max, deadline_ms: deadline_ms, frames: frames)
98
100
  )
99
101
  end
100
102
 
@@ -149,7 +151,7 @@ module Pgbus
149
151
  conn = job.connection
150
152
  return if conn.dead?
151
153
 
152
- written = conn.enqueue(job.envelopes, deadline_ms: job.deadline_ms)
154
+ written = conn.enqueue(job.envelopes, deadline_ms: job.deadline_ms, frames: job.frames)
153
155
 
154
156
  if conn.dead?
155
157
  @on_dead.call(conn)