pgbus 0.17.1 → 0.17.2

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +48 -13
  4. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  5. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  6. data/app/controllers/pgbus/application_controller.rb +5 -2
  7. data/app/controllers/pgbus/batches_controller.rb +4 -1
  8. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  9. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  10. data/app/controllers/pgbus/events_controller.rb +52 -3
  11. data/app/controllers/pgbus/insights_controller.rb +19 -1
  12. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  13. data/app/controllers/pgbus/locks_controller.rb +7 -2
  14. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  15. data/app/controllers/pgbus/processes_controller.rb +3 -1
  16. data/app/controllers/pgbus/queues_controller.rb +16 -4
  17. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  18. data/app/frontend/pgbus/modules/charts.js +119 -77
  19. data/app/frontend/pgbus/style.css +1 -1
  20. data/app/frontend/pgbus/tailwind.css +18 -0
  21. data/app/helpers/pgbus/application_helper.rb +155 -113
  22. data/app/helpers/pgbus/button_helper.rb +88 -0
  23. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  24. data/app/helpers/pgbus/events_helper.rb +40 -0
  25. data/app/helpers/pgbus/insights_helper.rb +39 -0
  26. data/app/helpers/pgbus/processes_helper.rb +79 -0
  27. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  28. data/app/views/pgbus/batches/show.html.erb +3 -4
  29. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  30. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  31. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  32. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  33. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  34. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  35. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  36. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  37. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  38. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  39. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  40. data/app/views/pgbus/events/_list.html.erb +55 -0
  41. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  42. data/app/views/pgbus/events/_replay.html.erb +10 -0
  43. data/app/views/pgbus/events/_row.html.erb +162 -0
  44. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  45. data/app/views/pgbus/events/index.html.erb +27 -48
  46. data/app/views/pgbus/events/show.html.erb +25 -7
  47. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  48. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  49. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  50. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  51. data/app/views/pgbus/insights/show.html.erb +83 -100
  52. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  53. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  54. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  55. data/app/views/pgbus/jobs/index.html.erb +10 -10
  56. data/app/views/pgbus/jobs/show.html.erb +18 -20
  57. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  58. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  59. data/app/views/pgbus/outbox/index.html.erb +30 -27
  60. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  61. data/app/views/pgbus/processes/_row.html.erb +48 -0
  62. data/app/views/pgbus/processes/_status.html.erb +4 -0
  63. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  64. data/app/views/pgbus/queues/show.html.erb +69 -118
  65. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  66. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  67. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  68. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  69. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  70. data/config/i18n-tasks.yml +11 -2
  71. data/config/locales/da.yml +405 -113
  72. data/config/locales/de.yml +405 -113
  73. data/config/locales/en.yml +405 -113
  74. data/config/locales/es.yml +404 -112
  75. data/config/locales/fi.yml +404 -112
  76. data/config/locales/fr.yml +406 -114
  77. data/config/locales/it.yml +405 -113
  78. data/config/locales/ja.yml +405 -113
  79. data/config/locales/nb.yml +405 -113
  80. data/config/locales/nl.yml +405 -113
  81. data/config/locales/pt.yml +406 -114
  82. data/config/locales/sv.yml +404 -112
  83. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  84. data/lib/pgbus/active_job/executor.rb +16 -6
  85. data/lib/pgbus/cli/dlq.rb +32 -5
  86. data/lib/pgbus/client.rb +4 -2
  87. data/lib/pgbus/configuration.rb +47 -2
  88. data/lib/pgbus/dead_letter_header.rb +129 -0
  89. data/lib/pgbus/doctor.rb +8 -4
  90. data/lib/pgbus/event_bus/handler.rb +18 -11
  91. data/lib/pgbus/event_bus/registry.rb +6 -0
  92. data/lib/pgbus/failed_event_recorder.rb +34 -0
  93. data/lib/pgbus/instrumentation.rb +2 -1
  94. data/lib/pgbus/mcp/base_tool.rb +25 -0
  95. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  96. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  97. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  98. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  99. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  100. data/lib/pgbus/outbox/poller.rb +1 -1
  101. data/lib/pgbus/process/consumer.rb +53 -3
  102. data/lib/pgbus/process/dispatcher.rb +1 -1
  103. data/lib/pgbus/process/supervisor.rb +32 -11
  104. data/lib/pgbus/process/worker.rb +45 -8
  105. data/lib/pgbus/recurring/scheduler.rb +2 -1
  106. data/lib/pgbus/version.rb +1 -1
  107. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  108. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  109. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  110. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  111. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  112. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  113. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  114. data/lib/pgbus/web/data_source.rb +132 -254
  115. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  116. data/lib/pgbus/web/event_state.rb +71 -0
  117. data/lib/pgbus/web/health_app.rb +7 -3
  118. data/lib/pgbus/web/health_verdict.rb +78 -0
  119. data/lib/pgbus/web/insights_charts.rb +84 -0
  120. data/lib/pgbus/web/job_state.rb +111 -0
  121. data/lib/pgbus/web/process_activity.rb +97 -0
  122. data/lib/pgbus/web/process_state.rb +187 -0
  123. data/lib/pgbus/web/queue_summary.rb +104 -0
  124. data/lib/pgbus/web/time_format.rb +128 -0
  125. metadata +43 -4
  126. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  127. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  128. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
