pgbus 0.20.0 → 0.20.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -0
  3. data/README.md +3 -2
  4. data/app/frontend/pgbus/modules/charts.js +18 -0
  5. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  6. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  7. data/app/helpers/pgbus/workflows_helper.rb +2 -2
  8. data/app/models/pgbus/blocked_execution.rb +12 -0
  9. data/app/models/pgbus/uniqueness_key.rb +88 -3
  10. data/app/models/pgbus/workflow_step.rb +7 -0
  11. data/app/views/pgbus/workflows/_timeline.html.erb +11 -1
  12. data/config/locales/da.yml +4 -1
  13. data/config/locales/de.yml +4 -1
  14. data/config/locales/en.yml +4 -1
  15. data/config/locales/es.yml +4 -1
  16. data/config/locales/fi.yml +4 -1
  17. data/config/locales/fr.yml +4 -1
  18. data/config/locales/it.yml +4 -1
  19. data/config/locales/ja.yml +4 -1
  20. data/config/locales/nb.yml +4 -1
  21. data/config/locales/nl.yml +4 -1
  22. data/config/locales/pt.yml +4 -1
  23. data/config/locales/sv.yml +4 -1
  24. data/lib/pgbus/active_job/adapter.rb +81 -3
  25. data/lib/pgbus/active_job/delivery.rb +20 -6
  26. data/lib/pgbus/active_job/executor.rb +18 -8
  27. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  28. data/lib/pgbus/configuration.rb +43 -1
  29. data/lib/pgbus/dead_letter_header.rb +9 -1
  30. data/lib/pgbus/instrumentation.rb +3 -2
  31. data/lib/pgbus/integrations/appsignal/dashboard.json +30 -1
  32. data/lib/pgbus/metrics/workflow_metrics.rb +11 -1
  33. data/lib/pgbus/process/consumer.rb +2 -1
  34. data/lib/pgbus/process/dispatcher.rb +13 -3
  35. data/lib/pgbus/process/worker.rb +10 -1
  36. data/lib/pgbus/recurring/schedule.rb +1 -1
  37. data/lib/pgbus/streams/presence.rb +65 -36
  38. data/lib/pgbus/uniqueness.rb +23 -6
  39. data/lib/pgbus/version.rb +1 -1
  40. data/lib/pgbus/web/data_source/workflow_list.rb +50 -7
  41. data/lib/pgbus/web/data_source.rb +123 -15
  42. data/lib/pgbus/web/workflow_state.rb +16 -3
  43. data/lib/pgbus/web/workflow_timeline.rb +23 -6
  44. data/lib/pgbus/workflow/step_definition.rb +4 -2
  45. data/lib/pgbus/workflow/wait_timeout.rb +25 -4
  46. data/sig/pgbus/configuration.rbs +1 -0
  47. metadata +1 -1
@@ -1349,6 +1349,8 @@ it:
1349
1349
  unknown_html: 'Stato: %{status}.'
1350
1350
  waiting_batch_html: In attesa del lotto %{batch} (%{done} lavori su %{total} completati).
1351
1351
  waiting_signal_html: In attesa del segnale %{signal} da %{ago}.
1352
+ waiting_signal_late_html: In attesa del segnale %{signal} da %{ago} (in ritardo da %{late}). Il prossimo avviso di ritardo parte %{time}.
1353
+ waiting_signal_notify_html: In attesa del segnale %{signal} da %{ago}. Un avviso di ritardo parte %{time} e l'attesa continua.
1352
1354
  waiting_signal_timeout_html: In attesa del segnale %{signal} da %{ago}. L'attesa scade %{time}.
1353
1355
  waiting_step_html: In attesa di %{step}.
1354
1356
  not_found:
@@ -1451,7 +1453,7 @@ it:
1451
1453
  ready: Pronti
1452
1454
  waiting: In attesa
1453
1455
  timeline:
1454
- caption: 'Ogni tentativo, il più vecchio in alto: atteso (la sua attesa), in coda (previsto, non ancora preso), eseguito (colorato in base a come è finito) e in attesa (di un segnale o del suo lotto), con le pause su una riga a parte. La tabella dei tentativi qui sotto riporta le stesse durate.'
1456
+ caption: 'Ogni tentativo, il più vecchio in alto: atteso (la sua attesa), in coda (previsto, non ancora preso), eseguito (colorato in base a come è finito) e in attesa (di un segnale o del suo lotto), con le pause su una riga a parte. La tabella dei tentativi qui sotto riporta le stesse durate. Una linea tratteggiata segna ogni volta che un''attesa ha superato la scadenza.'
1455
1457
  empty: Ancora nessun tentativo.
1456
1458
  paused_period_html: In pausa dalle %{from} per %{duration}
1457
1459
  paused_row: In pausa
@@ -1465,3 +1467,4 @@ it:
1465
1467
  waited: Atteso
1466
1468
  waiting: In attesa
1467
1469
  title: Cronologia
1470
+ wait_timeout_html: 'L''attesa di %{step} ha superato la scadenza alle %{time} (on_timeout: %{action}).'
@@ -1349,6 +1349,8 @@ ja:
1349
1349
  unknown_html: 'ステータス: %{status}。'
1350
1350
  waiting_batch_html: バッチ %{batch} を待機中です(%{total} 件中 %{done} 件のジョブが完了)。
1351
1351
  waiting_signal_html: シグナル %{signal} を %{ago} から待機中です。
