pgbus 0.9.4 → 0.9.6

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +115 -0
  3. data/Rakefile +56 -3
  4. data/app/controllers/pgbus/dead_letter_controller.rb +5 -1
  5. data/app/helpers/pgbus/application_helper.rb +12 -2
  6. data/app/views/pgbus/dashboard/_processes_table.html.erb +1 -1
  7. data/app/views/pgbus/dashboard/_recent_failures.html.erb +1 -1
  8. data/app/views/pgbus/dashboard/_stats_cards.html.erb +1 -0
  9. data/app/views/pgbus/dead_letter/_messages_table.html.erb +33 -1
  10. data/app/views/pgbus/insights/show.html.erb +1 -1
  11. data/app/views/pgbus/jobs/_failed_table.html.erb +1 -1
  12. data/app/views/pgbus/processes/_processes_table.html.erb +1 -1
  13. data/app/views/pgbus/queues/show.html.erb +3 -0
  14. data/config/locales/da.yml +8 -0
  15. data/config/locales/de.yml +8 -0
  16. data/config/locales/en.yml +8 -0
  17. data/config/locales/es.yml +8 -0
  18. data/config/locales/fi.yml +8 -0
  19. data/config/locales/fr.yml +8 -0
  20. data/config/locales/it.yml +8 -0
  21. data/config/locales/ja.yml +8 -0
  22. data/config/locales/nb.yml +8 -0
  23. data/config/locales/nl.yml +8 -0
  24. data/config/locales/pt.yml +8 -0
  25. data/config/locales/sv.yml +8 -0
  26. data/lib/pgbus/cli.rb +16 -0
  27. data/lib/pgbus/client.rb +34 -7
  28. data/lib/pgbus/configuration.rb +17 -1
  29. data/lib/pgbus/integrations/appsignal/dashboard.json +353 -0
  30. data/lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json +224 -85
  31. data/lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json +154 -63
  32. data/lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json +218 -79
  33. data/lib/pgbus/integrations/appsignal/probe.rb +17 -2
  34. data/lib/pgbus/integrations/appsignal.rb +17 -0
  35. data/lib/pgbus/mcp/base_tool.rb +79 -0
  36. data/lib/pgbus/mcp/health_analyzer.rb +159 -0
  37. data/lib/pgbus/mcp/rack_app.rb +97 -0
  38. data/lib/pgbus/mcp/redactor.rb +72 -0
  39. data/lib/pgbus/mcp/runner.rb +73 -0
  40. data/lib/pgbus/mcp/server.rb +62 -0
  41. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +72 -0
  42. data/lib/pgbus/mcp/tools/dlq_tool.rb +48 -0
  43. data/lib/pgbus/mcp/tools/health_tool.rb +33 -0
  44. data/lib/pgbus/mcp/tools/job_detail_tool.rb +42 -0
  45. data/lib/pgbus/mcp/tools/jobs_tool.rb +56 -0
  46. data/lib/pgbus/mcp/tools/locks_tool.rb +28 -0
  47. data/lib/pgbus/mcp/tools/processes_tool.rb +32 -0
  48. data/lib/pgbus/mcp/tools/queue_detail_tool.rb +46 -0
  49. data/lib/pgbus/mcp/tools/queues_tool.rb +28 -0
  50. data/lib/pgbus/mcp/tools/recurring_tool.rb +27 -0
  51. data/lib/pgbus/mcp/tools/stats_tool.rb +40 -0
  52. data/lib/pgbus/mcp/tools/throughput_tool.rb +35 -0
  53. data/lib/pgbus/mcp.rb +55 -0
  54. data/lib/pgbus/process/heartbeat.rb +8 -2
  55. data/lib/pgbus/process/supervisor.rb +44 -0
  56. data/lib/pgbus/process/worker.rb +12 -1
  57. data/lib/pgbus/version.rb +1 -1
  58. data/lib/pgbus/web/data_source.rb +40 -2
  59. data/lib/pgbus.rb +15 -1
  60. metadata +21 -1
@@ -84,6 +84,7 @@ ja:
84
84
  stats_cards:
85
85
  enqueued: キューに追加済み
86
86
  failed_dlq: 失敗 / DLQ
87
+ failed_dlq_labels: 失敗 / Dead Lettered
87
88
  processes: プロセス
88
89
  queues: キュー
89
90
  recurring: 繰り返し
@@ -126,6 +127,7 @@ ja:
126
127
  queue: 'キュー:'
