pgbus 0.3.3 → 0.3.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/Rakefile +15 -0
  3. data/app/controllers/pgbus/dead_letter_controller.rb +17 -0
  4. data/app/controllers/pgbus/jobs_controller.rb +36 -0
  5. data/app/controllers/pgbus/locks_controller.rb +25 -0
  6. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -3
  7. data/app/frontend/pgbus/application.js +58 -1
  8. data/app/models/pgbus/job_lock.rb +16 -8
  9. data/app/models/pgbus/uniqueness_key.rb +36 -0
  10. data/app/views/pgbus/dead_letter/_messages_table.html.erb +22 -2
  11. data/app/views/pgbus/dead_letter/index.html.erb +9 -1
  12. data/app/views/pgbus/jobs/_enqueued_table.html.erb +36 -6
  13. data/app/views/pgbus/jobs/_failed_table.html.erb +35 -4
  14. data/app/views/pgbus/locks/index.html.erb +53 -28
  15. data/app/views/pgbus/queues/show.html.erb +58 -21
  16. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +2 -1
  17. data/config/locales/da.yml +21 -9
  18. data/config/locales/de.yml +21 -9
  19. data/config/locales/en.yml +51 -9
  20. data/config/locales/es.yml +21 -9
  21. data/config/locales/fi.yml +21 -9
  22. data/config/locales/fr.yml +21 -9
  23. data/config/locales/it.yml +21 -9
  24. data/config/locales/ja.yml +21 -9
  25. data/config/locales/nb.yml +21 -9
  26. data/config/locales/nl.yml +21 -9
  27. data/config/locales/pt.yml +21 -9
  28. data/config/locales/sv.yml +21 -9
  29. data/config/routes.rb +12 -1
  30. data/lib/generators/pgbus/migrate_job_locks_generator.rb +56 -0
  31. data/lib/generators/pgbus/templates/add_uniqueness_keys.rb.erb +13 -0
  32. data/lib/generators/pgbus/templates/migrate_job_locks_to_uniqueness_keys.rb.erb +33 -0
  33. data/lib/pgbus/active_job/executor.rb +34 -20
  34. data/lib/pgbus/client.rb +18 -2
  35. data/lib/pgbus/process/dispatcher.rb +33 -10
  36. data/lib/pgbus/process/worker.rb +4 -1
  37. data/lib/pgbus/recurring/schedule.rb +38 -35
  38. data/lib/pgbus/stat_buffer.rb +107 -0
  39. data/lib/pgbus/uniqueness.rb +24 -39
  40. data/lib/pgbus/version.rb +1 -1
  41. data/lib/pgbus/web/data_source.rb +49 -18
  42. metadata +6 -1
@@ -239,16 +239,12 @@ ja:
239
239
  index:
240
240
  description: 重複ジョブ実行を防ぐアクティブなユニークロック
241
241
  empty: アクティブなロックはありません
242
- executing: 実行中
243
242
  headers:
244
243
  age: 経過時間
245
- expires: 有効期限
246
- job_class: ジョブクラス
247
244
  lock_key: ロックキー
248
- owner: 所有者
249
- state: 状態
250
- queued: キューに追加済み
251
- title: ジョブロック
245
+ msg_id: メッセージID
246
+ queue_name: キュー
247
+ title: 一意性キー
252
248
  outbox:
253
249
  index:
254
250
  description: PGMQへの公開待ちのトランザクショナルアウトボックスエントリ
@@ -305,23 +301,35 @@ ja:
305
301
  purge_confirm: "%{name} からすべてのメッセージを削除しますか?"
306
302
  resume: 再開
307
303
  show:
304
+ arguments: 引数
308
305
  delete_confirm: このキューを完全に削除しますか?この操作は取り消せません。
309
306
  delete_queue: キューを削除
310
307
  depth: 深さ:
311
308
  discard: 破棄
312
309
  discard_confirm: このメッセージを破棄しますか?
313
310
  empty: キューは空です
311
+ full_json_payload: 完全なJSONペイロード
314
312
  headers:
315
- actions: アクション
316
313
  enqueued: エンキュー済み
317
314
  id: ID
318
- payload: ペイロード
315
+ job_class: ジョブクラス
319
316
  reads: 読み取り回数
320
317
  vt: VT
318
+ headers_section: ヘッダー
319
+ job_id: ジョブID:
321
320
  message_discard_failed: メッセージを破棄できませんでした。
