pgbus 0.22.0 → 0.23.1

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +41 -14
  4. data/Rakefile +7 -1
  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 +142 -20
  31. data/lib/pgbus/active_job/quiet_logs.rb +57 -0
  32. data/lib/pgbus/autovacuum_tuning.rb +2 -2
  33. data/lib/pgbus/cli/dlq.rb +2 -1
  34. data/lib/pgbus/client/async_commit.rb +149 -0
  35. data/lib/pgbus/client/native_statements.rb +28 -16
  36. data/lib/pgbus/client.rb +181 -17
  37. data/lib/pgbus/concurrency/blocked_execution.rb +9 -1
  38. data/lib/pgbus/configuration.rb +71 -6
  39. data/lib/pgbus/dead_letter_header.rb +3 -0
  40. data/lib/pgbus/doctor.rb +55 -2
  41. data/lib/pgbus/failed_event_recorder.rb +5 -2
  42. data/lib/pgbus/generators/migration_detector.rb +29 -2
  43. data/lib/pgbus/instrumentation.rb +16 -1
  44. data/lib/pgbus/metrics/subscriber.rb +27 -1
  45. data/lib/pgbus/process/completer.rb +4 -2
  46. data/lib/pgbus/process/worker.rb +28 -3
  47. data/lib/pgbus/queue_name_fold.rb +145 -0
  48. data/lib/pgbus/queue_name_validator.rb +20 -2
  49. data/lib/pgbus/recurring/schedule.rb +22 -13
  50. data/lib/pgbus/stat_buffer.rb +67 -26
  51. data/lib/pgbus/streams/cursor.rb +17 -6
  52. data/lib/pgbus/streams/envelope.rb +16 -0
  53. data/lib/pgbus/streams/key.rb +1 -1
  54. data/lib/pgbus/table_maintenance.rb +1 -1
  55. data/lib/pgbus/uniqueness.rb +43 -16
  56. data/lib/pgbus/version.rb +1 -1
  57. data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
  58. data/lib/pgbus/web/data_source/resend.rb +185 -0
  59. data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
  60. data/lib/pgbus/web/data_source.rb +55 -37
  61. data/lib/pgbus/web/dead_letter_reason.rb +6 -0
  62. data/lib/pgbus/web/streamer/connection.rb +36 -29
  63. data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
  64. data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
  65. data/lib/pgbus/web/streamer/instance.rb +8 -5
  66. data/lib/pgbus/web/streamer/listener.rb +239 -62
  67. data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
  68. data/lib/pgbus/web/streamer/registry.rb +55 -29
  69. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
  70. data/lib/tasks/pgbus_queues.rake +27 -0
  71. data/sig/pgbus/active_job/adapter.rbs +1 -1
  72. data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
  73. data/sig/pgbus/client.rbs +6 -5
  74. data/sig/pgbus/configuration.rbs +4 -0
  75. metadata +11 -3
@@ -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)
@@ -4,18 +4,34 @@ module Pgbus
4
4
  module Web
5
5
  module Streamer
6
6
  # Worker-local in-memory registry of SSE connections indexed by stream
7
- # name and connection id. Thread-safe via a single mutex. Reads return
8
- # snapshots so iterators never hold the lock.
7
+ # name and connection id. Thread-safe via a single mutex.
9
8
  #
10
- # Registry operations are O(1) under contention (mutex-protected hash
11
- # lookups), and iteration is O(n) over a snapshot. The data structure is
12
- # deliberately boring — the interesting parts of the streamer (LISTEN
13
- # multiplexing, write scheduling, replay race handling) live elsewhere.
9
+ # Reads are copy-free (issue #621): connections_for and each_connection
10
+ # hand out a frozen snapshot that is built on the first read after a
11
+ # membership change and cached until the next one, never mutated in
12
+ # place (the rule docs/concurrency.md sets for memos handed to other
13
+ # threads). Every wake and every heartbeat tick on an unchanged stream
14
+ # gets the same object back: one uncontended lock, no copy. The
15
+ # snapshot is not rebuilt on register / unregister themselves, so a
16
+ # connect storm of M clients does not copy the M-element list M times
17
+ # (an eager copy-on-write registry measured +35 MB of RSS and 80 ms for
18
+ # 2 000 registers, against 0.4 MB and 2.5 ms for this one). Each
19
+ # stream's members are an identity Hash (insertion-ordered), so a
20
+ # register or an unregister is O(1), not a scan of the stream.
14
21
  class Registry
22
+ EMPTY = [].freeze
23
+
15
24
  def initialize
16
25
  @mutex = Mutex.new
