pgbus 0.9.7 → 0.9.9

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +454 -25
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +37 -0
  6. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  7. data/config/locales/da.yml +4 -0
  8. data/config/locales/de.yml +4 -0
  9. data/config/locales/en.yml +4 -0
  10. data/config/locales/es.yml +4 -0
  11. data/config/locales/fi.yml +4 -0
  12. data/config/locales/fr.yml +4 -0
  13. data/config/locales/it.yml +4 -0
  14. data/config/locales/ja.yml +4 -0
  15. data/config/locales/nb.yml +4 -0
  16. data/config/locales/nl.yml +4 -0
  17. data/config/locales/pt.yml +4 -0
  18. data/config/locales/sv.yml +4 -0
  19. data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  20. data/lib/pgbus/active_job/adapter.rb +1 -1
  21. data/lib/pgbus/active_job/executor.rb +25 -4
  22. data/lib/pgbus/cli/dlq.rb +164 -0
  23. data/lib/pgbus/cli.rb +18 -1
  24. data/lib/pgbus/client/connection_health.rb +194 -0
  25. data/lib/pgbus/client.rb +592 -73
  26. data/lib/pgbus/config_loader.rb +50 -4
  27. data/lib/pgbus/configuration.rb +294 -79
  28. data/lib/pgbus/dedup_cache.rb +8 -0
  29. data/lib/pgbus/doctor.rb +275 -0
  30. data/lib/pgbus/engine.rb +15 -0
  31. data/lib/pgbus/execution_pools/async_pool.rb +9 -2
  32. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  33. data/lib/pgbus/generators/config_converter.rb +7 -5
  34. data/lib/pgbus/generators/migration_detector.rb +20 -16
  35. data/lib/pgbus/instrumentation.rb +3 -1
  36. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  37. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  38. data/lib/pgbus/metrics/backend.rb +38 -0
  39. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  40. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  41. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  42. data/lib/pgbus/metrics/subscriber.rb +214 -0
  43. data/lib/pgbus/metrics.rb +43 -0
  44. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  45. data/lib/pgbus/pgmq_schema.rb +7 -2
  46. data/lib/pgbus/process/consumer.rb +354 -18
  47. data/lib/pgbus/process/consumer_priority.rb +34 -0
  48. data/lib/pgbus/process/dispatcher.rb +265 -41
  49. data/lib/pgbus/process/heartbeat.rb +18 -5
  50. data/lib/pgbus/process/memory_usage.rb +48 -0
  51. data/lib/pgbus/process/notify_listener.rb +26 -7
  52. data/lib/pgbus/process/notify_probe.rb +96 -0
  53. data/lib/pgbus/process/primary_validator.rb +53 -0
  54. data/lib/pgbus/process/signal_handler.rb +6 -0
  55. data/lib/pgbus/process/supervisor.rb +423 -50
  56. data/lib/pgbus/process/worker.rb +288 -35
  57. data/lib/pgbus/recurring/schedule.rb +1 -2
  58. data/lib/pgbus/recurring/scheduler.rb +15 -1
  59. data/lib/pgbus/serializer.rb +4 -4
  60. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  61. data/lib/pgbus/streams/signed_name.rb +2 -2
  62. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  63. data/lib/pgbus/table_maintenance.rb +13 -2
  64. data/lib/pgbus/uniqueness.rb +11 -12
  65. data/lib/pgbus/version.rb +1 -1
  66. data/lib/pgbus/web/data_source.rb +36 -4
  67. data/lib/pgbus/web/health_app.rb +102 -0
  68. data/lib/pgbus/web/health_server.rb +144 -0
  69. data/lib/pgbus/web/payload_filter.rb +3 -3
  70. data/lib/pgbus/web/streamer/instance.rb +58 -2
  71. data/lib/pgbus/web/streamer/listener.rb +69 -21
  72. data/lib/pgbus.rb +77 -0
  73. data/lib/tasks/pgbus_doctor.rake +12 -0
  74. metadata +19 -4
  75. data/app/models/pgbus/job_lock.rb +0 -98
  76. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
data/Rakefile CHANGED
@@ -8,7 +8,16 @@ end
8
8
 