1352
+ waiting_signal_late_html: シグナル %{signal} を %{ago} から待機中です(%{late} から遅延)。次の遅延通知は %{time} に送られます。
1353
+ waiting_signal_notify_html: シグナル %{signal} を %{ago} から待機中です。遅延通知は %{time} に送られ、待機は続きます。
1352
1354
  waiting_signal_timeout_html: シグナル %{signal} を %{ago} から待機中です。待機は %{time} にタイムアウトします。
1353
1355
  waiting_step_html: "%{step} を待機中です。"
1354
1356
  not_found:
@@ -1451,7 +1453,7 @@ ja:
1451
1453
  ready: 実行待ち
1452
1454
  waiting: 待機中
1453
1455
  timeline:
1454
- caption: 各試行を古い順に表示します。待機(待ち時間)、キュー(期限到来、未取得)、実行(終了状態で色分け)、待機中(シグナルまたはバッチ待ち)で、一時停止は別の行に表示します。下の試行テーブルにも同じ時間が載っています。
1456
+ caption: 各試行を古い順に表示します。待機(待ち時間)、キュー(期限到来、未取得)、実行(終了状態で色分け)、待機中(シグナルまたはバッチ待ち)で、一時停止は別の行に表示します。下の試行テーブルにも同じ時間が載っています。破線は待機が期限を過ぎた時点を示します。
1455
1457
  empty: 試行はまだありません。
1456
1458
  paused_period_html: "%{from} から %{duration} 一時停止"
1457
1459
  paused_row: 一時停止
@@ -1465,3 +1467,4 @@ ja:
1465
1467
  waited: 待機済み
1466
1468
  waiting: 待機中
1467
1469
  title: タイムライン
1470
+ wait_timeout_html: "%{step} の待機が %{time} に期限を過ぎました(on_timeout: %{action})。"
@@ -1349,6 +1349,8 @@ nb:
1349
1349
  unknown_html: 'Status: %{status}.'
1350
1350
  waiting_batch_html: Venter på gruppen %{batch} (%{done} av %{total} jobber ferdige).
1351
1351
  waiting_signal_html: Venter på signalet %{signal} siden %{ago}.
1352
+ waiting_signal_late_html: Venter på signalet %{signal} siden %{ago} (forsinket siden %{late}). Neste forsinkelsesvarsel sendes %{time}.
1353
+ waiting_signal_notify_html: Venter på signalet %{signal} siden %{ago}. Et forsinkelsesvarsel sendes %{time}, og ventingen fortsetter.
1352
1354
  waiting_signal_timeout_html: Venter på signalet %{signal} siden %{ago}. Ventetiden utløper %{time}.
1353
1355
  waiting_step_html: Venter på %{step}.
1354
1356
  not_found:
@@ -1451,7 +1453,7 @@ nb:
1451
1453
  ready: Klare
1452
1454
  waiting: Venter
1453
1455
  timeline:
1454
- caption: 'Hvert forsøk, eldst øverst: ventet (ventetiden), i kø (forfalt, ikke hentet ennå), kjørte (farget etter utfall) og venter (på et signal eller sin gruppe), med pausene på en egen rad. Forsøkstabellen under viser de samme varighetene.'
1456
+ caption: 'Hvert forsøk, eldst øverst: ventet (ventetiden), i kø (forfalt, ikke hentet ennå), kjørte (farget etter utfall) og venter (på et signal eller sin gruppe), med pausene på en egen rad. Forsøkstabellen under viser de samme varighetene. En stiplet linje markerer hver gang en ventetid passerte fristen.'
1455
1457
  empty: Ingen forsøk ennå.
1456
1458
  paused_period_html: Satt på pause %{from} i %{duration}
1457
1459
  paused_row: På pause
@@ -1465,3 +1467,4 @@ nb:
1465
1467
  waited: Ventet
1466
1468
  waiting: Venter
1467
1469
  title: Tidslinje
1470
+ wait_timeout_html: 'Ventetiden for %{step} passerte fristen %{time} (on_timeout: %{action}).'
@@ -1349,6 +1349,8 @@ nl:
1349
1349
  unknown_html: 'Status: %{status}.'
1350
1350
  waiting_batch_html: Wacht op groep %{batch} (%{done} van %{total} taken klaar).
1351
1351
  waiting_signal_html: Wacht op signaal %{signal}, gestart %{ago}.
1352
+ waiting_signal_late_html: Wacht op signaal %{signal}, gestart %{ago} (te laat sinds %{late}). De volgende melding over de vertraging gaat %{time} uit.
1353
+ waiting_signal_notify_html: Wacht op signaal %{signal}, gestart %{ago}. Een melding over de vertraging gaat %{time} uit, en het wachten gaat door.
1352
1354
  waiting_signal_timeout_html: Wacht op signaal %{signal}, gestart %{ago}. De wachttijd verloopt %{time}.
1353
1355
  waiting_step_html: Wacht op %{step}.
1354
1356
  not_found:
@@ -1451,7 +1453,7 @@ nl:
1451
1453
  ready: Klaar
1452
1454
  waiting: Wachtend
1453
1455
  timeline:
1454
- caption: 'Elke poging, oudste bovenaan: gewacht (de wachttijd), in wachtrij (aan de beurt, nog niet opgepakt), uitgevoerd (gekleurd naar hoe het afliep) en wachtend (op een signaal of de groep), met de pauzes op een eigen rij. De pogingentabel hieronder toont dezelfde duren.'
1456
+ caption: 'Elke poging, oudste bovenaan: gewacht (de wachttijd), in wachtrij (aan de beurt, nog niet opgepakt), uitgevoerd (gekleurd naar hoe het afliep) en wachtend (op een signaal of de groep), met de pauzes op een eigen rij. De pogingentabel hieronder toont dezelfde duren. Een stippellijn markeert elke keer dat een wachttijd zijn deadline passeerde.'
1455
1457
  empty: Nog geen pogingen.