@@ -36,6 +36,10 @@ Pgbus.configure do |c|
36
36
  c.workers = []
37
37
  c.capsule :default, queues: %w[critical default], threads: 5
38
38
  c.capsule :low, queues: %w[low], threads: 2
39
+ # CPU-bound jobs (rendering, reports, PDFs): threads share one GVL, so use
40
+ # processes instead. Capsule-level recycle limits override the global ones.
41
+ # c.capsule :render, queues: %w[render], threads: 1, processes: 4,
42
+ # max_memory_mb: 1_536
39
43
 
40
44
  # --- Worker recycling (prevents memory bloat) --------------------------
41
45
  c.max_jobs_per_worker = 10_000
@@ -26,7 +26,10 @@ module Pgbus
26
26
  read_count = message.read_ct.to_i
27
27
 
28
28
  if read_count > config.max_retries
29
- handle_dead_letter(message, queue_name, payload, source_queue: source_queue)
29
+ # Read the last error before clear! deletes the only row that has
30
+ # it: the DLQ copy carries it in its headers (issue #495).
31
+ last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
32
+ handle_dead_letter(message, queue_name, payload, source_queue: source_queue, last_error: last_error)
30
33
  FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
31
34
  signal_concurrency(payload)
32
35
  signal_batch_discarded(payload)
@@ -39,7 +42,8 @@ module Pgbus
39
42
  job_id: payload["job_id"],
40
43
  provider_job_id: payload["provider_job_id"],
41
44
  read_ct: read_count,
42
- msg_id: message.msg_id.to_i
45
+ msg_id: message.msg_id.to_i,
46
+ error: last_error&.dig(:error_class)
43
47
  )
44
48
  Pgbus.logger.debug { "[Pgbus::Executor] dead_lettered #{log_tag(message, queue_name)} job_class=#{job_class}" }
45
49
  return :dead_lettered
@@ -428,20 +432,26 @@ module Pgbus
428
432
  defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
429
433
  end
430
434
 
431
- def handle_dead_letter(message, queue_name, payload, source_queue: nil)
435
+ def handle_dead_letter(message, queue_name, payload, source_queue: nil, last_error: nil)
432
436
  Pgbus.logger.warn do
433
437
  job_class = payload["job_class"] || "unknown"
434
- "[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts"
438
+ error = last_error ? " (last error: #{last_error[:error_class]})" : ""
439
+ "[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts#{error}"
435
440
  end
441
+ headers = DeadLetterHeader.build(
442
+ existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "worker",
443
+ source_queue: source_queue || config.queue_name(queue_name), attempts: message.read_ct.to_i,
444
+ max_retries: config.max_retries, error: last_error
445
+ )
436
446
  if source_queue
437
447
  client.ensure_dead_letter_queue(queue_name)
438
448
  dlq_name = config.dead_letter_queue_name(queue_name)
439
449
  client.transaction do |txn|
440
- txn.produce(dlq_name, message.message, headers: message.headers)
450
+ txn.produce(dlq_name, message.message, headers: headers)
441
451
  txn.delete(source_queue, message.msg_id.to_i)
442
452
  end
443
453
  else
444
- client.move_to_dead_letter(queue_name, message)
454
+ client.move_to_dead_letter(queue_name, message, headers: headers)
445
455
  end
446
456
  end
447
457
  end
data/lib/pgbus/cli/dlq.rb CHANGED
@@ -12,7 +12,8 @@ module Pgbus
12
12
  module DLQ
13
13
  module_function
14
14
 
15
- ROW_FORMAT = "%-10s %-32s %-28s %-8s %s"
15
+ ROW_FORMAT = "%-10s %-32s %-28s %-8s %-26s %s"
16
+ REASON_WIDTH = 40
16
17
 
17
18
  def start(args, data_source: Pgbus::Web::DataSource.new)
18
19
  subcommand = args.first
@@ -41,7 +42,7 @@ module Pgbus
41
42
  end
42
43
 
43
44
  print_list_table(messages)
44
- puts "-" * 96
45
+ puts "-" * 140
45
46
  puts "Total dead-letter messages: #{data_source.dlq_total_count} " \