9
9
  require "rubocop/rake_task"
10
10
 
11
- RuboCop::RakeTask.new
11
+ # Lint only the gem's own source — NOT the nested docs/ site. docs/ is a separate
12
+ # consuming app with its own bundle and .rubocop.yml (it inherit_gems
13
+ # rubocop-rails-omakase and requires docs_kit/rubocop, both absent from the gem's
14
+ # bundle). RuboCop loads a directory's .rubocop.yml while scanning it — before
15
+ # AllCops/Exclude applies — so a bare run discovers docs/.rubocop.yml and crashes
16
+ # on the unresolvable gem inheritance. Passing explicit paths stops the
17
+ # discovery. docs/ lints itself in the Docs site workflow. (Same fix as docs-kit.)
18
+ RuboCop::RakeTask.new do |task|
19
+ task.patterns = %w[app benchmarks config lib spec Gemfile Rakefile pgbus.gemspec]
20
+ end
12
21
 
13
22
  namespace :bench do
14
23
  bench_dir = "benchmarks"
@@ -29,6 +29,43 @@ module Pgbus
29
29
  end
30
30
  end
31
31
 
32
+ # Order the rate keys are rendered in, and their i18n label suffixes.
33
+ WORKER_RATE_KEYS = %w[processed failed dequeued].freeze
34
+
35
+ # Metadata keys rendered specially elsewhere (throughput badge, health
36
+ # status) and therefore excluded from the generic key/value dump.
37
+ WORKER_INTERNAL_METADATA_KEYS = %w[rates jobs_processed jobs_failed in_flight loop_tick_at].freeze
38
+
39
+ # The subset of a process's metadata to render as generic key/value badges:
40
+ # everything except the throughput/health keys that have dedicated rendering.
41
+ def pgbus_display_metadata(metadata)
42
+ return {} unless metadata.is_a?(Hash)
43
+
44
+ metadata.reject { |k, _| WORKER_INTERNAL_METADATA_KEYS.include?(k.to_s) }
45
+ end
46
+
47
+ # Render a worker's per-second throughput rates (from heartbeat metadata)
48
+ # as a compact human-readable string, e.g. "12.4/s processed · 0.2/s failed".
49
+ # Zero rates are omitted; returns nil when there are no non-zero rates so
50
+ # callers can fall back to the raw metadata rendering.
51
+ def pgbus_worker_rates(metadata)
52
+ return nil unless metadata.is_a?(Hash)
53
+
54
+ rates = metadata["rates"]
55
+ return nil unless rates.is_a?(Hash)
56
+
57
+ parts = WORKER_RATE_KEYS.filter_map do |key|
58
+ value = rates[key].to_f
59
+ next if value.zero?
60
+
61
+ label = I18n.t("pgbus.processes.processes_table.rates.#{key}", default: key)
62
+ "#{value}/s #{label}"
63
+ end
64
+ return nil if parts.empty?
65
+
66
+ parts.join(" · ")
67
+ end
68
+
32
69
  def pgbus_status_badge(healthy_or_status)
33
70
  status = case healthy_or_status
34
71
  when true then :healthy
@@ -21,7 +21,10 @@
21
21
  <td data-label="Last Heartbeat" class="px-4 py-3 text-sm text-gray-500"><%= pgbus_time_ago(p[:last_heartbeat_at]) %></td>
22
22
  <td data-label="Metadata" class="px-4 py-3 text-sm text-gray-500 font-mono text-xs max-w-xs truncate">
23
23
  <% if p[:metadata].is_a?(Hash) %>
24
- <% p[:metadata].each do |k, v| %>
24
+ <% if (rates = pgbus_worker_rates(p[:metadata])) %>
25
+ <span class="inline-flex items-center rounded bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 px-1.5 py-0.5 text-xs mr-1 font-medium"><%= rates %></span>
26
+ <% end %>
27
+ <% pgbus_display_metadata(p[:metadata]).each do |k, v| %>
25
28
  <span class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-xs mr-1"><%= k %>: <%= v %></span>
26
29
  <% end %>
27
30
  <% end %>
@@ -458,6 +458,10 @@ da:
458
458
  metadata: Metadata
459
459
  pid: PID
460
460
  status: Status