127
128
  visible_at: '表示日時:'
128
129
  retry: 再試行
130
+ total_label: デッドレターキュー内のメッセージ
129
131
  show:
130
132
  back: DLQに戻る
131
133
  discard: 破棄
@@ -225,6 +227,10 @@ ja:
225
227
  bulk_select_all: すべて選択
226
228
  bulk_select_row: "%{id}を選択"
227
229
  bulk_selected: 選択済み
230
+ pagination:
231
+ next: 次へ
232
+ previous: 前へ
233
+ showing: "%{total} 件中 %{from}–%{to} 件を表示"
228
234
  paused_badge: 一時停止中
229
235
  queue_badge:
230
236
  dlq: DLQ
@@ -235,6 +241,7 @@ ja:
235
241
  status_badge:
236
242
  healthy: 正常
237
243
  stale: 期限切れ
244
+ stalled: 停滞
238
245
  insights:
239
246
  show:
240
247
  charts:
@@ -476,6 +483,7 @@ ja:
476
483
  resume: 再開
477
484
  show:
478
485
  arguments: 引数
486
+ back: キューに戻る
479
487
  delete_confirm: このキューを完全に削除しますか?この操作は取り消せません。
480
488
  delete_queue: キューを削除
481
489
  depth: 深さ:
@@ -84,6 +84,7 @@ nb:
84
84
  stats_cards:
85
85
  enqueued: I kø
86
86
  failed_dlq: Mislyktes / DLQ
87
+ failed_dlq_labels: Mislyktes / Dead Lettered
87
88
  processes: Prosesser
88
89
  queues: Køer
89
90
  recurring: Gjentakende
@@ -126,6 +127,7 @@ nb:
126
127
  queue: 'Kø:'
127
128
  visible_at: 'Synlig ved:'
128
129
  retry: Prøv igjen
130
+ total_label: meldinger i dead letter-kø
129
131
  show:
130
132
  back: Tilbake til DLQ
131
133
  discard: Forkast
@@ -225,6 +227,10 @@ nb:
225
227
  bulk_select_all: Velg alle
226
228
  bulk_select_row: Velg %{id}
227
229
  bulk_selected: valgt
230
+ pagination:
231
+ next: Neste
232
+ previous: Forrige
233
+ showing: Viser %{from}–%{to} av %{total}
228
234
  paused_badge: Pause
229
235
  queue_badge:
230
236
  dlq: DLQ
@@ -235,6 +241,7 @@ nb:
235
241
  status_badge:
236
242
  healthy: Sunn
237
243
  stale: Utdatert
244
+ stalled: Fastlåst
238
245
  insights:
239
246
  show:
240
247
  charts:
@@ -476,6 +483,7 @@ nb:
476
483
  resume: Gjenoppta
477
484
  show:
478
485
  arguments: Argumenter
486
+ back: Tilbake til køer
479
487
  delete_confirm: Slette denne køen permanent? Dette kan ikke angres.
480
488
  delete_queue: Slett kø
481
489
  depth: 'Dybde:'
@@ -84,6 +84,7 @@ nl:
84
84
  stats_cards:
85
85
  enqueued: In de wachtrij geplaatst
86
86
  failed_dlq: Mislukt / DLQ
87
+ failed_dlq_labels: Mislukt / Dead Lettered
87
88
  processes: Processen
88
89
  queues: Wachtrijen
89
90
  recurring: Terugkerend
@@ -126,6 +127,7 @@ nl:
126
127
  queue: 'Wachtrij:'
127
128
  visible_at: 'Zichtbaar op:'
128
129
  retry: Opnieuw proberen
130
+ total_label: berichten in dead letter-wachtrij
129
131
  show:
130
132
  back: Terug naar DLQ
131
133
  discard: Verwerpen
@@ -225,6 +227,10 @@ nl:
225
227
  bulk_select_all: Alles selecteren
226
228
  bulk_select_row: Selecteer %{id}
227
229
  bulk_selected: geselecteerd
230
+ pagination:
231
+ next: Volgende
232
+ previous: Vorige
233
+ showing: "%{from}–%{to} van %{total} weergegeven"
228
234
  paused_badge: Gepauzeerd
229
235
  queue_badge:
230
236
  dlq: DLQ
@@ -235,6 +241,7 @@ nl:
235
241
  status_badge:
236
242
  healthy: Gezond
237
243
  stale: Verouderd
