pgbus 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +36 -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 +145 -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
|
|
@@ -2031,20 +2149,30 @@ module Pgbus
|
|
|
2031
2149
|
# unchanged, as does :options mode (where forward_connection_variables
|
|
2032
2150
|
# already baked the GUCs into `options`). See issue #332.
|
|
2033
2151
|
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?
|
|
2152
|
+
variables = session_gucs(conn_opts)
|
|
2153
|
+
return conn_opts unless variables
|
|
2039
2154
|
|
|
2040
2155
|
pg_opts = conn_opts.except(:variables)
|
|
2041
2156
|
lambda do
|
|
2042
2157
|
conn = PG.connect(pg_opts)
|
|
2043
|
-
|
|
2158
|
+
apply_session_gucs(conn, variables)
|
|
2044
2159
|
conn
|
|
2045
2160
|
end
|
|
2046
2161
|
end
|
|
2047
2162
|
|
|
2163
|
+
# The database.yml variables :session GUC mode applies with SET after
|
|
2164
|
+
# connect, or nil when nothing is applied that way.
|
|
2165
|
+
def session_gucs(conn_opts)
|
|
2166
|
+
return unless config.connection_guc_mode == :session && conn_opts.is_a?(Hash)
|
|
2167
|
+
|
|
2168
|
+
variables = conn_opts[:variables]
|
|
2169
|
+
variables unless variables.nil? || variables.empty?
|
|
2170
|
+
end
|
|
2171
|
+
|
|
2172
|
+
def apply_session_gucs(conn, variables)
|
|
2173
|
+
variables.each { |name, value| conn.exec("SET #{name} = '#{value}'") }
|
|
2174
|
+
end
|
|
2175
|
+
|
|
2048
2176
|
def apply_connection_bounds(conn_opts)
|
|
2049
2177
|
timeout = config.read_timeout
|
|
2050
2178
|
return conn_opts unless timeout&.positive?
|
|
@@ -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
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -52,6 +52,37 @@ module Pgbus
|
|
|
52
52
|
# Batch::Sweep then cannot tell a deleted batch job from one whose message
|
|
53
53
|
# vanished another way (a purged queue): both count as completed, silently.
|
|
54
54
|
attr_reader :completed_jobs
|
|
55
|
+
# completion_synchronous_commit (issue #619, opt-in): false commits a job's
|
|
56
|
+
# completion (the executor's archive or delete, the completer's
|
|
57
|
+
# archive_batch or delete_batch) with `SET LOCAL synchronous_commit TO off`,
|
|
58
|
+
# in one libpq pipeline: BEGIN, the SET LOCAL, the prepared statement and
|
|
59
|
+
# COMMIT go out with one sync, so the job stops waiting for its WAL flush.
|
|
60
|
+
# A completion is then acknowledged before its WAL record reaches disk: a
|
|
61
|
+
# Postgres server crash (not a client crash, not a planned switchover), or a
|
|
62
|
+
# failover to a standby that has not yet received that WAL (it also stops
|
|
63
|
+
# waiting for a synchronous standby), within about three times
|
|
64
|
+
# wal_writer_delay can lose completions committed in that window, and each
|
|
65
|
+
# lost completion redelivers its job after visibility_timeout. Sends, retries, dead-letter moves, the EventBus
|
|
66
|
+
# consumer's archive and dashboard actions keep full durability, and a
|
|
67
|
+
# completion inside a caller's transaction is left to that transaction.
|
|
68
|
+
# Native statements on pgbus's own connections only: with
|
|
69
|
+
# native_statements = false, or on ActiveRecord's shared connection (a
|
|
70
|
+
# connection_params Proc), it has no effect (pgbus doctor warns). Needs
|
|
71
|
+
# libpq 14+ and pg 1.5+ (pipeline mode), checked by validate!. true
|
|
72
|
+
# (default) commits as before.
|
|
73
|
+
attr_reader :completion_synchronous_commit
|
|
74
|
+
# quiet_active_job_logs (issue #618): true makes each worker process
|
|
75
|
+
# unsubscribe ActiveJob's LogSubscriber from perform_start.active_job and
|
|
76
|
+
# perform.active_job (Pgbus::ActiveJob::QuietLogs), so the per-job
|
|
77
|
+
# "Performing …" / "Performed …" info lines stop, and with them ActiveJob's
|
|
78
|
+
# "Error performing …" line (the same perform listener writes it): an
|
|
79
|
+
# exception that escapes ActiveJob's handlers is still logged by
|
|
80
|
+
# ErrorReporter and recorded in pgbus_failed_events, one retry_on catches
|
|
81
|
+
# still logs ActiveJob's "Retrying …" line, and a job a before_perform
|
|
82
|
+
# callback halted logs nothing.
|
|
83
|
+
# Enqueued, Retrying, Stopped retrying and Discarded still log, and the web
|
|
84
|
+
# process is untouched. Without ActiveJob it does nothing.
|
|
85
|
+
attr_reader :quiet_active_job_logs
|
|
55
86
|
# visibility_heartbeat / visibility_heartbeat_interval: while a job runs,
|
|
56
87
|
# its message's visibility timeout is re-armed every interval seconds
|
|
57
88
|
# (default: a third of visibility_timeout), so a job that outlives the
|
|
@@ -367,6 +398,8 @@ module Pgbus
|
|
|
367
398
|
@completion_batching = false
|
|
368
399
|
@completion_batch_size = 100
|
|
369
400
|
@completed_jobs = :archive
|
|
401
|
+
@completion_synchronous_commit = true
|
|
402
|
+
@quiet_active_job_logs = false
|
|
370
403
|
@execution_mode = :threads
|
|
371
404
|
|
|
372
405
|
@max_jobs_per_worker = nil
|
|
@@ -564,12 +597,13 @@ module Pgbus
|
|
|
564
597
|
@streams_idle_timeout = 3_600 # 1 hour
|
|
565
598
|
# 250ms — this value plays two roles: (1) the TCP keepalive
|
|
566
599
|
# interval for the streamer's PG LISTEN connection, and (2) the
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
#
|
|
570
|
-
#
|
|
571
|
-
#
|
|
572
|
-
#
|
|
600
|
+
# budget (plus 1s) a stream's first connect waits for a stuck local
|
|
601
|
+
# Listener to acknowledge its ensure_listening (with
|
|
602
|
+
# streams_listen_scope :master, HubClient waits this cycle plus 2s
|
|
603
|
+
# before failing over). A live listener
|
|
604
|
+
# is woken through a pipe and acks within a thread hop, and a
|
|
605
|
+
# connect on a stream that already has a subscriber does not
|
|
606
|
+
# wait at all (issue #630), so connects no longer pay this cycle.
|
|
573
607
|
@streams_listen_health_check_ms = 250
|
|
574
608
|
@streams_write_deadline_ms = 5_000
|
|
575
609
|
# Deadline (ms) for a socket write made INSIDE the dispatcher's
|
|
@@ -856,6 +890,22 @@ module Pgbus
|
|
|
856
890
|
@completed_jobs = mode
|
|
857
891
|
end
|
|
858
892
|
|
|
893
|
+
def completion_synchronous_commit=(value)
|
|
894
|
+
unless [true, false].include?(value)
|
|
895
|
+
raise Pgbus::ConfigurationError, "completion_synchronous_commit must be true or false (got #{value.inspect})"
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
@completion_synchronous_commit = value
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def quiet_active_job_logs=(value)
|
|
902
|
+
unless [true, false].include?(value)
|
|
903
|
+
raise Pgbus::ConfigurationError, "quiet_active_job_logs must be true or false (got #{value.inspect})"
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
@quiet_active_job_logs = value
|
|
907
|
+
end
|
|
908
|
+
|
|
859
909
|
def native_statements=(value)
|
|
860
910
|
raise Pgbus::ConfigurationError, "native_statements must be true or false" unless [true, false].include?(value)
|
|
861
911
|
|
|
@@ -1052,6 +1102,7 @@ module Pgbus
|
|
|
1052
1102
|
|
|
1053
1103
|
validate_read_ahead!
|
|
1054
1104
|
validate_completion_batching!
|
|
1105
|
+
validate_completion_synchronous_commit!
|
|
1055
1106
|
validate_capsule_processes!
|
|
1056
1107
|
|
|
1057
1108
|
if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
|
|
@@ -1843,6 +1894,20 @@ module Pgbus
|
|
|
1843
1894
|
"execution_mode :async"
|
|
1844
1895
|
end
|
|
1845
1896
|
|
|
1897
|
+
# The asynchronous completion commit runs in libpq pipeline mode (issue
|
|
1898
|
+
# #619): libpq 14+ and a pg gem that wraps it (1.5+). Checked here, once,
|
|
1899
|
+
# rather than on every completion.
|
|
1900
|
+
def validate_completion_synchronous_commit!
|
|
1901
|
+
return if completion_synchronous_commit
|
|
1902
|
+
|
|
1903
|
+
require "pg" unless defined?(::PG::Connection)
|
|
1904
|
+
return if ::PG::Connection.method_defined?(:enter_pipeline_mode) && ::PG.library_version >= 140_000
|
|
1905
|
+
|
|
1906
|
+
raise Pgbus::ConfigurationError,
|
|
1907
|
+
"completion_synchronous_commit = false needs libpq 14+ and pg 1.5+ (pipeline mode); " \
|
|
1908
|
+
"this process has libpq #{::PG.library_version} and pg #{::PG::VERSION}"
|
|
1909
|
+
end
|
|
1910
|
+
|
|
1846
1911
|
# Shared resolver for a dedicated connection (streamer LISTEN / worker
|
|
1847
1912
|
# NotifyListener) that can override the base `connection_options` to bypass a
|
|
1848
1913
|
# pooler. Precedence: a full `url` wins outright; otherwise `host`/`port`
|
|
@@ -20,6 +20,9 @@ module Pgbus
|
|
|
20
20
|
ORIGINAL_KEY = "pgbus_original_headers"
|
|
21
21
|
VERSION = 1
|
|
22
22
|
REASON_MAX_RETRIES = "max_retries_exceeded"
|
|
23
|
+
# A :while_executing duplicate skipped on every read while another
|
|
24
|
+
# message held its key, until read_ct passed max_retries (issue #629).
|
|
25
|
+
REASON_UNIQUENESS_CONFLICT = "uniqueness_conflict"
|
|
23
26
|
# The release that started writing the block: rows without it predate it.
|
|
24
27
|
SINCE = "0.18.0"
|
|
25
28
|
MESSAGE_LIMIT = 1_000
|