461
+ rates:
462
+ dequeued: hentet
463
+ failed: mislykkedes
464
+ processed: behandlet
461
465
  queues:
462
466
  destroy:
463
467
  success: Køen %{name} slettet.
@@ -458,6 +458,10 @@ de:
458
458
  metadata: Metadaten
459
459
  pid: PID
460
460
  status: Status
461
+ rates:
462
+ dequeued: entnommen
463
+ failed: fehlgeschlagen
464
+ processed: verarbeitet
461
465
  queues:
462
466
  destroy:
463
467
  success: Warteschlange %{name} gelöscht.
@@ -458,6 +458,10 @@ en:
458
458
  metadata: Metadata
459
459
  pid: PID
460
460
  status: Status
461
+ rates:
462
+ dequeued: dequeued
463
+ failed: failed
464
+ processed: processed
461
465
  queues:
462
466
  destroy:
463
467
  success: Queue %{name} deleted.
@@ -458,6 +458,10 @@ es:
458
458
  metadata: Metadatos
459
459
  pid: PID
460
460
  status: Estado
461
+ rates:
462
+ dequeued: extraídos
463
+ failed: fallidos
464
+ processed: procesados
461
465
  queues:
462
466
  destroy:
463
467
  success: Cola %{name} eliminada.
@@ -458,6 +458,10 @@ fi:
458
458
  metadata: Metatiedot
459
459
  pid: PID
460
460
  status: Tila
461
+ rates:
462
+ dequeued: haettu
463
+ failed: epäonnistui
464
+ processed: käsitelty
461
465
  queues:
462
466
  destroy:
463
467
  success: Jono %{name} poistettu.
@@ -458,6 +458,10 @@ fr:
458
458
  metadata: Métadonnées
459
459
  pid: PID
460
460
  status: Statut
461
+ rates:
462
+ dequeued: retirés
463
+ failed: échoués
464
+ processed: traités
461
465
  queues:
462
466
  destroy:
463
467
  success: File d'attente %{name} supprimée.
@@ -458,6 +458,10 @@ it:
458
458
  metadata: Metadati
459
459
  pid: PID
460
460
  status: Stato
461
+ rates:
462
+ dequeued: prelevati
463
+ failed: falliti
464
+ processed: elaborati
461
465
  queues:
462
466
  destroy:
463
467
  success: Coda %{name} eliminata.
@@ -458,6 +458,10 @@ ja:
458
458
  metadata: メタデータ
459
459
  pid: PID
460
460
  status: ステータス
461
+ rates:
462
+ dequeued: 取得
463
+ failed: 失敗
464
+ processed: 処理
461
465
  queues:
462
466
  destroy:
463
467
  success: キュー %{name} を削除しました。
@@ -458,6 +458,10 @@ nb:
458
458
  metadata: Metadata
459
459
  pid: PID
460
460
  status: Status
461
+ rates:
462
+ dequeued: hentet
463
+ failed: mislyktes
464
+ processed: behandlet
461
465
  queues:
462
466
  destroy:
463
467
  success: Køen %{name} slettet.
@@ -458,6 +458,10 @@ nl:
458
458
  metadata: Metadata
459
459
  pid: PID
460
460
  status: Status
461
+ rates:
462
+ dequeued: opgehaald
463
+ failed: mislukt
464
+ processed: verwerkt
461
465
  queues:
462
466
  destroy:
463
467
  success: Wachtrij %{name} verwijderd.
@@ -458,6 +458,10 @@ pt:
458
458
  metadata: Metadados
459
459
  pid: PID
460
460
  status: Status
461
+ rates:
462
+ dequeued: retirados
463
+ failed: falharam
464
+ processed: processados
461
465
  queues:
462
466
  destroy:
463
467
  success: Fila %{name} excluída.
@@ -458,6 +458,10 @@ sv:
458
458
  metadata: Metadata
459
459
  pid: PID
460
460
  status: Status
461
+ rates:
462
+ dequeued: hämtade
463
+ failed: misslyckade
464
+ processed: bearbetade
461
465
  queues:
462
466
  destroy:
463
467
  success: Kön %{name} borttagen.