244
+ stalled: Vastgelopen
238
245
  insights:
239
246
  show:
240
247
  charts:
@@ -476,6 +483,7 @@ nl:
476
483
  resume: Hervatten
477
484
  show:
478
485
  arguments: Argumenten
486
+ back: Terug naar wachtrijen
479
487
  delete_confirm: Deze wachtrij permanent verwijderen? Dit kan niet ongedaan worden gemaakt.
480
488
  delete_queue: Wachtrij verwijderen
481
489
  depth: 'Diepte:'
@@ -84,6 +84,7 @@ pt:
84
84
  stats_cards:
85
85
  enqueued: Enfileirado
86
86
  failed_dlq: Falhou / DLQ
87
+ failed_dlq_labels: Falhados / Dead Lettered
87
88
  processes: Processos
88
89
  queues: Filas
89
90
  recurring: Recorrente
@@ -126,6 +127,7 @@ pt:
126
127
  queue: 'Fila:'
127
128
  visible_at: 'Visível em:'
128
129
  retry: Tentar novamente
130
+ total_label: mensagens na fila de mensagens mortas
129
131
  show:
130
132
  back: Voltar para DLQ
131
133
  discard: Descartar
@@ -225,6 +227,10 @@ pt:
225
227
  bulk_select_all: Selecionar todos
226
228
  bulk_select_row: Selecionar %{id}
227
229
  bulk_selected: selecionado
230
+ pagination:
231
+ next: Próximo
232
+ previous: Anterior
233
+ showing: Mostrando %{from}–%{to} de %{total}
228
234
  paused_badge: Pausado
229
235
  queue_badge:
230
236
  dlq: DLQ
@@ -235,6 +241,7 @@ pt:
235
241
  status_badge:
236
242
  healthy: Saudável
237
243
  stale: Obsoleto
244
+ stalled: Bloqueado
238
245
  insights:
239
246
  show:
240
247
  charts:
@@ -476,6 +483,7 @@ pt:
476
483
  resume: Retomar
477
484
  show:
478
485
  arguments: Argumentos
486
+ back: Voltar às filas
479
487
  delete_confirm: Excluir permanentemente esta fila? Esta ação não pode ser desfeita.
480
488
  delete_queue: Excluir fila
481
489
  depth: 'Profundidade:'
@@ -84,6 +84,7 @@ sv:
84
84
  stats_cards:
85
85
  enqueued: Inlagda
86
86
  failed_dlq: Misslyckade / DLQ
87
+ failed_dlq_labels: Misslyckade / Dead Lettered
87
88
  processes: Processer
88
89
  queues: Köer
89
90
  recurring: Återkommande
@@ -126,6 +127,7 @@ sv:
126
127
  queue: 'Kö:'
127
128
  visible_at: 'Synlig vid:'
128
129
  retry: Försök igen
130
+ total_label: meddelanden i dead letter-kö
129
131
  show:
130
132
  back: Tillbaka till DLQ
131
133
  discard: Kassera
@@ -225,6 +227,10 @@ sv:
225
227
  bulk_select_all: Markera alla
226
228
  bulk_select_row: Markera %{id}
227
229
  bulk_selected: valda
230
+ pagination:
231
+ next: Nästa
232
+ previous: Föregående
233
+ showing: Visar %{from}–%{to} av %{total}
228
234
  paused_badge: Pausad
229
235
  queue_badge:
230
236
  dlq: DLQ
@@ -235,6 +241,7 @@ sv:
235
241
  status_badge:
236
242
  healthy: Hälsosam
237
243
  stale: Föråldrad
244
+ stalled: Fastlåst
238
245
  insights:
239
246
  show:
240
247
  charts:
@@ -476,6 +483,7 @@ sv:
476
483
  resume: Återuppta
477
484
  show:
478
485
  arguments: Argument
486
+ back: Tillbaka till köer
479
487
  delete_confirm: Ta bort denna kö permanent? Detta kan inte ångras.
480
488
  delete_queue: Ta bort kö
481
489
  depth: 'Djup:'
data/lib/pgbus/cli.rb CHANGED
@@ -16,6 +16,8 @@ module Pgbus
16
16
  show_status
17
17
  when "queues"
18
18
  list_queues
19
+ when "mcp"
20
+ start_mcp_server
19
21
  when "version"
20
22
  puts "pgbus #{Pgbus::VERSION}"
21
23
  when "help", "--help", "-h"