322
321
  message_discarded: メッセージを破棄しました。
323
322
  message_retried: メッセージの可視性をリセットしました。
324
323
  message_retry_failed: メッセージをリトライできませんでした。
324
+ metadata: メタデータ
325
+ metadata_labels:
326
+ last_read: 最終読み取り:
327
+ locale: ロケール:
328
+ priority: 優先度:
329
+ queue: キュー:
330
+ scheduled: スケジュール済み:
331
+ timezone: タイムゾーン:
332
+ visible_at: 表示可能日時:
325
333
  pause: 一時停止
326
334
  pause_confirm: 処理を一時停止しますか?
327
335
  purge_confirm: すべてのメッセージを削除しますか?
@@ -337,6 +345,10 @@ ja:
337
345
  one: "%{count} タスクが設定されています"
338
346
  other: "%{count} タスクが設定されています"
339
347
  title: 定期タスク
348
+ toggle:
349
+ disabled: タスクが無効になりました
350
+ enabled: タスクが有効になりました
351
+ failed: タスクの切り替えに失敗しました
340
352
  show:
341
353
  back: 戻る
342
354
  configuration: 設定
@@ -239,16 +239,12 @@ nb:
239
239
  index:
240
240
  description: Aktive unike låser som forhindrer duplisert jobbkjøring
241
241
  empty: Ingen aktive låser
242
- executing: Utfører
243
242
  headers:
244
243
  age: Alder
245
- expires: Utløper
246
- job_class: Jobbklasse
247
244
  lock_key: Låsenøkkel
248
- owner: Eier
249
- state: Status
250
- queued: I kø
251
- title: Jobblåser
245
+ msg_id: Meldings-ID
246
+ queue_name:
247
+ title: Unikhetsnøkler
252
248
  outbox:
253
249
  index:
254
250
  description: Transaksjonelle utgående postkasseoppføringer som venter på publisering til PGMQ
@@ -305,23 +301,35 @@ nb:
305
301
  purge_confirm: Rens alle meldinger fra %{name}?
306
302
  resume: Gjenoppta
307
303
  show:
304
+ arguments: Argumenter
308
305
  delete_confirm: Slette denne køen permanent? Dette kan ikke angres.
309
306
  delete_queue: Slett kø
310
307
  depth: 'Dybde:'
311
308
  discard: Forkast
312
309
  discard_confirm: Forkaste denne meldingen?
313
310
  empty: Køen er tom
311
+ full_json_payload: Full JSON-payload
314
312
  headers:
315
- actions: Handlinger
316
313
  enqueued: I kø
317
314
  id: ID
318
- payload: Innhold
315
+ job_class: Jobbklasse
319
316
  reads: Lesninger
320
317
  vt: VT
318
+ headers_section: Overskrifter
319
+ job_id: 'Jobb-ID:'
321
320
  message_discard_failed: Kunne ikke forkaste meldingen.
322
321
  message_discarded: Melding forkastet.
323
322
  message_retried: Meldingens synlighet tilbakestilt.
324
323
  message_retry_failed: Kunne ikke prøve meldingen igjen.
324
+ metadata: Metadata
325
+ metadata_labels:
326
+ last_read: 'Sist lest:'
327
+ locale: 'Språk:'
328
+ priority: 'Prioritet:'
329
+ queue: 'Kø:'
330
+ scheduled: 'Planlagt:'
331
+ timezone: 'Tidssone:'
332
+ visible_at: 'Synlig fra:'
325
333
  pause: Pause
326
334
  pause_confirm: Pause behandling?
327
335
  purge_confirm: Rens alle meldinger?
@@ -337,6 +345,10 @@ nb:
337
345
  one: "%{count} oppgave konfigurert"
338
346
  other: "%{count} oppgaver konfigurert"
339
347
  title: Gjentakende oppgaver
348
+ toggle:
349
+ disabled: Oppgave deaktivert
350
+ enabled: Oppgave aktivert
351
+ failed: Kunne ikke endre oppgave
340
352
  show:
341
353
  back: Tilbake
342
354
  configuration: Konfigurasjon
@@ -239,16 +239,12 @@ nl:
239
239
  index:
240
240
  description: Actieve uniekheidsvergrendelingen voorkomen dubbele taakuitvoering
241
241
  empty: Geen actieve vergrendelingen
242
- executing: Uitvoeren
243
242
  headers:
244
243
  age: Leeftijd