@@ -6,13 +6,13 @@ require_relative "migration_path"
6
6
 
7
7
  module Pgbus
8
8
  module Generators
9
- class AddJobLocksGenerator < Rails::Generators::Base
9
+ class AddUniquenessKeysGenerator < Rails::Generators::Base
10
10
  include ActiveRecord::Generators::Migration
11
11
  include MigrationPath
12
12
 
13
13
  source_root File.expand_path("templates", __dir__)
14
14
 
15
- desc "Add job locks table for uniqueness guarantees"
15
+ desc "Add uniqueness keys table for ensures_uniqueness support"
16
16
 
17
17
  class_option :database,
18
18
  type: :string,
@@ -20,13 +20,13 @@ module Pgbus
20
20
  desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
21
21
 
22
22
  def create_migration_file
23
- migration_template "add_job_locks.rb.erb",
24
- File.join(pgbus_migrate_path, "add_pgbus_job_locks.rb")
23
+ migration_template "add_uniqueness_keys.rb.erb",
24
+ File.join(pgbus_migrate_path, "add_pgbus_uniqueness_keys.rb")
25
25
  end
26
26
 
27
27
  def display_post_install
28
28
  say ""
29
- say "Pgbus job locks table installed!", :green
29
+ say "Pgbus uniqueness keys table installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
32
  say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
@@ -151,7 +151,7 @@ module Pgbus
151
151
  msg_ids = Pgbus.client.send_batch(queue, payloads)
152
152
 
153
153
  unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
154
- raise "Pgbus batch enqueue failed: expected #{jobs.size} ids, got #{msg_ids&.size || 0}"
154
+ raise Pgbus::EnqueueError, "Pgbus batch enqueue failed: expected #{jobs.size} ids, got #{msg_ids&.size || 0}"
155
155
  end
156
156
 
157
157
  jobs.zip(msg_ids).each { |job, id| job.provider_job_id = id }
@@ -296,14 +296,35 @@ module Pgbus
296
296
  Pgbus.logger.warn { "[Pgbus] Batch discard signal failed: #{e.message}" }
297
297
  end
298
298
 
299
+ # Archiving is idempotent — archiving an already-archived message is a
300
+ # no-op — so a connection error here is safe to retry once, unlike sends
301
+ # where a retry could duplicate the message. Without the retry, a job
302
+ # that succeeded but failed to archive redelivers after VT expiry and
303
+ # runs twice. If the retry also fails, fall through to the normal
304
+ # failure path (recorded failure + VT-based redelivery).
299
305
  def archive_from(queue_name, msg_id, source_queue: nil)
300
- if source_queue
301
- client.archive_message(source_queue, msg_id, prefixed: false)
302
- else
303
- client.archive_message(queue_name, msg_id)
306
+ attempts = 0
307
+ begin
308
+ if source_queue
309
+ client.archive_message(source_queue, msg_id, prefixed: false)
310
+ else
311
+ client.archive_message(queue_name, msg_id)
312
+ end
313
+ rescue StandardError => e
314
+ attempts += 1
315
+ raise unless attempts == 1 && connection_error?(e)
316
+
317
+ Pgbus.logger.warn do
318
+ "[Pgbus::Executor] Archive failed on connection error, retrying once: #{e.message}"
319
+ end
320
+ retry
304
321
  end
305
322
  end
306
323
 
324
+ def connection_error?(error)
325
+ defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
326
+ end
327
+
307
328
  def handle_dead_letter(message, queue_name, payload, source_queue: nil)
308
329
  Pgbus.logger.warn do