1456
1458
  paused_period_html: Gepauzeerd op %{from} gedurende %{duration}
1457
1459
  paused_row: Gepauzeerd
@@ -1465,3 +1467,4 @@ nl:
1465
1467
  waited: Gewacht
1466
1468
  waiting: Wachtend
1467
1469
  title: Tijdlijn
1470
+ wait_timeout_html: 'De wachttijd van %{step} verstreek op %{time} (on_timeout: %{action}).'
@@ -1349,6 +1349,8 @@ pt:
1349
1349
  unknown_html: 'Status: %{status}.'
1350
1350
  waiting_batch_html: Aguardando o lote %{batch} (%{done} de %{total} trabalhos concluídos).
1351
1351
  waiting_signal_html: Aguardando o sinal %{signal} desde %{ago}.
1352
+ waiting_signal_late_html: Aguardando o sinal %{signal} desde %{ago} (atrasado desde %{late}). O próximo aviso de atraso sai %{time}.
1353
+ waiting_signal_notify_html: Aguardando o sinal %{signal} desde %{ago}. Um aviso de atraso sai %{time} e a espera continua.
1352
1354
  waiting_signal_timeout_html: Aguardando o sinal %{signal} desde %{ago}. A espera expira %{time}.
1353
1355
  waiting_step_html: Aguardando %{step}.
1354
1356
  not_found:
@@ -1451,7 +1453,7 @@ pt:
1451
1453
  ready: Prontos
1452
1454
  waiting: Aguardando
1453
1455
  timeline:
1454
- caption: 'Cada tentativa, a mais antiga no topo: esperou (sua espera), na fila (vencida, ainda não pega), executou (colorida conforme terminou) e aguardando (um sinal ou seu lote), com as pausas em linha própria. A tabela de tentativas abaixo lista as mesmas durações.'
1456
+ caption: 'Cada tentativa, a mais antiga no topo: esperou (sua espera), na fila (vencida, ainda não pega), executou (colorida conforme terminou) e aguardando (um sinal ou seu lote), com as pausas em linha própria. A tabela de tentativas abaixo lista as mesmas durações. Uma linha tracejada marca cada vez que uma espera passou do prazo.'
1455
1457
  empty: Ainda não há tentativas.
1456
1458
  paused_period_html: Em pausa desde %{from} durante %{duration}
1457
1459
  paused_row: Em pausa
@@ -1465,3 +1467,4 @@ pt:
1465
1467
  waited: Esperou
1466
1468
  waiting: Aguardando
1467
1469
  title: Linha do tempo
1470
+ wait_timeout_html: 'A espera de %{step} passou do prazo em %{time} (on_timeout: %{action}).'
@@ -1349,6 +1349,8 @@ sv:
1349
1349
  unknown_html: 'Status: %{status}.'
1350
1350
  waiting_batch_html: Väntar på gruppen %{batch} (%{done} av %{total} jobb klara).
1351
1351
  waiting_signal_html: Väntar på signalen %{signal} sedan %{ago}.
1352
+ waiting_signal_late_html: Väntar på signalen %{signal} sedan %{ago} (sen sedan %{late}). Nästa förseningsavisering går ut %{time}.
1353
+ waiting_signal_notify_html: Väntar på signalen %{signal} sedan %{ago}. En förseningsavisering går ut %{time}, och väntan fortsätter.
1352
1354
  waiting_signal_timeout_html: Väntar på signalen %{signal} sedan %{ago}. Väntan tar slut %{time}.
1353
1355
  waiting_step_html: Väntar på %{step}.
1354
1356
  not_found:
@@ -1451,7 +1453,7 @@ sv:
1451
1453
  ready: Redo
1452
1454
  waiting: Väntar
1453
1455
  timeline:
1454
- caption: 'Varje försök, äldst överst: väntade (dess väntetid), köad (förfallen, ännu inte hämtad), körde (färgad efter utfall) och väntar (på en signal eller sin grupp), med pauserna på en egen rad. Försökstabellen nedan visar samma tider.'
1456
+ caption: 'Varje försök, äldst överst: väntade (dess väntetid), köad (förfallen, ännu inte hämtad), körde (färgad efter utfall) och väntar (på en signal eller sin grupp), med pauserna på en egen rad. Försökstabellen nedan visar samma tider. En streckad linje markerar varje gång en väntan passerade sin tidsgräns.'
1455
1457
  empty: Inga försök ännu.
1456
1458
  paused_period_html: Pausad %{from} i %{duration}
1457
1459
  paused_row: Pausad
@@ -1465,3 +1467,4 @@ sv:
1465
1467
  waited: Väntade
1466
1468
  waiting: Väntar
1467
1469
  title: Tidslinje
1470
+ wait_timeout_html: 'Väntan för %{step} passerade sin tidsgräns %{time} (on_timeout: %{action}).'
@@ -80,6 +80,7 @@ module Pgbus
80
80
  # reset: once the send returns, msg_id is set and the rescue routes
81
81
  # on that instead.
82
82
  sending = false
83
+ retry_lock = hand_lock_to_retry(active_job, payload_hash, queue)
83
84
 
84
85
  if key && concurrency
85
86
  # The check and the park commit together, under the semaphore row
@@ -115,6 +116,9 @@ module Pgbus
115
116
  # Bind before backfill so a live message is never left with an unbound
116
117
  # uniqueness row if execution-row bookkeeping raises.