245
- expires: Verloopt
246
- job_class: Taakklasse
247
244
  lock_key: Vergrendelingssleutel
248
- owner: Eigenaar
249
- state: Status
250
- queued: In wachtrij
251
- title: Taakvergrendelingen
245
+ msg_id: Bericht-ID
246
+ queue_name: Wachtrij
247
+ title: Uniciteitsleutels
252
248
  outbox:
253
249
  index:
254
250
  description: Transactionele uitgaande berichten wachten op publicatie naar PGMQ
@@ -305,23 +301,35 @@ nl:
305
301
  purge_confirm: Alle berichten verwijderen uit %{name}?
306
302
  resume: Hervatten
307
303
  show:
304
+ arguments: Argumenten
308
305
  delete_confirm: Deze wachtrij permanent verwijderen? Dit kan niet ongedaan worden gemaakt.
309
306
  delete_queue: Wachtrij verwijderen
310
307
  depth: 'Diepte:'
311
308
  discard: Verwerpen
312
309
  discard_confirm: Dit bericht verwerpen?
313
310
  empty: Wachtrij is leeg
311
+ full_json_payload: Volledige JSON payload
314
312
  headers:
315
- actions: Acties
316
313
  enqueued: In de wachtrij geplaatst
317
314
  id: ID
318
- payload: Inhoud
315
+ job_class: Taakklasse
319
316
  reads: Lezingen
320
317
  vt: VT
318
+ headers_section: Headers
319
+ job_id: 'Taak ID:'
321
320
  message_discard_failed: Kon bericht niet verwerpen.
322
321
  message_discarded: Bericht verworpen.
323
322
  message_retried: Zichtbaarheid van bericht gereset.
324
323
  message_retry_failed: Kon bericht niet opnieuw proberen.
324
+ metadata: Metadata
325
+ metadata_labels:
326
+ last_read: 'Laatst gelezen:'
327
+ locale: 'Locale:'
328
+ priority: 'Prioriteit:'
329
+ queue: 'Wachtrij:'
330
+ scheduled: 'Gepland:'
331
+ timezone: 'Tijdzone:'
332
+ visible_at: 'Zichtbaar op:'
325
333
  pause: Pauzeren
326
334
  pause_confirm: Verwerking pauzeren?
327
335
  purge_confirm: Alle berichten verwijderen?
@@ -337,6 +345,10 @@ nl:
337
345
  one: "%{count} taak geconfigureerd"
338
346
  other: "%{count} taken geconfigureerd"
339
347
  title: Terugkerende taken
348
+ toggle:
349
+ disabled: Taak uitgeschakeld
350
+ enabled: Taak ingeschakeld
351
+ failed: Kon taak niet omschakelen
340
352
  show:
341
353
  back: Terug
342
354
  configuration: Configuratie
@@ -239,16 +239,12 @@ pt:
239
239
  index:
240
240
  description: Bloqueios de exclusividade ativos impedindo a execução duplicada do trabalho
241
241
  empty: Nenhum bloqueio ativo
242
- executing: Executando
243
242
  headers:
244
243
  age: Idade
245
- expires: Expira
246
- job_class: Classe do Trabalho
247
244
  lock_key: Chave de Bloqueio
248
- owner: Proprietário
249
- state: Estado
250
- queued: Na fila
251
- title: Bloqueios de Trabalho
245
+ msg_id: ID da mensagem
246
+ queue_name: Fila
247
+ title: Chaves de unicidade
252
248
  outbox:
253
249
  index:
254
250
  description: Entradas da caixa de saída transacional pendentes de publicação para PGMQ
@@ -305,23 +301,35 @@ pt:
305
301
  purge_confirm: Limpar todas as mensagens de %{name}?
306
302
  resume: Retomar
307
303
  show:
304
+ arguments: Argumentos
308
305
  delete_confirm: Excluir permanentemente esta fila? Esta ação não pode ser desfeita.
309
306
  delete_queue: Excluir fila
310
307
  depth: 'Profundidade:'
311
308
  discard: Descartar
312
309
  discard_confirm: Descartar esta mensagem?
313
310
  empty: Fila está vazia
311
+ full_json_payload: Carga JSON completa
314
312
  headers:
315
- actions: Ações
316
313
  enqueued: Enfileirado
317
314
  id: ID
318
- payload: Carga útil
315
+ job_class: Classe do Trabalho
319
316
  reads: Leituras
320
317
  vt: VT