309
330
  job_class = payload["job_class"] || "unknown"
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Pgbus
6
+ module CLI
7
+ # Dead-letter queue management for headless deployments and incident
8
+ # runbooks. Every operation routes through +Web::DataSource+ so the CLI
9
+ # shares the dashboard's exact semantics — origin-queue re-enqueue,
10
+ # transactional retry, lock release on discard, sensitive-payload
11
+ # filtering — with zero raw SQL or direct PGMQ calls.
12
+ module DLQ
13
+ module_function
14
+
15
+ ROW_FORMAT = "%-10s %-32s %-28s %-8s %s"
16
+
17
+ def start(args, data_source: Pgbus::Web::DataSource.new)
18
+ subcommand = args.first
19
+ rest = args[1..] || []
20
+
21
+ case subcommand
22
+ when "list" then list(rest, data_source)
23
+ when "show" then show(rest, data_source)
24
+ when "retry" then retry_one(rest, data_source)
25
+ when "retry-all" then retry_all(data_source)
26
+ when "purge" then purge(rest, data_source)
27
+ else
28
+ warn "Unknown dlq subcommand: #{subcommand.inspect}" if subcommand
29
+ print_help
30
+ exit 1
31
+ end
32
+ end
33
+
34
+ def list(args, data_source)
35
+ options = parse_list_options(args)
36
+ messages = data_source.dlq_messages(page: options[:page], per_page: options[:per_page])
37
+
38
+ if messages.empty?
39
+ puts "No dead-letter messages."
40
+ return
41
+ end
42
+
43
+ print_list_table(messages)
44
+ puts "-" * 96
45
+ puts "Total dead-letter messages: #{data_source.dlq_total_count} " \
46
+ "(page #{options[:page]}, #{options[:per_page]} per page)"
47
+ end
48
+
49
+ def show(args, data_source)
50
+ msg_id = require_msg_id(args)
51
+ detail = data_source.dlq_message_detail(msg_id)
52
+ abort_missing(msg_id) unless detail
53
+
54
+ puts "msg_id: #{detail[:msg_id]}"
55
+ puts "dlq queue: #{detail[:queue_name]}"
56
+ puts "origin: #{origin_queue(detail[:queue_name])}"
57
+ puts "read_ct: #{detail[:read_ct]}"
58
+ puts "enqueued_at: #{detail[:enqueued_at]}"
59
+ puts "payload:"
60
+ puts Pgbus::Web::PayloadFilter.filter_json(detail[:message])
61
+ end
62
+
63
+ def retry_one(args, data_source)
64
+ msg_id = require_msg_id(args)
65
+ detail = data_source.dlq_message_detail(msg_id)
66
+ abort_missing(msg_id) unless detail
67
+
68
+ if data_source.retry_dlq_message(detail[:queue_name], detail[:msg_id])
69
+ puts "Re-enqueued msg_id #{detail[:msg_id]} to #{origin_queue(detail[:queue_name])}."
70
+ else
71
+ warn "Failed to retry msg_id #{detail[:msg_id]}."
72
+ exit 1
73
+ end
74
+ end
75
+
76
+ def retry_all(data_source)
77
+ count = data_source.retry_all_dlq
78
+ puts "Re-enqueued #{count} dead-letter message#{"s" unless count == 1}."
79
+ end
80
+
81
+ def purge(args, data_source)
82
+ if args.include?("--all")
83
+ purge_all(args, data_source)
84
+ else
85
+ purge_one(args, data_source)
86
+ end
87
+ end
88
+
89
+ def purge_one(args, data_source)
90
+ msg_id = require_msg_id(args)
91
+ detail = data_source.dlq_message_detail(msg_id)
92
+ abort_missing(msg_id) unless detail
93
+
94
+ if data_source.discard_dlq_message(detail[:queue_name], detail[:msg_id])
95
+ puts "Purged msg_id #{detail[:msg_id]} from #{detail[:queue_name]}."
96
+ else
97
+ warn "Failed to purge msg_id #{detail[:msg_id]}."
98
+ exit 1
99
+ end
100
+ end
101
+
102
+ def purge_all(args, data_source)
103
+ unless args.include?("--yes")
104
+ warn "Refusing to purge all dead-letter messages without --yes."
105
+ exit 1
106
+ end
107
+
108
+ count = data_source.discard_all_dlq
109
+ puts "Purged #{count} dead-letter message#{"s" unless count == 1}."
110
+ end
111
+
112
+ def parse_list_options(args)
113
+ options = { page: 1, per_page: 25 }
114
+ OptionParser.new do |opts|
115
+ opts.banner = "Usage: pgbus dlq list [options]"
116
+ opts.on("--page N", Integer, "Page number (default: 1)") { |v| options[:page] = v }
117
+ opts.on("--per-page N", Integer, "Messages per page (default: 25)") { |v| options[:per_page] = v }
118
+ end.parse!(args.dup)
119
+ options
120
+ end
121
+
122
+ def print_list_table(messages)
123
+ puts format(ROW_FORMAT, "MSG_ID", "DLQ QUEUE", "ORIGIN QUEUE", "READ_CT", "ENQUEUED_AT")
124
+ puts "-" * 96
125
+ messages.each do |m|
126
+ puts format(ROW_FORMAT, m[:msg_id], m[:queue_name], origin_queue(m[:queue_name]),
127
+ m[:read_ct], m[:enqueued_at])
128
+ end
129
+ end
130
+
131
+ def origin_queue(dlq_queue_name)
132
+ dlq_queue_name.delete_suffix(Pgbus::DEAD_LETTER_SUFFIX)
133
+ end
134
+
135
+ def require_msg_id(args)
136
+ msg_id = args.find { |a| a !~ /\A--/ }
137
+ unless msg_id
138
+ warn "A msg_id is required."
139
+ exit 1
140
+ end
141
+ msg_id
142
+ end
143
+
144
+ def abort_missing(msg_id)
145
+ warn "No dead-letter message found with msg_id #{msg_id}."
146
+ exit 1
147
+ end
148
+
149
+ def print_help
150
+ puts <<~HELP
151
+ Usage: pgbus dlq <subcommand> [options]
152
+
153
+ Subcommands:
154
+ list [--page N] [--per-page N] List dead-letter messages
155
+ show MSG_ID Show one message (payload filtered)
156
+ retry MSG_ID Re-enqueue a message to its origin queue
157
+ retry-all Re-enqueue every dead-letter message
158
+ purge MSG_ID Discard a single message
159
+ purge --all --yes Discard every dead-letter message
160
+ HELP
161
+ end
162
+ end
163
+ end
164
+ end
data/lib/pgbus/cli.rb CHANGED
@@ -16,8 +16,12 @@ module Pgbus
16
16
  show_status