117
118
  bind_acquired_uniqueness_lock(queue, msg_id) if msg_id
119
+ bind_retry_lock(retry_lock, queue, msg_id) if msg_id
120
+ # Dropped by its concurrency limit: no retry will run, the attempt does.
121
+ return_retry_lock(retry_lock) unless msg_id || blocked
118
122
  Batch.backfill_execution(payload_hash, msg_id, physical_queue(queue, priority)) if msg_id
119
123
  # A retry re-enqueue that is now live (sent, or parked as a blocked
120
124
  # execution) must stop the original attempt from signalling completion.
@@ -135,6 +139,7 @@ module Pgbus
135
139
  # database error from the slot upsert or the park is not ambiguous.
136
140
  if msg_id.nil? && !(sending && ambiguous_delivery?(e))
137
141
  rollback_acquired_uniqueness_lock
142
+ return_retry_lock(retry_lock)
138
143
  uncount_batch_job(payload_hash, active_job)
139
144
  else
140
145
  # Drop the thread-local so a later discard on this thread cannot
@@ -247,9 +252,10 @@ module Pgbus
247
252
  # the executor still holds the key (it releases only on success/DLQ). So
248
253
  # a re-enqueue with executions > 0 would otherwise hit its own held key,
249
254
  # be rejected as a duplicate (JobNotUnique), and dead-letter the original
250
- # while losing the retry. Let the retry through; the existing key row
251
- # correctly stays held until the job finally succeeds or dead-letters.
252
- # See issue #333.
255
+ # while losing the retry. Let the retry through (issue #333); the key
256
+ # row is handed to it (hand_lock_to_retry, then bind_retry_lock once
257
+ # sent), so the attempt's release leaves it held until the job finally
258
+ # succeeds or dead-letters (issue #564).
253
259
  return false if active_job.executions.to_i.positive?
254
260
 
255
261
  result = Uniqueness.acquire_enqueue_lock(uniqueness_key, active_job, queue_name: queue)
@@ -285,6 +291,78 @@ module Pgbus
285
291
  Pgbus.logger.warn { "[Pgbus] Uniqueness bind failed: #{e.message}" }
286
292
  end
287
293
 
294
+ # The job's :until_executed key and the delivery of the attempt running
295
+ # it, when this enqueue is that attempt's retry: the retry is let
296
+ # through the key the attempt holds and never acquires one, so the key
297
+ # row has to follow it. nil for a first attempt (it acquired its own
298
+ # row), a retry enqueued outside the executor (no attempt to take it
299
+ # from), or a job run inline inside another job, whose delivery it sees.
300
+ def retry_uniqueness_lock(active_job, payload_hash)
301
+ key = Uniqueness.extract_key(payload_hash)
302
+ return unless key && Uniqueness.extract_strategy(payload_hash) == :until_executed
303
+ return unless active_job.executions.to_i.positive?
304
+
305
+ delivery = Delivery.current
306
+ [key, delivery] if delivery && delivery[:job_id] == active_job.job_id
307
+ end
308
+
309
+ # Take the attempt's row for its retry before the retry exists — sent or
310
+ # parked — the way a first enqueue acquires before its send (see
311
+ # UniquenessKey.unbind!). Returns [key, delivery, stamp] for
312
+ # bind_retry_lock / return_retry_lock, or nil when there is nothing to
313
+ # hand over. A failure is logged and the retry goes out without the row,
314
+ # which then stays with the attempt.
315
+ def hand_lock_to_retry(active_job, payload_hash, queue)
316
+ key, delivery = retry_uniqueness_lock(active_job, payload_hash)
317
+ return unless key
318
+
319
+ stamp = UniquenessKey.unbind!(key, from_queue_name: Uniqueness.lock_queue_name(delivery[:queue_name]),
320
+ from_msg_id: delivery[:msg_id], from_enqueued_at: delivery[:enqueued_at],
321
+ queue_name: Uniqueness.lock_queue_name(queue))
322
+ return [key, delivery, stamp] if stamp
323
+
324
+ warn_retry_lock_not_held(key)
325
+ nil
326
+ rescue StandardError => e
327
+ Pgbus.logger.warn { "[Pgbus] Uniqueness handoff to retry failed for #{key}: #{e.message}" }
328
+ nil
329
+ end
330
+
331
+ # Point the handed-over row at the sent retry. The retry is live, so a
332
+ # failure is logged, not raised: the row stays unbound and stamped
333
+ # before the send, and the retry's own release takes it.
334
+ def bind_retry_lock(retry_lock, queue, msg_id)
335
+ return unless retry_lock
336
+
337
+ key, _delivery, stamp = retry_lock
338
+ UniquenessKey.bind!(key, queue_name: Uniqueness.lock_queue_name(queue), msg_id: msg_id, acquired_at: stamp)
339
+ rescue StandardError => e
340
+ Pgbus.logger.warn { "[Pgbus] Uniqueness bind to retry failed for #{key}: #{e.message}" }
341
+ end
342
+
343
+ # No retry came of it (dropped by its concurrency limit, or the send
344
+ # failed before any message existed): bind the row back to the attempt,
345
+ # which runs on and releases it when it finishes.
346
+ def return_retry_lock(retry_lock)
347
+ return unless retry_lock
348
+
349
+ key, delivery, stamp = retry_lock
350
+ UniquenessKey.bind!(key, queue_name: Uniqueness.lock_queue_name(delivery[:queue_name]),
351
+ msg_id: delivery[:msg_id], acquired_at: stamp)
352
+ rescue StandardError => e
353
+ Pgbus.logger.warn { "[Pgbus] Uniqueness return to the attempt failed for #{key}: #{e.message}" }
354
+ end
355
+
356
+ # The row is gone (released by an operator, reaped) or a successor's:
357
+ # the retry runs without the key, and a fresh enqueue is not deduped
358
+ # against it.
359
+ def warn_retry_lock_not_held(key)
360
+ Pgbus.logger.warn do
361
+ "[Pgbus] Uniqueness key #{key} is no longer held by the attempt that enqueued its retry; " \
362
+ "the retry runs without it"
363
+ end
364
+ end
365
+
288
366
  # Reverses inject_batch_metadata for a job discarded at enqueue time
