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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/app/controllers/pgbus/locks_controller.rb +27 -0
  4. data/app/frontend/pgbus/style.css +1 -1
  5. data/app/models/pgbus/blocked_execution.rb +33 -7
  6. data/app/models/pgbus/semaphore.rb +8 -3
  7. data/app/models/pgbus/uniqueness_key.rb +36 -0
  8. data/app/views/pgbus/dashboard/_stats_cards.html.erb +14 -1
  9. data/app/views/pgbus/locks/_concurrency.html.erb +94 -0
  10. data/app/views/pgbus/locks/_uniqueness.html.erb +80 -0
  11. data/app/views/pgbus/locks/index.html.erb +5 -76
  12. data/config/locales/da.yml +42 -1
  13. data/config/locales/de.yml +42 -1
  14. data/config/locales/en.yml +42 -1
  15. data/config/locales/es.yml +42 -1
  16. data/config/locales/fi.yml +42 -1
  17. data/config/locales/fr.yml +42 -1
  18. data/config/locales/it.yml +42 -1
  19. data/config/locales/ja.yml +42 -1
  20. data/config/locales/nb.yml +42 -1
  21. data/config/locales/nl.yml +42 -1
  22. data/config/locales/pt.yml +42 -1
  23. data/config/locales/sv.yml +42 -1
  24. data/config/routes.rb +5 -0
  25. data/lib/pgbus/active_job/adapter.rb +83 -9
  26. data/lib/pgbus/active_job/executor.rb +55 -15
  27. data/lib/pgbus/concurrency/blocked_execution.rb +59 -18
  28. data/lib/pgbus/concurrency/semaphore.rb +34 -0
  29. data/lib/pgbus/concurrency.rb +36 -2
  30. data/lib/pgbus/instrumentation.rb +3 -0
  31. data/lib/pgbus/integrations/appsignal/dashboard.json +38 -0
  32. data/lib/pgbus/integrations/appsignal/probe.rb +10 -0
  33. data/lib/pgbus/mcp/base_tool.rb +6 -1
  34. data/lib/pgbus/mcp/server.rb +2 -1
  35. data/lib/pgbus/mcp/tools/concurrency_tool.rb +32 -0
  36. data/lib/pgbus/mcp.rb +1 -0
  37. data/lib/pgbus/process/dispatcher.rb +6 -13
  38. data/lib/pgbus/version.rb +1 -1
  39. data/lib/pgbus/visibility_heartbeat.rb +24 -3
  40. data/lib/pgbus/web/data_source.rb +210 -1
  41. data/lib/pgbus/web/metrics_serializer.rb +51 -3
  42. metadata +4 -1
@@ -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
- title: Uniciteitsleutels
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
@@ -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
- title: Chaves de unicidade
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
@@ -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
- title: Unikhetsnycklar
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
- result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
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 result == :acquired
78
- msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
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
- if msg_id.nil?
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
- # Message is live: drop the thread-local so a later discard on this
105
- # thread cannot release that job's uniqueness lock, but do not
106
- # DELETE the pgbus_uniqueness_keys row.
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
- archive_from(queue_name, msg_id, source_queue: source_queue)
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
- # Atomic permit handoff: try to promote a blocked job first.
326
- # promote_next wraps delete + enqueue in a transaction so neither is lost.
327
- # If promoted, the slot stays occupied (no release needed).
328
- # Only release the semaphore if there's nothing to promote.
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
- client.archive_message(source_queue, msg_id, prefixed: false)
364
- else
365
- client.archive_message(queue_name, msg_id)
366
- end
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: JSON.generate(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 and enqueue
27
- # the job in a single transaction. Returns true if a job was promoted, false
28
- # otherwise. This avoids losing a blocked row if enqueue fails.
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
- if released && msg_id
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
- !!released
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
- # Delete blocked executions that have expired.
59
- # Returns the count of deleted rows.
60
- def expire_stale
61
- Pgbus::BlockedExecution.expired(Time.current).delete_all
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).