46
47
  "(page #{options[:page]}, #{options[:per_page]} per page)"
47
48
  end
@@ -56,6 +57,7 @@ module Pgbus
56
57
  puts "origin: #{origin_queue(detail[:queue_name])}"
57
58
  puts "read_ct: #{detail[:read_ct]}"
58
59
  puts "enqueued_at: #{detail[:enqueued_at]}"
60
+ print_dead_letter(Pgbus::Web::DeadLetterReason.present(detail))
59
61
  puts "payload:"
60
62
  puts Pgbus::Web::PayloadFilter.filter_json(detail[:message])
61
63
  end
@@ -120,14 +122,39 @@ module Pgbus
120
122
  end
121
123
 
122
124
  def print_list_table(messages)
123
- puts format(ROW_FORMAT, "MSG_ID", "DLQ QUEUE", "ORIGIN QUEUE", "READ_CT", "ENQUEUED_AT")
124
- puts "-" * 96
125
+ puts format(ROW_FORMAT, "MSG_ID", "DLQ QUEUE", "ORIGIN QUEUE", "READ_CT", "ENQUEUED_AT", "REASON")
126
+ puts "-" * 140
125
127
  messages.each do |m|
128
+ reason = Pgbus::Web::DeadLetterReason.present(m)
126
129
  puts format(ROW_FORMAT, m[:msg_id], m[:queue_name], origin_queue(m[:queue_name]),
127
- m[:read_ct], m[:enqueued_at])
130
+ m[:read_ct], m[:enqueued_at], (reason.error_class || reason.reason_key).to_s[0, REASON_WIDTH])
128
131
  end
129
132
  end
130
133
 
134
+ # Why the message died, from its pgbus_dead_letter header (issue #495).
135
+ def print_dead_letter(result)
136
+ if result.legacy?
137
+ puts "reason: not recorded (dead-lettered before pgbus #{Pgbus::DeadLetterHeader::SINCE})"
138
+ return
139
+ end
140
+
141
+ puts "died: #{result.died_at}"
142
+ puts "attempts: #{result.attempts}/#{result.max_retries}"
143
+ puts "source: #{result.source_queue}"
144
+ puts "reason: #{result.reason}"
145
+ puts "error: #{dead_letter_error(result)}"
146
+ puts "retried: #{result.retried_before} time(s) from the DLQ before" if result.retried_before
147
+ end
148
+
149
+ def dead_letter_error(result)
150
+ return "none recorded" unless result.error_class
151
+
152
+ error = "#{result.error_class}: #{result.error_message} (attempt #{result.error_attempt})"
153
+ # A stored message is untrusted: newlines would break the layout and
154
+ # ANSI sequences could rewrite the operator's terminal.
155
+ error.gsub(/[\x00-\x1f\x7f]/) { |char| format("\\x%02X", char.ord) }
156
+ end
157
+
131
158
  def origin_queue(dlq_queue_name)
132
159
  dlq_queue_name.delete_suffix(Pgbus::DEAD_LETTER_SUFFIX)
133
160
  end
data/lib/pgbus/client.rb CHANGED
@@ -592,7 +592,9 @@ module Pgbus
592
592
  end
593
593
  end
594
594
 
595
- def move_to_dead_letter(queue_name, message)
595
+ # headers: what the DLQ copy carries. Movers pass the message's headers
596
+ # merged with a DeadLetterHeader block; the default copies them verbatim.
597
+ def move_to_dead_letter(queue_name, message, headers: message.headers)
596
598
  dlq_name = config.dead_letter_queue_name(queue_name)
597
599
  full_queue = config.queue_name(queue_name)
598
600
 
@@ -600,7 +602,7 @@ module Pgbus
600
602
  ensure_dead_letter_queue(queue_name)
601
603
  synchronized do
602
604
  @pgmq.transaction do |txn|
603
- txn.produce(dlq_name, message.message, headers: message.headers)
605
+ txn.produce(dlq_name, message.message, headers: headers)
604
606
  txn.delete(full_queue, message.msg_id.to_i)
605
607
  end
606
608
  end
@@ -590,6 +590,21 @@ module Pgbus
590
590
  entry.fetch(:read_ahead, nil) || read_ahead
591
591
  end
592
592
 
593
+ # Recycle limits a capsule may set for its own workers (issue #503).
594
+ RECYCLE_LIMIT_KEYS = %i[max_jobs_per_worker max_memory_mb max_worker_lifetime].freeze
595
+
596
+ # How many identical worker processes the supervisor forks for one
597
+ # capsule (issue #503). Default 1.
598
+ def processes_for(entry)
599
+ entry.fetch(:processes, nil) || 1
600
+ end
601
+
602
+ # One recycle limit for a capsule, falling back to the global value
603
+ # (mirrors read_ahead_for). nil means the limit is off.
604
+ def recycle_limit_for(entry, key)
605
+ entry.fetch(key, nil) || public_send(key)
606
+ end
607
+
593
608
  # Returns the execution mode for a specific worker config hash,