289
367
  # (uniqueness duplicate or concurrency :discard conflict): the message is
290
368
  # never sent, so it can never signal completion. Only applies to a job
@@ -4,23 +4,30 @@ module Pgbus
4
4
  module ActiveJob
5
5
  # The PGMQ delivery of the job running in this fiber:
6
6
  #
7
- # Pgbus::ActiveJob::Delivery.current # => { msg_id: 42, read_ct: 2, queue_name: "default" }
7
+ # Pgbus::ActiveJob::Delivery.current
8
+ # # => { msg_id: 42, read_ct: 2, queue_name: "default",
9
+ # # enqueued_at: "2026-10-18 10:30:00+00", job_id: "8f0c…" }
8
10
  #
9
11
  # Set by Executor#execute around perform_now and cleared after, also when
10
12
  # the job raises; nil outside the executor (perform_now in a test, a
11
13
  # top-level inline-adapter job). A job run inline from inside an executing
12
14
  # job sees the enclosing job's delivery; StepJob only acts on it when the
13
- # step row's msg_id matches, so that is harmless. Fiber-local (Thread.current[]), so concurrent jobs under
15
+ # step row's msg_id matches, and the adapter only when job_id is its own,
16
+ # so that is harmless. Fiber-local (Thread.current[]), so concurrent jobs under
14
17
  # execution_mode: :async each see their own. Pgbus::Workflow::StepJob reads
15
- # read_ct to tell a redelivery after a worker died from a first delivery.
18
+ # read_ct to tell a redelivery after a worker died from a first delivery;
19
+ # the adapter reads the message identity and enqueued_at to move the
20
+ # job's uniqueness lock onto a retry the job enqueues (issue #564).
16
21
  #
17
- # The three values sit in three fiber-locals and the Hash is built only
22
+ # The values sit in one fiber-local each and the Hash is built only
18
23
  # when read: setting them allocates nothing, which keeps the executor's
19
24
  # per-job allocation budget (spec/pgbus/active_job/executor_allocation_budget_spec.rb).
20
25
  module Delivery
21
26
  MSG_ID_KEY = :pgbus_delivery_msg_id
22
27
  READ_CT_KEY = :pgbus_delivery_read_ct
23
28
  QUEUE_NAME_KEY = :pgbus_delivery_queue_name
29
+ ENQUEUED_AT_KEY = :pgbus_delivery_enqueued_at
30
+ JOB_ID_KEY = :pgbus_delivery_job_id
24
31
 
25
32
  module_function
26
33
 
@@ -28,21 +35,28 @@ module Pgbus
28
35
  msg_id = Thread.current[MSG_ID_KEY]
29
36
  return nil if msg_id.nil?
30
37
 
31
- { msg_id: msg_id, read_ct: Thread.current[READ_CT_KEY], queue_name: Thread.current[QUEUE_NAME_KEY] }
38
+ { msg_id: msg_id, read_ct: Thread.current[READ_CT_KEY], queue_name: Thread.current[QUEUE_NAME_KEY],
39
+ enqueued_at: Thread.current[ENQUEUED_AT_KEY], job_id: Thread.current[JOB_ID_KEY] }
32
40
  end
33
41
 
34
- def with(msg_id:, read_ct:, queue_name:)
42
+ def with(msg_id:, read_ct:, queue_name:, enqueued_at: nil, job_id: nil)
35
43
  previous_msg_id = Thread.current[MSG_ID_KEY]
36
44
  previous_read_ct = Thread.current[READ_CT_KEY]
37
45
  previous_queue_name = Thread.current[QUEUE_NAME_KEY]
46
+ previous_enqueued_at = Thread.current[ENQUEUED_AT_KEY]
47
+ previous_job_id = Thread.current[JOB_ID_KEY]
38
48
  Thread.current[MSG_ID_KEY] = msg_id
39
49
  Thread.current[READ_CT_KEY] = read_ct
40
50
  Thread.current[QUEUE_NAME_KEY] = queue_name
51
+ Thread.current[ENQUEUED_AT_KEY] = enqueued_at
52
+ Thread.current[JOB_ID_KEY] = job_id
41
53
  yield
42
54
  ensure
43
55
  Thread.current[MSG_ID_KEY] = previous_msg_id
44
56
  Thread.current[READ_CT_KEY] = previous_read_ct
45
57
  Thread.current[QUEUE_NAME_KEY] = previous_queue_name
58
+ Thread.current[ENQUEUED_AT_KEY] = previous_enqueued_at
59
+ Thread.current[JOB_ID_KEY] = previous_job_id
46
60
  end
47
61
  end
48
62
  end
@@ -33,7 +33,7 @@ module Pgbus
33
33
  FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
34
34
  signal_concurrency(payload)
35
35
  signal_batch_discarded(payload)
36
- Uniqueness.release_lock(Uniqueness.extract_key(payload))
36
+ release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message)
37
37
  record_stat(payload, queue_name, "dead_lettered", execution_start, message: message)