26
+ # stream name → identity Hash of its connections (connection => true),
27
+ # mutated under @mutex only.
17
28
  @by_stream = {}
18
29
  @by_id = {}
30
+ # stream name → the frozen snapshot of @by_stream[name]; an entry is
31
+ # dropped when that stream's membership changes.
32
+ @stream_snapshots = {}
33
+ # The frozen snapshot of every connection, nil after a change.
34
+ @all_snapshot = nil
19
35
  end
20
36
 
21
37
  def register(connection)
@@ -32,8 +48,11 @@ module Pgbus
32
48
  # return orphaned objects and writes would go nowhere.
33
49
  evict_from_stream(existing) if existing
34
50
 
51
+ # Not already a member: the same object under its id returned
52
+ # above, and a connection's id never changes.
35
53
  @by_id[connection.id] = connection
36
- (@by_stream[connection.stream_name] ||= Set.new).add(connection)
54
+ (@by_stream[connection.stream_name] ||= {}.compare_by_identity)[connection] = true
55
+ membership_changed(connection.stream_name)
37
56
  end
38
57
  end
39
58
 
@@ -48,11 +67,8 @@ module Pgbus
48
67
  existing = @by_id.delete(connection.id)
49
68
  return false unless existing
50
69
 
51
- set = @by_stream[existing.stream_name]
52
- if set
53
- set.delete(existing)
54
- @by_stream.delete(existing.stream_name) if set.empty?
55
- end
70
+ evict_from_stream(existing)
71
+ membership_changed(existing.stream_name)
56
72
  true
57
73
  end
58
74
  end
@@ -61,25 +77,27 @@ module Pgbus
61
77
  @mutex.synchronize { @by_id[id] }
62
78
  end
63
79
 
64
- # Returns a snapshot Array of connections on the given stream.
65
- # Mutating the result has no effect on the registry.
80
+ # The frozen Array of connections on the given stream: the same
81
+ # object until the stream's membership changes.
66
82
  def connections_for(stream_name)
67
83
  @mutex.synchronize do
68
- set = @by_stream[stream_name]
69
- set ? set.to_a : []
84
+ # No entry for a stream without members: a wake that lands after
85
+ # the last subscriber left must not leave a slot behind for every
86
+ # per-record stream name (order_42, …) the process ever served.
87
+ members = @by_stream[stream_name]
88
+ next EMPTY unless members
89
+
90
+ @stream_snapshots[stream_name] ||= members.keys.freeze
70
91
  end
71
92
  end
72
93
 
73
- # Snapshot list of stream names with at least one registered connection.
94
+ # Frozen list of stream names with at least one registered connection.
74
95
  def streams
75
- @mutex.synchronize { @by_stream.keys.dup }
96
+ @mutex.synchronize { @by_stream.keys.freeze }
76
97
  end
77
98
 
78
99
  def empty?(stream_name)
79
- @mutex.synchronize do
80
- set = @by_stream[stream_name]
81
- set.nil? || set.empty?
82
- end
100
+ @mutex.synchronize { !@by_stream.key?(stream_name) }
83
101
  end
84
102
 
85
103
  def size
@@ -87,22 +105,30 @@ module Pgbus
87
105
  end
88
106
 
89
107
  # Yields every registered connection across all streams. The iteration
90
- # walks a snapshot so the block may safely call back into register/
91
- # unregister without risk of deadlock or skipped items.
108
+ # walks the current frozen snapshot, so the block may safely call back
109
+ # into register/unregister without risk of deadlock or skipped items.
92
110
  def each_connection(&)
93
- snapshot = @mutex.synchronize { @by_id.values.dup }
111
+ snapshot = @mutex.synchronize { @all_snapshot ||= @by_id.values.freeze }
94
112
  snapshot.each(&)
95
113
  end
96
114
 
97
115
  private
98
116
 
117
+ # Must be called while holding @mutex.
118
+ def membership_changed(stream_name)
119
+ @stream_snapshots.delete(stream_name)
120
+ @all_snapshot = nil
121
+ end
122
+
99
123
  # Must be called while holding @mutex.
100
124
  def evict_from_stream(connection)
101
- set = @by_stream[connection.stream_name]
102
- return unless set
125
+ name = connection.stream_name
126
+ members = @by_stream[name]
127
+ return unless members
103
128
 
104
- set.delete(connection)
105
- @by_stream.delete(connection.stream_name) if set.empty?
129
+ members.delete(connection)
130
+ @by_stream.delete(name) if members.empty?
131
+ @stream_snapshots.delete(name)
106
132
  end
107
133
  end
108
134
  end