594
609
  # falling back to the global execution_mode setting.
595
610
  def execution_mode_for(worker_config)
@@ -846,6 +861,7 @@ module Pgbus
846
861
  end
847
862
 
848
863
  validate_read_ahead!
864
+ validate_capsule_processes!
849
865
 
850
866
  if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
851
867
  raise Pgbus::ConfigurationError, "priority_levels must be an integer between 1 and 10"
@@ -879,6 +895,27 @@ module Pgbus
879
895
  warn_read_ahead_without_heartbeat(settings)
880
896
  end
881
897
 
898
+ # Per-capsule processes (a positive Integer) and recycle limits (a positive
899
+ # number; unset falls back to the global value). The Array form of
900
+ # +workers=+ bypasses +capsule+, so both are checked here (issue #503).
901
+ def validate_capsule_processes!
902
+ Array(workers).each do |w|
903
+ validate_processes!(w[:processes]) unless w[:processes].nil?
904
+ RECYCLE_LIMIT_KEYS.each do |key|
905
+ value = w[key]
906
+ next if value.nil? || (value.is_a?(Numeric) && value.positive?)
907
+
908
+ raise Pgbus::ConfigurationError, "capsule #{key} must be a positive number (got #{value.inspect})"
909
+ end
910
+ end
911
+ end
912
+
913
+ def validate_processes!(value)
914
+ return if value.is_a?(Integer) && value.positive?
915
+
916
+ raise Pgbus::ConfigurationError, "capsule processes must be a positive Integer (got #{value.inspect})"
917
+ end
918
+
882
919
  # Buffered claims are kept invisible only by the visibility heartbeat.
883
920
  # Without it a message buffered longer than visibility_timeout can be
884
921
  # claimed by another worker while it still waits here. Legal, but warned.
@@ -1209,15 +1246,23 @@ module Pgbus
1209
1246
  #
1210
1247
  # c.capsule :critical, queues: %w[critical], threads: 5
1211
1248
  # c.capsule :gated, queues: %w[gated], threads: 1, single_active_consumer: true
1249
+ # c.capsule :render, queues: %w[render], threads: 1, processes: 4,
1250
+ # max_memory_mb: 1_536, max_worker_lifetime: 6.hours
1251
+ #
1252
+ # processes: N forks N identical workers for the capsule (CPU-bound jobs:
1253
+ # threads share one GVL, processes do not). The capsule-level recycle
1254
+ # limits override the global ones for this capsule's workers only.
1212
1255
  #
1213
1256
  # Names must be unique. Queues must not overlap with capsules already
1214
1257
  # defined (would cause double-processing). Composes with the string DSL —
1215
1258
  # +c.workers "..."+ followed by +c.capsule :name, ...+ appends the
1216
1259
  # named capsule to the list parsed from the string.
1217
- def capsule(name, queues:, threads:, **)
1260
+ def capsule(name, queues:, threads:, **options)
1218
1261
  raise Pgbus::ConfigurationError, "capsule queues must be a non-empty Array" unless queues.is_a?(Array) && queues.any?
1219
1262
  raise Pgbus::ConfigurationError, "capsule threads must be a positive Integer" unless threads.is_a?(Integer) && threads.positive?
1220
1263
 
1264
+ validate_processes!(options[:processes]) unless options[:processes].nil?
1265
+
1221
1266
  normalized_name = name.to_s
1222
1267
  @workers ||= []
1223
1268
 
@@ -1227,7 +1272,7 @@ module Pgbus
1227
1272
 
1228
1273
  validate_no_queue_overlap!(queues)
1229
1274
 
1230
- @workers << { name: normalized_name, queues: queues, threads: threads, ** }
1275
+ @workers << { name: normalized_name, queues: queues, threads: threads, **options }
1231
1276
  end
1232
1277
 