@@ -132,6 +134,13 @@ module Pgbus
132
134
  end
133
135
  end
134
136
 
137
+ # Boots the read-only MCP diagnostic server over stdio. Loaded lazily so
138
+ # the optional `mcp` gem is only required when this command is invoked.
139
+ def start_mcp_server
140
+ Pgbus::MCP.load!
141
+ Pgbus::MCP::Runner.run
142
+ end
143
+
135
144
  def list_queues
136
145
  Pgbus.client.list_queues
137
146
  metrics = Pgbus.client.metrics
@@ -154,6 +163,7 @@ module Pgbus
154
163
  start Start the Pgbus supervisor (workers + dispatcher)
155
164
  status Show running Pgbus processes
156
165
  queues List queues with metrics
166
+ mcp Start the read-only MCP diagnostic server over stdio
157
167
  version Show version
158
168
  help Show this help
159
169
 
@@ -172,6 +182,12 @@ module Pgbus
172
182
  pattern)
173
183
  --execution-mode Execution mode: threads (default) or async
174
184
  (fiber-based, lower connection usage)
185
+
186
+ Environment for `mcp`:
187
+ PGBUS_MCP_TOKEN If set, PGBUS_MCP_AUTH_TOKEN must match for
188
+ the server to start (optional token gate).
189
+ PGBUS_MCP_ALLOW_PAYLOADS Truthy to let tools return raw message
190
+ bodies/headers (off by default; redacted).
175
191
  HELP
176
192
  end
177
193
  end
data/lib/pgbus/client.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "timeout"
4
5
  require_relative "client/read_after"
5
6
  require_relative "client/ensure_stream_queue"
6
7
  require_relative "client/notify_stream"
@@ -110,7 +111,7 @@ module Pgbus
110
111
  full_name = config.queue_name(queue_name)
111
112
  Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
112
113
  with_stale_connection_retry do
113
- synchronized { @pgmq.read(full_name, vt: vt || config.visibility_timeout) }
114
+ synchronized { with_read_timeout { @pgmq.read(full_name, vt: vt || config.visibility_timeout) } }
114
115
  end
115
116
  end
116
117
  end
@@ -119,7 +120,7 @@ module Pgbus
119
120
  full_name = config.queue_name(queue_name)
120
121
  Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
121
122
  with_stale_connection_retry do
122
- synchronized { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) }
123
+ synchronized { with_read_timeout { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
123
124
  end
124
125
  end
125
126
  end
@@ -141,7 +142,7 @@ module Pgbus
141
142
 
142
143
  msgs = Instrumentation.instrument("pgbus.client.read_batch", queue: pq_name, qty: remaining) do
143
144
  with_stale_connection_retry do
144
- synchronized { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) }
145
+ synchronized { with_read_timeout { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) } }
145
146
  end
146
147
  end || []
147
148
 
@@ -180,7 +181,9 @@ module Pgbus
180
181
  Instrumentation.instrument("pgbus.client.read_multi", queues: full_names, qty: qty, limit: limit) do
181
182
  with_stale_connection_retry do
182
183
  synchronized do
183
- @pgmq.read_multi(full_names, vt: vt || config.visibility_timeout, qty: qty, limit: limit)
184
+ with_read_timeout do
185
+ @pgmq.read_multi(full_names, vt: vt || config.visibility_timeout, qty: qty, limit: limit)
186
+ end
184
187
  end
185
188
  end
186
189
  end
@@ -357,7 +360,7 @@ module Pgbus
357
360
  full_name = config.queue_name(queue_name)
358
361
  Instrumentation.instrument("pgbus.client.read_grouped", queue: full_name, qty: qty) do
359
362
  with_stale_connection_retry do