318
+ headers_section: Cabeçalhos
319
+ job_id: 'ID do Trabalho:'
321
320
  message_discard_failed: Não foi possível descartar a mensagem.
322
321
  message_discarded: Mensagem descartada.
323
322
  message_retried: Visibilidade da mensagem redefinida.
324
323
  message_retry_failed: Não foi possível tentar novamente a mensagem.
324
+ metadata: Metadados
325
+ metadata_labels:
326
+ last_read: 'Última leitura:'
327
+ locale: 'Localidade:'
328
+ priority: 'Prioridade:'
329
+ queue: 'Fila:'
330
+ scheduled: 'Agendado:'
331
+ timezone: 'Fuso horário:'
332
+ visible_at: 'Visível em:'
325
333
  pause: Pausar
326
334
  pause_confirm: Pausar processamento?
327
335
  purge_confirm: Limpar todas as mensagens?
@@ -337,6 +345,10 @@ pt:
337
345
  one: "%{count} tarefa configurada"
338
346
  other: "%{count} tarefas configuradas"
339
347
  title: Tarefas Recorrentes
348
+ toggle:
349
+ disabled: Tarefa desativada
350
+ enabled: Tarefa ativada
351
+ failed: Falha ao alternar tarefa
340
352
  show:
341
353
  back: Voltar
342
354
  configuration: Configuração
@@ -239,16 +239,12 @@ sv:
239
239
  index:
240
240
  description: Aktiva unika lås som förhindrar duplicerad jobbexekvering
241
241
  empty: Inga aktiva lås
242
- executing: Utför
243
242
  headers:
244
243
  age: Ålder
245
- expires: Upphör
246
- job_class: Jobbklass
247
244
  lock_key: Låsningsnyckel
248
- owner: Ägare
249
- state: Status
250
- queued: I kö
251
- title: Jobblås
245
+ msg_id: Meddelande-ID
246
+ queue_name:
247
+ title: Unikhetsnycklar
252
248
  outbox:
253
249
  index:
254
250
  description: Transaktionella utboxposter som väntar på publicering till PGMQ
@@ -305,23 +301,35 @@ sv:
305
301
  purge_confirm: Rensa alla meddelanden från %{name}?
306
302
  resume: Återuppta
307
303
  show:
304
+ arguments: Argument
308
305
  delete_confirm: Ta bort denna kö permanent? Detta kan inte ångras.
309
306
  delete_queue: Ta bort kö
310
307
  depth: 'Djup:'
311
308
  discard: Kassera
312
309
  discard_confirm: Kassera detta meddelande?
313
310
  empty: Kön är tom
311
+ full_json_payload: Fullständig JSON-payload
314
312
  headers:
315
- actions: Åtgärder
316
313
  enqueued: Inlagd
317
314
  id: ID
318
- payload: Innehåll
315
+ job_class: Jobbklass
319
316
  reads: Läsningar
320
317
  vt: VT
318
+ headers_section: Headers
319
+ job_id: 'Jobb-ID:'
321
320
  message_discard_failed: Kunde inte kassera meddelandet.
322
321
  message_discarded: Meddelande kasserat.
323
322
  message_retried: Meddelandets synlighet återställd.
324
323
  message_retry_failed: Kunde inte försöka igen med meddelandet.
324
+ metadata: Metadata
325
+ metadata_labels:
326
+ last_read: 'Senast läst:'
327
+ locale: 'Språk:'
328
+ priority: 'Prioritet:'
329
+ queue: 'Kö:'
330
+ scheduled: 'Schemalagt:'
331
+ timezone: 'Tidszon:'
332
+ visible_at: 'Synlig vid:'
325
333
  pause: Pausa
326
334
  pause_confirm: Pausa bearbetning?
327
335
  purge_confirm: Rensa alla meddelanden?
@@ -337,6 +345,10 @@ sv:
337
345
  one: "%{count} uppgift konfigurerad"
338
346
  other: "%{count} uppgifter konfigurerade"
339
347
  title: Återkommande uppgifter
348
+ toggle:
349
+ disabled: Uppgift inaktiverad
350
+ enabled: Uppgift aktiverad
351
+ failed: Kunde inte ändra uppgift
340
352
  show:
341
353
  back: Tillbaka
342
354
  configuration: Konfiguration
data/config/routes.rb CHANGED
@@ -22,6 +22,8 @@ Pgbus::Engine.routes.draw do
22
22
  post :retry_all
