pgbus 0.3.3 → 0.3.4

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/pgbus/dead_letter_controller.rb +17 -0
  3. data/app/controllers/pgbus/jobs_controller.rb +36 -0
  4. data/app/controllers/pgbus/locks_controller.rb +25 -0
  5. data/app/frontend/pgbus/application.js +45 -0
  6. data/app/models/pgbus/job_lock.rb +16 -8
  7. data/app/models/pgbus/uniqueness_key.rb +36 -0
  8. data/app/views/pgbus/dead_letter/_messages_table.html.erb +22 -2
  9. data/app/views/pgbus/dead_letter/index.html.erb +9 -1
  10. data/app/views/pgbus/jobs/_enqueued_table.html.erb +36 -6
  11. data/app/views/pgbus/jobs/_failed_table.html.erb +35 -4
  12. data/app/views/pgbus/locks/index.html.erb +53 -28
  13. data/config/locales/da.yml +3 -7
  14. data/config/locales/de.yml +3 -7
  15. data/config/locales/en.yml +33 -7
  16. data/config/locales/es.yml +3 -7
  17. data/config/locales/fi.yml +3 -7
  18. data/config/locales/fr.yml +3 -7
  19. data/config/locales/it.yml +3 -7
  20. data/config/locales/ja.yml +3 -7
  21. data/config/locales/nb.yml +3 -7
  22. data/config/locales/nl.yml +3 -7
  23. data/config/locales/pt.yml +3 -7
  24. data/config/locales/sv.yml +3 -7
  25. data/config/routes.rb +12 -1
  26. data/lib/generators/pgbus/migrate_job_locks_generator.rb +56 -0
  27. data/lib/generators/pgbus/templates/add_uniqueness_keys.rb.erb +13 -0
  28. data/lib/generators/pgbus/templates/migrate_job_locks_to_uniqueness_keys.rb.erb +33 -0
  29. data/lib/pgbus/active_job/executor.rb +34 -20
  30. data/lib/pgbus/client.rb +18 -2
  31. data/lib/pgbus/process/dispatcher.rb +33 -10
  32. data/lib/pgbus/process/worker.rb +4 -1
  33. data/lib/pgbus/recurring/schedule.rb +38 -35
  34. data/lib/pgbus/stat_buffer.rb +92 -0
  35. data/lib/pgbus/uniqueness.rb +24 -39
  36. data/lib/pgbus/version.rb +1 -1
  37. data/lib/pgbus/web/data_source.rb +46 -15
  38. metadata +6 -1
@@ -44,6 +44,12 @@ en:
44
44
  index:
45
45
  discard_all: Discard All
46
46
  discard_all_confirm: Permanently discard all DLQ messages?
47
+ discard_selected: Discard Selected
48
+ discard_selected_confirm: Discard selected DLQ messages?
49
+ discarded_selected:
50
+ one: Discarded 1 DLQ message.
51
+ other: Discarded %{count} DLQ messages.
52
+ none_selected: No messages selected.
47
53
  retry_all: Retry All
48
54
  retry_all_confirm: Retry all DLQ messages?
49
55
  title: Dead Letter Queue
@@ -114,6 +120,9 @@ en:
114
120
  not_found: Event not found
115
121
  title: Event %{event_id}
116
122
  helpers:
123
+ bulk_select_all: Select all
124
+ bulk_select_row: Select %{id}
125
+ bulk_selected: selected
117
126
  paused_badge: Paused
118
127
  queue_badge:
119
128
  dlq: DLQ
@@ -216,6 +225,12 @@ en:
216
225
  discard_all: Discard All
217
226
  discard_all_confirm: Discard all failed jobs?
218
227
  discard_all_enqueued_notice: Discarded %{count} enqueued jobs and released their locks.
228
+ discard_selected: Discard Selected
229
+ discard_selected_confirm: Discard selected items?
230
+ discarded_selected:
231
+ one: Discarded 1 selected item.
232
+ other: Discarded %{count} selected items.
233
+ none_selected: No items selected.
219
234
  retry_all: Retry All
220
235
  retry_all_confirm: Retry all failed jobs?
221
236
  title: Jobs
@@ -253,18 +268,29 @@ en:
253
268
  toggle_menu: Toggle menu
254
269
  locks:
255
270
  index:
271
+ all_locks_discarded:
272
+ one: Discarded 1 lock.
273
+ other: Discarded %{count} locks.
256
274
  description: Active uniqueness locks preventing duplicate job execution
275
+ discard: Discard
276
+ discard_all: Discard All
277
+ discard_all_confirm: Permanently discard all locks? This may allow duplicate job execution.
278
+ discard_confirm: Discard this lock? The associated job may be enqueued again.
279
+ discard_selected: Discard Selected
280
+ discard_selected_confirm: Discard selected locks?
257
281
  empty: No active locks
258
- executing: Executing
259
282
  headers:
260
283
  age: Age
261
- expires: Expires
262
- job_class: Job Class
263
284
  lock_key: Lock Key