360
- synchronized { @pgmq.read_grouped(full_name, vt: vt || config.visibility_timeout, qty: qty) }
363
+ synchronized { with_read_timeout { @pgmq.read_grouped(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
361
364
  end
362
365
  end
363
366
  end
@@ -366,7 +369,7 @@ module Pgbus
366
369
  full_name = config.queue_name(queue_name)
367
370
  Instrumentation.instrument("pgbus.client.read_grouped_rr", queue: full_name, qty: qty) do
368
371
  with_stale_connection_retry do
369
- synchronized { @pgmq.read_grouped_rr(full_name, vt: vt || config.visibility_timeout, qty: qty) }
372
+ synchronized { with_read_timeout { @pgmq.read_grouped_rr(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
370
373
  end
371
374
  end
372
375
  end
@@ -374,7 +377,7 @@ module Pgbus
374
377
  def read_grouped_head(queue_name, qty:, vt: nil)
375
378
  full_name = config.queue_name(queue_name)
376
379
  with_stale_connection_retry do
377
- synchronized { @pgmq.read_grouped_head(full_name, vt: vt || config.visibility_timeout, qty: qty) }
380
+ synchronized { with_read_timeout { @pgmq.read_grouped_head(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
378
381
  end
379
382
  end
380
383
 
@@ -696,6 +699,30 @@ module Pgbus
696
699
  # connection was dead *before* pgmq-ruby tried to use it, so no SQL was
697
700
  # ever sent. Mid-flight errors like "server closed the connection" are
698
701
  # excluded from the pattern list for this reason.
702
+ # Bound a read at config.read_timeout so a dead socket raises
703
+ # Pgbus::ReadTimeoutError instead of blocking the worker loop forever.
704
+ #
705
+ # MUST wrap only the bare `@pgmq.read*` call, sitting *inside* both
706
+ # `synchronized` and `with_stale_connection_retry`:
707
+ #
708
+ # with_stale_connection_retry { synchronized { with_read_timeout { @pgmq.read* } } }
709
+ #
710
+ # Two reasons for this nesting:
711
+ # 1. On the shared-connection path @pgmq_mutex serializes all reads.
712
+ # The timeout clock must start only after the mutex is acquired,
713
+ # otherwise a thread queued behind another read is charged for the
714
+ # wait and raises a false ReadTimeoutError for a socket it never
715
+ # touched.
716
+ # 2. with_stale_connection_retry can retry once; with the timeout
717
+ # inside, each socket attempt gets its own full timeout budget
718
+ # rather than sharing one across both attempts.
719
+ def with_read_timeout(&)
720
+ timeout = config.read_timeout
721
+ return yield unless timeout&.positive?
722
+
723
+ Timeout.timeout(timeout, Pgbus::ReadTimeoutError, &)
724
+ end
725
+
699
726
  def with_stale_connection_retry
700
727
  attempts = 0
701
728
  begin
@@ -26,6 +26,12 @@ module Pgbus
26
26
  # Worker recycling
27
27
  attr_accessor :max_jobs_per_worker, :max_memory_mb, :max_worker_lifetime
28
28
 
29
+ # Liveness probe: supervisor kills a worker whose claim loop has not
30
+ # advanced for longer than stall_threshold seconds (default 90).
31
+ # read_timeout caps how long a single PGMQ read can block (default 30s),
32
+ # so a dead socket raises instead of parking the loop forever.
33
+ attr_accessor :stall_threshold, :read_timeout
34
+
29
35
  # Dispatcher settings
30
36
  attr_accessor :dispatch_interval
31
37
 
@@ -150,6 +156,9 @@ module Pgbus
150
156
  @max_memory_mb = nil
151
157
  @max_worker_lifetime = nil
152
158
 
159
+ @stall_threshold = 90
160
+ @read_timeout = 30
161
+
153
162
  @dispatch_interval = 1.0
154
163
 
155
164
  @circuit_breaker_enabled = true
@@ -207,7 +216,7 @@ module Pgbus
207
216
  @web_per_page = 25
208
217
  @web_live_updates = true
209
218
  @web_data_source = nil
210
- @insights_default_minutes = 30 * 24 * 60 # 30 days
219
+ @insights_default_minutes = 60 # 1 hour
211
220
  @base_controller_class = "::ActionController::Base"
212
221
  @return_to_app_url = nil
213
222
  @metrics_enabled = true
@@ -374,6 +383,13 @@ module Pgbus
374
383
  raise ArgumentError, "retry_backoff_jitter must be between 0 and 1"
375
384
  end
376
385
 
386
+ unless stall_threshold.nil? || (stall_threshold.is_a?(Numeric) && stall_threshold.positive?)
387
+ raise ArgumentError, "stall_threshold must be a positive number or nil to disable"
388
+ end
389
+ unless read_timeout.nil? || (read_timeout.is_a?(Numeric) && read_timeout.positive?)
390
+ raise ArgumentError, "read_timeout must be a positive number or nil to disable"
391
+ end
392
+
377
393
  # Validate global execution_mode
378
394
  ExecutionPools.normalize_mode(execution_mode)
379
395