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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Client
|
|
5
|
+
# Asynchronous completion commit (issue #619,
|
|
6
|
+
# `config.completion_synchronous_commit = false`). A completion statement
|
|
7
|
+
# (archive, delete and their batches) runs in its own transaction with
|
|
8
|
+
# synchronous_commit off, so the job is acknowledged without waiting for
|
|
9
|
+
# its WAL flush. The four queries go out in one libpq pipeline with one
|
|
10
|
+
# sync, so it is still one round trip (pg writes each query to the socket
|
|
11
|
+
# as it is sent, but waits for a reply only once, after the sync):
|
|
12
|
+
#
|
|
13
|
+
# BEGIN
|
|
14
|
+
# SET LOCAL synchronous_commit TO off
|
|
15
|
+
# <the prepared completion statement> <- the result the caller gets
|
|
16
|
+
# COMMIT
|
|
17
|
+
#
|
|
18
|
+
# SET LOCAL ends with the transaction, so the pooled connection never
|
|
19
|
+
# keeps the setting, and the statement stays prepared (no planning cost
|
|
20
|
+
# back, issue #605). Only a job's completion asks for it (the executor and
|
|
21
|
+
# the completer pass job_completion: true; the event consumer and the
|
|
22
|
+
# dashboard keep the durable commit), and NativeStatements#exec_native
|
|
23
|
+
# calls it only outside a caller's transaction: a completion inside one is
|
|
24
|
+
# the caller's to commit.
|
|
25
|
+
#
|
|
26
|
+
# A failed query aborts the rest of the pipeline and leaves the explicit
|
|
27
|
+
# BEGIN open in a failed state: the connection leaves pipeline mode, the
|
|
28
|
+
# transaction is rolled back, and the error is raised for run_native's
|
|
29
|
+
# re-run and short-row checks. A statement not yet prepared on this session
|
|
30
|
+
# runs through prepare_and_exec outside the pipeline, committed
|
|
31
|
+
# synchronously, once; the next call pipelines again.
|
|
32
|
+
module AsyncCommit
|
|
33
|
+
ASYNC_COMMIT_BEGIN = "BEGIN"
|
|
34
|
+
ASYNC_COMMIT_OFF = "SET LOCAL synchronous_commit TO off"
|
|
35
|
+
ASYNC_COMMIT_COMMIT = "COMMIT"
|
|
36
|
+
NO_PARAMS = [].freeze
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def exec_async_commit(conn, statement, params, prepared)
|
|
41
|
+
results = pipeline_async_commit(conn, statement, params, prepared)
|
|
42
|
+
failed = results.find { |result| result.result_status == PG::PGRES_FATAL_ERROR }
|
|
43
|
+
unless failed
|
|
44
|
+
NativeStatements.note_executed if prepared
|
|
45
|
+
return results[2]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
rollback_async_commit(conn)
|
|
49
|
+
begin
|
|
50
|
+
failed.check
|
|
51
|
+
rescue PG::InvalidSqlStatementName
|
|
52
|
+
raise unless prepared
|
|
53
|
+
|
|
54
|
+
prepare_and_exec(conn, statement, params)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Sends the four queries and one sync, and reads every result up to the
|
|
59
|
+
# sync: each query answers its result and then nil.
|
|
60
|
+
def pipeline_async_commit(conn, statement, params, prepared)
|
|
61
|
+
conn.enter_pipeline_mode
|
|
62
|
+
begin
|
|
63
|
+
conn.send_query_params(ASYNC_COMMIT_BEGIN, NO_PARAMS)
|
|
64
|
+
conn.send_query_params(ASYNC_COMMIT_OFF, NO_PARAMS)
|
|
65
|
+
if prepared
|
|
66
|
+
conn.send_query_prepared(statement.name, params)
|
|
67
|
+
else
|
|
68
|
+
conn.send_query_params(statement.sql, params)
|
|
69
|
+
end
|
|
70
|
+
conn.send_query_params(ASYNC_COMMIT_COMMIT, NO_PARAMS)
|
|
71
|
+
conn.pipeline_sync
|
|
72
|
+
read_pipeline(conn)
|
|
73
|
+
ensure
|
|
74
|
+
leave_pipeline(conn)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_pipeline(conn)
|
|
79
|
+
results = []
|
|
80
|
+
ended = false
|
|
81
|
+
loop do
|
|
82
|
+
result = conn.get_result
|
|
83
|
+
if result.nil?
|
|
84
|
+
# One nil closes each query's results; two in a row means libpq
|
|
85
|
+
# has nothing more, so the sync will not come. That session's
|
|
86
|
+
# state cannot be trusted: pgmq-ruby re-runs on a fresh one.
|
|
87
|
+
if ended
|
|
88
|
+
discard_session(conn, "A completion pipeline ended before its sync")
|
|
89
|
+
raise PG::ConnectionBad, "pipeline ended before its sync"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
ended = true
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
ended = false
|
|
96
|
+
break if result.result_status == PG::PGRES_PIPELINE_SYNC
|
|
97
|
+
|
|
98
|
+
results << result
|
|
99
|
+
end
|
|
100
|
+
results
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A connection back in the pool in pipeline mode would break the next
|
|
104
|
+
# caller. A lost one is left as it is: pgmq-ruby resets a bad connection
|
|
105
|
+
# at its next checkout, and a reset leaves pipeline mode. One that cannot
|
|
106
|
+
# leave (results left unread by an interrupted read) is reset here.
|
|
107
|
+
def leave_pipeline(conn)
|
|
108
|
+
return if conn.pipeline_status == PG::PQ_PIPELINE_OFF || conn.status == PG::CONNECTION_BAD
|
|
109
|
+
|
|
110
|
+
conn.exit_pipeline_mode
|
|
111
|
+
rescue PG::Error => e
|
|
112
|
+
discard_session(conn, "Could not leave pipeline mode after a completion (#{e.message})")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resets a session whose pipeline state cannot be trusted. A reset that
|
|
116
|
+
# fails (the server is gone) is logged and left there, so the error the
|
|
117
|
+
# caller is already raising is the one that surfaces; pgmq-ruby resets a
|
|
118
|
+
# bad connection at its next checkout.
|
|
119
|
+
# A reset reconnects with the same conninfo (the read bounds ride in it)
|
|
120
|
+
# but does not re-run the connect lambda, so the :session-mode SETs are
|
|
121
|
+
# applied again here.
|
|
122
|
+
def discard_session(conn, reason)
|
|
123
|
+
Pgbus.logger.warn { "[Pgbus::Client] #{reason}, resetting the connection" }
|
|
124
|
+
begin
|
|
125
|
+
conn.reset
|
|
126
|
+
rescue PG::Error => e
|
|
127
|
+
Pgbus.logger.warn { "[Pgbus::Client] Resetting the connection failed: #{e.message}" }
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
reapply_session_gucs(conn)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def reapply_session_gucs(conn)
|
|
134
|
+
apply_session_gucs(conn, @session_gucs) if @session_gucs
|
|
135
|
+
rescue PG::Error => e
|
|
136
|
+
Pgbus.logger.warn do
|
|
137
|
+
"[Pgbus::Client] Re-applying the session's database.yml variables after the reset failed: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The failed BEGIN block (INERROR) is still open after the sync. A failed
|
|
142
|
+
# COMMIT has already ended it (IDLE); a lost connection reports UNKNOWN
|
|
143
|
+
# and has nothing to roll back.
|
|
144
|
+
def rollback_async_commit(conn)
|
|
145
|
+
conn.exec("ROLLBACK") if [PG::PQTRANS_INERROR, PG::PQTRANS_INTRANS].include?(conn.transaction_status)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -299,8 +299,11 @@ module Pgbus
|
|
|
299
299
|
# call otherwise. Return values match pgmq-ruby's: Messages with String
|
|
300
300
|
# fields, String ids, true/false for one archive/delete.
|
|
301
301
|
|
|
302
|
+
# The send statements bind the delay as $3::integer. is_a?(Integer) also
|
|
303
|
+
# takes a whole-second ActiveSupport::Duration, which binds as its seconds;
|
|
304
|
+
# a fractional delay or a Time goes through pgmq-ruby.
|
|
302
305
|
def exec_produce(name, message, headers:, delay:)
|
|
303
|
-
statements = delay.is_a?(
|
|
306
|
+
statements = delay.is_a?(Integer) && native_statements_for(name)
|
|
304
307
|
return @pgmq.produce(name, message, headers: headers, delay: delay) unless statements
|
|
305
308
|
|
|
306
309
|
run_native(statements[:send], [message, headers, delay])[0]["msg_id"]
|
|
@@ -308,7 +311,7 @@ module Pgbus
|
|
|
308
311
|
|
|
309
312
|
def exec_produce_batch(name, messages, headers:, delay:)
|
|
310
313
|
statements = messages.any? && (headers.nil? || headers.length == messages.length) &&
|
|
311
|
-
delay.is_a?(
|
|
314
|
+
delay.is_a?(Integer) && native_statements_for(name)
|
|
312
315
|
return @pgmq.produce_batch(name, messages, headers: headers, delay: delay) unless statements
|
|
313
316
|
|
|
314
317
|
encoder = PG::TextEncoder::Array.new
|
|
@@ -325,32 +328,37 @@ module Pgbus
|
|
|
325
328
|
result.map { |row| PGMQ::Message.new(row) }
|
|
326
329
|
end
|
|
327
330
|
|
|
328
|
-
|
|
331
|
+
# async: commit with synchronous_commit off (AsyncCommit, issue #619);
|
|
332
|
+
# true only for a job's completion under completion_synchronous_commit
|
|
333
|
+
# = false.
|
|
334
|
+
def exec_archive(name, msg_id, async: false)
|
|
329
335
|
statements = native_statements_for(name)
|
|
330
336
|
return @pgmq.archive(name, msg_id) unless statements
|
|
331
337
|
|
|
332
|
-
run_native(statements[:archive], [msg_id], rows: 1).ntuples.positive?
|
|
338
|
+
run_native(statements[:archive], [msg_id], rows: 1, async: async).ntuples.positive?
|
|
333
339
|
end
|
|
334
340
|
|
|
335
|
-
def exec_archive_batch(name, msg_ids)
|
|
341
|
+
def exec_archive_batch(name, msg_ids, async: false)
|
|
336
342
|
statements = msg_ids.any? && native_statements_for(name)
|
|
337
343
|
return @pgmq.archive_batch(name, msg_ids) unless statements
|
|
338
344
|
|
|
339
|
-
|
|
345
|
+
params = [PG::TextEncoder::Array.new.encode(msg_ids)]
|
|
346
|
+
run_native(statements[:archive_batch], params, rows: msg_ids, async: async).map { |row| row["msg_id"] }
|
|
340
347
|
end
|
|
341
348
|
|
|
342
|
-
def exec_delete(name, msg_id)
|
|
349
|
+
def exec_delete(name, msg_id, async: false)
|
|
343
350
|
statements = native_statements_for(name)
|
|
344
351
|
return @pgmq.delete(name, msg_id) unless statements
|
|
345
352
|
|
|
346
|
-
run_native(statements[:delete], [msg_id], rows: 1).ntuples.positive?
|
|
353
|
+
run_native(statements[:delete], [msg_id], rows: 1, async: async).ntuples.positive?
|
|
347
354
|
end
|
|
348
355
|
|
|
349
|
-
def exec_delete_batch(name, msg_ids)
|
|
356
|
+
def exec_delete_batch(name, msg_ids, async: false)
|
|
350
357
|
statements = msg_ids.any? && native_statements_for(name)
|
|
351
358
|
return @pgmq.delete_batch(name, msg_ids) unless statements
|
|
352
359
|
|
|
353
|
-
|
|
360
|
+
params = [PG::TextEncoder::Array.new.encode(msg_ids)]
|
|
361
|
+
run_native(statements[:delete_batch], params, rows: msg_ids, async: async).map { |row| row["msg_id"] }
|
|
354
362
|
end
|
|
355
363
|
|
|
356
364
|
# rows: the rows an archive or delete returns when every id was still
|
|
@@ -366,12 +374,12 @@ module Pgbus
|
|
|
366
374
|
# id another worker had already taken also makes a re-run come back
|
|
367
375
|
# short; every waiter of that batch then claims :ambiguous, the side the
|
|
368
376
|
# executor already errs on.
|
|
369
|
-
def run_native(statement, params, rows: nil)
|
|
377
|
+
def run_native(statement, params, rows: nil, async: false)
|
|
370
378
|
attempt = 0
|
|
371
379
|
lost = nil
|
|
372
380
|
@pgmq.with_connection do |conn|
|
|
373
381
|
attempt += 1
|
|
374
|
-
result = exec_native(conn, statement, params)
|
|
382
|
+
result = exec_native(conn, statement, params, async)
|
|
375
383
|
if rows && attempt > 1 && result.ntuples < (rows.is_a?(Array) ? rows.uniq.size : rows)
|
|
376
384
|
raise PGMQ::Errors::ConnectionError, "#{COMPLETION_NOT_RESENT}: #{lost}"
|
|
377
385
|
end
|
|
@@ -383,10 +391,14 @@ module Pgbus
|
|
|
383
391
|
end
|
|
384
392
|
end
|
|
385
393
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
394
|
+
# async (a job's completion under completion_synchronous_commit = false)
|
|
395
|
+
# goes through AsyncCommit, unless it runs inside a caller's transaction.
|
|
396
|
+
def exec_native(conn, statement, params, async)
|
|
397
|
+
return conn.exec_params(statement.sql, params) if inside_caller_transaction?(conn)
|
|
398
|
+
|
|
399
|
+
unprepared = @native_unprepared || NativeStatements.unprepared?
|
|
400
|
+
return exec_async_commit(conn, statement, params, !unprepared) if async
|
|
401
|
+
return conn.exec_params(statement.sql, params) if unprepared
|
|
390
402
|
|
|
391
403
|
begin
|
|
392
404
|
result = conn.exec_prepared(statement.name, params)
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -18,6 +18,7 @@ module Pgbus
|
|
|
18
18
|
include EnsureStreamQueue
|
|
19
19
|
include NotifyStream
|
|
20
20
|
include NativeStatements
|
|
21
|
+
include AsyncCommit
|
|
21
22
|
|
|
22
23
|
attr_reader :pgmq, :config, :connection_health
|
|
23
24
|
|
|
@@ -29,6 +30,12 @@ module Pgbus
|
|
|
29
30
|
PGMQ_META_CHECK_SQL = "SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1"
|
|
30
31
|
private_constant :PGMQ_META_CHECK_SQL
|
|
31
32
|
|
|
33
|
+
# pgbus's own queues only ($1: the lower-case "<queue_prefix>_"): another
|
|
34
|
+
# PGMQ user of the same schema keeps its spelling.
|
|
35
|
+
MIXED_CASE_QUEUES_SQL = "SELECT queue_name FROM pgmq.meta WHERE queue_name <> lower(queue_name) " \
|
|
36
|
+
"AND left(lower(queue_name), length($1)) = $1 ORDER BY queue_name"
|
|
37
|
+
private_constant :MIXED_CASE_QUEUES_SQL
|
|
38
|
+
|
|
32
39
|
PGMQ_INSTALL_SAVEPOINT = "pgbus_pgmq_install"
|
|
33
40
|
private_constant :PGMQ_INSTALL_SAVEPOINT
|
|
34
41
|
|
|
@@ -130,7 +137,11 @@ module Pgbus
|
|
|
130
137
|
# Both raise clean PG errors — no Ruby Timeout, no Thread#raise. Only
|
|
131
138
|
# safe on this dedicated-connection branch — never on the shared-AR Proc
|
|
132
139
|
# path, where statement_timeout would leak into application queries.
|
|
133
|
-
conn_opts =
|
|
140
|
+
conn_opts = apply_connection_bounds(conn_opts)
|
|
141
|
+
# What the connect lambda SETs per session (:session GUC mode), kept so
|
|
142
|
+
# a session reset after a failed completion pipeline gets it again.
|
|
143
|
+
@session_gucs = session_gucs(conn_opts)
|
|
144
|
+
conn_opts = wrap_session_gucs(conn_opts)
|
|
134
145
|
@pgmq = PGMQ::Client.new(conn_opts, pool_size: config.resolved_pool_size, pool_timeout: config.pool_timeout)
|
|
135
146
|
@pgmq_mutex = nil
|
|
136
147
|
# Dedicated streams pool (issue #315): isolates the durable-stream
|
|
@@ -174,6 +185,12 @@ module Pgbus
|
|
|
174
185
|
# Per physical queue: its native statements, or false (issue #605).
|
|
175
186
|
@native_statements = Concurrent::Map.new
|
|
176
187
|
@native_unprepared = config.native_statements && ar_disables_prepared_statements?
|
|
188
|
+
# Job completions commit with synchronous_commit off (issue #619).
|
|
189
|
+
# Native statements only: the pgmq functions are never wrapped. Never on
|
|
190
|
+
# ActiveRecord's own connection (the Proc path): pgbus does not pipeline
|
|
191
|
+
# on, or reset, a connection it does not own.
|
|
192
|
+
@async_completion_commit = config.native_statements && !config.completion_synchronous_commit &&
|
|
193
|
+
!@shared_connection
|
|
177
194
|
# Guards the one-time build of the publisher autoscale trigger (issue #323).
|
|
178
195
|
# NOT @pgmq_mutex — that is nil on the dedicated path (the only path the
|
|
179
196
|
# trigger exists on), so it wouldn't serialize concurrent first-publishers.
|
|
@@ -303,6 +320,18 @@ module Pgbus
|
|
|
303
320
|
end
|
|
304
321
|
end
|
|
305
322
|
|
|
323
|
+
# pgmq.meta rows spelled with an upper-case letter (issue #633). PGMQ
|
|
324
|
+
# lower-cases table names, so each shares its table with the lower-case
|
|
325
|
+
# spelling pgbus now uses; `rails generate pgbus:fold_queue_name_case`
|
|
326
|
+
# renames them. Pgbus::Doctor reports them.
|
|
327
|
+
def mixed_case_queue_names
|
|
328
|
+
synchronized do
|
|
329
|
+
@pgmq.with_connection do |conn|
|
|
330
|
+
conn.exec_params(MIXED_CASE_QUEUES_SQL, ["#{config.queue_prefix.to_s.downcase}_"]).column_values(0)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
306
335
|
# The most recently recorded installed PGMQ schema version string (e.g.
|
|
307
336
|
# "1.5.0"), read from the pgbus_pgmq_schema_versions tracking table. Returns
|
|
308
337
|
# nil when nothing is tracked yet or the table does not exist — the same
|
|
@@ -551,35 +580,45 @@ module Pgbus
|
|
|
551
580
|
|
|
552
581
|
# Delete a message. Pass prefixed: false when queue_name is already
|
|
553
582
|
# the full PGMQ queue name (e.g. from priority sub-queues or dashboard).
|
|
554
|
-
|
|
583
|
+
#
|
|
584
|
+
# job_completion: true marks the call as a job's completion (the executor
|
|
585
|
+
# and the completer pass it). Under config.completion_synchronous_commit =
|
|
586
|
+
# false only those commit with synchronous_commit off (issue #619); every
|
|
587
|
+
# other caller (the event consumer, whose replay reads the archive, and the
|
|
588
|
+
# dashboard) keeps the durable commit. The same holds for the three below.
|
|
589
|
+
def delete_message(queue_name, msg_id, prefixed: true, job_completion: false)
|
|
555
590
|
name = prefixed ? config.queue_name(queue_name) : queue_name
|
|
591
|
+
async = job_completion && @async_completion_commit
|
|
556
592
|
with_stale_connection_retry do
|
|
557
|
-
synchronized { exec_delete(name, msg_id) }
|
|
593
|
+
synchronized { exec_delete(name, msg_id, async: async) }
|
|
558
594
|
end
|
|
559
595
|
end
|
|
560
596
|
|
|
561
597
|
# Archive a message. Pass prefixed: false when queue_name is already
|
|
562
598
|
# the full PGMQ queue name.
|
|
563
|
-
def archive_message(queue_name, msg_id, prefixed: true)
|
|
599
|
+
def archive_message(queue_name, msg_id, prefixed: true, job_completion: false)
|
|
564
600
|
name = prefixed ? config.queue_name(queue_name) : queue_name
|
|
601
|
+
async = job_completion && @async_completion_commit
|
|
565
602
|
with_stale_connection_retry do
|
|
566
|
-
synchronized { exec_archive(name, msg_id) }
|
|
603
|
+
synchronized { exec_archive(name, msg_id, async: async) }
|
|
567
604
|
end
|
|
568
605
|
end
|
|
569
606
|
|
|
570
607
|
# Batch archive — moves multiple messages to the archive table in one call.
|
|
571
|
-
def archive_batch(queue_name, msg_ids, prefixed: true)
|
|
608
|
+
def archive_batch(queue_name, msg_ids, prefixed: true, job_completion: false)
|
|
572
609
|
name = prefixed ? config.queue_name(queue_name) : queue_name
|
|
610
|
+
async = job_completion && @async_completion_commit
|
|
573
611
|
with_stale_connection_retry do
|
|
574
|
-
synchronized { exec_archive_batch(name, msg_ids) }
|
|
612
|
+
synchronized { exec_archive_batch(name, msg_ids, async: async) }
|
|
575
613
|
end
|
|
576
614
|
end
|
|
577
615
|
|
|
578
616
|
# Batch delete — permanently removes multiple messages in one call.
|
|
579
|
-
def delete_batch(queue_name, msg_ids, prefixed: true)
|
|
617
|
+
def delete_batch(queue_name, msg_ids, prefixed: true, job_completion: false)
|
|
580
618
|
name = prefixed ? config.queue_name(queue_name) : queue_name
|
|
619
|
+
async = job_completion && @async_completion_commit
|
|
581
620
|
with_stale_connection_retry do
|
|
582
|
-
synchronized { exec_delete_batch(name, msg_ids) }
|
|
621
|
+
synchronized { exec_delete_batch(name, msg_ids, async: async) }
|
|
583
622
|
end
|
|
584
623
|
end
|
|
585
624
|
|
|
@@ -704,12 +743,23 @@ module Pgbus
|
|
|
704
743
|
end
|
|
705
744
|
end
|
|
706
745
|
|
|
707
|
-
|
|
746
|
+
# +before_drop+ runs only when the table itself is dropped (not for a
|
|
747
|
+
# case duplicate, whose table stays), decided once: the dashboard
|
|
748
|
+
# releases the dropped messages' uniqueness locks there.
|
|
749
|
+
def drop_queue(queue_name, prefixed: true, &before_drop)
|
|
708
750
|
name = prefixed ? config.queue_name(queue_name) : queue_name
|
|
751
|
+
if case_duplicate?(name)
|
|
752
|
+
forget_case_duplicate(name)
|
|
753
|
+
return true
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
before_drop&.call
|
|
757
|
+
|
|
709
758
|
result = with_stale_connection_retry do
|
|
710
759
|
synchronized { @pgmq.drop_queue(name) }
|
|
711
760
|
end
|
|
712
761
|
@queues_created.delete(name)
|
|
762
|
+
forget_other_spellings(name)
|
|
713
763
|
# Failed-event rows are keyed by the name the executor saw (physical or
|
|
714
764
|
# logical); a recreated queue reuses msg_ids, so neither may survive.
|
|
715
765
|
FailedEventRecorder.clear_queue!([name, name.delete_prefix("#{config.queue_prefix}_")].uniq)
|
|
@@ -1606,6 +1656,67 @@ module Pgbus
|
|
|
1606
1656
|
duplicate_install_error?(error) || duplicate_install_error?(error.cause)
|
|
1607
1657
|
end
|
|
1608
1658
|
|
|
1659
|
+
# A mixed-case pgmq.meta row from before issue #633 whose lower-case
|
|
1660
|
+
# spelling is registered too: both name one table, the lower-case row is
|
|
1661
|
+
# the queue every current process uses. Dropping the duplicate (the
|
|
1662
|
+
# dashboard lists both until pgbus:fold_queue_name_case runs, and the
|
|
1663
|
+
# orphan stream sweep sees both) must not drop that table.
|
|
1664
|
+
def case_duplicate?(name)
|
|
1665
|
+
return false if name == name.downcase
|
|
1666
|
+
|
|
1667
|
+
synchronized { queue_registered?(name) && queue_registered?(name.downcase) }
|
|
1668
|
+
end
|
|
1669
|
+
|
|
1670
|
+
# Removes only the duplicate's pgmq.meta row (its throttle and topic
|
|
1671
|
+
# bindings cascade); the table and the lower-case queue stay. The shared
|
|
1672
|
+
# table's insert trigger may carry the duplicate's name (an older process
|
|
1673
|
+
# armed it last), so it is re-armed under the lower-case name with the
|
|
1674
|
+
# throttle the lower-case row had (else the duplicate's): with the
|
|
1675
|
+
# duplicate's throttle row gone, that trigger would NOTIFY no more.
|
|
1676
|
+
def forget_case_duplicate(name)
|
|
1677
|
+
canonical = name.downcase
|
|
1678
|
+
with_stale_connection_retry do
|
|
1679
|
+
synchronized do
|
|
1680
|
+
throttle = notify_throttle_for(canonical, name)
|
|
1681
|
+
@pgmq.with_connection do |conn|
|
|
1682
|
+
conn.exec_params("DELETE FROM pgmq.meta WHERE queue_name = $1", [name])
|
|
1683
|
+
end
|
|
1684
|
+
@pgmq.enable_notify_insert(canonical, throttle_interval_ms: throttle) if throttle && !notify_trigger_current?(canonical, throttle)
|
|
1685
|
+
end
|
|
1686
|
+
end
|
|
1687
|
+
@queues_created.delete(name)
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
# The NOTIFY throttle of the first of +names+ that has one, or nil (no
|
|
1691
|
+
# throttle row, or a PGMQ without notify_insert_throttle).
|
|
1692
|
+
def notify_throttle_for(*names)
|
|
1693
|
+
@pgmq.with_connection do |conn|
|
|
1694
|
+
next nil unless conn.exec("SELECT to_regclass('pgmq.notify_insert_throttle')").getvalue(0, 0)
|
|
1695
|
+
|
|
1696
|
+
rows = conn.exec_params(
|
|
1697
|
+
"SELECT queue_name, throttle_interval_ms FROM pgmq.notify_insert_throttle WHERE queue_name = ANY($1::text[])",
|
|
1698
|
+
["{#{names.join(",")}}"]
|
|
1699
|
+
).to_a
|
|
1700
|
+
row = names.lazy.filter_map { |n| rows.find { |r| r["queue_name"] == n } }.first
|
|
1701
|
+
row && Integer(row["throttle_interval_ms"])
|
|
1702
|
+
end
|
|
1703
|
+
end
|
|
1704
|
+
|
|
1705
|
+
# pgmq.drop_queue removes the table and only its own pgmq.meta row; a
|
|
1706
|
+
# mixed-case spelling from before issue #633 would be left naming a table
|
|
1707
|
+
# that is gone. Idempotent, so a stale pooled socket retries it.
|
|
1708
|
+
def forget_other_spellings(name)
|
|
1709
|
+
with_stale_connection_retry do
|
|
1710
|
+
synchronized do
|
|
1711
|
+
@pgmq.with_connection do |conn|
|
|
1712
|
+
conn.exec_params(
|
|
1713
|
+
"DELETE FROM pgmq.meta WHERE lower(queue_name) = lower($1) AND queue_name <> $1", [name]
|
|
1714
|
+
)
|
|
1715
|
+
end
|
|
1716
|
+
end
|
|
1717
|
+
end
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1609
1720
|
# Whether pgmq.meta records the queue — the authoritative "create
|
|
1610
1721
|
# committed" signal (pgmq.create writes it atomically with the
|
|
1611
1722
|
# tables). The pooled checkout is a sequential sibling of the failed
|
|
@@ -1622,7 +1733,13 @@ module Pgbus
|
|
|
1622
1733
|
end
|
|
1623
1734
|
|
|
1624
1735
|
# Check whether the NOTIFY trigger already exists on this queue with the
|
|
1625
|
-
# expected throttle interval
|
|
1736
|
+
# expected throttle interval, armed under this spelling of its name. PGMQ
|
|
1737
|
+
# 1.13.1's trigger NOTIFYs on the channel of the name it was armed with
|
|
1738
|
+
# (its argument), and every spelling of a name shares the queue's table
|
|
1739
|
+
# (issue #633): a process from before #633 that re-arms it under a
|
|
1740
|
+
# mixed-case name moves its NOTIFYs to a channel no listener uses. A
|
|
1741
|
+
# trigger without an argument (older PGMQ) NOTIFYs on the table's name,
|
|
1742
|
+
# which is the lower-case spelling. When it does, we can skip the destructive
|
|
1626
1743
|
# DROP TRIGGER + CREATE TRIGGER cycle that causes deadlocks when multiple
|
|
1627
1744
|
# forked processes race during bootstrap.
|
|
1628
1745
|
#
|
|
@@ -1644,6 +1761,7 @@ module Pgbus
|
|
|
1644
1761
|
WHERE n.nspname = 'pgmq'
|
|
1645
1762
|
AND c.relname = pgmq.format_table_name($1, 'q')
|
|
1646
1763
|
AND t.tgname = '#{NOTIFY_TRIGGER_NAME}'
|
|
1764
|
+
AND (t.tgnargs = 0 OR t.tgargs = convert_to($1, 'UTF8') || '\\000'::bytea)
|
|
1647
1765
|
AND EXISTS (
|
|
1648
1766
|
SELECT 1 FROM pgmq.notify_insert_throttle
|
|
1649
1767
|
WHERE queue_name = $1
|
|
@@ -1758,6 +1876,16 @@ module Pgbus
|
|
|
1758
1876
|
# restart or a brief failover — that outlasts the first immediate retry
|
|
1759
1877
|
# still gets a second, backed-off chance rather than failing an enqueue
|
|
1760
1878
|
# the caller may never retry.
|
|
1879
|
+
#
|
|
1880
|
+
# The last retry runs on freshly reloaded pools (issue #645). A reset that
|
|
1881
|
+
# Thread#raise interrupted mid-poll leaves the pooled PG::Connection in
|
|
1882
|
+
# CONNECTION_STARTED: neither finished? nor CONNECTION_BAD, so pgmq-ruby's
|
|
1883
|
+
# checkout health check keeps handing it out and libpq refuses every send
|
|
1884
|
+
# ("no connection to the server"). The first retry still goes through that
|
|
1885
|
+
# health check alone, which replaces a socket the server closed without
|
|
1886
|
+
# touching the rest of the pool; only when it fails too are both pools'
|
|
1887
|
+
# idle connections dropped (reload_pools_before_last_stale_retry), as the
|
|
1888
|
+
# #354 wedged-read path does.
|
|
1761
1889
|
STALE_RETRY_ATTEMPTS = 2
|
|
1762
1890
|
private_constant :STALE_RETRY_ATTEMPTS
|
|
1763
1891
|
|
|
@@ -1889,6 +2017,31 @@ module Pgbus
|
|
|
1889
2017
|
end
|
|
1890
2018
|
end
|
|
1891
2019
|
|
|
2020
|
+
# Drop both pools' idle connections before the last stale retry (issue
|
|
2021
|
+
# #645; see STALE_RETRY_ATTEMPTS), so it checks out a fresh connection
|
|
2022
|
+
# instead of the one pgmq-ruby's health check kept handing back. Skipped
|
|
2023
|
+
# silently on the shared-AR Proc path: those slots wrap ActiveRecord's own
|
|
2024
|
+
# socket (#reload refuses there with a warning that would be noise on every
|
|
2025
|
+
# retry). Each pool reloads on its own, so one failing does not keep the
|
|
2026
|
+
# other's connections. A reload failure is logged, never raised: the stale
|
|
2027
|
+
# ConnectionError the retry may raise next is the actionable error.
|
|
2028
|
+
def reload_pools_before_last_stale_retry(attempt, error)
|
|
2029
|
+
return if @shared_connection
|
|
2030
|
+
|
|
2031
|
+
Pgbus.logger.warn do
|
|
2032
|
+
"[Pgbus::Client] Dropping pooled connections before the last stale-connection retry " \
|
|
2033
|
+
"(attempt #{attempt}/#{STALE_RETRY_ATTEMPTS}): #{error.message}"
|
|
2034
|
+
end
|
|
2035
|
+
{ "job" => @pgmq, "streams" => @streams_pool }.each do |name, pool|
|
|
2036
|
+
pool.reload
|
|
2037
|
+
rescue StandardError => e
|
|
2038
|
+
Pgbus.logger.warn do
|
|
2039
|
+
"[Pgbus::Client] #{name} pool reload before the last stale-connection retry failed: " \
|
|
2040
|
+
"#{e.class}: #{e.message}"
|
|
2041
|
+
end
|
|
2042
|
+
end
|
|
2043
|
+
end
|
|
2044
|
+
|
|
1892
2045
|
# True when libpq's connection-baked read bounds (statement_timeout +
|
|
1893
2046
|
# tcp_user_timeout + keepalives) fully cover both a slow query AND a
|
|
1894
2047
|
# dead/hung socket, so with_read_timeout can skip the Ruby Timeout entirely.
|
|
@@ -2031,20 +2184,30 @@ module Pgbus
|
|
|
2031
2184
|
# unchanged, as does :options mode (where forward_connection_variables
|
|
2032
2185
|
# already baked the GUCs into `options`). See issue #332.
|
|
2033
2186
|
def wrap_session_gucs(conn_opts)
|
|
2034
|
-
|
|
2035
|
-
return conn_opts unless
|
|
2036
|
-
|
|
2037
|
-
variables = conn_opts[:variables]
|
|
2038
|
-
return conn_opts if variables.nil? || variables.empty?
|
|
2187
|
+
variables = session_gucs(conn_opts)
|
|
2188
|
+
return conn_opts unless variables
|
|
2039
2189
|
|
|
2040
2190
|
pg_opts = conn_opts.except(:variables)
|
|
2041
2191
|
lambda do
|
|
2042
2192
|
conn = PG.connect(pg_opts)
|
|
2043
|
-
|
|
2193
|
+
apply_session_gucs(conn, variables)
|
|
2044
2194
|
conn
|
|
2045
2195
|
end
|
|
2046
2196
|
end
|
|
2047
2197
|
|
|
2198
|
+
# The database.yml variables :session GUC mode applies with SET after
|
|
2199
|
+
# connect, or nil when nothing is applied that way.
|
|
2200
|
+
def session_gucs(conn_opts)
|
|
2201
|
+
return unless config.connection_guc_mode == :session && conn_opts.is_a?(Hash)
|
|
2202
|
+
|
|
2203
|
+
variables = conn_opts[:variables]
|
|
2204
|
+
variables unless variables.nil? || variables.empty?
|
|
2205
|
+
end
|
|
2206
|
+
|
|
2207
|
+
def apply_session_gucs(conn, variables)
|
|
2208
|
+
variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
|
|
2209
|
+
end
|
|
2210
|
+
|
|
2048
2211
|
def apply_connection_bounds(conn_opts)
|
|
2049
2212
|
timeout = config.read_timeout
|
|
2050
2213
|
return conn_opts unless timeout&.positive?
|
|
@@ -2165,6 +2328,7 @@ module Pgbus
|
|
|
2165
2328
|
"[Pgbus::Client] Retrying after stale pgmq connection " \
|
|
2166
2329
|
"(attempt #{attempts}/#{STALE_RETRY_ATTEMPTS}): #{e.message}"
|
|
2167
2330
|
end
|
|
2331
|
+
reload_pools_before_last_stale_retry(attempts, e) if attempts == STALE_RETRY_ATTEMPTS
|
|
2168
2332
|
retry
|
|
2169
2333
|
end
|
|
2170
2334
|
end
|
|
@@ -6,6 +6,12 @@ require "time"
|
|
|
6
6
|
module Pgbus
|
|
7
7
|
module Concurrency
|
|
8
8
|
module BlockedExecution
|
|
9
|
+
# Headers a parked job is to be sent with. pgbus_blocked_executions has
|
|
10
|
+
# no headers column, so a dashboard retry parked behind its limit
|
|
11
|
+
# (Web::DataSource::ResendSlot) carries them in its payload under this
|
|
12
|
+
# key; promote_next takes them out and sends them as headers.
|
|
13
|
+
HEADERS_KEY = "pgbus_park_headers"
|
|
14
|
+
|
|
9
15
|
class << self
|
|
10
16
|
# Insert a blocked execution for a job that hit the concurrency limit.
|
|
11
17
|
#
|
|
@@ -64,8 +70,10 @@ module Pgbus
|
|
|
64
70
|
|
|
65
71
|
# Carry the enqueuer's priority through: under priority routing it
|
|
66
72
|
# picks the _pN sub-queue, not just the release order (issue #423).
|
|
73
|
+
headers = released[:payload].is_a?(Hash) ? released[:payload].delete(HEADERS_KEY) : nil
|
|
67
74
|
msg_id = client.send_message(released[:queue_name], released[:payload],
|
|
68
|
-
delay: actual_delay, priority: released[:priority]
|
|
75
|
+
delay: actual_delay, priority: released[:priority],
|
|
76
|
+
**(headers ? { headers: headers } : {}))
|
|
69
77
|
end
|
|
70
78
|
|
|
71
79
|
return false unless released && msg_id
|