pgbus 0.21.4 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +37 -15
- data/Rakefile +15 -2
- 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 +197 -46
- data/lib/pgbus/active_job/quiet_logs.rb +57 -0
- data/lib/pgbus/autovacuum_tuning.rb +2 -2
- data/lib/pgbus/batch/sweep.rb +2 -0
- data/lib/pgbus/batch.rb +4 -1
- data/lib/pgbus/cli/dlq.rb +2 -1
- data/lib/pgbus/client/async_commit.rb +149 -0
- data/lib/pgbus/client/native_statements.rb +432 -0
- data/lib/pgbus/client.rb +156 -20
- data/lib/pgbus/concurrency/blocked_execution.rb +10 -1
- data/lib/pgbus/configuration.rb +158 -6
- data/lib/pgbus/dead_letter_header.rb +3 -0
- data/lib/pgbus/doctor.rb +55 -2
- data/lib/pgbus/event_bus/handler.rb +2 -0
- 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 +189 -0
- data/lib/pgbus/process/consumer.rb +15 -1
- data/lib/pgbus/process/worker.rb +57 -4
- 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/serializer.rb +1 -0
- 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/visibility_heartbeat.rb +16 -12
- 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 +7 -5
- data/sig/pgbus/configuration.rbs +12 -0
- data/sig/pgbus/process/completer.rbs +26 -0
- metadata +14 -3
|
@@ -85,11 +85,14 @@ module Pgbus
|
|
|
85
85
|
# queue is dropped: PGMQ restarts msg_ids on recreate, so a surviving
|
|
86
86
|
# row would aim the dashboard's retry/discard at an unrelated message.
|
|
87
87
|
# Queue names are validated word characters, so the array literal is safe.
|
|
88
|
+
# Matched in any case (issue #633): the dropped queue's name is lower
|
|
89
|
+
# case, while a row may carry the executor's configured spelling
|
|
90
|
+
# ("Critical") or a physical name from before #633.
|
|
88
91
|
def clear_queue!(queue_names)
|
|
89
92
|
connection.exec_delete(
|
|
90
|
-
"DELETE FROM pgbus_failed_events WHERE queue_name = ANY($1::text[])",
|
|
93
|
+
"DELETE FROM pgbus_failed_events WHERE lower(queue_name) = ANY($1::text[])",
|
|
91
94
|
"FailedEvent Clear Queue",
|
|
92
|
-
["{#{queue_names.join(",")}}"]
|
|
95
|
+
["{#{queue_names.map(&:downcase).join(",")}}"]
|
|
93
96
|
)
|
|
94
97
|
rescue StandardError => e
|
|
95
98
|
Pgbus.logger.error do
|
|
@@ -102,7 +102,8 @@ module Pgbus
|
|
|
102
102
|
add_batch_executions: "pgbus:add_batch_executions",
|
|
103
103
|
add_batch_callback_jobs: "pgbus:add_batch_callback_jobs",
|
|
104
104
|
tune_autovacuum: "pgbus:tune_autovacuum",
|
|
105
|
-
tune_fillfactor: "pgbus:tune_fillfactor"
|
|
105
|
+
tune_fillfactor: "pgbus:tune_fillfactor",
|
|
106
|
+
fold_queue_name_case: "pgbus:fold_queue_name_case"
|
|
106
107
|
}.freeze
|
|
107
108
|
|
|
108
109
|
# Human-friendly description of each migration for the generator
|
|
@@ -128,9 +129,15 @@ module Pgbus
|
|
|
128
129
|
add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
|
|
129
130
|
add_batch_callback_jobs: "batch callback-job columns (configured ActiveJob instances as batch callbacks)",
|
|
130
131
|
tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables",
|
|
131
|
-
tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)"
|
|
132
|
+
tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)",
|
|
133
|
+
fold_queue_name_case: "lower-case spelling for queues created in mixed case (PGMQ's table names are lower case)"
|
|
132
134
|
}.freeze
|
|
133
135
|
|
|
136
|
+
# A pgbus pgmq.meta row ($1: the lower-case "<queue_prefix>_") spelled
|
|
137
|
+
# with an upper-case letter (issue #633).
|
|
138
|
+
MIXED_CASE_QUEUE_SQL = "SELECT 1 FROM pgmq.meta WHERE queue_name <> lower(queue_name) " \
|
|
139
|
+
"AND left(lower(queue_name), length($1)) = $1 LIMIT 1"
|
|
140
|
+
|
|
134
141
|
def initialize(connection)
|
|
135
142
|
@connection = connection
|
|
136
143
|
end
|
|
@@ -143,6 +150,9 @@ module Pgbus
|
|
|
143
150
|
return [FRESH_INSTALL] if fresh_install?
|
|
144
151
|
|
|
145
152
|
[
|
|
153
|
+
# First: the tuning migrations below build table names from
|
|
154
|
+
# pgmq.meta, which a mixed-case row spells unlike its table.
|
|
155
|
+
*queue_name_case_migrations,
|
|
146
156
|
*uniqueness_key_migrations,
|
|
147
157
|
*job_stats_migrations,
|
|
148
158
|
*stream_stats_migrations,
|
|
@@ -328,6 +338,23 @@ module Pgbus
|
|
|
328
338
|
[:tune_autovacuum]
|
|
329
339
|
end
|
|
330
340
|
|
|
341
|
+
# Issue #633: a queue an older pgbus created in mixed case shares its
|
|
342
|
+
# lower-case table with the spelling pgbus now uses. An unreadable
|
|
343
|
+
# pgmq.meta queues nothing: the doctor's "Queue name case" check says why.
|
|
344
|
+
def queue_name_case_migrations
|
|
345
|
+
return [] unless pgmq_schema_exists?
|
|
346
|
+
return [] unless mixed_case_queue?
|
|
347
|
+
|
|
348
|
+
[:fold_queue_name_case]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def mixed_case_queue?
|
|
352
|
+
prefix = "#{Pgbus.configuration.queue_prefix.to_s.downcase}_"
|
|
353
|
+
connection.select_value(MIXED_CASE_QUEUE_SQL.gsub("$1", connection.quote(prefix))).present?
|
|
354
|
+
rescue StandardError
|
|
355
|
+
false
|
|
356
|
+
end
|
|
357
|
+
|
|
331
358
|
# Fillfactor tuning: check if any PGMQ queue table already has
|
|
332
359
|
# fillfactor applied. If not, queue the migration.
|
|
333
360
|
def fillfactor_migrations
|
|
@@ -16,7 +16,22 @@ module Pgbus
|
|
|
16
16
|
# pgbus.job_completed — job archived successfully
|
|
17
17
|
# pgbus.job_failed — job raised; carries :exception_object
|
|
18
18
|
# pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed;
|
|
19
|
-
# :error is the last recorded error class (or nil)
|
|
19
|
+
# :error is the last recorded error class (or nil);
|
|
20
|
+
# :reason is max_retries_exceeded, or uniqueness_conflict
|
|
21
|
+
# for a :while_executing copy with no failed attempt whose
|
|
22
|
+
# key's row is not its own (skipped while another message
|
|
23
|
+
# held the key); also uniqueness_conflict when that row
|
|
24
|
+
# lookup itself fails
|
|
25
|
+
# pgbus.job_skipped — a :while_executing copy was read while another
|
|
26
|
+
# message held its key and left for a later delivery
|
|
27
|
+
# (on_conflict :reject or :log)
|
|
28
|
+
# payload: queue, job_class, job_id, provider_job_id,
|
|
29
|
+
# msg_id, read_ct, on_conflict, vt (seconds until it
|
|
30
|
+
# is delivered again)
|
|
31
|
+
# pgbus.job_discarded — a copy was completed without running
|
|
32
|
+
# payload: queue, job_class, job_id, provider_job_id,
|
|
33
|
+
# msg_id, reason ("uniqueness": a :while_executing copy
|
|
34
|
+
# under on_conflict: :discard)
|
|
20
35
|
# pgbus.job_visibility_extended — heartbeat re-armed a running job's visibility timeout
|
|
21
36
|
# payload: queue, job_class, msg_id, vt, extensions
|
|
22
37
|
# pgbus.event_processed — event handler succeeded
|
|
@@ -60,6 +60,8 @@ module Pgbus
|
|
|
60
60
|
subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
|
|
61
61
|
subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
|
|
62
62
|
subscribe("pgbus.job_dead_lettered") { |event| on_job_dead_lettered(event) },
|
|
63
|
+
subscribe("pgbus.job_skipped") { |event| on_job_skipped(event) },
|
|
64
|
+
subscribe("pgbus.job_discarded") { |event| on_job_discarded(event) },
|
|
63
65
|
subscribe("pgbus.job_visibility_extended") { |event| on_job_visibility_extended(event) },
|
|
64
66
|
subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
|
|
65
67
|
subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
|
|
@@ -121,11 +123,35 @@ module Pgbus
|
|
|
121
123
|
)
|
|
122
124
|
end
|
|
123
125
|
|
|
126
|
+
# reason: max_retries_exceeded, or uniqueness_conflict for a
|
|
127
|
+
# :while_executing duplicate skipped until it ran out of reads (#629).
|
|
124
128
|
def on_job_dead_lettered(event)
|
|
125
129
|
payload = event.payload
|
|
126
130
|
backend.increment(
|
|
127
131
|
"#{METRIC_PREFIX}queue_job_count", 1,
|
|
128
|
-
compact(queue: payload[:queue], job_class: payload[:job_class], status: "dead_lettered"
|
|
132
|
+
compact(queue: payload[:queue], job_class: payload[:job_class], status: "dead_lettered",
|
|
133
|
+
reason: payload[:reason])
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A :while_executing duplicate left for a later delivery because
|
|
138
|
+
# another message held its key (issue #629): counted per skip, so one
|
|
139
|
+
# copy waiting out a long holder counts several times.
|
|
140
|
+
def on_job_skipped(event)
|
|
141
|
+
payload = event.payload
|
|
142
|
+
backend.increment(
|
|
143
|
+
"#{METRIC_PREFIX}queue_job_count", 1,
|
|
144
|
+
compact(queue: payload[:queue], job_class: payload[:job_class], status: "skipped", reason: "uniqueness")
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A :while_executing duplicate dropped under on_conflict: :discard.
|
|
149
|
+
def on_job_discarded(event)
|
|
150
|
+
payload = event.payload
|
|
151
|
+
backend.increment(
|
|
152
|
+
"#{METRIC_PREFIX}queue_job_count", 1,
|
|
153
|
+
compact(queue: payload[:queue], job_class: payload[:job_class], status: "discarded",
|
|
154
|
+
reason: payload[:reason])
|
|
129
155
|
)
|
|
130
156
|
end
|
|
131
157
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Batched completion (issue #606, `config.completion_batching`): one per
|
|
6
|
+
# worker, it archives the messages of jobs that finish close together with
|
|
7
|
+
# one `archive_batch` per physical queue instead of one `pgmq.archive` each.
|
|
8
|
+
#
|
|
9
|
+
# There is no timer. An idle completer flushes a lone id at once; ids that
|
|
10
|
+
# arrive while a flush is in flight wait for the next one, so under load a
|
|
11
|
+
# batch is "what arrived during the previous round trip".
|
|
12
|
+
# `batch_size` only caps the ids per statement.
|
|
13
|
+
#
|
|
14
|
+
# The job thread waits for its own result (`complete` blocks), so the
|
|
15
|
+
# executor's post-archive sequence runs on it as before. Threads only: a
|
|
16
|
+
# blocking wait would stall an Async reactor (Configuration#validate!).
|
|
17
|
+
#
|
|
18
|
+
# completed_jobs: :delete (the config setting, issue #607) flushes with
|
|
19
|
+
# `delete_batch` instead; the results keep their names, so :archived
|
|
20
|
+
# means "completed" and :already_archived "already gone".
|
|
21
|
+
class Completer
|
|
22
|
+
# Raised on a waiting job thread whose flush can no longer happen: the
|
|
23
|
+
# flusher was stopped past its join budget, or died.
|
|
24
|
+
class Stopped < Pgbus::Error; end
|
|
25
|
+
|
|
26
|
+
# One job thread's wait for its id's result.
|
|
27
|
+
class Waiter
|
|
28
|
+
def initialize
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@done = ConditionVariable.new
|
|
31
|
+
@state = nil
|
|
32
|
+
@value = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve(value) = settle(:value, value)
|
|
36
|
+
|
|
37
|
+
def fail(error) = settle(:error, error)
|
|
38
|
+
|
|
39
|
+
def value
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@done.wait(@mutex) until @state
|
|
42
|
+
raise @value if @state == :error
|
|
43
|
+
|
|
44
|
+
@value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# First settle wins: a waiter already served is not failed again by
|
|
51
|
+
# the flusher's last-resort cleanup.
|
|
52
|
+
def settle(state, value)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
next if @state
|
|
55
|
+
|
|
56
|
+
@state = state
|
|
57
|
+
@value = value
|
|
58
|
+
@done.broadcast
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
BATCH_OPS = { archive: :archive_batch, delete: :delete_batch }.freeze
|
|
64
|
+
|
|
65
|
+
def initialize(client:, batch_size:, completed_jobs: :archive)
|
|
66
|
+
@batch_op = BATCH_OPS.fetch(completed_jobs) do
|
|
67
|
+
raise ArgumentError, "completed_jobs must be :archive or :delete (got #{completed_jobs.inspect})"
|
|
68
|
+
end
|
|
69
|
+
@client = client
|
|
70
|
+
@batch_size = batch_size
|
|
71
|
+
@mutex = Mutex.new
|
|
72
|
+
@wake = ConditionVariable.new
|
|
73
|
+
@pending = {}
|
|
74
|
+
@taken = nil
|
|
75
|
+
@stopping = false
|
|
76
|
+
# Owned by this completer: one per worker, built with it. #stop asks
|
|
77
|
+
# it to drain and exit, then joins it (Worker#shutdown, #immediate_shutdown).
|
|
78
|
+
@thread = Thread.new { run } # rubocop:disable ThreadSafety/NewThread
|
|
79
|
+
@thread.name = "pgbus-completer"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Archives msg_id from the physical (already prefixed) queue and returns
|
|
83
|
+
# :archived, or :already_archived when the message was gone (another
|
|
84
|
+
# worker archived it). Raises the flush's exception on this thread.
|
|
85
|
+
def complete(queue, msg_id)
|
|
86
|
+
waiter = @mutex.synchronize do
|
|
87
|
+
next if @stopping
|
|
88
|
+
|
|
89
|
+
entry = Waiter.new
|
|
90
|
+
(@pending[queue] ||= []) << [msg_id, entry]
|
|
91
|
+
@wake.signal
|
|
92
|
+
entry
|
|
93
|
+
end
|
|
94
|
+
return waiter.value if waiter
|
|
95
|
+
|
|
96
|
+
# Stopped: complete on the caller's thread, with the same results.
|
|
97
|
+
done = @client.public_send(@batch_op, queue, [msg_id], prefixed: false, job_completion: true)
|
|
98
|
+
done.empty? ? :already_archived : :archived
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Flushes whatever is pending, then joins the flusher. Past timeout the
|
|
102
|
+
# flusher is killed and every waiter it had not served raises Stopped.
|
|
103
|
+
# Later `complete` calls archive on their own thread. Idempotent.
|
|
104
|
+
def stop(timeout: nil)
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
@stopping = true
|
|
107
|
+
@wake.signal
|
|
108
|
+
end
|
|
109
|
+
return if @thread.join(timeout)
|
|
110
|
+
|
|
111
|
+
@thread.kill
|
|
112
|
+
@thread.join
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def alive? = @thread.alive?
|
|
116
|
+
|
|
117
|
+
# Ids waiting for the next flush (test and introspection seam).
|
|
118
|
+
def pending_count
|
|
119
|
+
@mutex.synchronize { @pending.sum { |_queue, entries| entries.size } }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# True while a flush is running (test and introspection seam).
|
|
123
|
+
def flushing?
|
|
124
|
+
@mutex.synchronize { !@taken.nil? }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def run
|
|
130
|
+
while (batch = take)
|
|
131
|
+
flush(batch)
|
|
132
|
+
@mutex.synchronize { @taken = nil }
|
|
133
|
+
end
|
|
134
|
+
ensure
|
|
135
|
+
fail_unserved
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Waits for pending ids and takes all of them; nil once stopping with
|
|
139
|
+
# nothing left.
|
|
140
|
+
def take
|
|
141
|
+
@mutex.synchronize do
|
|
142
|
+
@wake.wait(@mutex) while @pending.empty? && !@stopping
|
|
143
|
+
next if @pending.empty?
|
|
144
|
+
|
|
145
|
+
@taken = @pending
|
|
146
|
+
@pending = {}
|
|
147
|
+
@taken
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def flush(batch)
|
|
152
|
+
batch.each do |queue, entries|
|
|
153
|
+
entries.each_slice(@batch_size) { |slice| flush_slice(queue, slice) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# pgmq returns one row per archived id (as a String); an id already gone
|
|
158
|
+
# is absent. A duplicated id (the same message held by two threads after
|
|
159
|
+
# a redelivery) is archived once, so only its first waiter gets :archived.
|
|
160
|
+
def flush_slice(queue, slice)
|
|
161
|
+
archived = @client.public_send(@batch_op, queue, slice.map(&:first).uniq, prefixed: false, job_completion: true)
|
|
162
|
+
.to_set(&:to_i)
|
|
163
|
+
slice.each { |msg_id, waiter| waiter.resolve(archived.delete?(msg_id) ? :archived : :already_archived) }
|
|
164
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- delivered to the waiters, never swallowed
|
|
165
|
+
log_flush_failure(queue, slice, e) unless e.is_a?(StandardError)
|
|
166
|
+
slice.map(&:last).each { |waiter| waiter.fail(e) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def log_flush_failure(queue, slice, error)
|
|
170
|
+
Pgbus.logger.error do
|
|
171
|
+
"[Pgbus::Completer] #{@batch_op} on #{queue} (#{slice.size} ids) raised #{error.class}: #{error.message}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The flusher is exiting: nothing may be left waiting on it.
|
|
176
|
+
def fail_unserved
|
|
177
|
+
leftovers = @mutex.synchronize do
|
|
178
|
+
entries = [@taken, @pending].compact.flat_map(&:values).flatten(1)
|
|
179
|
+
@taken = nil
|
|
180
|
+
@pending = {}
|
|
181
|
+
@stopping = true
|
|
182
|
+
entries
|
|
183
|
+
end
|
|
184
|
+
error = Stopped.new("the completer stopped before archiving this message")
|
|
185
|
+
leftovers.map(&:last).each { |waiter| waiter.fail(error) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -264,7 +264,7 @@ module Pgbus
|
|
|
264
264
|
dispatch(handlers, message, queue_name)
|
|
265
265
|
end
|
|
266
266
|
|
|
267
|
-
|
|
267
|
+
archive_handled(queue_name, message)
|
|
268
268
|
# A redelivery that succeeds leaves no stale failure behind. First
|
|
269
269
|
# deliveries never had one: no query on the hot path (#484's gate).
|
|
270
270
|
if message.read_ct.to_i > 1
|
|
@@ -367,6 +367,20 @@ module Pgbus
|
|
|
367
367
|
# as retrying with its error. No ErrorReporter call: Handler#process!
|
|
368
368
|
# already instruments pgbus.event_failed, which feeds the reporters.
|
|
369
369
|
# A handler queue has no priority sub-queues: its one table is the source.
|
|
370
|
+
# The handlers ran. An archive whose reply was lost after it may have
|
|
371
|
+
# committed, and whose re-run found the message gone (issue #607
|
|
372
|
+
# review), leaves nothing to retry: the message is handled.
|
|
373
|
+
def archive_handled(queue_name, message)
|
|
374
|
+
Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
|
|
375
|
+
rescue StandardError => e # pgmq loads lazily: completion_lost? checks the class
|
|
376
|
+
raise unless Client::NativeStatements.completion_lost?(e)
|
|
377
|
+
|
|
378
|
+
Pgbus.logger.warn do
|
|
379
|
+
"[Pgbus] Consumer archive of #{message.msg_id} on #{queue_name} lost its reply and the message is gone: " \
|
|
380
|
+
"counted as handled (#{e.message})"
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
370
384
|
def record_failed_event(message, queue_name, error)
|
|
371
385
|
FailedEventRecorder.record!(
|
|
372
386
|
queue_name: queue_name, msg_id: message.msg_id.to_i, source_queue: config.queue_name(queue_name),
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -62,6 +62,12 @@ module Pgbus
|
|
|
62
62
|
"fair_share and group_mode are mutually exclusive — this worker has group_mode " \
|
|
63
63
|
"#{(@group_mode || config.group_mode).inspect} while config.fair_share is set"
|
|
64
64
|
end
|
|
65
|
+
# The completer blocks a job thread on its flush, which would stall an
|
|
66
|
+
# Async reactor (issue #606); the mode can arrive here without validate!.
|
|
67
|
+
if config.completion_batching && @execution_mode == :async
|
|
68
|
+
raise Pgbus::ConfigurationError,
|
|
69
|
+
"completion_batching supports execution_mode :threads only; this worker runs :async"
|
|
70
|
+
end
|
|
65
71
|
@single_active_consumer = single_active_consumer
|
|
66
72
|
@consumer_priority = consumer_priority
|
|
67
73
|
@lifecycle = Lifecycle.new
|
|
@@ -94,7 +100,16 @@ module Pgbus
|
|
|
94
100
|
else
|
|
95
101
|
stat_buffer
|
|
96
102
|
end
|
|
97
|
-
|
|
103
|
+
# One per worker when config.completion_batching (issue #606): archives
|
|
104
|
+
# the jobs that finish close together in one archive_batch, or deletes
|
|
105
|
+
# them with delete_batch under config.completed_jobs = :delete (#607).
|
|
106
|
+
@completer = if config.completion_batching
|
|
107
|
+
Completer.new(client: Pgbus.client, batch_size: config.completion_batch_size,
|
|
108
|
+
completed_jobs: config.completed_jobs)
|
|
109
|
+
end
|
|
110
|
+
# The worker's config, so the executor and the completer read one
|
|
111
|
+
# completed_jobs (issue #607).
|
|
112
|
+
@executor = Pgbus::ActiveJob::Executor.new(config: config, stat_buffer: @stat_buffer, completer: @completer)
|
|
98
113
|
@wake_signal = wake_signal || WakeSignal.new
|
|
99
114
|
@pool = ExecutionPools.build(
|
|
100
115
|
mode: @execution_mode,
|
|
@@ -179,6 +194,7 @@ module Pgbus
|
|
|
179
194
|
POOL_TERMINATION_WAIT = 5
|
|
180
195
|
|
|
181
196
|
def run
|
|
197
|
+
quiet_active_job_logs
|
|
182
198
|
setup_signals
|
|
183
199
|
start_heartbeat
|
|
184
200
|
resolve_wildcard_queues
|
|
@@ -239,6 +255,13 @@ module Pgbus
|
|
|
239
255
|
Pgbus.stopping = true
|
|
240
256
|
@lifecycle.transition_to!(:stopped)
|
|
241
257
|
@wake_signal.notify!
|
|
258
|
+
# Before the pool: a job thread killed while it waits on a flush would
|
|
259
|
+
# skip its signals while the flush still commits (archived, never
|
|
260
|
+
# signalled). Stopping first resolves every waiter; a job that finishes
|
|
261
|
+
# inside the grace then archives on its own thread. Bounded by the same
|
|
262
|
+
# grace: a flush wedged on a hung Postgres is abandoned, and its jobs
|
|
263
|
+
# fail with Completer::Stopped and re-run after visibility_timeout.
|
|
264
|
+
@completer&.stop(timeout: config.immediate_shutdown_grace)
|
|
242
265
|
ExecutionPools.terminate(@pool, grace: config.immediate_shutdown_grace)
|
|
243
266
|
end
|
|
244
267
|
|
|
@@ -260,6 +283,18 @@ module Pgbus
|
|
|
260
283
|
|
|
261
284
|
private
|
|
262
285
|
|
|
286
|
+
# config.quiet_active_job_logs (issue #618): only worker processes perform
|
|
287
|
+
# jobs, so only they drop the per-job "Performing …" / "Performed …"
|
|
288
|
+
# lines. ActiveJob::LogSubscriber attaches when active_job/base loads,
|
|
289
|
+
# which an app with no job class to eager-load has not done yet, so load
|
|
290
|
+
# it first (defined? does not trigger ActiveJob's autoload of Base).
|
|
291
|
+
def quiet_active_job_logs
|
|
292
|
+
return unless config.quiet_active_job_logs && defined?(::ActiveJob::Base)
|
|
293
|
+
|
|
294
|
+
require "active_job/base"
|
|
295
|
+
Pgbus::ActiveJob::QuietLogs.apply!
|
|
296
|
+
end
|
|
297
|
+
|
|
263
298
|
# One loop step (issue #486): feed buffered claims to free slots, read
|
|
264
299
|
# the deficit (free slots + read_ahead - buffered, capped by
|
|
265
300
|
# prefetch_limit), feed again. With read_ahead 0 the buffer never
|
|
@@ -488,11 +523,18 @@ module Pgbus
|
|
|
488
523
|
def process_message(message, queue_name, source_queue: nil)
|
|
489
524
|
result = @executor.execute(message, queue_name, source_queue: source_queue)
|
|
490
525
|
@jobs_processed.increment
|
|
491
|
-
|
|
492
|
-
|
|
526
|
+
# A skipped uniqueness duplicate comes back later: counting it now
|
|
527
|
+
# would count one job twice (issue #629).
|
|
528
|
+
@rate_counter.increment(:processed) unless result == :skipped
|
|
529
|
+
case result
|
|
530
|
+
when :failed
|
|
493
531
|
@jobs_failed.increment
|
|
494
532
|
@rate_counter.increment(:failed)
|
|
495
533
|
@circuit_breaker.record_failure(queue_name)
|
|
534
|
+
when :skipped, :discarded
|
|
535
|
+
# No job ran: neither a failure nor a success that would wipe the
|
|
536
|
+
# failure streak of a queue whose real jobs keep failing.
|
|
537
|
+
nil
|
|
496
538
|
else
|
|
497
539
|
@circuit_breaker.record_success(queue_name)
|
|
498
540
|
end
|
|
@@ -547,7 +589,12 @@ module Pgbus
|
|
|
547
589
|
prefix = "#{config.queue_prefix}_"
|
|
548
590
|
if (match = MISSING_QUEUE_REGEX.match(error.message))
|
|
549
591
|
physical_name = match[1]
|
|
550
|
-
|
|
592
|
+
# Postgres names the table as PGMQ spells it (lower case, issue #633;
|
|
593
|
+
# separators normalized), so find the queue as configured.
|
|
594
|
+
# A priority sub-queue (_pN) belongs to its base queue.
|
|
595
|
+
logical_name = @queues.find { |q| config.priority_queue_names(q).include?(physical_name) } ||
|
|
596
|
+
@queues.find { |q| config.queue_name(q) == physical_name } ||
|
|
597
|
+
physical_name.delete_prefix(prefix)
|
|
551
598
|
if @queues.delete(logical_name)
|
|
552
599
|
Pgbus.logger.warn { "[Pgbus] Evicted deleted queue '#{logical_name}' (#{physical_name}) from worker" }
|
|
553
600
|
end
|
|
@@ -927,7 +974,13 @@ module Pgbus
|
|
|
927
974
|
# config.drain_timeout for in-flight jobs. A job still running here has
|
|
928
975
|
# proven it won't finish; waiting another full window would push the
|
|
929
976
|
# worker past the supervisor's shutdown_timeout deadline (issue #386).
|
|
977
|
+
residual_deadline = monotonic_now + POOL_TERMINATION_WAIT
|
|
930
978
|
@pool.wait_for_termination(POOL_TERMINATION_WAIT)
|
|
979
|
+
# After the pool: every waiter is a job thread, so the drain already
|
|
980
|
+
# flushed theirs; this flushes any left and joins the flusher. It shares
|
|
981
|
+
# the pool's residual budget rather than adding its own, so shutdown
|
|
982
|
+
# stays inside the supervisor's drain_timeout + 5 default deadline.
|
|
983
|
+
@completer&.stop(timeout: [residual_deadline - monotonic_now, 0].max)
|
|
931
984
|
VisibilityHeartbeat.stop
|
|
932
985
|
@stat_buffer&.stop
|
|
933
986
|
@queue_lock&.unlock_all
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# Issue #633 upgrade path. PGMQ lower-cases its table names
|
|
5
|
+
# (pgmq.format_table_name), so a queue an older pgbus created as
|
|
6
|
+
# "pgbus_Orders" lives in pgmq.q_pgbus_orders while pgmq.meta records
|
|
7
|
+
# "pgbus_Orders". pgbus now spells every queue in lower case; this renames
|
|
8
|
+
# the old rows to match their tables.
|
|
9
|
+
#
|
|
10
|
+
# Only rows change. The queue tables are shared by both spellings, so nothing
|
|
11
|
+
# here drops, renames or copies a table: dropping either spelling's queue
|
|
12
|
+
# through PGMQ would remove the shared table.
|
|
13
|
+
#
|
|
14
|
+
# Run by the pgbus:fold_queue_name_case migration and by
|
|
15
|
+
# `rake pgbus:queues:fold_case` (for a row an older process recreated after
|
|
16
|
+
# the migration ran). Idempotent. Takes an ActiveRecord connection and
|
|
17
|
+
# folds pgbus's queues only (those named "<prefix>_…" in any case), so
|
|
18
|
+
# another PGMQ user of the same schema keeps its spelling and its NOTIFY
|
|
19
|
+
# channel.
|
|
20
|
+
module QueueNameFold
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def run!(connection, prefix: Pgbus.configuration.queue_prefix)
|
|
24
|
+
fold_pgmq_meta(connection, "#{prefix.to_s.downcase}_")
|
|
25
|
+
fold_unique_column(connection, :pgbus_stream_queues, prefer: "")
|
|
26
|
+
fold_unique_column(connection, :pgbus_queue_states, prefer: "paused DESC,")
|
|
27
|
+
fold_column(connection, :pgbus_uniqueness_keys, :queue_name)
|
|
28
|
+
fold_failed_event_source_queues(connection)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# One mixed-case pgmq.meta row at a time: add the lower-case row, move its
|
|
32
|
+
# topic bindings, delete the old row (and its throttle and binding rows),
|
|
33
|
+
# then re-arm the insert-NOTIFY trigger under the new name. PGMQ 1.13.1's
|
|
34
|
+
# trigger NOTIFYs on the channel of the name it was armed with, which
|
|
35
|
+
# listeners now spell in lower case. Where both spellings exist, the
|
|
36
|
+
# lower-case row's throttle wins.
|
|
37
|
+
def fold_pgmq_meta(connection, own_prefix)
|
|
38
|
+
prefix = connection.quote(own_prefix)
|
|
39
|
+
connection.execute(<<~SQL)
|
|
40
|
+
DO $$
|
|
41
|
+
DECLARE
|
|
42
|
+
q RECORD;
|
|
43
|
+
folded TEXT;
|
|
44
|
+
throttle INTEGER;
|
|
45
|
+
has_throttle BOOLEAN := to_regclass('pgmq.notify_insert_throttle') IS NOT NULL;
|
|
46
|
+
has_bindings BOOLEAN := to_regclass('pgmq.topic_bindings') IS NOT NULL;
|
|
47
|
+
BEGIN
|
|
48
|
+
FOR q IN
|
|
49
|
+
SELECT queue_name, is_partitioned, is_unlogged, created_at
|
|
50
|
+
FROM pgmq.meta
|
|
51
|
+
WHERE queue_name <> lower(queue_name)
|
|
52
|
+
AND left(lower(queue_name), length(#{prefix})) = #{prefix}
|
|
53
|
+
ORDER BY queue_name
|
|
54
|
+
LOOP
|
|
55
|
+
folded := lower(q.queue_name);
|
|
56
|
+
|
|
57
|
+
INSERT INTO pgmq.meta (queue_name, is_partitioned, is_unlogged, created_at)
|
|
58
|
+
VALUES (folded, q.is_partitioned, q.is_unlogged, q.created_at)
|
|
59
|
+
ON CONFLICT DO NOTHING;
|
|
60
|
+
|
|
61
|
+
throttle := NULL;
|
|
62
|
+
IF has_throttle THEN
|
|
63
|
+
SELECT COALESCE(
|
|
64
|
+
(SELECT throttle_interval_ms FROM pgmq.notify_insert_throttle WHERE queue_name = folded),
|
|
65
|
+
(SELECT throttle_interval_ms FROM pgmq.notify_insert_throttle WHERE queue_name = q.queue_name)
|
|
66
|
+
) INTO throttle;
|
|
67
|
+
DELETE FROM pgmq.notify_insert_throttle WHERE queue_name = q.queue_name;
|
|
68
|
+
END IF;
|
|
69
|
+
|
|
70
|
+
IF has_bindings THEN
|
|
71
|
+
INSERT INTO pgmq.topic_bindings (pattern, queue_name, bound_at)
|
|
72
|
+
SELECT pattern, folded, bound_at FROM pgmq.topic_bindings WHERE queue_name = q.queue_name
|
|
73
|
+
ON CONFLICT DO NOTHING;
|
|
74
|
+
DELETE FROM pgmq.topic_bindings WHERE queue_name = q.queue_name;
|
|
75
|
+
END IF;
|
|
76
|
+
|
|
77
|
+
DELETE FROM pgmq.meta WHERE queue_name = q.queue_name;
|
|
78
|
+
|
|
79
|
+
IF throttle IS NOT NULL THEN
|
|
80
|
+
PERFORM pgmq.enable_notify_insert(folded, throttle);
|
|
81
|
+
END IF;
|
|
82
|
+
END LOOP;
|
|
83
|
+
END $$;
|
|
84
|
+
SQL
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A column with a unique index: keep one row per lower-case name (the
|
|
88
|
+
# `prefer` order first, then the row already in lower case, then the
|
|
89
|
+
# oldest), delete the rest, then lower-case what is left.
|
|
90
|
+
def fold_unique_column(connection, table, prefer:)
|
|
91
|
+
return unless connection.table_exists?(table)
|
|
92
|
+
|
|
93
|
+
connection.execute(<<~SQL)
|
|
94
|
+
DELETE FROM #{table} WHERE id IN (
|
|
95
|
+
SELECT id FROM (
|
|
96
|
+
SELECT id, row_number() OVER (
|
|
97
|
+
PARTITION BY lower(queue_name)
|
|
98
|
+
ORDER BY #{prefer} (queue_name = lower(queue_name)) DESC, id
|
|
99
|
+
) AS rank
|
|
100
|
+
FROM #{table}
|
|
101
|
+
WHERE lower(queue_name) IN (SELECT lower(queue_name) FROM #{table} WHERE queue_name <> lower(queue_name))
|
|
102
|
+
) ranked
|
|
103
|
+
WHERE rank > 1
|
|
104
|
+
)
|
|
105
|
+
SQL
|
|
106
|
+
fold_column(connection, table, :queue_name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fold_column(connection, table, column)
|
|
110
|
+
return unless connection.table_exists?(table) && connection.column_exists?(table, column)
|
|
111
|
+
|
|
112
|
+
connection.execute("UPDATE #{table} SET #{column} = lower(#{column}) WHERE #{column} <> lower(#{column})")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# source_queue is the physical queue a failure was read from, now spelled in
|
|
116
|
+
# lower case. queue_name is the executor's logical name, as configured, and
|
|
117
|
+
# stays. A message that failed under both spellings (before and after the
|
|
118
|
+
# deploy) has two rows: one per (queue_name, msg_id, folded source) stays,
|
|
119
|
+
# the lower-case one if there is one (written after the deploy), else the
|
|
120
|
+
# newest, so the (queue_name, msg_id, source_queue) unique index holds.
|
|
121
|
+
def fold_failed_event_source_queues(connection)
|
|
122
|
+
return unless connection.table_exists?(:pgbus_failed_events) && connection.column_exists?(:pgbus_failed_events, :source_queue)
|
|
123
|
+
|
|
124
|
+
connection.execute(<<~SQL)
|
|
125
|
+
DELETE FROM pgbus_failed_events WHERE id IN (
|
|
126
|
+
SELECT id FROM (
|
|
127
|
+
SELECT id, row_number() OVER (
|
|
128
|
+
PARTITION BY queue_name, msg_id, lower(source_queue)
|
|
129
|
+
ORDER BY (source_queue = lower(source_queue)) DESC, id DESC
|
|
130
|
+
) AS rank
|
|
131
|
+
FROM pgbus_failed_events
|
|
132
|
+
WHERE (queue_name, msg_id, lower(source_queue)) IN (
|
|
133
|
+
SELECT queue_name, msg_id, lower(source_queue) FROM pgbus_failed_events
|
|
134
|
+
WHERE source_queue <> lower(source_queue)
|
|
135
|
+
)
|
|
136
|
+
) ranked
|
|
137
|
+
WHERE rank > 1
|
|
138
|
+
)
|
|
139
|
+
SQL
|
|
140
|
+
fold_column(connection, :pgbus_failed_events, :source_queue)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method :fold_pgmq_meta, :fold_unique_column, :fold_column, :fold_failed_event_source_queues
|
|
144
|
+
end
|
|
145
|
+
end
|