38
38
  instrument(
39
39
  "pgbus.job_dead_lettered",
@@ -100,7 +100,10 @@ module Pgbus
100
100
  assign_batch_id(job, payload)
101
101
  Pgbus.logger.debug { "[Pgbus::Executor] running #{log_tag(message, queue_name)} job_class=#{job_class}" }
102
102
  with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload) do
103
- Delivery.with(msg_id: msg_id, read_ct: read_count, queue_name: queue_name) { execute_job(job) }
103
+ Delivery.with(msg_id: msg_id, read_ct: read_count, queue_name: queue_name,
104
+ enqueued_at: message.enqueued_at, job_id: payload["job_id"]) do
105
+ execute_job(job)
106
+ end
104
107
  end
105
108
  # retry_on re-enqueues from inside perform_now and returns normally:
106
109
  # this attempt is done (archive it) but the job is not — the retry
@@ -121,7 +124,7 @@ module Pgbus
121
124
  end
122
125
  Pgbus.logger.debug { "[Pgbus::Executor] archived #{log_tag(message, queue_name)} job_class=#{job_class}" }
123
126
  job_succeeded = true
124
- release_uniqueness_lock(uniqueness_key)
127
+ release_uniqueness_lock(uniqueness_key, queue_name, message)
125
128
  # Only FailedEventRecorder.record! writes this table, and only after a
126
129
  # failed attempt, so a first delivery has no row: skip the per-job
127
130
  # DELETE round trip (issue #484). A stale row from a dropped-and-
@@ -146,7 +149,7 @@ module Pgbus
146
149
  # A failed :while_executing attempt is no longer executing — release
147
150
  # so the retry (same message, after VT) can acquire. :until_executed
148
151
  # keeps its lock until success/DLQ by design (#126, #333).
