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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +41 -14
- data/Rakefile +7 -1
- data/app/assets/javascripts/pgbus/stream_source_element.js +17 -3
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +4 -4
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
- data/app/controllers/pgbus/dead_letter_controller.rb +3 -1
- data/app/controllers/pgbus/jobs_controller.rb +2 -1
- data/app/models/pgbus/blocked_execution.rb +3 -2
- data/app/models/pgbus/queue_state.rb +6 -0
- data/app/models/pgbus/uniqueness_key.rb +11 -0
- data/config/locales/da.yml +1 -0
- data/config/locales/de.yml +1 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/locales/fi.yml +1 -0
- data/config/locales/fr.yml +1 -0
- data/config/locales/it.yml +1 -0
- data/config/locales/ja.yml +1 -0
- data/config/locales/nb.yml +1 -0
- data/config/locales/nl.yml +1 -0
- data/config/locales/pt.yml +1 -0
- data/config/locales/sv.yml +1 -0
- data/lib/generators/pgbus/fold_queue_name_case_generator.rb +57 -0
- data/lib/generators/pgbus/templates/fold_queue_name_case.rb.erb +12 -0
- data/lib/generators/pgbus/templates/tune_autovacuum.rb.erb +2 -2
- data/lib/generators/pgbus/templates/tune_fillfactor.rb.erb +1 -1
- data/lib/pgbus/active_job/adapter.rb +3 -2
- data/lib/pgbus/active_job/executor.rb +142 -20
- data/lib/pgbus/active_job/quiet_logs.rb +57 -0
- data/lib/pgbus/autovacuum_tuning.rb +2 -2
- data/lib/pgbus/cli/dlq.rb +2 -1
- data/lib/pgbus/client/async_commit.rb +149 -0
- data/lib/pgbus/client/native_statements.rb +28 -16
- data/lib/pgbus/client.rb +181 -17
- data/lib/pgbus/concurrency/blocked_execution.rb +9 -1
- data/lib/pgbus/configuration.rb +71 -6
- data/lib/pgbus/dead_letter_header.rb +3 -0
- data/lib/pgbus/doctor.rb +55 -2
- data/lib/pgbus/failed_event_recorder.rb +5 -2
- data/lib/pgbus/generators/migration_detector.rb +29 -2
- data/lib/pgbus/instrumentation.rb +16 -1
- data/lib/pgbus/metrics/subscriber.rb +27 -1
- data/lib/pgbus/process/completer.rb +4 -2
- data/lib/pgbus/process/worker.rb +28 -3
- data/lib/pgbus/queue_name_fold.rb +145 -0
- data/lib/pgbus/queue_name_validator.rb +20 -2
- data/lib/pgbus/recurring/schedule.rb +22 -13
- data/lib/pgbus/stat_buffer.rb +67 -26
- data/lib/pgbus/streams/cursor.rb +17 -6
- data/lib/pgbus/streams/envelope.rb +16 -0
- data/lib/pgbus/streams/key.rb +1 -1
- data/lib/pgbus/table_maintenance.rb +1 -1
- data/lib/pgbus/uniqueness.rb +43 -16
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
- data/lib/pgbus/web/data_source/resend.rb +185 -0
- data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
- data/lib/pgbus/web/data_source.rb +55 -37
- data/lib/pgbus/web/dead_letter_reason.rb +6 -0
- data/lib/pgbus/web/streamer/connection.rb +36 -29
- data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
- data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
- data/lib/pgbus/web/streamer/instance.rb +8 -5
- data/lib/pgbus/web/streamer/listener.rb +239 -62
- data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
- data/lib/pgbus/web/streamer/registry.rb +55 -29
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
- data/lib/tasks/pgbus_queues.rake +27 -0
- data/sig/pgbus/active_job/adapter.rbs +1 -1
- data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
- data/sig/pgbus/client.rbs +6 -5
- data/sig/pgbus/configuration.rbs +4 -0
- 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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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:
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
|
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
|
|
131
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
163
|
-
#
|
|
164
|
-
#
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
# #
|
|
198
|
-
#
|
|
199
|
-
#
|
|
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
|
|
250
|
-
# thread
|
|
251
|
-
#
|
|
252
|
-
#
|
|
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
|
|
260
|
-
#
|
|
261
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
8
|
-
# snapshots so iterators never hold the lock.
|
|
7
|
+
# name and connection id. Thread-safe via a single mutex.
|
|
9
8
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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] ||=
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
#
|
|
65
|
-
#
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
#
|
|
94
|
+
# Frozen list of stream names with at least one registered connection.
|
|
74
95
|
def streams
|
|
75
|
-
@mutex.synchronize { @by_stream.keys.
|
|
96
|
+
@mutex.synchronize { @by_stream.keys.freeze }
|
|
76
97
|
end
|
|
77
98
|
|
|
78
99
|
def empty?(stream_name)
|
|
79
|
-
@mutex.synchronize
|
|
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
|
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
125
|
+
name = connection.stream_name
|
|
126
|
+
members = @by_stream[name]
|
|
127
|
+
return unless members
|
|
103
128
|
|
|
104
|
-
|
|
105
|
-
@by_stream.delete(
|
|
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
|