23
23
  post :discard_all
24
24
  post :discard_all_enqueued
25
+ post :discard_selected_failed
26
+ post :discard_selected_enqueued
25
27
  end
26
28
  end
27
29
 
@@ -48,11 +50,20 @@ Pgbus::Engine.routes.draw do
48
50
  collection do
49
51
  post :retry_all
50
52
  post :discard_all
53
+ post :discard_selected
51
54
  end
52
55
  end
53
56
 
54
57
  resources :outbox, only: [:index], controller: "outbox"
55
- resources :locks, only: [:index]
58
+ resources :locks, only: [:index] do
59
+ member do
60
+ post :discard
61
+ end
62
+ collection do
63
+ post :discard_selected
64
+ post :discard_all
65
+ end
66
+ end
56
67
  resource :insights, only: [:show], controller: "insights"
57
68
 
58
69
  get :set_locale, to: "locale#update"
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Pgbus
7
+ module Generators
8
+ class MigrateJobLocksGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Migrate pgbus_job_locks to lightweight pgbus_uniqueness_keys table"
14
+
15
+ class_option :database,
16
+ type: :string,
17
+ default: nil,
18
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
+
20
+ def create_migration_file
21
+ if separate_database?
22
+ migration_template "migrate_job_locks_to_uniqueness_keys.rb.erb",
23
+ "db/pgbus_migrate/migrate_pgbus_job_locks_to_uniqueness_keys.rb"
24
+ else
25
+ migration_template "migrate_job_locks_to_uniqueness_keys.rb.erb",
26
+ "db/migrate/migrate_pgbus_job_locks_to_uniqueness_keys.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus uniqueness keys migration created!", :green
33
+ say ""
34
+ say "This migration will:"
35
+ say " 1. Create the new pgbus_uniqueness_keys table (3 columns, 1 index)"
36
+ say " 2. Migrate existing locks from pgbus_job_locks"
37
+ say " 3. Drop the old pgbus_job_locks table (8 columns, 3 indexes)"
38
+ say ""
39
+ say "Next steps:"
40
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
41
+ say " 2. Restart pgbus: bin/pgbus start"
42
+ say ""
43
+ end
44
+
45
+ private
46
+
47
+ def migration_version
48
+ "[#{ActiveRecord::Migration.current_version}]"
49
+ end
50
+
51
+ def separate_database?
52
+ options[:database].present?
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,13 @@
1
+ class AddPgbusUniquenessKeys < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_uniqueness_keys, id: false do |t|
4
+ t.string :lock_key, null: false
5
+ t.string :queue_name, null: false
6
+ t.bigint :msg_id, null: false
7
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
8
+ end
9
+
10
+ add_index :pgbus_uniqueness_keys, :lock_key,
11
+ unique: true, name: "idx_pgbus_uniqueness_keys_key"
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ class MigratePgbusJobLocksToUniquenessKeys < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ # Create the new lightweight uniqueness keys table
4
+ unless table_exists?(:pgbus_uniqueness_keys)
5
+ create_table :pgbus_uniqueness_keys, id: false do |t|
6
+ t.string :lock_key, null: false
7
+ t.string :queue_name, null: false
8
+ t.bigint :msg_id, null: false
9
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
10
+ end
11
+
12
+ add_index :pgbus_uniqueness_keys, :lock_key,
13
+ unique: true, name: "idx_pgbus_uniqueness_keys_key"
14
+ end
15
+
16
+ # Drop the old table. Require it to be empty — active locks should be
17
+ # drained before migrating (stop workers, let VT expire, restart).
18
+ if table_exists?(:pgbus_job_locks)
19
+ count = execute("SELECT COUNT(*) FROM pgbus_job_locks").first["count"].to_i
20
+ if count > 0
21
+ raise "pgbus_job_locks has #{count} active lock(s). " \
22
+ "Drain workers and wait for locks to clear before migrating."
23
+ end
24
+
25
+ drop_table :pgbus_job_locks
26
+ end
27
+ end
28
+
29
+ def down
30
+ raise ActiveRecord::IrreversibleMigration,
31
+ "Cannot safely reconstruct pgbus_job_locks from pgbus_uniqueness_keys"
32
+ end
33
+ end
@@ -7,9 +7,10 @@ module Pgbus
7
7
  class Executor
8
8
  attr_reader :client, :config
9
9
 