149
- release_uniqueness_lock(uniqueness_key) if uniqueness_strategy == :while_executing
152
+ release_uniqueness_lock(uniqueness_key, queue_name, message) if uniqueness_strategy == :while_executing
150
153
  instrument(
151
154
  "pgbus.job_failed",
152
155
  queue: queue_name,
@@ -192,10 +195,15 @@ module Pgbus
192
195
  job.batch_id = batch_id
193
196
  end
194
197
 
195
- def release_uniqueness_lock(key)
198
+ # Releases only the row this message holds, never the key as such: a
199
+ # row the job moved onto its retry (issue #564), or a successor's, stays.
200
+ # The queue is spelled as the row was bound (Uniqueness.lock_queue_name),
201
+ # as for release_duplicate_execution_lock.
202
+ def release_uniqueness_lock(key, queue_name, message)
196
203
  return unless key
197
204
 
198
- Uniqueness.release_lock(key)
205
+ UniquenessKey.release_held_by!(key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
206
+ msg_id: message.msg_id.to_i, enqueued_at: message.enqueued_at)
199
207
  rescue StandardError => e
200
208
  Pgbus.logger.warn { "[Pgbus] Uniqueness release failed: #{e.message}" }
201
209
  end
@@ -425,7 +433,8 @@ module Pgbus
425
433
 
426
434
  # Same queue the execution lock was acquired under, so the match
427
435
  # cannot land on another queue's message of the same id.
428
- UniquenessKey.release_if_bound!(uniqueness_key, queue_name: queue_name, msg_id: msg_id)
436
+ UniquenessKey.release_if_bound!(uniqueness_key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
437
+ msg_id: msg_id)
429
438
  rescue StandardError => e
430
439
  Pgbus.logger.warn { "[Pgbus] Duplicate-execution lock release failed: #{e.message}" }
431
440
  end
@@ -442,7 +451,8 @@ module Pgbus
442
451
  end
443
452
  headers = DeadLetterHeader.build(
444
453
  existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "worker",
445
- source_queue: source_queue || config.queue_name(queue_name), attempts: message.read_ct.to_i,
454
+ source_queue: source_queue || config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
455
+ source_enqueued_at: message.enqueued_at, attempts: message.read_ct.to_i,
446
456
  max_retries: config.max_retries, error: last_error
447
457
  )
448
458
  if source_queue
@@ -16,13 +16,21 @@ module Pgbus
16
16
  # once. Handing it a pre-serialized String stored a JSON *string*
17
17
  # (double-encoded), which every document reader of the column —
18
18
  # `payload->>'job_id'`, the job-class lookup, `scheduled_at` — misread.
19
+ #
20
+ # created_at is left to the column's CURRENT_TIMESTAMP default: the
21
+ # job's uniqueness lock is stamped by the database too, and discarding
22
+ # a parked job releases only a lock acquired no later than the park
23
+ # (UniquenessKey.release_if_unbound!), so both sides need one clock.
19
24
  def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
20
- Pgbus::BlockedExecution.create!(
21
- concurrency_key: concurrency_key,
22
- queue_name: queue_name,
23
- payload: payload,
24
- priority: priority,
25
- expires_at: Time.current + duration
25
+ Pgbus::BlockedExecution.insert!(
26
+ {
27
+ concurrency_key: concurrency_key,
28
+ queue_name: queue_name,
29
+ payload: payload,
30
+ priority: priority,
31
+ expires_at: Time.current + duration
32
+ },
33
+ record_timestamps: false
26
34
  )
27
35
  end
28
36
 
@@ -13,7 +13,8 @@ module Pgbus
13
13
  attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
14
14
 
15
15
  # Queue settings
16
- attr_accessor :default_queue, :queue_prefix
16
+ attr_accessor :default_queue
17
+ attr_reader :queue_prefix # rubocop:disable Style/AccessorGrouping -- the writer normalizes (see queue_prefix=)
17
18
 
18
19
  # Worker settings
19
20
  attr_accessor :polling_interval, :prefetch_limit, :execution_mode
@@ -597,6 +598,25 @@ module Pgbus
597
598
  @finalized = false
598
599
  end
599
600
 
601
+ # Stored the way the queue tables spell it ("my-app" → "my_app"). Table
602
+ # names are unchanged (queue_name normalizes the joined name the same
603
+ # way), but code that strips the prefix off a table name to get its queue
604
+ # back (the dashboard, the worker, a job's uniqueness lock queue) then
605
+ # finds it. A value normalize cannot spell (nothing table-safe left, or
606
+ # too long) is kept as given, as before: validate! rejects one that is
607
+ # too long, and queue_name drops one with nothing table-safe in it.
608
+ def queue_prefix=(value)
609
+ @queue_prefix = if value.is_a?(String) && !value.empty?
610
+ begin
611
+ QueueNameValidator.normalize(value)
612
+ rescue ArgumentError
613
+ value
614
+ end
615
+ else
616
+ value
617
+ end
618
+ end
619
+
600
620
  def queue_name(name)
601
621
  full = "#{queue_prefix}_#{name}"
602
622
  QueueNameValidator.normalize(full)
@@ -1002,6 +1022,27 @@ module Pgbus
1002
1022
  raise Pgbus::ConfigurationError, "capsule processes must be a positive Integer (got #{value.inspect})"
1003
1023
  end
1004
1024
 
1025
+ # A prefix with a leading, trailing or doubled underscore is kept as
1026
+ # written (its tables already exist under it), but queue_name collapses
1027
+ # the separator after it only for a queue name that needs normalizing:
1028
+ # "myapp_" gives myapp__orders yet myapp_orders_handler for
1029
+ # "orders-handler". A job's own release and the dashboard derive the same
1030
+ # lock queue for it and still match, but the reaper cannot resolve that
1031
+ # name back to the table, so a lock orphaned there is never reclaimed and
1032
+ # has to be released by key. Legal, but warned.
1033
+ def warn_queue_prefix_underscores
1034
+ return unless queue_prefix.match?(/\A_|_\z|__/)
1035
+
1036
+ suggested = queue_prefix.squeeze("_").delete_prefix("_").delete_suffix("_")
1037
+ suggestion = suggested.empty? ? "" : "; prefer #{suggested.inspect} for new deployments"
1038
+ Pgbus.logger.warn do
1039
+ "[Pgbus] queue_prefix #{queue_prefix.inspect} has a leading, trailing or doubled underscore — a queue " \
1040
+ "whose name needs normalizing (\"orders-handler\") gets a table spelled unlike its queue, so the " \
1041
+ "reaper cannot find that table: a uniqueness lock orphaned there (a worker crash before its release) " \
1042
+ "is never reclaimed and has to be released by key#{suggestion}"
1043
+ end
1044
+ end
1045
+
1005
1046
  # Buffered claims are kept invisible only by the visibility heartbeat.
1006
1047
  # Without it a message buffered longer than visibility_timeout can be
1007
1048
  # claimed by another worker while it still waits here. Legal, but warned.
@@ -1075,6 +1116,7 @@ module Pgbus
1075
1116
  { queue_prefix: queue_prefix, default_queue: default_queue }.each do |name, value|
1076
1117
  raise Pgbus::ConfigurationError, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
1077
1118
  end
1119
+ warn_queue_prefix_underscores
1078
1120
 
1079
1121
  # web_auth gates the dashboard; a non-callable value silently leaves it
1080
1122
  # open (mirror streams_presence_member).
@@ -30,7 +30,13 @@ module Pgbus
30
30
 
31
31
  # error: FailedEventRecorder.last_error's Hash, or nil when no failure
32
32
  # was recorded. Returns the merged headers as a JSON string.
33
- def build(existing:, reason:, source:, source_queue:, attempts:, max_retries:, error: nil, now: Time.now.utc)
33
+ # source_msg_id / source_enqueued_at: the message's id in source_queue and
34
+ # when it was sent (the copy gets a new id and enqueued_at in the DLQ).
35
+ # A discard of the copy uses both to release only the uniqueness lock the
36
+ # original still holds: bound to it, or unbound and acquired before its
37
+ # send, never a lock it handed to a retry after (issue #564).
38
+ def build(existing:, reason:, source:, source_queue:, attempts:, max_retries:, error: nil, source_msg_id: nil,
39
+ source_enqueued_at: nil, now: Time.now.utc)
34
40
  headers = decode(existing, warn: true)
35
41
  retries = count(headers.delete(RETRIES_KEY))
36
42
  block = {
@@ -42,6 +48,8 @@ module Pgbus
42
48
  "max_retries" => max_retries,
43
49
  "dead_lettered_at" => iso(now)
44
50
  }
51
+ block["source_msg_id"] = source_msg_id if source_msg_id
52
+ block["source_enqueued_at"] = iso(source_enqueued_at) if source_enqueued_at
45
53
  block["retries_from_dlq"] = retries if retries.positive?
46
54
  block.merge!(error_fields(error)) if error
47
55
  JSON.generate(headers.merge(KEY => block))
@@ -79,9 +79,10 @@ module Pgbus
79
79
  # payload: workflow_class, workflow_id, name, duplicate,
80
80
  # woke (true when it woke the step waiting for it)
81
81
  # pgbus.workflow.wait_timed_out — a waiting step's wait_timeout: passed; its on_timeout
82
- # applied (the sweep, or TestHelpers#expire_wait)
82
+ # applied (the sweep, or TestHelpers#expire_wait); under
83
+ # notify the step keeps waiting, once per sweep that finds it late
83
84
  # payload: workflow_class, workflow_id, step_name, attempt,
84
- # action (pause! | cancel! | skip! | run)
85
+ # action (pause! | cancel! | skip! | run | notify)
85
86
  # pgbus.workflow.gauge — emitted by each dispatcher workflow sweep
86
87
  # payload: statuses ([{ workflow_class:, status:, count: }]),
87
88
  # stuck ([{ workflow_class:, reason:, count: }]), zero-filled
@@ -4,7 +4,7 @@
4
4
  ],
5
5
  "dashboard": {
6
6
  "title": "Pgbus",
7
- "description": "PostgreSQL-native job processing and event bus built on PGMQ.\nMonitor job throughput, queue depth, latency, dead letter queues,\nworker processes, event handlers, and stream broadcasts.\nWorkflow step outcomes and run times, and stuck workflows.\n",
7
+ "description": "PostgreSQL-native job processing and event bus built on PGMQ.\nMonitor job throughput, queue depth, latency, dead letter queues,\nworker processes, event handlers, and stream broadcasts.\nWorkflow step outcomes and run times, stuck workflows and late waits.\n",
8
8
  "visuals": [
9
9
  {
10
10
  "type": "timeseries",
@@ -520,6 +520,35 @@
520
520
  ]
521
521
  }
522
522
  ]
523
+ },
524
+ {
525
+ "type": "timeseries",
526
+ "display": "LINE",
527
+ "title": "Late workflow waits",
528
+ "description": "Deadlines an on_timeout: :notify wait missed, by class and step: the run keeps waiting for its signal.",
529
+ "line_label": "%workflow_class% - %step%",
530
+ "format": "number",
531
+ "draw_null_as_zero": true,
532
+ "metrics": [
533
+ {
534
+ "name": "pgbus_workflow_wait_overdue_total",
535
+ "fields": [
536
+ {
537
+ "field": "COUNTER"
538
+ }
539
+ ],
540
+ "tags": [
541
+ {
542
+ "key": "workflow_class",
543
+ "value": "*"
544
+ },
545
+ {
546
+ "key": "step",
547
+ "value": "*"
548
+ }
549
+ ]
550
+ }
551
+ ]
523
552
  }