1233
1278
  # Look up a capsule by its name. Accepts symbol or string. Returns the
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Pgbus
7
+ # The `pgbus_dead_letter` block a mover writes into a message's PGMQ
8
+ # headers when it moves the message to a dead-letter queue (issue #495):
9
+ # why it was moved, from where, after how many attempts, and the last
10
+ # recorded error. Pure — no I/O — so the executor, the consumer, the
11
+ # dashboard, MCP and the CLI all read and write the same shape.
12
+ #
13
+ # PGMQ headers travel as raw JSON strings, so every entry point accepts a
14
+ # String (or a Hash, or nil) and build/strip_for_retry return a String.
15
+ module DeadLetterHeader
16
+ KEY = "pgbus_dead_letter"
17
+ # Top-level counter on a live message retried out of a DLQ. Folded into
18
+ # the block as `retries_from_dlq` when the message dies again.
19
+ RETRIES_KEY = "pgbus_dlq_retries"
20
+ ORIGINAL_KEY = "pgbus_original_headers"
21
+ VERSION = 1
22
+ REASON_MAX_RETRIES = "max_retries_exceeded"
23
+ # The release that started writing the block: rows without it predate it.
24
+ SINCE = "0.18.0"
25
+ MESSAGE_LIMIT = 1_000
26
+ BACKTRACE_LINES = 10
27
+ BACKTRACE_LIMIT = 2_000
28
+
29
+ module_function
30
+
31
+ # error: FailedEventRecorder.last_error's Hash, or nil when no failure
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)
34
+ headers = decode(existing, warn: true)
35
+ retries = count(headers.delete(RETRIES_KEY))
36
+ block = {
37
+ "version" => VERSION,
38
+ "reason" => reason,
39
+ "source" => source,
40
+ "source_queue" => source_queue,
41
+ "attempts" => attempts,
42
+ "max_retries" => max_retries,
43
+ "dead_lettered_at" => iso(now)
44
+ }
45
+ block["retries_from_dlq"] = retries if retries.positive?
46
+ block.merge!(error_fields(error)) if error
47
+ JSON.generate(headers.merge(KEY => block))
48
+ end
49
+
50
+ # The block as a string-keyed Hash, or nil. Never raises.
51
+ def parse(headers)
52
+ block = decode(headers, warn: false)[KEY]
53
+ block.is_a?(Hash) ? block : nil
54
+ end
55
+
56
+ # A live message must not claim to be dead: drop the block, and carry
57
+ # how many times it has come back out of a DLQ. A DLQ message has no
58
+ # top-level counter (build folded it into the block), so the block is
59
+ # the only place to read the previous count from.
60
+ def strip_for_retry(headers)
61
+ retries = count(parse(headers)&.dig("retries_from_dlq")) + 1
62
+ stripped = decode(headers, warn: false)
63
+ stripped.delete(KEY)
64
+ stripped[RETRIES_KEY] = retries
65
+ JSON.generate(stripped)
66
+ end
67
+
68
+ # A counter read from headers anyone could have written: an Integer or a
69
+ # numeric String counts, anything else is 0. Never raises, so a bad value
70
+ # can't stop a dead-letter.
71
+ def count(value)
72
+ case value
73
+ when Integer then value
74
+ when String then Integer(value, 10, exception: false) || 0
75
+ else 0
76
+ end
77
+ end
78
+
79
+ def error_fields(error)
80
+ {
81
+ "error_class" => error[:error_class],
82
+ "error_message" => error[:error_message]&.to_s&.slice(0, MESSAGE_LIMIT),
83
+ "backtrace" => backtrace_head(error[:backtrace]),
84
+ "error_attempt" => error[:retry_count].to_i + 1,
85
+ "error_recorded_at" => error[:failed_at] && iso(error[:failed_at])
86
+ }.compact
87
+ end
88
+
89
+ def backtrace_head(backtrace)
90
+ return if backtrace.nil? || backtrace.empty?
91
+
92
+ lines = backtrace.is_a?(Array) ? backtrace : backtrace.to_s.split("\n")
93
+ lines.first(BACKTRACE_LINES).join("\n").slice(0, BACKTRACE_LIMIT)
94
+ end
95
+
96
+ def iso(value)
97
+ case value
98
+ when Time then value.getutc.iso8601(6)
99
+ when String then value
100
+ else value.respond_to?(:to_time) ? value.to_time.utc.iso8601(6) : value.to_s
101
+ end
102
+ end
103
+
104
+ # Always a Hash. Headers that are not a JSON object are kept, not
105
+ # dropped, under ORIGINAL_KEY.
106
+ def decode(headers, warn:)
107
+ case headers
108
+ when nil, "" then {}
109
+ when Hash then headers.transform_keys(&:to_s)
110
+ else wrap(JSON.parse(headers.to_s), headers, warn)
111
+ end
112
+ rescue JSON::ParserError
113
+ log_kept(headers) if warn
114
+ { ORIGINAL_KEY => headers }
115
+ end
116
+
117
+ def wrap(parsed, raw, warn)
118
+ return parsed if parsed.is_a?(Hash)
119
+
120
+ log_kept(raw) if warn
121
+ { ORIGINAL_KEY => parsed }
122
+ end
123
+
124
+ # The raw value is not logged: headers can carry caller secrets.
125
+ def log_kept(_raw)
126
+ Pgbus.logger.warn { "[Pgbus] Dead-letter headers were not a JSON object; kept under #{ORIGINAL_KEY}" }
127
+ end
128
+ end
129
+ end
data/lib/pgbus/doctor.rb CHANGED
@@ -402,23 +402,27 @@ module Pgbus
402
402
  # per worker/consumer fork. Streams add one per web-server process, which
