pgbus 0.16.3 → 0.16.5
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 +29 -0
- data/app/controllers/pgbus/locks_controller.rb +27 -0
- data/app/frontend/pgbus/style.css +1 -1
- data/app/models/pgbus/blocked_execution.rb +33 -7
- data/app/models/pgbus/semaphore.rb +8 -3
- data/app/models/pgbus/uniqueness_key.rb +36 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +14 -1
- data/app/views/pgbus/locks/_concurrency.html.erb +94 -0
- data/app/views/pgbus/locks/_uniqueness.html.erb +80 -0
- data/app/views/pgbus/locks/index.html.erb +5 -76
- data/config/locales/da.yml +42 -1
- data/config/locales/de.yml +42 -1
- data/config/locales/en.yml +42 -1
- data/config/locales/es.yml +42 -1
- data/config/locales/fi.yml +42 -1
- data/config/locales/fr.yml +42 -1
- data/config/locales/it.yml +42 -1
- data/config/locales/ja.yml +42 -1
- data/config/locales/nb.yml +42 -1
- data/config/locales/nl.yml +42 -1
- data/config/locales/pt.yml +42 -1
- data/config/locales/sv.yml +42 -1
- data/config/routes.rb +5 -0
- data/lib/pgbus/active_job/adapter.rb +83 -9
- data/lib/pgbus/active_job/executor.rb +55 -15
- data/lib/pgbus/concurrency/blocked_execution.rb +59 -18
- data/lib/pgbus/concurrency/semaphore.rb +34 -0
- data/lib/pgbus/concurrency.rb +36 -2
- data/lib/pgbus/instrumentation.rb +3 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +38 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +10 -0
- data/lib/pgbus/mcp/base_tool.rb +6 -1
- data/lib/pgbus/mcp/server.rb +2 -1
- data/lib/pgbus/mcp/tools/concurrency_tool.rb +32 -0
- data/lib/pgbus/mcp.rb +1 -0
- data/lib/pgbus/process/dispatcher.rb +6 -13
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +24 -3
- data/lib/pgbus/web/data_source.rb +210 -1
- data/lib/pgbus/web/metrics_serializer.rb +51 -3
- metadata +4 -1
data/config/locales/nl.yml
CHANGED
|
@@ -86,6 +86,8 @@ nl:
|
|
|
86
86
|
enqueued: In de wachtrij geplaatst
|
|
87
87
|
failed_dlq: Mislukt / DLQ
|
|
88
88
|
failed_dlq_labels: Mislukt / Dead Lettered
|
|
89
|
+
parked_jobs: Geparkeerde jobs
|
|
90
|
+
parked_jobs_oldest: oudste %{age}
|
|
89
91
|
processes: Processen
|
|
90
92
|
queues: Wachtrijen
|
|
91
93
|
recurring: Terugkerend
|
|
@@ -404,6 +406,43 @@ nl:
|
|
|
404
406
|
toggle_dark_mode: Donkere modus schakelen
|
|
405
407
|
toggle_menu: Menu wisselen
|
|
406
408
|
locks:
|
|
409
|
+
concurrency:
|
|
410
|
+
cards:
|
|
411
|
+
keys_at_limit: Sleutels op de limiet
|
|
412
|
+
keys_at_limit_hint: geen plek vrij
|
|
413
|
+
oldest_wait: Langste wachttijd
|
|
414
|
+
oldest_wait_hint: job die het langst wacht
|
|
415
|
+
parked_jobs: Geparkeerde jobs
|
|
416
|
+
parked_jobs_hint: wachten op een plek
|
|
417
|
+
slots_held: Bezette plekken
|
|
418
|
+
slots_held_hint: over alle sleutels
|
|
419
|
+
description: Sleutels gedeclareerd met limits_concurrency, de plekken die ze bezetten en de jobs die erachter geparkeerd staan
|
|
420
|
+
discard_parked: Geparkeerde verwijderen
|
|
421
|
+
discard_parked_confirm:
|
|
422
|
+
one: De 1 job die achter deze sleutel geparkeerd staat verwijderen? Hij draait nooit meer.
|
|
423
|
+
other: De %{count} jobs die achter deze sleutel geparkeerd staan verwijderen? Ze draaien nooit meer.
|
|
424
|
+
empty: Geen gelijktijdigheidssleutels in gebruik
|
|
425
|
+
headers:
|
|
426
|
+
in_use: In gebruik
|
|
427
|
+
key: Sleutel
|
|
428
|
+
lease: Lease
|
|
429
|
+
oldest_wait: Langste wachttijd
|
|
430
|
+
parked: Geparkeerd
|
|
431
|
+
key_released:
|
|
432
|
+
one: "%{key} vrijgegeven en 1 job gepromoveerd."
|
|
433
|
+
other: "%{key} vrijgegeven en %{count} jobs gepromoveerd."
|
|
434
|
+
lease:
|
|
435
|
+
expired: Verlopen
|
|
436
|
+
live: Actief
|
|
437
|
+
none: Geen houder
|
|
438
|
+
no_key: Geen gelijktijdigheidssleutel opgegeven.
|
|
439
|
+
parked_discarded:
|
|
440
|
+
one: 1 geparkeerde job verwijderd.
|
|
441
|
+
other: "%{count} geparkeerde jobs verwijderd."
|
|
442
|
+
release: Vrijgeven
|
|
443
|
+
release_confirm: "%{key} vrijgeven en de geparkeerde jobs promoveren?"
|
|
444
|
+
release_confirm_live: De lease op %{key} is nog vers, dus er draait waarschijnlijk nog een job. Vrijgeven laat er een naast starten. Doorgaan?
|
|
445
|
+
title: Gelijktijdigheid
|
|
407
446
|
index:
|
|
408
447
|
all_locks_discarded:
|
|
409
448
|
one: 1 slot verwijderd.
|
|
@@ -427,7 +466,9 @@ nl:
|
|
|
427
466
|
one: 1 slot verwijderd.
|
|
428
467
|
other: "%{count} sloten verwijderd."
|
|
429
468
|
none_selected: Geen sloten geselecteerd.
|
|
430
|
-
|
|
469
|
+
page_description: Uniciteitssleutels en gelijktijdigheidsplekken die jobs tegenhouden
|
|
470
|
+
title: Vergrendelingen
|
|
471
|
+
uniqueness_title: Uniciteitsleutels
|
|
431
472
|
outbox:
|
|
432
473
|
index:
|
|
433
474
|
description: Transactionele uitgaande berichten wachten op publicatie naar PGMQ
|
data/config/locales/pt.yml
CHANGED
|
@@ -86,6 +86,8 @@ pt:
|
|
|
86
86
|
enqueued: Enfileirado
|
|
87
87
|
failed_dlq: Falhou / DLQ
|
|
88
88
|
failed_dlq_labels: Falhados / Dead Lettered
|
|
89
|
+
parked_jobs: Trabalhos estacionados
|
|
90
|
+
parked_jobs_oldest: mais antigo %{age}
|
|
89
91
|
processes: Processos
|
|
90
92
|
queues: Filas
|
|
91
93
|
recurring: Recorrente
|
|
@@ -404,6 +406,43 @@ pt:
|
|
|
404
406
|
toggle_dark_mode: Alternar modo escuro
|
|
405
407
|
toggle_menu: Alternar menu
|
|
406
408
|
locks:
|
|
409
|
+
concurrency:
|
|
410
|
+
cards:
|
|
411
|
+
keys_at_limit: Chaves no limite
|
|
412
|
+
keys_at_limit_hint: sem vaga livre
|
|
413
|
+
oldest_wait: Espera mais longa
|
|
414
|
+
oldest_wait_hint: o trabalho que espera há mais tempo
|
|
415
|
+
parked_jobs: Trabalhos estacionados
|
|
416
|
+
parked_jobs_hint: à espera de uma vaga
|
|
417
|
+
slots_held: Vagas ocupadas
|
|
418
|
+
slots_held_hint: em todas as chaves
|
|
419
|
+
description: Chaves declaradas com limits_concurrency, as vagas que ocupam e os trabalhos estacionados atrás delas
|
|
420
|
+
discard_parked: Descartar estacionados
|
|
421
|
+
discard_parked_confirm:
|
|
422
|
+
one: Descartar o trabalho estacionado atrás desta chave? Nunca será executado.
|
|
423
|
+
other: Descartar os %{count} trabalhos estacionados atrás desta chave? Nunca serão executados.
|
|
424
|
+
empty: Nenhuma chave de concorrência em uso
|
|
425
|
+
headers:
|
|
426
|
+
in_use: Em uso
|
|
427
|
+
key: Chave
|
|
428
|
+
lease: Concessão
|
|
429
|
+
oldest_wait: Espera mais longa
|
|
430
|
+
parked: Estacionados
|
|
431
|
+
key_released:
|
|
432
|
+
one: "%{key} libertada e 1 trabalho promovido."
|
|
433
|
+
other: "%{key} libertada e %{count} trabalhos promovidos."
|
|
434
|
+
lease:
|
|
435
|
+
expired: Expirada
|
|
436
|
+
live: Ativa
|
|
437
|
+
none: Sem detentor
|
|
438
|
+
no_key: Nenhuma chave de concorrência indicada.
|
|
439
|
+
parked_discarded:
|
|
440
|
+
one: 1 trabalho estacionado descartado.
|
|
441
|
+
other: "%{count} trabalhos estacionados descartados."
|
|
442
|
+
release: Libertar
|
|
443
|
+
release_confirm: Libertar %{key} e promover os seus trabalhos estacionados?
|
|
444
|
+
release_confirm_live: A concessão de %{key} ainda está válida, por isso é provável que um trabalho continue a correr. Libertar permite que outro arranque ao lado. Continuar?
|
|
445
|
+
title: Concorrência
|
|
407
446
|
index:
|
|
408
447
|
all_locks_discarded:
|
|
409
448
|
one: 1 bloqueio descartado.
|
|
@@ -427,7 +466,9 @@ pt:
|
|
|
427
466
|
one: 1 bloqueio descartado.
|
|
428
467
|
other: "%{count} bloqueios descartados."
|
|
429
468
|
none_selected: Nenhum bloqueio selecionado.
|
|
430
|
-
|
|
469
|
+
page_description: Chaves de unicidade e vagas de concorrência que estão a reter trabalhos
|
|
470
|
+
title: Bloqueios
|
|
471
|
+
uniqueness_title: Chaves de unicidade
|
|
431
472
|
outbox:
|
|
432
473
|
index:
|
|
433
474
|
description: Entradas da caixa de saída transacional pendentes de publicação para PGMQ
|
data/config/locales/sv.yml
CHANGED
|
@@ -86,6 +86,8 @@ sv:
|
|
|
86
86
|
enqueued: Inlagda
|
|
87
87
|
failed_dlq: Misslyckade / DLQ
|
|
88
88
|
failed_dlq_labels: Misslyckade / Dead Lettered
|
|
89
|
+
parked_jobs: Parkerade jobb
|
|
90
|
+
parked_jobs_oldest: äldst %{age}
|
|
89
91
|
processes: Processer
|
|
90
92
|
queues: Köer
|
|
91
93
|
recurring: Återkommande
|
|
@@ -404,6 +406,43 @@ sv:
|
|
|
404
406
|
toggle_dark_mode: Växla mörkt läge
|
|
405
407
|
toggle_menu: Växla meny
|
|
406
408
|
locks:
|
|
409
|
+
concurrency:
|
|
410
|
+
cards:
|
|
411
|
+
keys_at_limit: Nycklar vid gränsen
|
|
412
|
+
keys_at_limit_hint: ingen ledig plats
|
|
413
|
+
oldest_wait: Längsta väntan
|
|
414
|
+
oldest_wait_hint: längst ett jobb har väntat
|
|
415
|
+
parked_jobs: Parkerade jobb
|
|
416
|
+
parked_jobs_hint: väntar på en plats
|
|
417
|
+
slots_held: Hållna platser
|
|
418
|
+
slots_held_hint: över alla nycklar
|
|
419
|
+
description: Nycklar deklarerade med limits_concurrency, platserna de håller och jobben som parkerats bakom dem
|
|
420
|
+
discard_parked: Kasta parkerade
|
|
421
|
+
discard_parked_confirm:
|
|
422
|
+
one: Kasta det 1 jobb som parkerats bakom denna nyckel? Det kommer aldrig att köras.
|
|
423
|
+
other: Kasta de %{count} jobb som parkerats bakom denna nyckel? De kommer aldrig att köras.
|
|
424
|
+
empty: Inga samtidighetsnycklar används
|
|
425
|
+
headers:
|
|
426
|
+
in_use: Används
|
|
427
|
+
key: Nyckel
|
|
428
|
+
lease: Lås
|
|
429
|
+
oldest_wait: Längsta väntan
|
|
430
|
+
parked: Parkerade
|
|
431
|
+
key_released:
|
|
432
|
+
one: Frigjorde %{key} och befordrade 1 jobb.
|
|
433
|
+
other: Frigjorde %{key} och befordrade %{count} jobb.
|
|
434
|
+
lease:
|
|
435
|
+
expired: Utgånget
|
|
436
|
+
live: Aktivt
|
|
437
|
+
none: Ingen innehavare
|
|
438
|
+
no_key: Ingen samtidighetsnyckel angiven.
|
|
439
|
+
parked_discarded:
|
|
440
|
+
one: Kastade 1 parkerat jobb.
|
|
441
|
+
other: Kastade %{count} parkerade jobb.
|
|
442
|
+
release: Frigör
|
|
443
|
+
release_confirm: Frigör %{key} och befordra dess parkerade jobb?
|
|
444
|
+
release_confirm_live: Låset på %{key} är fortfarande färskt, så ett jobb körs troligen än. Att frigöra låter ett annat starta bredvid det. Fortsätta?
|
|
445
|
+
title: Samtidighet
|
|
407
446
|
index:
|
|
408
447
|
all_locks_discarded:
|
|
409
448
|
one: Kasserade 1 lås.
|
|
@@ -427,7 +466,9 @@ sv:
|
|
|
427
466
|
one: Kasserade 1 lås.
|
|
428
467
|
other: Kasserade %{count} lås.
|
|
429
468
|
none_selected: Inga lås valda.
|
|
430
|
-
|
|
469
|
+
page_description: Unikhetsnycklar och samtidighetsplatser som håller tillbaka jobb
|
|
470
|
+
title: Lås
|
|
471
|
+
uniqueness_title: Unikhetsnycklar
|
|
431
472
|
outbox:
|
|
432
473
|
index:
|
|
433
474
|
description: Transaktionella utboxposter som väntar på publicering till PGMQ
|
data/config/routes.rb
CHANGED
|
@@ -81,6 +81,11 @@ Pgbus::Engine.routes.draw do
|
|
|
81
81
|
collection do
|
|
82
82
|
post :discard_selected
|
|
83
83
|
post :discard_all
|
|
84
|
+
# Collection routes, not member: a concurrency key is a free-form
|
|
85
|
+
# string that may contain ".", "/" or ":" and does not belong in a
|
|
86
|
+
# path segment. Both take the key as a form param.
|
|
87
|
+
post :release_key
|
|
88
|
+
post :discard_parked
|
|
84
89
|
end
|
|
85
90
|
end
|
|
86
91
|
resource :insights, only: [:show], controller: "insights"
|
|
@@ -70,17 +70,40 @@ module Pgbus
|
|
|
70
70
|
priority = active_job.try(:priority)
|
|
71
71
|
msg_id = nil
|
|
72
72
|
blocked = false
|
|
73
|
+
# Only a failure raised from inside the produce can be ambiguous.
|
|
74
|
+
# Everything before it — taking the slot, parking the job — proves no
|
|
75
|
+
# message exists, however database-shaped the error looks. Never
|
|
76
|
+
# reset: once the send returns, msg_id is set and the rescue routes
|
|
77
|
+
# on that instead.
|
|
78
|
+
sending = false
|
|
73
79
|
|
|
74
80
|
if key && concurrency
|
|
75
|
-
|
|
81
|
+
# The check and the park commit together, under the semaphore row
|
|
82
|
+
# lock the upsert holds even when it reports :blocked — so a holder
|
|
83
|
+
# signalling right now waits and then sees the parked row instead
|
|
84
|
+
# of stranding it (rails/solid_queue#712).
|
|
85
|
+
acquired = false
|
|
86
|
+
Pgbus::Semaphore.transaction(requires_new: true) do
|
|
87
|
+
acquired = Concurrency::Semaphore.acquire(key, concurrency[:limit], slot_lease(concurrency, delay)) ==
|
|
88
|
+
:acquired
|
|
89
|
+
blocked = handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority) unless
|
|
90
|
+
acquired
|
|
91
|
+
end
|
|
76
92
|
|
|
77
|
-
if
|
|
78
|
-
|
|
93
|
+
if acquired
|
|
94
|
+
# Deliberately AFTER the commit. PGMQ has its own connection, so
|
|
95
|
+
# the send can never join this transaction; sending first would
|
|
96
|
+
# mean a failed commit leaves the message live with the slot
|
|
97
|
+
# rolled back, and the next enqueue runs beside it. This way the
|
|
98
|
+
# only crash window leaves a slot held with no message — the
|
|
99
|
+
# sweep reclaims it, and until then the key is under-admitted,
|
|
100
|
+
# never over-admitted.
|
|
101
|
+
sending = true
|
|
102
|
+
msg_id = send_holding_slot(key, queue, payload_hash, delay: delay, priority: priority)
|
|
79
103
|
active_job.provider_job_id = msg_id
|
|
80
|
-
else
|
|
81
|
-
blocked = handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority)
|
|
82
104
|
end
|
|
83
105
|
else
|
|
106
|
+
sending = true
|
|
84
107
|
msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
|
|
85
108
|
active_job.provider_job_id = msg_id
|
|
86
109
|
end
|
|
@@ -97,18 +120,69 @@ module Pgbus
|
|
|
97
120
|
Thread.current[:pgbus_acquired_uniqueness_key] = nil
|
|
98
121
|
active_job
|
|
99
122
|
rescue StandardError => e
|
|
100
|
-
|
|
123
|
+
# An ambiguous send is treated exactly like a live message: the
|
|
124
|
+
# produce may have committed with only its reply lost, and undoing
|
|
125
|
+
# the bookkeeping for a message that is in fact live is the worse
|
|
126
|
+
# error — it would leave that job running with no uniqueness lock and
|
|
127
|
+
# uncounted by its batch. A batch left waiting for a job that never
|
|
128
|
+
# existed is recovered by the stalled-batch sweep; a batch that
|
|
129
|
+
# finishes early and fires its callback is not recoverable. The
|
|
130
|
+
# `sending` guard keeps that reprieve to the produce itself: a
|
|
131
|
+
# database error from the slot upsert or the park is not ambiguous.
|
|
132
|
+
if msg_id.nil? && !(sending && ambiguous_delivery?(e))
|
|
101
133
|
rollback_acquired_uniqueness_lock
|
|
102
134
|
uncount_batch_job(payload_hash)
|
|
103
135
|
else
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
136
|
+
# Drop the thread-local so a later discard on this thread cannot
|
|
137
|
+
# release that job's uniqueness lock, but do not DELETE the
|
|
138
|
+
# pgbus_uniqueness_keys row.
|
|
107
139
|
Thread.current[:pgbus_acquired_uniqueness_key] = nil
|
|
108
140
|
end
|
|
109
141
|
raise e
|
|
110
142
|
end
|
|
111
143
|
|
|
144
|
+
# True when a failure raised from inside the produce reached the
|
|
145
|
+
# database, so the message may have been written even though the reply
|
|
146
|
+
# did not come back. Deliberately an over-approximation: the client's
|
|
147
|
+
# pre-produce queue setup is inside the same call, so its connection
|
|
148
|
+
# errors are counted as ambiguous too. That errs toward keeping a lock
|
|
149
|
+
# and a batch count the reapers can reclaim, rather than dropping
|
|
150
|
+
# bookkeeping for a message that turns out to be live.
|
|
151
|
+
def ambiguous_delivery?(error)
|
|
152
|
+
(defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)) ||
|
|
153
|
+
(defined?(PG::Error) && error.is_a?(PG::Error))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A slot is leased for `duration` of silence, but the visibility
|
|
157
|
+
# heartbeat that renews it only starts once a worker picks the message
|
|
158
|
+
# up. A scheduled job waits in PGMQ until then, so its lease has to
|
|
159
|
+
# cover the delay as well or the sweep expires it mid-wait and promotes
|
|
160
|
+
# a second job for the same key.
|
|
161
|
+
def slot_lease(concurrency, delay)
|
|
162
|
+
Concurrency.effective_duration(concurrency[:duration]) + delay.to_i
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Hand the slot back only when the failure proves nothing was
|
|
166
|
+
# produced. On an ambiguous outcome the message may well be live, and
|
|
167
|
+
# releasing would admit a second job beside it — so the hold stands
|
|
168
|
+
# and the lease expiry plus the dispatcher's sweep reclaim it.
|
|
169
|
+
def send_holding_slot(key, queue, payload_hash, delay:, priority:)
|
|
170
|
+
Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
if ambiguous_delivery?(e)
|
|
173
|
+
Pgbus.logger.warn do
|
|
174
|
+
"[Pgbus] Send outcome unknown for #{key}; holding its concurrency slot until the lease expires: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
else
|
|
177
|
+
begin
|
|
178
|
+
Concurrency::Semaphore.release(key)
|
|
179
|
+
rescue StandardError => release_error
|
|
180
|
+
Pgbus.logger.warn { "[Pgbus] Could not release concurrency slot after failed send: #{release_error.message}" }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
|
|
112
186
|
def physical_queue(queue, priority)
|
|
113
187
|
Pgbus.client.target_queue(queue, priority)
|
|
114
188
|
end
|
|
@@ -96,13 +96,24 @@ module Pgbus
|
|
|
96
96
|
# `batch` (and `batch.enqueue` for open batches) work inside a job.
|
|
97
97
|
assign_batch_id(job, payload)
|
|
98
98
|
Pgbus.logger.debug { "[Pgbus::Executor] running #{tag} job_class=#{job_class}" }
|
|
99
|
-
with_visibility_heartbeat(job, queue_name, msg_id, source_queue) { execute_job(job) }
|
|
99
|
+
with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload) { execute_job(job) }
|
|
100
100
|
# retry_on re-enqueues from inside perform_now and returns normally:
|
|
101
101
|
# this attempt is done (archive it) but the job is not — the retry
|
|
102
102
|
# message carries the batch tag and signals on its own outcome.
|
|
103
103
|
retried = Batch.retry_reenqueued?(payload["job_id"])
|
|
104
104
|
Pgbus.logger.debug { "[Pgbus::Executor] perform_returned #{tag} job_class=#{job_class}" }
|
|
105
|
-
|
|
105
|
+
# Archiving is the exact-once claim on this execution.
|
|
106
|
+
# :already_archived means another worker archived the message (our
|
|
107
|
+
# heartbeat lapsed and it was redelivered): that worker owns the
|
|
108
|
+
# completion signals, and signalling again here would release the
|
|
109
|
+
# concurrency slot a second time (rails/solid_queue#761).
|
|
110
|
+
if archive_from(queue_name, msg_id, source_queue: source_queue) == :already_archived
|
|
111
|
+
Pgbus.logger.warn do
|
|
112
|
+
"[Pgbus::Executor] already archived elsewhere, skipping signals #{tag} job_class=#{job_class}"
|
|
113
|
+
end
|
|
114
|
+
release_duplicate_execution_lock(uniqueness_key, uniqueness_strategy, queue_name, msg_id)
|
|
115
|
+
return :duplicate
|
|
116
|
+
end
|
|
106
117
|
Pgbus.logger.debug { "[Pgbus::Executor] archived #{tag} job_class=#{job_class}" }
|
|
107
118
|
job_succeeded = true
|
|
108
119
|
release_uniqueness_lock(uniqueness_key)
|
|
@@ -177,14 +188,18 @@ module Pgbus
|
|
|
177
188
|
# Keep the message invisible while perform runs (see VisibilityHeartbeat).
|
|
178
189
|
# Wraps only the perform: the heartbeat must be gone before archive or
|
|
179
190
|
# the retry backoff touches the same message's VT.
|
|
180
|
-
def with_visibility_heartbeat(job, queue_name, msg_id, source_queue, &)
|
|
191
|
+
def with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload, &)
|
|
181
192
|
klass = job.class
|
|
182
193
|
per_job = klass.respond_to?(:pgbus_visibility_heartbeat_enabled) ? klass.pgbus_visibility_heartbeat_enabled : nil
|
|
183
194
|
return yield if per_job == false
|
|
184
195
|
|
|
196
|
+
# The heartbeat also keeps the job's semaphore alive, so `duration`
|
|
197
|
+
# bounds silence, not run time.
|
|
198
|
+
concurrency_key = Concurrency.extract_key(payload)
|
|
185
199
|
VisibilityHeartbeat.track(
|
|
186
200
|
client: client, queue_name: source_queue || queue_name, prefixed: source_queue.nil?,
|
|
187
|
-
msg_id: msg_id, job_class: klass.name, config: config,
|
|
201
|
+
msg_id: msg_id, job_class: klass.name, config: config,
|
|
202
|
+
concurrency: concurrency_key && [concurrency_key, Concurrency.config_for(klass)[:duration]], &
|
|
188
203
|
)
|
|
189
204
|
end
|
|
190
205
|
|
|
@@ -322,12 +337,10 @@ module Pgbus
|
|
|
322
337
|
key = Concurrency.extract_key(payload)
|
|
323
338
|
return unless key
|
|
324
339
|
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
|
|
329
|
-
promoted = Concurrency::BlockedExecution.promote_next(key, client: client)
|
|
330
|
-
Concurrency::Semaphore.release(key) unless promoted
|
|
340
|
+
# Release the slot and hand it to the next parked job in one
|
|
341
|
+
# transaction; the semaphore row lock orders this against a
|
|
342
|
+
# concurrent enqueue that is parking a job.
|
|
343
|
+
Concurrency::Semaphore.signal(key, client: client)
|
|
331
344
|
rescue StandardError => e
|
|
332
345
|
Pgbus.logger.warn { "[Pgbus] Concurrency signal failed: #{e.message}" }
|
|
333
346
|
end
|
|
@@ -356,14 +369,26 @@ module Pgbus
|
|
|
356
369
|
# that succeeded but failed to archive redelivers after VT expiry and
|
|
357
370
|
# runs twice. If the retry also fails, fall through to the normal
|
|
358
371
|
# failure path (recorded failure + VT-based redelivery).
|
|
372
|
+
# Returns :archived, :already_archived, or :ambiguous.
|
|
373
|
+
#
|
|
374
|
+
# A `false` reported after our OWN retry is ambiguous, not a duplicate:
|
|
375
|
+
# the first archive may well have committed with only its reply lost to
|
|
376
|
+
# the connection error that triggered the retry. Claiming it is the
|
|
377
|
+
# safe reading — the alternative strands this job's concurrency slot,
|
|
378
|
+
# batch and uniqueness state on every such blip, whereas a genuine
|
|
379
|
+
# duplicate would additionally require another worker to have taken and
|
|
380
|
+
# finished the same message inside the retry window.
|
|
359
381
|
def archive_from(queue_name, msg_id, source_queue: nil)
|
|
360
382
|
attempts = 0
|
|
361
383
|
begin
|
|
362
|
-
if source_queue
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
384
|
+
archived = if source_queue
|
|
385
|
+
client.archive_message(source_queue, msg_id, prefixed: false)
|
|
386
|
+
else
|
|
387
|
+
client.archive_message(queue_name, msg_id)
|
|
388
|
+
end
|
|
389
|
+
return :archived unless archived == false
|
|
390
|
+
|
|
391
|
+
attempts.positive? ? :ambiguous : :already_archived
|
|
367
392
|
rescue StandardError => e
|
|
368
393
|
attempts += 1
|
|
369
394
|
raise unless attempts == 1 && connection_error?(e)
|
|
@@ -375,6 +400,21 @@ module Pgbus
|
|
|
375
400
|
end
|
|
376
401
|
end
|
|
377
402
|
|
|
403
|
+
# A :while_executing lock is bound to THIS message, so an execution
|
|
404
|
+
# that loses the archive race still has to hand its own lock back —
|
|
405
|
+
# conditionally, so it can never delete a successor's row. An
|
|
406
|
+
# :until_executed lock belongs to the job as a whole and is released by
|
|
407
|
+
# the worker that actually archived it.
|
|
408
|
+
def release_duplicate_execution_lock(uniqueness_key, uniqueness_strategy, queue_name, msg_id)
|
|
409
|
+
return unless uniqueness_key && uniqueness_strategy == :while_executing
|
|
410
|
+
|
|
411
|
+
# Same queue the execution lock was acquired under, so the match
|
|
412
|
+
# cannot land on another queue's message of the same id.
|
|
413
|
+
UniquenessKey.release_if_bound!(uniqueness_key, queue_name: queue_name, msg_id: msg_id)
|
|
414
|
+
rescue StandardError => e
|
|
415
|
+
Pgbus.logger.warn { "[Pgbus] Duplicate-execution lock release failed: #{e.message}" }
|
|
416
|
+
end
|
|
417
|
+
|
|
378
418
|
def connection_error?(error)
|
|
379
419
|
defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
|
|
380
420
|
end
|
|
@@ -7,11 +7,20 @@ module Pgbus
|
|
|
7
7
|
module BlockedExecution
|
|
8
8
|
class << self
|
|
9
9
|
# Insert a blocked execution for a job that hit the concurrency limit.
|
|
10
|
+
#
|
|
11
|
+
# `expires_at` is a re-check hint for the sweep's ordering only — a
|
|
12
|
+
# parked job is never deleted for being old. The only way out of the
|
|
13
|
+
# table is promotion.
|
|
14
|
+
#
|
|
15
|
+
# The payload goes in as a Hash: the jsonb attribute serializes it
|
|
16
|
+
# once. Handing it a pre-serialized String stored a JSON *string*
|
|
17
|
+
# (double-encoded), which every document reader of the column —
|
|
18
|
+
# `payload->>'job_id'`, the job-class lookup, `scheduled_at` — misread.
|
|
10
19
|
def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
|
|
11
20
|
Pgbus::BlockedExecution.create!(
|
|
12
21
|
concurrency_key: concurrency_key,
|
|
13
22
|
queue_name: queue_name,
|
|
14
|
-
payload:
|
|
23
|
+
payload: payload,
|
|
15
24
|
priority: priority,
|
|
16
25
|
expires_at: Time.current + duration
|
|
17
26
|
)
|
|
@@ -23,42 +32,53 @@ module Pgbus
|
|
|
23
32
|
Pgbus::BlockedExecution.release_next!(concurrency_key)
|
|
24
33
|
end
|
|
25
34
|
|
|
26
|
-
# Atomically promote the next blocked execution: delete the row
|
|
27
|
-
# the job in a single transaction.
|
|
28
|
-
#
|
|
35
|
+
# Atomically promote the next blocked execution: delete the row, take a
|
|
36
|
+
# semaphore slot for it and enqueue the job in a single transaction.
|
|
37
|
+
# Returns true if a job was promoted, false otherwise.
|
|
38
|
+
#
|
|
39
|
+
# The slot is taken through the same guarded upsert an enqueue uses,
|
|
40
|
+
# so a promotion can never push the key past its limit; when no slot
|
|
41
|
+
# is free the savepoint rolls back and the row stays parked. Runs as a
|
|
42
|
+
# savepoint so `Semaphore.signal` can wrap it with the release.
|
|
29
43
|
def promote_next(concurrency_key, client:, delay: 0)
|
|
30
44
|
released = nil
|
|
31
45
|
msg_id = nil
|
|
32
|
-
Pgbus::BlockedExecution.transaction do
|
|
46
|
+
Pgbus::BlockedExecution.transaction(requires_new: true) do
|
|
33
47
|
released = release_next(concurrency_key)
|
|
34
48
|
raise ActiveRecord::Rollback unless released
|
|
35
49
|
|
|
50
|
+
# Resolve the delay first: a parked scheduled job goes back into
|
|
51
|
+
# PGMQ invisible, and nothing renews its lease until a worker
|
|
52
|
+
# picks it up, so the wait has to be part of the lease.
|
|
36
53
|
actual_delay = resolve_delay(released[:payload], delay)
|
|
54
|
+
raise ActiveRecord::Rollback unless slot_taken?(concurrency_key, released[:payload], actual_delay)
|
|
55
|
+
|
|
37
56
|
# Carry the enqueuer's priority through: under priority routing it
|
|
38
57
|
# picks the _pN sub-queue, not just the release order (issue #423).
|
|
39
58
|
msg_id = client.send_message(released[:queue_name], released[:payload],
|
|
40
59
|
delay: actual_delay, priority: released[:priority])
|
|
41
60
|
end
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
begin
|
|
45
|
-
Batch.backfill_execution(released[:payload], msg_id,
|
|
46
|
-
client.target_queue(released[:queue_name], released[:priority]))
|
|
47
|
-
rescue StandardError => e
|
|
48
|
-
Pgbus.logger.warn { "[Pgbus] Batch execution backfill failed after promote: #{e.message}" }
|
|
49
|
-
end
|
|
50
|
-
end
|
|
62
|
+
return false unless released && msg_id
|
|
51
63
|
|
|
52
|
-
|
|
64
|
+
backfill(released, msg_id, client)
|
|
65
|
+
true
|
|
53
66
|
rescue StandardError => e
|
|
54
67
|
Pgbus.logger.warn { "[Pgbus] Promote blocked execution failed for #{concurrency_key}: #{e.message}" }
|
|
55
68
|
false
|
|
56
69
|
end
|
|
57
70
|
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
# Sweep: promote every parked job that can take a slot right now.
|
|
72
|
+
# Covers what the completion-time signal cannot — a holder that died
|
|
73
|
+
# (its semaphore expired and was swept) or a promote that failed.
|
|
74
|
+
# Returns the number of jobs promoted.
|
|
75
|
+
def promote_pending(client:, per_key: 100)
|
|
76
|
+
Pgbus::BlockedExecution.repair_double_encoded!
|
|
77
|
+
Pgbus::BlockedExecution.promotable_keys.sum do |key|
|
|
78
|
+
promoted = 0
|
|
79
|
+
promoted += 1 while promoted < per_key && promote_next(key, client: client)
|
|
80
|
+
promoted
|
|
81
|
+
end
|
|
62
82
|
end
|
|
63
83
|
|
|
64
84
|
# Count blocked executions for a given key. Useful for testing/monitoring.
|
|
@@ -68,6 +88,27 @@ module Pgbus
|
|
|
68
88
|
|
|
69
89
|
private
|
|
70
90
|
|
|
91
|
+
# The batch execution row is bookkeeping, not the promotion. Give it
|
|
92
|
+
# its own savepoint: `Semaphore.signal` calls promote_next inside a
|
|
93
|
+
# transaction, and a database error out here would poison that
|
|
94
|
+
# transaction — the commit then fails, un-deleting the parked row and
|
|
95
|
+
# un-taking the slot while the message is already live, so the job
|
|
96
|
+
# runs a second time.
|
|
97
|
+
def backfill(released, msg_id, client)
|
|
98
|
+
Pgbus::BlockedExecution.transaction(requires_new: true) do
|
|
99
|
+
Batch.backfill_execution(released[:payload], msg_id,
|
|
100
|
+
client.target_queue(released[:queue_name], released[:priority]))
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
Pgbus.logger.warn { "[Pgbus] Batch execution backfill failed after promote: #{e.message}" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def slot_taken?(concurrency_key, payload, delay = 0)
|
|
107
|
+
config = Concurrency.config_for_payload(payload)
|
|
108
|
+
expires_at = Time.current + Concurrency.effective_duration(config[:duration]) + delay.to_i
|
|
109
|
+
Pgbus::Semaphore.acquire!(concurrency_key, config[:limit], expires_at) == :acquired
|
|
110
|
+
end
|
|
111
|
+
|
|
71
112
|
def resolve_delay(payload, default_delay)
|
|
72
113
|
scheduled_at = payload["scheduled_at"]
|
|
73
114
|
return default_delay unless scheduled_at
|
|
@@ -6,6 +6,12 @@ module Pgbus
|
|
|
6
6
|
class << self
|
|
7
7
|
# Attempt to acquire a slot in the semaphore for the given key.
|
|
8
8
|
# Returns :acquired if a slot was available, :blocked if the limit is reached.
|
|
9
|
+
#
|
|
10
|
+
# The upsert takes the semaphore row lock until the surrounding
|
|
11
|
+
# transaction commits — even when it returns :blocked. The adapter
|
|
12
|
+
# relies on that: parking the job inside the same transaction means a
|
|
13
|
+
# holder that signals concurrently waits on `release` below and then
|
|
14
|
+
# sees the parked row (rails/solid_queue#712).
|
|
9
15
|
def acquire(key, max_value, duration)
|
|
10
16
|
expires_at = Time.current + duration
|
|
11
17
|
Pgbus::Semaphore.acquire!(key, max_value, expires_at)
|
|
@@ -16,6 +22,34 @@ module Pgbus
|
|
|
16
22
|
Pgbus::Semaphore.where(key: key).update_all("value = GREATEST(value - 1, 0)")
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
# A job holding a slot is done: give the slot back and hand it to the
|
|
26
|
+
# next parked job for this key, in one transaction. The decrement locks
|
|
27
|
+
# the semaphore row first, so an enqueue that is parking a job right
|
|
28
|
+
# now commits before the promote looks for parked rows.
|
|
29
|
+
def signal(key, client:)
|
|
30
|
+
Pgbus::Semaphore.transaction do
|
|
31
|
+
release(key)
|
|
32
|
+
BlockedExecution.promote_next(key, client: client)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Push the semaphore's expiry out while a holder is still running.
|
|
37
|
+
# Driven by the visibility heartbeat, so `duration` is the longest a
|
|
38
|
+
# holder may go silent before its slot is presumed dead — not a cap on
|
|
39
|
+
# how long a job may run.
|
|
40
|
+
def touch(key, duration)
|
|
41
|
+
# Floored here as well as at acquire: renewing for a raw duration
|
|
42
|
+
# shorter than the gap to the next beat would let the lease lapse
|
|
43
|
+
# mid-run and the sweep promote beside a running job.
|
|
44
|
+
# Start the lease once the connection is in hand: waiting on a busy
|
|
45
|
+
# pool would otherwise be charged against the renewal.
|
|
46
|
+
floored = Concurrency.effective_duration(duration)
|
|
47
|
+
Pgbus::Semaphore.connection_pool.with_connection do
|
|
48
|
+
Pgbus::Semaphore.where(key: key)
|
|
49
|
+
.update_all(["expires_at = GREATEST(expires_at, ?)", Time.current + floored])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
19
53
|
# Delete semaphores that have expired (safety net for crashed workers).
|
|
20
54
|
# Returns an array of hashes with expired keys.
|
|
21
55
|
# Uses DELETE ... RETURNING for atomicity (no race between pluck and delete).
|