17
17
  when "queues"
18
18
  list_queues
19
+ when "dlq"
20
+ DLQ.start(args[1..] || [])
19
21
  when "mcp"
20
22
  start_mcp_server
23
+ when "doctor"
24
+ run_doctor
21
25
  when "version"
22
26
  puts "pgbus #{Pgbus::VERSION}"
23
27
  when "help", "--help", "-h"
@@ -107,7 +111,7 @@ module Pgbus
107
111
  def apply_capsule_filter(name)
108
112
  capsule = Pgbus.configuration.capsule_named(name)
109
113
  unless capsule
110
- available = (Pgbus.configuration.workers || []).filter_map { |c| c[:name] || c["name"] }
114
+ available = (Pgbus.configuration.workers || []).filter_map { |c| c[:name] }
111
115
  raise ArgumentError,
112
116
  "no capsule named #{name.inspect} (available: #{available.join(", ")})"
113
117
  end
@@ -141,6 +145,15 @@ module Pgbus
141
145
  Pgbus::MCP::Runner.run
142
146
  end
143
147
 
148
+ # Runs the deployment preflight diagnostics and prints the report. Exits 1
149
+ # on any failed check so it can gate a deploy or CI run; exits 0 when all
150
+ # checks pass (warnings do not fail). Doctor itself never raises.
151
+ def run_doctor
152
+ doctor = Pgbus::Doctor.new
153
+ puts doctor.report
154
+ exit 1 unless doctor.success?
155
+ end
156
+
144
157
  def list_queues
145
158
  Pgbus.client.list_queues
146
159
  metrics = Pgbus.client.metrics
@@ -163,7 +176,11 @@ module Pgbus
163
176
  start Start the Pgbus supervisor (workers + dispatcher)
164
177
  status Show running Pgbus processes
165
178
  queues List queues with metrics
179
+ dlq Inspect and drain dead-letter queues
180
+ (list/show/retry/retry-all/purge)
166
181
  mcp Start the read-only MCP diagnostic server over stdio
182
+ doctor Run environment diagnostics (config, DB, PGMQ, queues,
183
+ LISTEN/NOTIFY, process liveness); exits 1 on any failure
167
184
  version Show version
168
185
  help Show this help
169
186