403
403
  # the doctor cannot count from here, so it is reported as a clause.
404
404
  def check_connection_budget
405
- capsules = @config.role_enabled?(:workers) ? Array(@config.workers).size : 0
405
+ worker_configs = @config.role_enabled?(:workers) ? Array(@config.workers) : []
406
+ capsules = worker_configs.size
407
+ # A processes: N capsule forks N workers, each with its own LISTEN under :fork scope.
408
+ worker_forks = worker_configs.sum { |w| @config.processes_for(w) }
406
409
  consumers = @config.role_enabled?(:consumers) ? Array(@config.event_consumers).size : 0
407
410
 
408
411
  count =
409
412
  if !@config.worker_notify_wakeup?
410
413
  0
411
414
  elsif @config.worker_notify_scope == :supervisor
412
- (capsules + consumers).positive? ? 1 : 0
415
+ (worker_forks + consumers).positive? ? 1 : 0
413
416
  else
414
- capsules + consumers
417
+ worker_forks + consumers
415
418
  end
416
419
 
417
420
  detail = format(
418
421
  "%<count>d direct LISTEN connection%<plural>s pinned (scope=%<scope>s; " \
419
- "%<capsules>d capsule%<cap_plural>s + %<consumers>d consumer%<con_plural>s%<share>s)",
422
+ "%<capsules>d capsule%<cap_plural>s%<forks>s + %<consumers>d consumer%<con_plural>s%<share>s)",
420
423
  count: count, plural: count == 1 ? "" : "s", scope: @config.worker_notify_scope,
421
424
  capsules: capsules, cap_plural: capsules == 1 ? "" : "s",
425
+ forks: worker_forks == capsules ? "" : " (#{worker_forks} worker processes)",
422
426
  consumers: consumers, con_plural: consumers == 1 ? "" : "s",
423
427
  share: count == 1 && @config.worker_notify_scope == :supervisor ? " share it" : ""
424
428
  )
@@ -15,6 +15,23 @@ module Pgbus
15
15
  def dedup_cache
16
16
  @dedup_cache ||= DedupCache.new
17
17
  end
18
+
19
+ # How long a pending claim may stay silent before its holder counts as
20
+ # dead. ClaimBeat refreshes a live claim from the visibility heartbeat,
21
+ # which lands every extension inside [interval, 1.5 * interval] of the
22
+ # previous one — two intervals leaves margin for a late beat without
23
+ # stretching the window past the visibility timeout it rides on.
24
+ #
25
+ # With the heartbeat disabled a claim is never refreshed, so the window
26
+ # degrades to "roughly the first two thirds of one visibility timeout
27
+ # after the claim" — a redelivery, which cannot arrive before the VT has
28
+ # lapsed, still re-runs exactly as it did before issue #470.
29
+ #
30
+ # The dashboard reads the same window to tell a claim being handled
31
+ # from one that went silent (issue #494).
32
+ def claim_ownership_window
33
+ Pgbus.configuration.effective_visibility_heartbeat_interval * 2
34
+ end
18
35
  end
19
36
 
20
37
  # Outcome of the two-phase claim. `age` is how long the losing delivery
@@ -196,18 +213,8 @@ module Pgbus
196
213
  ClaimResult.new(status: :claimed, age: age)
197
214
  end
198
215
 
199
- # How long a pending claim may stay silent before its holder counts as
200
- # dead. ClaimBeat refreshes a live claim from the visibility heartbeat,
201
- # which lands every extension inside [interval, 1.5 * interval] of the
202
- # previous one — two intervals leaves margin for a late beat without
203
- # stretching the window past the visibility timeout it rides on.
204
- #
205
- # With the heartbeat disabled a claim is never refreshed, so the window
206
- # degrades to "roughly the first two thirds of one visibility timeout
207
- # after the claim" — a redelivery, which cannot arrive before the VT has
208
- # lapsed, still re-runs exactly as it did before issue #470.
209
216
  def claim_ownership_window
210
- Pgbus.configuration.effective_visibility_heartbeat_interval * 2
217
+ self.class.claim_ownership_window
211
218
  end
212
219
 
213
220
  # Register this claim with the message's beat for exactly the duration of
@@ -125,6 +125,12 @@ module Pgbus
125
125
  subscription_pattern.start_with?(topic_filter.delete_suffix(".#"))
126
126
  end
127
127
 