524
553
  ]
525
554
  }
@@ -12,6 +12,7 @@ module Pgbus
12
12
  # pgbus_workflows_paused_total{workflow_class, error_class} counter (error_class absent for a manual pause)
13
13
  # pgbus_workflows_cancelled_total{workflow_class} counter
14
14
  # pgbus_workflow_stalls_total{workflow_class, step, detection} counter (detection: redelivery | sweep)
15
+ # pgbus_workflow_wait_overdue_total{workflow_class, step} counter (a late notice of an on_timeout: :notify wait)
15
16
  # pgbus_workflows{workflow_class, status} gauge, from each sweep
16
17
  # pgbus_workflows_stuck{workflow_class, reason} gauge, from each sweep
17
18
  #
@@ -20,7 +21,7 @@ module Pgbus
20
21
  module WorkflowMetrics
21
22
  EVENTS = %w[
22
23
  pgbus.workflow.step pgbus.workflow_finished pgbus.workflow_paused pgbus.workflow_cancelled
23
- pgbus.workflow.stall_detected pgbus.workflow.gauge
24
+ pgbus.workflow.stall_detected pgbus.workflow.wait_timed_out pgbus.workflow.gauge
24
25
  ].freeze
25
26
 
26
27
  module_function
@@ -37,6 +38,7 @@ module Pgbus
37
38
  when "pgbus.workflow_cancelled" then [counter("pgbus_workflows_cancelled_total", workflow_class: klass)]
38
39
  when "pgbus.workflow.stall_detected"
39
40
  [counter("pgbus_workflow_stalls_total", workflow_class: klass, step: payload[:step_name], detection: payload[:detection])]
41
+ when "pgbus.workflow.wait_timed_out" then wait_timed_out_samples(klass, payload)
40
42
  when "pgbus.workflow.gauge" then gauge_samples(payload)
41
43
  else []
42
44
  end
@@ -49,6 +51,14 @@ module Pgbus
49
51
  samples
50
52
  end
51
53
 
54
+ # Only :notify keeps waiting past the deadline; every other on_timeout
55
+ # ends the wait and is counted by the step or run it ends.
56
+ def wait_timed_out_samples(klass, payload)
57
+ return [] unless payload[:action] == "notify"
58
+
59
+ [counter("pgbus_workflow_wait_overdue_total", workflow_class: klass, step: payload[:step_name])]
60
+ end
61
+
52
62
  def gauge_samples(payload)
53
63
  statuses = Array(payload[:statuses]).map do |row|
54
64
  [:gauge, "pgbus_workflows", row[:count], { workflow_class: row[:workflow_class], status: row[:status] }]
@@ -296,7 +296,8 @@ module Pgbus
296
296
  last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
297
297
  headers = DeadLetterHeader.build(
298
298
  existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "consumer",
299
- source_queue: config.queue_name(queue_name), attempts: message.read_ct.to_i,
299
+ source_queue: config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
300
+ source_enqueued_at: message.enqueued_at, attempts: message.read_ct.to_i,
300
301
  max_retries: config.max_retries, error: last_error
301
302
  )
302
303
  Pgbus.client.move_to_dead_letter(queue_name, message, headers: headers)