10
- def initialize(client: Pgbus.client, config: Pgbus.configuration)
10
+ def initialize(client: Pgbus.client, config: Pgbus.configuration, stat_buffer: nil)
11
11
  @client = client
12
12
  @config = config
13
+ @stat_buffer = stat_buffer
13
14
  end
14
15
 
15
16
  def execute(message, queue_name, source_queue: nil)
@@ -29,15 +30,14 @@ module Pgbus
29
30
  job_class = payload["job_class"]
30
31
  uniqueness_key = Uniqueness.extract_key(payload)
31
32
  uniqueness_strategy = Uniqueness.extract_strategy(payload)
32
- uniqueness_ttl = payload[Uniqueness::TTL_KEY] || Uniqueness::DEFAULT_LOCK_TTL
33
33
 
34
34
  if uniqueness_key
35
35
  case uniqueness_strategy
36
36
  when :until_executed
37
- # Transition the queued lock to executing state with our PID.
38
- # The lock was acquired at enqueue time now we claim ownership
39
- # so the reaper can correlate it with our heartbeat.
40
- Uniqueness.claim_for_execution!(uniqueness_key, ttl: uniqueness_ttl)
37
+ # No claim step needed PGMQ's visibility timeout is the execution lock.
38
+ # The uniqueness key row was inserted at enqueue time and will be
39
+ # released on completion or DLQ.
40
+ nil
41
41
  when :while_executing
42
42
  # Acquire the lock now. If another worker is already executing
43
43
  # this job, skip it — VT will expire and it'll be retried.
@@ -96,18 +96,20 @@ module Pgbus
96
96
  def record_stat(payload, queue_name, status, start_time, message: nil)
97
97
  return unless config.stats_enabled
98
98
 
99
- duration_ms = ((monotonic_now - start_time) * 1000).round
100
- enqueue_latency_ms = compute_enqueue_latency(message)
101
- retry_count = message ? [message.read_ct.to_i - 1, 0].max : 0
102
-
103
- JobStat.record!(
99
+ attrs = {
104
100
  job_class: payload&.dig("job_class") || "unknown",
105
101
  queue_name: queue_name,
106
102
  status: status,
107
- duration_ms: duration_ms,
108
- enqueue_latency_ms: enqueue_latency_ms,
109
- retry_count: retry_count
110
- )
103
+ duration_ms: ((monotonic_now - start_time) * 1000).round,
104
+ enqueue_latency_ms: compute_enqueue_latency(message),
105
+ retry_count: message ? [message.read_ct.to_i - 1, 0].max : 0
106
+ }
107
+
108
+ if @stat_buffer
109
+ @stat_buffer.push(attrs)
110
+ else
111
+ JobStat.record!(**attrs)
112
+ end
111
113
  rescue StandardError => e
112
114
  Pgbus.logger.debug { "[Pgbus] Stat recording failed: #{e.message}" }
113
115
  end
@@ -115,18 +117,30 @@ module Pgbus
115
117
  def compute_enqueue_latency(message)
116
118
  return unless message
117
119
 
118
- enqueued_at_str = message.enqueued_at
119
- return unless enqueued_at_str
120
+ enqueued_at = message.enqueued_at
121
+ return unless enqueued_at
122
+
123
+ # Fast path: numeric epoch (float seconds) avoids Time.parse entirely.
124
+ # PGMQ returns enqueued_at as a Time or string depending on the driver.
125
+ case enqueued_at
126
+ when Numeric
127
+ [((Time.now.to_f - enqueued_at) * 1000).round, 0].max
128
+ when Time
129
+ [((Time.now.utc - enqueued_at.utc) * 1000).round, 0].max
130
+ else
131
+ parse_enqueue_latency_from_string(enqueued_at.to_s)
132
+ end
133
+ rescue ArgumentError, TypeError
134
+ nil
135
+ end
120
136
 
121
- str = enqueued_at_str.to_s
137
+ def parse_enqueue_latency_from_string(str)
122
138
  # PGMQ enqueued_at is TIMESTAMPTZ (always UTC internally).
123
139
  # If the string lacks an explicit offset, assume UTC to avoid
124
140
  # misinterpretation when the system timezone is non-UTC.
125
141
  str = "#{str} UTC" unless str.match?(/[+-]\d{2}:?\d{2}\s*$|Z\s*$/i)
126
142
  enqueued_at = Time.parse(str)
127
143
  [((Time.now.utc - enqueued_at) * 1000).round, 0].max