128
+ # Whether +routing_key+ matches a subscription +pattern+ ("*" one word,
129
+ # "#" any). The dashboard checks a replay with it (issue #494).
130
+ def pattern_matches?(pattern, routing_key)
131
+ matches?(pattern, routing_key)
132
+ end
133
+
128
134
  private
129
135
 
130
136
  # True when running inside a rake schema/asset task, where setup_all!(safe:)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Pgbus
4
6
  # Records job failures to pgbus_failed_events for dashboard visibility.
5
7
  # Uses upsert (INSERT ON CONFLICT UPDATE) keyed on (queue_name, msg_id)
@@ -47,6 +49,31 @@ module Pgbus
47
49
  false
48
50
  end
49
51
 
52
+ # The message's latest recorded failure, read by a mover just before it
53
+ # dead-letters the message and clears the row (issue #495). failed_at
54
+ # comes back as an ISO 8601 UTC string whatever the adapter decodes it
55
+ # to. A failed read returns nil: it must never stop a dead-letter.
56
+ def last_error(queue_name:, msg_id:)
57
+ row = connection.select_one(
58
+ "SELECT error_class, error_message, backtrace, retry_count, failed_at " \
59
+ "FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2 LIMIT 1",
60
+ "FailedEvent Last Error",
61
+ [queue_name, msg_id.to_i]
62
+ )
63
+ return unless row
64
+
65
+ {
66
+ error_class: row["error_class"],
67
+ error_message: row["error_message"],
68
+ backtrace: row["backtrace"],
69
+ retry_count: row["retry_count"].to_i,
70
+ failed_at: iso_utc(row["failed_at"])
71
+ }
72
+ rescue StandardError => e
73
+ Pgbus.logger.debug { "[Pgbus] FailedEvent last_error read failed: #{e.class}: #{e.message}" }
74
+ nil
75
+ end
76
+
50
77
  # Drops every row recorded under any of `queue_names`. Called when a
51
78
  # queue is dropped: PGMQ restarts msg_ids on recreate, so a surviving
52
79
  # row would aim the dashboard's retry/discard at an unrelated message.
@@ -82,6 +109,13 @@ module Pgbus
82
109
 
83
110
  private
84
111
 
112
+ def iso_utc(value)
113
+ time = value.is_a?(String) ? Time.parse(value) : value
114
+ time&.utc&.iso8601(6)
115
+ rescue ArgumentError
116
+ value.to_s
117
+ end
118
+
85
119
  def connection
86
120
  if defined?(BusRecord) && BusRecord.connected?
87
121
  BusRecord.connection
@@ -15,7 +15,8 @@ module Pgbus
15
15
  # pgbus.executor.execute — full job execution (deserialize + perform + archive)
16
16
  # pgbus.job_completed — job archived successfully
17
17
  # pgbus.job_failed — job raised; carries :exception_object
18
- # pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed
18
+ # pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed;
19
+ # :error is the last recorded error class (or nil)
19
20
  # pgbus.job_visibility_extended — heartbeat re-armed a running job's visibility timeout
20
21
  # payload: queue, job_class, msg_id, vt, extensions
21
22
  # pgbus.event_processed — event handler succeeded
@@ -70,6 +70,31 @@ module Pgbus
70
70
  ::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(redacted) }])
71
71
  end
72
72
 
73
+ # Past the count cap the total is a lower bound, so only a full page
74
+ # can have another one behind it. At the page ceiling the next call
75
+ # would clamp back to this page, so there is no "more" to fetch.
76
+ def more_pages?(count, page:, per_page:, shown:)
77
+ return false if page >= self::MAX_PAGE
78
+
79
+ count.capped? ? shown == per_page : page * per_page < count.total
80
+ end
81
+
82
+ DEAD_LETTER_SUMMARY_KEYS = %w[reason source source_queue attempts max_retries dead_lettered_at
83
+ retries_from_dlq error_class error_attempt error_recorded_at].freeze
84
+ DEAD_LETTER_ERROR_KEYS = %w[error_message backtrace].freeze
85
+
86
+ # Why a DLQ row died, from its DeadLetterHeader block (issue #495), or
87
+ # nil for a row dead-lettered before the block existed. The error
88
+ # message and backtrace can quote payload values, and the Redactor
89
+ # does not know those keys, so they are added only with include_error.
90
+ def dead_letter_summary(row, include_error:)
91
+ block = Pgbus::DeadLetterHeader.parse(row[:headers])
92
+ return unless block
93
+
94
+ keys = include_error ? DEAD_LETTER_SUMMARY_KEYS + DEAD_LETTER_ERROR_KEYS : DEAD_LETTER_SUMMARY_KEYS
95
+ block.slice(*keys)
96
+ end
97
+
73
98
  # Wrap an error message as an MCP error response (isError: true) so the