264
- owner: Owner
265
- state: State
266
- queued: Queued
267
- title: Job Locks
285
+ msg_id: Message ID
286
+ queue_name: Queue
287
+ lock_discard_failed: Could not discard lock.
288
+ lock_discarded: Lock discarded.
289
+ locks_discarded:
290
+ one: Discarded 1 lock.
291
+ other: Discarded %{count} locks.
292
+ none_selected: No locks selected.
293
+ title: Uniqueness Keys
268
294
  outbox:
269
295
  index:
270
296
  description: Transactional outbox entries pending publication to PGMQ
@@ -239,16 +239,12 @@ es:
239
239
  index:
240
240
  description: Bloqueos de unicidad activos que impiden la ejecución duplicada del trabajo
241
241
  empty: No hay bloqueos activos
242
- executing: Ejecutando
243
242
  headers:
244
243
  age: Antigüedad
245
- expires: Expira
246
- job_class: Clase de trabajo
247
244
  lock_key: Clave de bloqueo
248
- owner: Propietario
249
- state: Estado
250
- queued: En cola
251
- title: Bloqueos de trabajo
245
+ msg_id: ID de mensaje
246
+ queue_name: Cola
247
+ title: Claves de unicidad
252
248
  outbox:
253
249
  index:
254
250
  description: Entradas de bandeja de salida transaccional pendientes de publicación en PGMQ
@@ -239,16 +239,12 @@ fi:
239
239
  index:
240
240
  description: Aktiiviset ainutlaatuisuuden lukot estävät päällekkäisen työn suorittamisen
241
241
  empty: Ei aktiivisia lukkoja
242
- executing: Suoritetaan
243
242
  headers:
244
243
  age: Ikä
245
- expires: Vanhenee
246
- job_class: Työluokka
247
244
  lock_key: Lukon avain
248
- owner: Omistaja
249
- state: Tila
250
- queued: Jonossa
251
- title: Työn lukot
245
+ msg_id: Viestin tunnus
246
+ queue_name: Jono
247
+ title: Yksikäsitteisyysavaimet
252
248
  outbox:
253
249
  index:
254
250
  description: Transaktionaaliset lähetyslaatikon merkinnät odottavat julkaisua PGMQ:lle
@@ -239,16 +239,12 @@ fr:
239
239
  index:
240
240
  description: Verrous d'unicité actifs empêchant l'exécution de travaux en double
241
241
  empty: Aucun verrou actif
242
- executing: Exécution
243
242
  headers:
244
243
  age: Âge
245
- expires: Expire
246
- job_class: Classe de travail
247
244
  lock_key: Clé de verrou
248
- owner: Propriétaire
249
- state: État
250
- queued: En file d'attente
251
- title: Verrous de travail
245
+ msg_id: ID du message
246
+ queue_name: File
247
+ title: Clés d'unicité
252
248
  outbox:
253
249
  index:
254
250
  description: Entrées de boîte d'envoi transactionnelle en attente de publication vers PGMQ
@@ -239,16 +239,12 @@ it:
239
239
  index:
240
240
  description: Blocchi di unicità attivi che impediscono l'esecuzione duplicata del lavoro
241
241
  empty: Nessun blocco attivo
242
- executing: In esecuzione
243
242
  headers:
244
243
  age: Età
245
- expires: Scade
246
- job_class: Classe del lavoro
247
244
  lock_key: Chiave di blocco
248
- owner: Proprietario
249
- state: Stato
250
- queued: In coda
251
- title: Blocchi del lavoro
245
+ msg_id: ID messaggio
246
+ queue_name: Coda
247
+ title: Chiavi di unicità
252
248
  outbox:
253
249
  index:
254
250
  description: Voci dell'outbox transazionali in attesa di pubblicazione su PGMQ
@@ -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への公開待ちのトランザクショナルアウトボックスエントリ
@@ -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
@@ -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
@@ -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
@@ -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
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
@@ -25,7 +25,8 @@ module Pgbus
25
25
  @rate_counter = RateCounter.new(:processed, :failed, :dequeued)
26
26
  @started_at = Time.current
27
27
  @started_at_monotonic = monotonic_now
28
- @executor = Pgbus::ActiveJob::Executor.new
28
+ @stat_buffer = config.stats_enabled ? Pgbus::StatBuffer.new : nil
29
+ @executor = Pgbus::ActiveJob::Executor.new(stat_buffer: @stat_buffer)
29
30
  @pool = Concurrent::FixedThreadPool.new(threads)
30
31
  @circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
31
32
  @queue_lock = QueueLock.new if @single_active_consumer
@@ -62,6 +63,7 @@ module Pgbus
62
63
  break if @lifecycle.draining? && @pool.queue_length.zero?
63
64
 
64
65
  claim_and_execute if @lifecycle.can_process?
66
+ @stat_buffer&.flush_if_due
65
67
  @wake_signal.wait(timeout: config.polling_interval) if @lifecycle.draining? || @lifecycle.paused?
66
68
  end
67
69
 
@@ -318,6 +320,7 @@ module Pgbus
318
320
  Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
319
321
  @pool.shutdown
320
322
  @pool.wait_for_termination(30)
323
+ @stat_buffer&.stop
321
324
  @queue_lock&.unlock_all
322
325
  @heartbeat&.stop
323
326
  restore_signals