128
- rescue ArgumentError, TypeError
129
- nil
130
144
  end
131
145
 
132
146
  def handle_failure(_message, _queue_name, error)
data/lib/pgbus/client.rb CHANGED
@@ -76,8 +76,7 @@ module Pgbus
76
76
  def send_batch(queue_name, payloads, headers: nil, delay: 0)
77
77
  full_name = config.queue_name(queue_name)
78
78
  ensure_queue(queue_name)
79
- serialized = payloads.map { |p| serialize(p) }
80
- serialized_headers = headers&.map { |h| h.nil? ? nil : serialize(h) }
79
+ serialized, serialized_headers = serialize_batch(payloads, headers)
81
80
  Instrumentation.instrument("pgbus.client.send_batch", queue: full_name, size: payloads.size) do
82
81
  synchronized { @pgmq.produce_batch(full_name, serialized, headers: serialized_headers, delay: delay) }
83
82
  end
@@ -378,5 +377,22 @@ module Pgbus
378
377
  JSON.generate(data)
379
378
  end
380
379
  end
380
+
381
+ # Single-pass serialization of payloads and optional headers.
382
+ # Avoids two separate .map iterations over the same index range.
383
+ def serialize_batch(payloads, headers)
384
+ serialized = Array.new(payloads.size)
385
+ serialized_headers = headers ? Array.new(headers.size) : nil
386
+
387
+ payloads.each_with_index do |p, i|
388
+ serialized[i] = serialize(p)
389
+ if serialized_headers && i < headers.size
390
+ h = headers[i]
391
+ serialized_headers[i] = h.nil? ? nil : serialize(h)
392
+ end
393
+ end
394
+
395
+ [serialized, serialized_headers]
396
+ end
381
397
  end
382
398
  end
@@ -144,16 +144,39 @@ module Pgbus
144
144
  end
145
145
 
146
146
  def cleanup_job_locks
147
- # Primary: reap orphaned locks whose owner worker is no longer alive.
148
- # Cross-references (owner_pid, owner_hostname) against pgbus_processes heartbeats.
149
- reaped = JobLock.reap_orphaned!
150
- Pgbus.logger.info { "[Pgbus] Reaped #{reaped} orphaned job locks" } if reaped.positive?
151
-
152
- # Last resort: clean up locks with expired TTL (handles case where
153
- # even the reaper/supervisor is dead and locks are truly abandoned).
154
- expired = JobLock.cleanup_expired!
155
- Pgbus.logger.debug { "[Pgbus] Cleaned up #{expired} expired job locks" } if expired.positive?
156
- # No rescue here — let run_if_due handle the error and retry next tick
147
+ # Clean up orphaned uniqueness keys whose msg_id no longer exists
148
+ # in any PGMQ queue. This handles the rare case where a message is
149
+ # lost (e.g., queue table truncated) but the uniqueness key remains.
150
+ reaped = reap_orphaned_uniqueness_keys
151
+ Pgbus.logger.info { "[Pgbus] Reaped #{reaped} orphaned uniqueness keys" } if reaped.positive?
152
+ end
153
+
154
+ def reap_orphaned_uniqueness_keys
155
+ keys = UniquenessKey.all.to_a
156
+ return 0 if keys.empty?
157
+
158
+ threshold = Time.current - (config.visibility_timeout * 2)
159
+
160
+ orphaned = keys.select do |key|
161
+ # msg_id == 0 means pre-produce placeholder or :while_executing lock.
162
+ # These are live locks — never reap them based on msg_id alone.
163
+ # Only reap if old enough that the job is certainly gone.
164
+ next false if key.msg_id.zero? && (!key.created_at || key.created_at >= threshold)
165
+ next true if key.msg_id.zero? && key.created_at && key.created_at < threshold
166
+
167
+ # For real msg_ids, only reap if stale (old enough that VT has
168
+ # long expired). The message itself may still be in the queue
169
+ # awaiting retry — age is the only safe signal without scanning
170
+ # every queue table.
171
+ key.created_at && key.created_at < threshold
172
+ end
173
+
174
+ return 0 if orphaned.empty?
175
+
176
+ UniquenessKey.where(lock_key: orphaned.map(&:lock_key)).delete_all
177
+ rescue StandardError => e
178
+ Pgbus.logger.warn { "[Pgbus] Uniqueness key cleanup failed: #{e.message}" }
179
+ 0
157
180
  end
158
181
 
159
182
  def cleanup_outbox