74
99
  # client surfaces it as a tool failure rather than a normal result.
75
100
  def error_response(message)
@@ -17,14 +17,33 @@ module Pgbus
17
17
  their limit — plus up to 100 key rows with value/limit, lease expiry,
18
18
  whether the lease is still fresh, parked count and oldest wait. A key
19
19
  with a stale lease and a parked backlog is a stuck pipeline: its holder
20
- died before releasing the slot.
20
+ died before releasing the slot. The key rows are paginated (default
21
+ 100 per page, capped at 100); the response also carries page,
22
+ per_page, total (number of keys, capped at 10000) and has_more.
21
23
  DESC
22
24
 
23
- input_schema(properties: {}, required: [])
25
+ MAX_PER_PAGE = 100
26
+ MAX_PAGE = 1_000
24
27
 
25
- def self.call(server_context: nil)
28
+ input_schema(
29
+ properties: {
30
+ page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
31
+ per_page: { type: "integer", description: "Rows per page (default 100, max 100).", minimum: 1 }
32
+ },
33
+ required: []
34
+ )
35
+
36
+ def self.call(page: 1, per_page: MAX_PER_PAGE, server_context: nil)
26
37
  data_source = data_source_from(server_context)
27
- json_response(data_source.concurrency_stats)
38
+ per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
39
+ page = page.to_i.clamp(1, MAX_PAGE)
40
+ stats = data_source.concurrency_stats(page: page, per_page: per_page)
41
+ count = data_source.list_count(:concurrency_keys)
42
+
43
+ json_response(
44
+ stats.merge(page: page, per_page: per_page, total: count.total,
45
+ has_more: more_pages?(count, page: page, per_page: per_page, shown: stats[:keys].size))
46
+ )
28
47
  end
29
48
  end
30
49
  end
@@ -12,7 +12,7 @@ module Pgbus
12
12
  title "Pgbus Dead-Letter Detail"
13
13
  description <<~DESC
14
14
  Inspect one dead-letter message: read_ct, vt, enqueued_at, source
15
- queue. Pass the full physical DLQ queue name (e.g.
15
+ queue, and a dead_letter summary of why it died. Pass the full physical DLQ queue name (e.g.
16
16
  "pgbus_default_dlq") in +queue+ to disambiguate — msg_ids are only
17
17
  unique within a single DLQ table, so the same id can exist in more
18
18
  than one. Without +queue+ the tool scans every "_dlq" table and
@@ -48,7 +48,9 @@ module Pgbus
48
48
  return error_response("Dead-letter message #{msg_id} not found") unless detail
49
49
  return detail if detail.is_a?(::MCP::Tool::Response)
50
50
 
51
- json_response({ message: detail }, server_context: server_context, include_payloads: include_payloads)
51
+ summary = dead_letter_summary(detail, include_error: payloads_allowed?(server_context, include_payloads))
52
+ json_response({ message: detail.merge(dead_letter: summary) },
53
+ server_context: server_context, include_payloads: include_payloads)
52
54
  end
53
55
 
54
56
  # When the caller didn't specify a queue, scan every DLQ for the id and
@@ -10,9 +10,12 @@ module Pgbus
10
10
  title "Pgbus Dead-Letter Queue"
11
11
  description <<~DESC
12
12
  List messages sitting in dead-letter queues (queues whose name ends
13
- in "_dlq") with read_ct, vt and enqueued_at. Paginated (default 25,
14
- max 100). Message bodies and headers are redacted unless the server
15
- allows payloads and include_payloads is set.
13
+ in "_dlq") with read_ct, vt and enqueued_at, and a dead_letter
14
+ summary of why each died (reason, source queue, attempts, error
15
+ class; null for messages dead-lettered before pgbus recorded it).
16
+ Paginated (default 25, max 100). Message bodies, headers and the
17
+ error message/backtrace are redacted unless the server allows
18
+ payloads and include_payloads is set.
16
19
  DESC
17
20
 
18
21
  MAX_PER_PAGE = 100
@@ -34,7 +37,10 @@ module Pgbus
34
37
  data_source = data_source_from(server_context)
35
38
  per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
36
39
  page = page.to_i.clamp(1, MAX_PAGE)
37
- rows = data_source.dlq_messages(page: page, per_page: per_page)
40
+ include_error = payloads_allowed?(server_context, include_payloads)
41
+ rows = data_source.dlq_messages(page: page, per_page: per_page).map do |row|
42
+ row.merge(dead_letter: dead_letter_summary(row, include_error: include_error))
43
+ end
38
44
 
39
45
  json_response(
40
46
  { page: page, per_page: per_page, messages: rows },