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
|
@@ -4,6 +4,12 @@ module Pgbus
|
|
|
4
4
|
class QueueState < BusRecord
|
|
5
5
|
self.table_name = "pgbus_queue_states"
|
|
6
6
|
|
|
7
|
+
# One row per queue whatever the spelling (issue #633): the dashboard
|
|
8
|
+
# names a queue from its pgmq.meta row, which PGMQ's lower-case table
|
|
9
|
+
# names make lower case, while a worker names it as configured
|
|
10
|
+
# ("Critical"). Applies to writes and to find_by/where.
|
|
11
|
+
normalizes :queue_name, with: ->(name) { name.to_s.downcase }
|
|
12
|
+
|
|
7
13
|
scope :paused, -> { where(paused: true) }
|
|
8
14
|
|
|
9
15
|
def self.paused?(queue_name)
|
|
@@ -191,6 +191,17 @@ module Pgbus
|
|
|
191
191
|
)
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
+
# True when the key's row is bound to this message (queue + msg_id): its
|
|
195
|
+
# own attempt holds it, as a crashed attempt leaves it. The executor asks
|
|
196
|
+
# this when it dead-letters a :while_executing message that never failed
|
|
197
|
+
# (issue #629): a crash loop, not a uniqueness conflict.
|
|
198
|
+
def self.bound_to?(lock_key, queue_name:, msg_id:)
|
|
199
|
+
!connection.select_value(
|
|
200
|
+
"SELECT 1 FROM #{table_name} WHERE lock_key = $1 AND queue_name = $2 AND msg_id = $3 LIMIT 1",
|
|
201
|
+
"UniquenessKey Bound To", [lock_key, queue_name.to_s, msg_id.to_i]
|
|
202
|
+
).nil?
|
|
203
|
+
end
|
|
204
|
+
|
|
194
205
|
# Check if a key is currently locked.
|
|
195
206
|
def self.locked?(lock_key)
|
|
196
207
|
result = connection.select_value(
|
data/config/locales/da.yml
CHANGED
|
@@ -224,6 +224,7 @@ da:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Genforsøgt fra DLQ én gang før.
|
|
226
226
|
other: Genforsøgt fra DLQ %{count} gange før.
|
|
227
|
+
uniqueness_conflict_html: Sprunget over ved alle %{attempts} læsninger (maks. %{max}), mens en anden kopi af jobbet holdt dets unikhedsnøgle — intet forsøg kørte
|
|
227
228
|
show:
|
|
228
229
|
back: Tilbage til DLQ
|
|
229
230
|
context: Kontekst
|
data/config/locales/de.yml
CHANGED
|
@@ -224,6 +224,7 @@ de:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Schon einmal aus der DLQ wiederholt.
|
|
226
226
|
other: Schon %{count}-mal aus der DLQ wiederholt.
|
|
227
|
+
uniqueness_conflict_html: Bei allen %{attempts} Lesevorgängen übersprungen (max. %{max}), weil eine andere Kopie des Jobs seinen Eindeutigkeitsschlüssel hielt — kein Versuch lief
|
|
227
228
|
show:
|
|
228
229
|
back: Zurück zur DLQ
|
|
229
230
|
context: Kontext
|
data/config/locales/en.yml
CHANGED
|
@@ -224,6 +224,7 @@ en:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Retried from the DLQ once before.
|
|
226
226
|
other: Retried from the DLQ %{count} times before.
|
|
227
|
+
uniqueness_conflict_html: Skipped on all %{attempts} reads (max %{max}) while another copy of the job held its uniqueness key — no attempt ran
|
|
227
228
|
show:
|
|
228
229
|
back: Back to DLQ
|
|
229
230
|
context: Context
|
data/config/locales/es.yml
CHANGED
|
@@ -224,6 +224,7 @@ es:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Reintentado desde la DLQ una vez antes.
|
|
226
226
|
other: Reintentado desde la DLQ %{count} veces antes.
|
|
227
|
+
uniqueness_conflict_html: Omitido en las %{attempts} lecturas (máx. %{max}) mientras otra copia del job tenía su clave de unicidad — no se ejecutó ningún intento
|
|
227
228
|
show:
|
|
228
229
|
back: Volver a DLQ
|
|
229
230
|
context: Contexto
|
data/config/locales/fi.yml
CHANGED
|
@@ -224,6 +224,7 @@ fi:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Yritetty uudelleen DLQ:sta kerran aiemmin.
|
|
226
226
|
other: Yritetty uudelleen DLQ:sta %{count} kertaa aiemmin.
|
|
227
|
+
uniqueness_conflict_html: Ohitettu kaikilla %{attempts} lukukerralla (enint. %{max}), koska työn toinen kopio piti sen yksilöllisyysavainta — yhtään yritystä ei ajettu
|
|
227
228
|
show:
|
|
228
229
|
back: Takaisin DLQ:hon
|
|
229
230
|
context: Konteksti
|
data/config/locales/fr.yml
CHANGED
|
@@ -224,6 +224,7 @@ fr:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Déjà relancé depuis la DLQ une fois.
|
|
226
226
|
other: Déjà relancé depuis la DLQ %{count} fois.
|
|
227
|
+
uniqueness_conflict_html: Ignoré à chacune des %{attempts} lectures (max %{max}) car une autre copie du job détenait sa clé d’unicité — aucune tentative n’a été exécutée
|
|
227
228
|
show:
|
|
228
229
|
back: Retour à la DLQ
|
|
229
230
|
context: Contexte
|
data/config/locales/it.yml
CHANGED
|
@@ -224,6 +224,7 @@ it:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Già riprovato dalla DLQ una volta.
|
|
226
226
|
other: Già riprovato dalla DLQ %{count} volte.
|
|
227
|
+
uniqueness_conflict_html: Saltato in tutte le %{attempts} letture (max %{max}) mentre un’altra copia del job deteneva la sua chiave di unicità — nessun tentativo eseguito
|
|
227
228
|
show:
|
|
228
229
|
back: Torna a DLQ
|
|
229
230
|
context: Contesto
|
data/config/locales/ja.yml
CHANGED
|
@@ -224,6 +224,7 @@ ja:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: 以前に DLQ から 1 回リトライされました。
|
|
226
226
|
other: 以前に DLQ から %{count} 回リトライされました。
|
|
227
|
+
uniqueness_conflict_html: 別のコピーが一意性キーを保持していたため、%{attempts} 回の読み取りすべてでスキップされました(上限 %{max} 回) — 試行は一度も実行されていません
|
|
227
228
|
show:
|
|
228
229
|
back: DLQに戻る
|
|
229
230
|
context: コンテキスト
|
data/config/locales/nb.yml
CHANGED
|
@@ -224,6 +224,7 @@ nb:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Forsøkt på nytt fra DLQ én gang før.
|
|
226
226
|
other: Forsøkt på nytt fra DLQ %{count} ganger før.
|
|
227
|
+
uniqueness_conflict_html: Hoppet over ved alle %{attempts} lesinger (maks. %{max}) mens en annen kopi av jobben holdt unikhetsnøkkelen — ingen forsøk kjørte
|
|
227
228
|
show:
|
|
228
229
|
back: Tilbake til DLQ
|
|
229
230
|
context: Kontekst
|
data/config/locales/nl.yml
CHANGED
|
@@ -224,6 +224,7 @@ nl:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Al eens eerder vanuit de DLQ opnieuw geprobeerd.
|
|
226
226
|
other: Al %{count} keer eerder vanuit de DLQ opnieuw geprobeerd.
|
|
227
|
+
uniqueness_conflict_html: Bij alle %{attempts} keer lezen overgeslagen (max %{max}) omdat een andere kopie van de job de uniciteitssleutel had — geen poging uitgevoerd
|
|
227
228
|
show:
|
|
228
229
|
back: Terug naar DLQ
|
|
229
230
|
context: Context
|
data/config/locales/pt.yml
CHANGED
|
@@ -224,6 +224,7 @@ pt:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Já reenviado da DLQ uma vez.
|
|
226
226
|
other: Já reenviado da DLQ %{count} vezes.
|
|
227
|
+
uniqueness_conflict_html: Ignorado em todas as %{attempts} leituras (máx. %{max}) enquanto outra cópia do job detinha sua chave de unicidade — nenhuma tentativa foi executada
|
|
227
228
|
show:
|
|
228
229
|
back: Voltar para DLQ
|
|
229
230
|
context: Contexto
|
data/config/locales/sv.yml
CHANGED
|
@@ -224,6 +224,7 @@ sv:
|
|
|
224
224
|
retried_before:
|
|
225
225
|
one: Omförsökt från DLQ en gång tidigare.
|
|
226
226
|
other: Omförsökt från DLQ %{count} gånger tidigare.
|
|
227
|
+
uniqueness_conflict_html: Hoppades över vid alla %{attempts} läsningar (max %{max}) medan en annan kopia av jobbet höll dess unikhetsnyckel — inget försök kördes
|
|
227
228
|
show:
|
|
228
229
|
back: Tillbaka till DLQ
|
|
229
230
|
context: Kontext
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "migration_path"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
module Generators
|
|
9
|
+
# Issue #633: PGMQ lower-cases its table names, so a queue pgbus created
|
|
10
|
+
# as "pgbus_Orders" before 0.23.0 lives in pgmq.q_pgbus_orders while
|
|
11
|
+
# pgmq.meta records the mixed-case spelling. pgbus now spells every queue
|
|
12
|
+
# in lower case; this migration brings an install's existing rows to that
|
|
13
|
+
# spelling. No table is dropped, renamed or copied.
|
|
14
|
+
class FoldQueueNameCaseGenerator < Rails::Generators::Base
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
include MigrationPath
|
|
17
|
+
|
|
18
|
+
source_root File.expand_path("templates", __dir__)
|
|
19
|
+
|
|
20
|
+
desc "Spell existing PGMQ queues (pgmq.meta, NOTIFY triggers, pgbus's queue-name columns) in lower case, " \
|
|
21
|
+
"the way their tables are"
|
|
22
|
+
|
|
23
|
+
class_option :database,
|
|
24
|
+
type: :string,
|
|
25
|
+
default: nil,
|
|
26
|
+
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
27
|
+
|
|
28
|
+
def create_migration_file
|
|
29
|
+
migration_template "fold_queue_name_case.rb.erb",
|
|
30
|
+
File.join(pgbus_migrate_path, "fold_pgbus_queue_name_case.rb")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def display_post_install
|
|
34
|
+
say ""
|
|
35
|
+
say "Pgbus queue-name fold migration created!", :green
|
|
36
|
+
say ""
|
|
37
|
+
say "PGMQ spells every queue table in lower case. This migration renames the"
|
|
38
|
+
say "pgmq.meta rows (and NOTIFY triggers) of queues created with upper-case"
|
|
39
|
+
say "letters to the lower-case spelling their tables already use. Messages stay"
|
|
40
|
+
say "where they are."
|
|
41
|
+
say ""
|
|
42
|
+
say "Next steps:"
|
|
43
|
+
say " 1. Deploy pgbus 0.23.0+ everywhere first: an older process recreates its mixed-case row"
|
|
44
|
+
say " 2. Run: rails db:migrate#{migrate_command_suffix}"
|
|
45
|
+
say " 3. Check: rake pgbus:doctor (\"Queue name case\" is ok); if it still names a queue,"
|
|
46
|
+
say " run: rake pgbus:queues:fold_case"
|
|
47
|
+
say ""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def migration_version
|
|
53
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class FoldPgbusQueueNameCase < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
# Issue #633. PGMQ lower-cases its table names, so a queue created as
|
|
3
|
+
# "pgbus_Orders" lives in pgmq.q_pgbus_orders while pgmq.meta records
|
|
4
|
+
# "pgbus_Orders". Pgbus::QueueNameFold renames the old rows to the lower-case
|
|
5
|
+
# spelling pgbus now uses; it changes rows only, never a table.
|
|
6
|
+
def up
|
|
7
|
+
Pgbus::QueueNameFold.run!(connection)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# The original spellings are not kept: PGMQ never used them for a table.
|
|
11
|
+
def down; end
|
|
12
|
+
end
|
|
@@ -25,8 +25,8 @@ class TunePgbusAutovacuum < ActiveRecord::Migration<%= migration_version %>
|
|
|
25
25
|
q RECORD;
|
|
26
26
|
BEGIN
|
|
27
27
|
FOR q IN SELECT queue_name FROM pgmq.meta LOOP
|
|
28
|
-
EXECUTE format('ALTER TABLE pgmq
|
|
29
|
-
EXECUTE format('ALTER TABLE pgmq
|
|
28
|
+
EXECUTE format('ALTER TABLE pgmq.%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', 'q_' || lower(q.queue_name));
|
|
29
|
+
EXECUTE format('ALTER TABLE pgmq.%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', 'a_' || lower(q.queue_name));
|
|
30
30
|
END LOOP;
|
|
31
31
|
END $$;
|
|
32
32
|
SQL
|
|
@@ -22,7 +22,7 @@ class TunePgbusFillfactor < ActiveRecord::Migration<%= migration_version %>
|
|
|
22
22
|
q RECORD;
|
|
23
23
|
BEGIN
|
|
24
24
|
FOR q IN SELECT queue_name FROM pgmq.meta LOOP
|
|
25
|
-
EXECUTE format('ALTER TABLE pgmq
|
|
25
|
+
EXECUTE format('ALTER TABLE pgmq.%I RESET (fillfactor)', 'q_' || lower(q.queue_name));
|
|
26
26
|
END LOOP;
|
|
27
27
|
END $$;
|
|
28
28
|
SQL
|
|
@@ -215,7 +215,7 @@ module Pgbus
|
|
|
215
215
|
return true
|
|
216
216
|
when :discard
|
|
217
217
|
Pgbus.logger.info { "[Pgbus] Discarding job #{active_job.class.name}: concurrency limit for #{key}" }
|
|
218
|
-
# The job will never run: roll back an
|
|
218
|
+
# The job will never run: roll back an enqueue-time uniqueness lock
|
|
219
219
|
# acquired earlier in this enqueue (no executor will release it), and
|
|
220
220
|
# uncount it from its batch so completion is not waited on forever.
|
|
221
221
|
rollback_acquired_uniqueness_lock
|
|
@@ -226,7 +226,8 @@ module Pgbus
|
|
|
226
226
|
false
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
-
# Releases an
|
|
229
|
+
# Releases an enqueue-time lock (:until_executed, :until_executing) this
|
|
230
|
+
# enqueue acquired, if any.
|
|
230
231
|
# Used when the job is dropped before a message is sent (concurrency
|
|
231
232
|
# :discard, or send_message raising) — otherwise the lock is orphaned
|
|
232
233
|
# because no executor will ever release it.
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "time"
|
|
4
|
-
|
|
5
3
|
module Pgbus
|
|
6
4
|
module ActiveJob
|
|
7
5
|
class Executor
|
|
8
6
|
attr_reader :client, :config
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
# completer: the worker's Process::Completer when config.completion_batching
|
|
9
|
+
# is on (issue #606): complete_from goes through it instead of one
|
|
10
|
+
# archive_message per job. config.completed_jobs (issue #607) is read
|
|
11
|
+
# once here: :delete finishes a successful job with delete_message.
|
|
12
|
+
def initialize(client: Pgbus.client, config: Pgbus.configuration, stat_buffer: nil, completer: nil)
|
|
11
13
|
@client = client
|
|
12
14
|
@config = config
|
|
13
15
|
@stat_buffer = stat_buffer
|
|
16
|
+
@completer = completer
|
|
17
|
+
@delete_completed = config.completed_jobs == :delete
|
|
18
|
+
@completed_word = @delete_completed ? "deleted" : "archived"
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
# Exceptions we never want to swallow — let the process die/signal propagate.
|
|
@@ -30,12 +35,16 @@ module Pgbus
|
|
|
30
35
|
# it: the DLQ copy carries it in its headers (issue #495).
|
|
31
36
|
last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i,
|
|
32
37
|
source_queue: physical_queue(queue_name, source_queue))
|
|
33
|
-
|
|
38
|
+
# Before the release below: the lookup asks whether the key's row is
|
|
39
|
+
# still this message's own (a crashed attempt's), see dead_letter_reason.
|
|
40
|
+
reason = dead_letter_reason(message, queue_name, payload, last_error)
|
|
41
|
+
handle_dead_letter(message, queue_name, payload, source_queue: source_queue, last_error: last_error,
|
|
42
|
+
reason: reason)
|
|
34
43
|
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i,
|
|
35
44
|
source_queue: physical_queue(queue_name, source_queue))
|
|
36
45
|
signal_concurrency(payload)
|
|
37
46
|
signal_batch_discarded(payload)
|
|
38
|
-
release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message)
|
|
47
|
+
release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message) if holds_uniqueness_row?(payload)
|
|
39
48
|
record_stat(payload, queue_name, "dead_lettered", execution_start, message: message)
|
|
40
49
|
instrument(
|
|
41
50
|
"pgbus.job_dead_lettered",
|
|
@@ -45,7 +54,8 @@ module Pgbus
|
|
|
45
54
|
provider_job_id: payload["provider_job_id"],
|
|
46
55
|
read_ct: read_count,
|
|
47
56
|
msg_id: message.msg_id.to_i,
|
|
48
|
-
error: last_error&.dig(:error_class)
|
|
57
|
+
error: last_error&.dig(:error_class),
|
|
58
|
+
reason: reason
|
|
49
59
|
)
|
|
50
60
|
Pgbus.logger.debug { "[Pgbus::Executor] dead_lettered #{log_tag(message, queue_name)} job_class=#{job_class}" }
|
|
51
61
|
return :dead_lettered
|
|
@@ -61,17 +71,23 @@ module Pgbus
|
|
|
61
71
|
# released on completion or DLQ.
|
|
62
72
|
nil
|
|
63
73
|
when :while_executing
|
|
64
|
-
# Acquire the lock now, bound to this message. If another
|
|
65
|
-
#
|
|
66
|
-
#
|
|
74
|
+
# Acquire the lock now, bound to this message. If another message
|
|
75
|
+
# holds it, the job's on_conflict decides (issue #629): :discard
|
|
76
|
+
# drops this copy, :reject and :log leave it for a backed-off
|
|
77
|
+
# redelivery. A row left by a crashed attempt of THIS message is
|
|
67
78
|
# re-acquired, not treated as a duplicate.
|
|
68
79
|
acquired = Uniqueness.acquire_execution_lock(
|
|
69
80
|
uniqueness_key, payload, msg_id: message.msg_id.to_i, queue_name: queue_name
|
|
70
81
|
)
|
|
71
|
-
unless acquired
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
return handle_uniqueness_conflict(message, queue_name, payload, uniqueness_key, source_queue) unless acquired
|
|
83
|
+
when :until_executing
|
|
84
|
+
# A debounce (issue #628): the row guarded the queued copy, and
|
|
85
|
+
# goes before perform so an enqueue that arrives while this runs
|
|
86
|
+
# is accepted. release_held_by! takes only the row this message
|
|
87
|
+
# holds, never a newer copy's (a dashboard re-send holds its own,
|
|
88
|
+
# #631). A redelivery (row already gone) matches nothing and runs; a failed release is logged and the
|
|
89
|
+
# job runs anyway — the reaper or the DLQ release takes the row.
|
|
90
|
+
release_uniqueness_lock(uniqueness_key, queue_name, message) if holds_uniqueness_row?(payload)
|
|
75
91
|
end
|
|
76
92
|
end
|
|
77
93
|
|
|
@@ -117,16 +133,19 @@ module Pgbus
|
|
|
117
133
|
# heartbeat lapsed and it was redelivered): that worker owns the
|
|
118
134
|
# completion signals, and signalling again here would release the
|
|
119
135
|
# concurrency slot a second time (rails/solid_queue#761).
|
|
120
|
-
if
|
|
136
|
+
if complete_from(queue_name, msg_id, source_queue: source_queue) == :already_archived
|
|
121
137
|
Pgbus.logger.warn do
|
|
122
|
-
"[Pgbus::Executor] already
|
|
138
|
+
"[Pgbus::Executor] already #{@completed_word} elsewhere, skipping signals " \
|
|
139
|
+
"#{log_tag(message, queue_name)} job_class=#{job_class}"
|
|
123
140
|
end
|
|
124
141
|
release_duplicate_execution_lock(uniqueness_key, uniqueness_strategy, queue_name, msg_id)
|
|
125
142
|
return :duplicate
|
|
126
143
|
end
|
|
127
|
-
Pgbus.logger.debug { "[Pgbus::Executor]
|
|
144
|
+
Pgbus.logger.debug { "[Pgbus::Executor] #{@completed_word} #{log_tag(message, queue_name)} job_class=#{job_class}" }
|
|
128
145
|
job_succeeded = true
|
|
129
|
-
|
|
146
|
+
# :until_executing released its row before perform; a newer copy's
|
|
147
|
+
# row is not this message's to take.
|
|
148
|
+
release_uniqueness_lock(uniqueness_key, queue_name, message) unless uniqueness_strategy == :until_executing
|
|
130
149
|
# Only FailedEventRecorder.record! writes this table, and only after a
|
|
131
150
|
# failed attempt, so a first delivery has no row: skip the per-job
|
|
132
151
|
# DELETE round trip (issue #484). A stale row from a dropped-and-
|
|
@@ -147,7 +166,7 @@ module Pgbus
|
|
|
147
166
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
148
167
|
# Widened from StandardError to catch Async::Stop / Async::Cancel
|
|
149
168
|
# (both inherit from Exception, not StandardError) under execution_mode: :async.
|
|
150
|
-
# Before this, a fiber interruption between perform_now and
|
|
169
|
+
# Before this, a fiber interruption between perform_now and complete_from
|
|
151
170
|
# silently lost control flow — no failed event row, no job_failed
|
|
152
171
|
# notification, uniqueness lock held until VT expired. See issue #126.
|
|
153
172
|
handle_failure(message, queue_name, e, payload: payload, source_queue: source_queue)
|
|
@@ -196,7 +215,8 @@ module Pgbus
|
|
|
196
215
|
# The table the message was read from: the _pN sub-queue under priority
|
|
197
216
|
# routing, else the logical queue's own. Each table numbers its messages
|
|
198
217
|
# from its own sequence, so a failed-event row is keyed on it (issue
|
|
199
|
-
# #583). Built only on the failure and redelivery paths
|
|
218
|
+
# #583). Built only on the failure and redelivery paths, and for the
|
|
219
|
+
# completer (issue #606), which groups by physical queue.
|
|
200
220
|
def physical_queue(queue_name, source_queue)
|
|
201
221
|
source_queue || config.queue_name(queue_name)
|
|
202
222
|
end
|
|
@@ -221,6 +241,15 @@ module Pgbus
|
|
|
221
241
|
Pgbus.logger.warn { "[Pgbus] Uniqueness release failed: #{e.message}" }
|
|
222
242
|
end
|
|
223
243
|
|
|
244
|
+
# False for an :until_executing retry_on re-enqueue (executions > 0): it
|
|
245
|
+
# went out through the free key without acquiring a row (the attempt's
|
|
246
|
+
# was released at its start), so the unbound clause of release_held_by!
|
|
247
|
+
# could only match a newer copy's row — one parked behind its
|
|
248
|
+
# concurrency limit before this retry was sent (issue #628).
|
|
249
|
+
def holds_uniqueness_row?(payload)
|
|
250
|
+
Uniqueness.extract_strategy(payload) != :until_executing || payload["executions"].to_i.zero?
|
|
251
|
+
end
|
|
252
|
+
|
|
224
253
|
# Keep the message invisible while perform runs (see VisibilityHeartbeat).
|
|
225
254
|
# Wraps only the perform: the heartbeat must be gone before archive or
|
|
226
255
|
# the retry backoff touches the same message's VT.
|
|
@@ -282,33 +311,43 @@ module Pgbus
|
|
|
282
311
|
Pgbus.logger.debug { "[Pgbus] Stat recording failed: #{e.message}" }
|
|
283
312
|
end
|
|
284
313
|
|
|
314
|
+
# pgmq-ruby sets no result type map, so enqueued_at is the TIMESTAMPTZ
|
|
315
|
+
# text PostgreSQL sends ("2026-10-29 10:11:12.123456+00", any offset).
|
|
316
|
+
# Time.new reads it in C at ~0.3 µs; Time.parse took ~7 µs a job and
|
|
317
|
+
# pg's TimestampWithTimeZone decoder ~3 µs (issue #604). Unreadable text
|
|
318
|
+
# raises ArgumentError: the stat is recorded without latency.
|
|
285
319
|
def compute_enqueue_latency(message)
|
|
286
320
|
return unless message
|
|
287
321
|
|
|
288
322
|
enqueued_at = message.enqueued_at
|
|
289
323
|
return unless enqueued_at
|
|
290
324
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
parse_enqueue_latency_from_string(enqueued_at.to_s)
|
|
300
|
-
end
|
|
325
|
+
epoch = case enqueued_at
|
|
326
|
+
when Numeric then enqueued_at
|
|
327
|
+
when Time then enqueued_at.to_f
|
|
328
|
+
else enqueued_at_epoch(enqueued_at.to_s)
|
|
329
|
+
end
|
|
330
|
+
return unless epoch
|
|
331
|
+
|
|
332
|
+
[((::Process.clock_gettime(::Process::CLOCK_REALTIME) - epoch) * 1000).round, 0].max
|
|
301
333
|
rescue ArgumentError, TypeError
|
|
302
334
|
nil
|
|
303
335
|
end
|
|
304
336
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
337
|
+
# A trailing "+00", "-03", "+05:30", "+0530" or "Z".
|
|
338
|
+
UTC_OFFSET_SUFFIX = /(?:[+-]\d\d(?::?\d\d)?|Z)\z/
|
|
339
|
+
private_constant :UTC_OFFSET_SUFFIX
|
|
340
|
+
|
|
341
|
+
# Time.new reads a string without an offset in the host's zone, but
|
|
342
|
+
# TIMESTAMPTZ is UTC inside, so such a string gets a "Z". Checked here
|
|
343
|
+
# rather than with `in: "UTC"`: that keyword costs a Hash per job.
|
|
344
|
+
# Time.new also takes a bare year ("2026") as a time; a string without a
|
|
345
|
+
# time of day is not a timestamp, so it has no latency.
|
|
346
|
+
def enqueued_at_epoch(str)
|
|
347
|
+
return unless str.include?(":")
|
|
348
|
+
|
|
349
|
+
str = "#{str}Z" unless str.match?(UTC_OFFSET_SUFFIX)
|
|
350
|
+
Time.new(str).to_f
|
|
312
351
|
end
|
|
313
352
|
|
|
314
353
|
def handle_failure(message, queue_name, error, payload: nil, source_queue: nil)
|
|
@@ -351,13 +390,88 @@ module Pgbus
|
|
|
351
390
|
RetryBackoff.compute_delay(attempt: attempt)
|
|
352
391
|
end
|
|
353
392
|
|
|
393
|
+
set_message_visibility(message, queue_name, delay, source_queue)
|
|
394
|
+
rescue StandardError => e
|
|
395
|
+
Pgbus.logger.debug { "[Pgbus] Retry backoff VT update failed: #{e.message}" }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def set_message_visibility(message, queue_name, delay, source_queue)
|
|
354
399
|
if source_queue
|
|
355
400
|
client.set_visibility_timeout(source_queue, message.msg_id.to_i, vt: delay, prefixed: false)
|
|
356
401
|
else
|
|
357
402
|
client.set_visibility_timeout(queue_name, message.msg_id.to_i, vt: delay)
|
|
358
403
|
end
|
|
359
|
-
|
|
360
|
-
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# A :while_executing message whose key another message holds (issue
|
|
407
|
+
# #629). The duplicate holds no uniqueness row, so nothing is released.
|
|
408
|
+
def handle_uniqueness_conflict(message, queue_name, payload, key, source_queue)
|
|
409
|
+
policy = Uniqueness.extract_on_conflict(payload)
|
|
410
|
+
if policy == :discard
|
|
411
|
+
discard_duplicate(message, queue_name, payload, key, source_queue)
|
|
412
|
+
else
|
|
413
|
+
skip_duplicate(message, queue_name, payload, key, source_queue, policy)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# :discard — the duplicate will never run: complete it, then the
|
|
418
|
+
# bookkeeping the dead-letter path does for such a message. Lost to
|
|
419
|
+
# another worker that completed it first: that worker owns the signals.
|
|
420
|
+
def discard_duplicate(message, queue_name, payload, key, source_queue)
|
|
421
|
+
msg_id = message.msg_id.to_i
|
|
422
|
+
if complete_from(queue_name, msg_id, source_queue: source_queue) == :already_archived
|
|
423
|
+
Pgbus.logger.warn do
|
|
424
|
+
"[Pgbus::Executor] already #{@completed_word} elsewhere, skipping signals " \
|
|
425
|
+
"#{log_tag(message, queue_name)} job_class=#{payload["job_class"]}"
|
|
426
|
+
end
|
|
427
|
+
return :duplicate
|
|
428
|
+
end
|
|
429
|
+
if message.read_ct.to_i > 1
|
|
430
|
+
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: msg_id,
|
|
431
|
+
source_queue: physical_queue(queue_name, source_queue))
|
|
432
|
+
end
|
|
433
|
+
signal_concurrency(payload)
|
|
434
|
+
signal_batch_discarded(payload)
|
|
435
|
+
instrument("pgbus.job_discarded", queue: queue_name, job_class: payload["job_class"], job_id: payload["job_id"],
|
|
436
|
+
provider_job_id: payload["provider_job_id"], msg_id: msg_id, reason: "uniqueness")
|
|
437
|
+
Pgbus.logger.info do
|
|
438
|
+
"[Pgbus] Discarded duplicate #{payload["job_class"]}: uniqueness key #{key} is held by another message " \
|
|
439
|
+
"(#{log_tag(message, queue_name)})"
|
|
440
|
+
end
|
|
441
|
+
:discarded
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# :reject / :log — the copy still runs once the holder is done, but each
|
|
445
|
+
# skip backs its redelivery off so the skips stretch over the holder's
|
|
446
|
+
# run. read_ct cannot be decremented, so a skip still counts toward
|
|
447
|
+
# max_retries; the dead-letter reason says when that is why it died.
|
|
448
|
+
def skip_duplicate(message, queue_name, payload, key, source_queue, policy)
|
|
449
|
+
delay = skip_delay(message, payload)
|
|
450
|
+
begin
|
|
451
|
+
set_message_visibility(message, queue_name, delay, source_queue)
|
|
452
|
+
rescue StandardError => e
|
|
453
|
+
Pgbus.logger.warn { "[Pgbus] Skip backoff VT update failed: #{e.message}" }
|
|
454
|
+
end
|
|
455
|
+
text = "[Pgbus] Skipping duplicate execution for #{payload["job_class"]}: uniqueness key #{key} is held by " \
|
|
456
|
+
"another message (#{log_tag(message, queue_name)}); next delivery in #{delay}s"
|
|
457
|
+
policy == :log ? Pgbus.logger.warn { text } : Pgbus.logger.info { text }
|
|
458
|
+
instrument("pgbus.job_skipped", queue: queue_name, job_class: payload["job_class"], job_id: payload["job_id"],
|
|
459
|
+
provider_job_id: payload["provider_job_id"], msg_id: message.msg_id.to_i,
|
|
460
|
+
read_ct: message.read_ct.to_i, on_conflict: policy, vt: delay)
|
|
461
|
+
:skipped
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# visibility_timeout * 2^(read_ct - 1), jittered and capped at the job's
|
|
465
|
+
# (or the global) retry_backoff_max, and never below visibility_timeout:
|
|
466
|
+
# a skip never comes back sooner than the read's own timeout would.
|
|
467
|
+
def skip_delay(message, payload)
|
|
468
|
+
vt = config.visibility_timeout
|
|
469
|
+
job_class = resolve_job_class(payload)
|
|
470
|
+
overrides = (job_class.respond_to?(:pgbus_retry_backoff_config) && job_class.pgbus_retry_backoff_config) || {}
|
|
471
|
+
max = [overrides[:max] || config.retry_backoff_max, vt].max
|
|
472
|
+
delay = RetryBackoff.compute_delay(attempt: message.read_ct.to_i, base: vt, max: max,
|
|
473
|
+
jitter: overrides[:jitter] || config.retry_backoff_jitter)
|
|
474
|
+
[delay, vt].max
|
|
361
475
|
end
|
|
362
476
|
|
|
363
477
|
def resolve_job_class(payload)
|
|
@@ -412,7 +526,13 @@ module Pgbus
|
|
|
412
526
|
# that succeeded but failed to archive redelivers after VT expiry and
|
|
413
527
|
# runs twice. If the retry also fails, fall through to the normal
|
|
414
528
|
# failure path (recorded failure + VT-based redelivery).
|
|
415
|
-
# Returns :archived, :already_archived, or :ambiguous.
|
|
529
|
+
# Returns :archived, :already_archived, or :ambiguous. Under
|
|
530
|
+
# config.completed_jobs = :delete it deletes the message instead
|
|
531
|
+
# (delete_message, or the completer's delete_batch): pgmq.delete is as
|
|
532
|
+
# idempotent and returns false the same way, so the symbols keep their
|
|
533
|
+
# names and "archived" means "completed". job_completion: true lets
|
|
534
|
+
# config.completion_synchronous_commit = false commit it with
|
|
535
|
+
# synchronous_commit off (issue #619).
|
|
416
536
|
#
|
|
417
537
|
# A `false` reported after our OWN retry is ambiguous, not a duplicate:
|
|
418
538
|
# the first archive may well have committed with only its reply lost to
|
|
@@ -421,13 +541,21 @@ module Pgbus
|
|
|
421
541
|
# batch and uniqueness state on every such blip, whereas a genuine
|
|
422
542
|
# duplicate would additionally require another worker to have taken and
|
|
423
543
|
# finished the same message inside the retry window.
|
|
424
|
-
def
|
|
544
|
+
def complete_from(queue_name, msg_id, source_queue: nil)
|
|
425
545
|
attempts = 0
|
|
426
546
|
begin
|
|
427
|
-
archived = if
|
|
428
|
-
|
|
547
|
+
archived = if @completer
|
|
548
|
+
@completer.complete(physical_queue(queue_name, source_queue), msg_id) == :archived
|
|
549
|
+
# Spelled out, no `**` forward: on Ruby 3.3 that
|
|
550
|
+
# allocates per job even without keywords (#484 budget).
|
|
551
|
+
elsif @delete_completed && source_queue
|
|
552
|
+
client.delete_message(source_queue, msg_id, prefixed: false, job_completion: true)
|
|
553
|
+
elsif @delete_completed
|
|
554
|
+
client.delete_message(queue_name, msg_id, job_completion: true)
|
|
555
|
+
elsif source_queue
|
|
556
|
+
client.archive_message(source_queue, msg_id, prefixed: false, job_completion: true)
|
|
429
557
|
else
|
|
430
|
-
client.archive_message(queue_name, msg_id)
|
|
558
|
+
client.archive_message(queue_name, msg_id, job_completion: true)
|
|
431
559
|
end
|
|
432
560
|
return :archived unless archived == false
|
|
433
561
|
|
|
@@ -437,7 +565,7 @@ module Pgbus
|
|
|
437
565
|
raise unless attempts == 1 && connection_error?(e)
|
|
438
566
|
|
|
439
567
|
Pgbus.logger.warn do
|
|
440
|
-
"[Pgbus::Executor] Archive failed on connection error, retrying once: #{e.message}"
|
|
568
|
+
"[Pgbus::Executor] #{@delete_completed ? "Delete" : "Archive"} failed on connection error, retrying once: #{e.message}"
|
|
441
569
|
end
|
|
442
570
|
retry
|
|
443
571
|
end
|
|
@@ -463,14 +591,37 @@ module Pgbus
|
|
|
463
591
|
defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
|
|
464
592
|
end
|
|
465
593
|
|
|
466
|
-
|
|
594
|
+
# uniqueness_conflict for a :while_executing message no attempt of
|
|
595
|
+
# which failed: it was skipped on every read until read_ct passed
|
|
596
|
+
# max_retries (issue #629). Not whether the key is held at this read:
|
|
597
|
+
# the backed-off skips span minutes and the holder may have finished
|
|
598
|
+
# inside the last one. The one other way to die without a failed-event
|
|
599
|
+
# row is a crash loop, and a crashed attempt leaves the key's row bound
|
|
600
|
+
# to this message: that keeps max_retries_exceeded. A failed lookup
|
|
601
|
+
# keeps the stronger signal, the missing failed-event row.
|
|
602
|
+
def dead_letter_reason(message, queue_name, payload, last_error)
|
|
603
|
+
return DeadLetterHeader::REASON_MAX_RETRIES if last_error
|
|
604
|
+
|
|
605
|
+
key = Uniqueness.extract_key(payload)
|
|
606
|
+
return DeadLetterHeader::REASON_MAX_RETRIES unless key && Uniqueness.extract_strategy(payload) == :while_executing
|
|
607
|
+
|
|
608
|
+
own = UniquenessKey.bound_to?(key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
|
|
609
|
+
msg_id: message.msg_id.to_i)
|
|
610
|
+
own ? DeadLetterHeader::REASON_MAX_RETRIES : DeadLetterHeader::REASON_UNIQUENESS_CONFLICT
|
|
611
|
+
rescue StandardError => e
|
|
612
|
+
Pgbus.logger.warn { "[Pgbus] Uniqueness row lookup failed, dead-lettering as uniqueness_conflict: #{e.message}" }
|
|
613
|
+
DeadLetterHeader::REASON_UNIQUENESS_CONFLICT
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def handle_dead_letter(message, queue_name, payload, source_queue: nil, last_error: nil,
|
|
617
|
+
reason: DeadLetterHeader::REASON_MAX_RETRIES)
|
|
467
618
|
Pgbus.logger.warn do
|
|
468
619
|
job_class = payload["job_class"] || "unknown"
|
|
469
620
|
error = last_error ? " (last error: #{last_error[:error_class]})" : ""
|
|
470
621
|
"[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts#{error}"
|
|
471
622
|
end
|
|
472
623
|
headers = DeadLetterHeader.build(
|
|
473
|
-
existing: message.headers, reason:
|
|
624
|
+
existing: message.headers, reason: reason, source: "worker",
|
|
474
625
|
source_queue: source_queue || config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
|
|
475
626
|
source_enqueued_at: message.enqueued_at, attempts: message.read_ct.to_i,
|
|
476
627
|
max_retries: config.max_retries, error: last_error
|