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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Top-level health verdict: OK / DEGRADED / STALLED, with the reasons
7
+ # that drove it. Delegates the actual analysis to HealthAnalyzer, which
8
+ # reads the same DataSource surface the dashboard uses. This is the
9
+ # single tool that catches the silent-worker-wedge class of incident.
10
+ class HealthTool < BaseTool
11
+ tool_name "pgbus_health"
12
+ title "Pgbus Health"
13
+ description <<~DESC
14
+ Top-level pgbus health verdict. Returns OK, DEGRADED, or STALLED with
15
+ the specific reasons. STALLED means the silent-wedge condition:
16
+ queues have visible backlog while workers are heart-beating but not
17
+ claiming (or a worker's claim loop has stalled). DEGRADED covers
18
+ stale processes, paused queues holding a backlog, dead-letter
19
+ buildup, or a long-running transaction pinning the MVCC horizon. Use
20
+ this first when triaging — it summarizes the whole system in one call
21
+ and is suitable for automated alerting.
22
+ DESC
23
+
24
+ input_schema(properties: {}, required: [])
25
+
26
+ def self.call(server_context: nil)
27
+ data_source = data_source_from(server_context)
28
+ json_response(HealthAnalyzer.new(data_source).verdict)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Inspect a single enqueued message by queue + msg_id. Returns its
7
+ # metadata (read_ct, vt, enqueued_at, last_read_at); the body and headers
8
+ # are redacted unless payloads are explicitly allowed. Maps to
9
+ # DataSource#job_detail.
10
+ class JobDetailTool < BaseTool
11
+ tool_name "pgbus_job_detail"
12
+ title "Pgbus Job Detail"
13
+ description <<~DESC
14
+ Inspect one enqueued message: read_ct, visibility timeout (vt),
15
+ enqueued_at, last_read_at. Pass the full physical queue name and the
16
+ numeric msg_id. The message body and headers are redacted unless the
17
+ server allows payloads and include_payloads is set.
18
+ DESC
19
+
20
+ input_schema(
21
+ properties: {
22
+ queue: { type: "string", description: "Full physical queue name (e.g. \"pgbus_default\")." },
23
+ msg_id: { type: "integer", description: "Numeric PGMQ message id." },
24
+ include_payloads: {
25
+ type: "boolean",
26
+ description: "Include the raw message body/headers. Only honored when the server allows payloads."
27
+ }
28
+ },
29
+ required: %w[queue msg_id]
30
+ )
31
+
32
+ def self.call(queue:, msg_id:, include_payloads: false, server_context: nil)
33
+ data_source = data_source_from(server_context)
34
+ detail = data_source.job_detail(queue, msg_id)
35
+ return error_response("Message #{msg_id} not found in #{queue}") unless detail
36
+
37
+ json_response({ job: detail }, server_context: server_context, include_payloads: include_payloads)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Paginated list of enqueued jobs/messages for a queue (or across all
7
+ # non-DLQ queues). Returns metadata — msg_id, read_ct, vt, enqueued_at,
8
+ # queue_name — with message bodies and headers redacted unless payloads
9
+ # are explicitly allowed. Maps to DataSource#jobs.
10
+ class JobsTool < BaseTool
11
+ tool_name "pgbus_jobs"
12
+ title "Pgbus Jobs"
13
+ description <<~DESC
14
+ List enqueued jobs/messages with read_ct, visibility timeout (vt) and
15
+ enqueued_at. Pass a full physical queue name to scope to one queue,
16
+ or omit it to span all non-DLQ queues. Paginated (default 25 per
17
+ page, capped at 100). Message bodies and headers are redacted unless
18
+ the server was started with payloads allowed and include_payloads is
19
+ set.
20
+ DESC
21
+
22
+ MAX_PER_PAGE = 100
23
+ MAX_PAGE = 1_000
24
+
25
+ input_schema(
26
+ properties: {
27
+ queue: {
28
+ type: "string",
29
+ description: "Full physical queue name (e.g. \"pgbus_default\"). Omit to span all non-DLQ queues."
30
+ },
31
+ page: { type: "integer", description: "1-based page number (default 1).", minimum: 1 },
32
+ per_page: { type: "integer", description: "Rows per page (default 25, max 100).", minimum: 1 },
33
+ include_payloads: {
34
+ type: "boolean",
35
+ description: "Include raw message bodies/headers. Only honored when the server allows payloads."
36
+ }
37
+ },
38
+ required: []
39
+ )
40
+
41
+ def self.call(queue: nil, page: 1, per_page: 25, include_payloads: false, server_context: nil)
42
+ data_source = data_source_from(server_context)
43
+ per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
44
+ page = page.to_i.clamp(1, MAX_PAGE)
45
+ rows = data_source.jobs(queue_name: queue, page: page, per_page: per_page)
46
+
47
+ json_response(
48
+ { queue: queue, page: page, per_page: per_page, jobs: rows },
49
+ server_context: server_context,
50
+ include_payloads: include_payloads
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Lists active job uniqueness locks (the leaked-uniqueness-lock
7
+ # diagnostic). Maps to DataSource#job_locks — lock_key, queue_name,
8
+ # msg_id, created_at, age_seconds. No payloads involved.
9
+ class LocksTool < BaseTool
10
+ tool_name "pgbus_locks"
11
+ title "Pgbus Locks"
12
+ description <<~DESC
13
+ List active job uniqueness locks with their lock_key, queue_name,
14
+ msg_id, and age in seconds. A lock that outlives its message
15
+ indicates a leaked uniqueness key blocking re-enqueues. Returns the
16
+ 100 most recent locks.
17
+ DESC
18
+
19
+ input_schema(properties: {}, required: [])
20
+
21
+ def self.call(server_context: nil)
22
+ data_source = data_source_from(server_context)
23
+ json_response({ locks: data_source.job_locks })
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Lists every pgbus process (worker/consumer/dispatcher/scheduler/
7
+ # supervisor/outbox/streamer) with kind, pid, heartbeat age, derived
8
+ # status and the claim-loop "stalled" verdict. Maps to
9
+ # DataSource#processes — which already computes :status as :healthy,
10
+ # :stale, or :stalled (the silent-wedge signal from #179/#181).
11
+ class ProcessesTool < BaseTool
12
+ tool_name "pgbus_processes"
13
+ title "Pgbus Processes"
14
+ description <<~DESC
15
+ List every pgbus process with its kind (worker, consumer,
16
+ dispatcher, scheduler, supervisor, outbox, streamer), hostname, pid,
17
+ last heartbeat time, and derived status. Status is "healthy",
18
+ "stale" (no recent heartbeat), or "stalled" (worker heart-beating but
19
+ its claim loop has stopped advancing — the silent-wedge condition).
20
+ Use this to answer "are workers alive but not claiming?".
21
+ DESC
22
+
23
+ input_schema(properties: {}, required: [])
24
+
25
+ def self.call(server_context: nil)
26
+ data_source = data_source_from(server_context)
27
+ json_response({ processes: data_source.processes })
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Per-queue detail: metrics, paused state, and table health (dead tuples,
7
+ # bloat, vacuum age). Maps to DataSource#queue_detail + #queue_paused? +
8
+ # #queue_health_detail. The +name+ is the full physical queue name as
9
+ # returned by pgbus_queues (e.g. "pgbus_default"), not the logical name.
10
+ class QueueDetailTool < BaseTool
11
+ tool_name "pgbus_queue_detail"
12
+ title "Pgbus Queue Detail"
13
+ description <<~DESC
14
+ Detailed status for a single queue: depth, visible count, message
15
+ ages, lifetime total, whether the queue is paused, and per-table
16
+ health (live/dead tuples, bloat ratio, last vacuum age). Pass the
17
+ full physical queue name as shown by pgbus_queues (e.g.
18
+ "pgbus_default").
19
+ DESC
20
+
21
+ input_schema(
22
+ properties: {
23
+ name: {
24
+ type: "string",
25
+ description: "Full physical queue name, e.g. \"pgbus_default\"."
26
+ }
27
+ },
28
+ required: ["name"]
29
+ )
30
+
31
+ def self.call(name:, server_context: nil)
32
+ data_source = data_source_from(server_context)
33
+ detail = data_source.queue_detail(name)
34
+ return error_response("Queue not found: #{name}") unless detail
35
+
36
+ json_response(
37
+ detail.merge(
38
+ paused: data_source.queue_paused?(name),
39
+ health: data_source.queue_health_detail(name)
40
+ )
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Lists every queue with depth, visible count, oldest-message age and
7
+ # throughput — the "are queues backed up?" diagnostic. Maps to
8
+ # DataSource#queues_with_metrics. No payloads are involved.
9
+ class QueuesTool < BaseTool
10
+ tool_name "pgbus_queues"
11
+ title "Pgbus Queues"
12
+ description <<~DESC
13
+ List all pgbus queues with their depth (queue_length), visible count
14
+ (messages whose visibility timeout has expired and are ready to be
15
+ claimed), oldest/newest message age in seconds, lifetime total, and
16
+ paused state. Use this to answer "are any queues backed up?".
17
+ DESC
18
+
19
+ input_schema(properties: {}, required: [])
20
+
21
+ def self.call(server_context: nil)
22
+ data_source = data_source_from(server_context)
23
+ json_response({ queues: data_source.queues_with_metrics })
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Recurring task schedule with last-run / next-run times. Maps to
7
+ # DataSource#recurring_tasks. The :arguments field is not part of the
8
+ # list payload, so no redaction is needed here.
9
+ class RecurringTool < BaseTool
10
+ tool_name "pgbus_recurring"
11
+ title "Pgbus Recurring Tasks"
12
+ description <<~DESC
13
+ List recurring (cron) tasks with their schedule, human-readable
14
+ schedule, queue, enabled state, last_run_at and next_run_at. Use this
15
+ to confirm scheduled work is firing on time.
16
+ DESC
17
+
18
+ input_schema(properties: {}, required: [])
19
+
20
+ def self.call(server_context: nil)
21
+ data_source = data_source_from(server_context)
22
+ json_response({ recurring_tasks: data_source.recurring_tasks })
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Recent job status counts + summary (success / failed / dead-lettered,
7
+ # durations). Maps to DataSource#job_status_counts + #job_stats_summary.
8
+ class StatsTool < BaseTool
9
+ tool_name "pgbus_stats"
10
+ title "Pgbus Stats"
11
+ description <<~DESC
12
+ Recent job statistics over the last N minutes (default 60, max 1440):
13
+ status counts (success/failed/dead_lettered) and a summary including
14
+ average and max duration. Use this to gauge error rates and latency.
15
+ DESC
16
+
17
+ MAX_MINUTES = 1440
18
+
19
+ input_schema(
20
+ properties: {
21
+ minutes: { type: "integer", description: "Look-back window in minutes (default 60, max 1440).", minimum: 1 }
22
+ },
23
+ required: []
24
+ )
25
+
26
+ def self.call(minutes: 60, server_context: nil)
27
+ data_source = data_source_from(server_context)
28
+ window = minutes.to_i.clamp(1, MAX_MINUTES)
29
+ json_response(
30
+ {
31
+ minutes: window,
32
+ status_counts: data_source.job_status_counts(minutes: window),
33
+ summary: data_source.job_stats_summary(minutes: window)
34
+ }
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Recent job throughput as a time series. Maps to
7
+ # DataSource#job_throughput. Used to answer "is the system draining?".
8
+ class ThroughputTool < BaseTool
9
+ tool_name "pgbus_throughput"
10
+ title "Pgbus Throughput"
11
+ description <<~DESC
12
+ Recent job throughput as a time series of {time, count} buckets over
13
+ the last N minutes (default 60, max 1440). Use this to confirm the
14
+ system is draining — a flat-zero series alongside a backlog is the
15
+ wedge signature.
16
+ DESC
17
+
18
+ MAX_MINUTES = 1440
19
+
20
+ input_schema(
21
+ properties: {
22
+ minutes: { type: "integer", description: "Look-back window in minutes (default 60, max 1440).", minimum: 1 }
23
+ },
24
+ required: []
25
+ )
26
+
27
+ def self.call(minutes: 60, server_context: nil)
28
+ data_source = data_source_from(server_context)
29
+ window = minutes.to_i.clamp(1, MAX_MINUTES)
30
+ json_response({ minutes: window, throughput: data_source.job_throughput(minutes: window) })
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/pgbus/mcp.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Optional read-only MCP (Model Context Protocol) diagnostic server.
5
+ #
6
+ # This namespace is deliberately kept out of pgbus's Zeitwerk loader because
7
+ # its tool classes subclass `MCP::Tool` from the optional `mcp` gem.
8
+ # Referencing that gem at autoload time would force every pgbus user to
9
+ # install it. Instead the subsystem is loaded explicitly via {load!}, which
10
+ # is called by the `pgbus mcp` CLI command (and by specs).
11
+ module MCP
12
+ # Files in dependency order: the gem first, then the base classes the
13
+ # tools depend on, then the tools, then the server/runner.
14
+ REQUIRES = %w[
15
+ pgbus/mcp/redactor
16
+ pgbus/mcp/base_tool
17
+ pgbus/mcp/health_analyzer
18
+ pgbus/mcp/tools/health_tool
19
+ pgbus/mcp/tools/queues_tool
20
+ pgbus/mcp/tools/queue_detail_tool
21
+ pgbus/mcp/tools/processes_tool
22
+ pgbus/mcp/tools/jobs_tool
23
+ pgbus/mcp/tools/job_detail_tool
24
+ pgbus/mcp/tools/dlq_tool
25
+ pgbus/mcp/tools/dlq_detail_tool
26
+ pgbus/mcp/tools/locks_tool
27
+ pgbus/mcp/tools/throughput_tool
28
+ pgbus/mcp/tools/stats_tool
29
+ pgbus/mcp/tools/recurring_tool
30
+ pgbus/mcp/server
31
+ pgbus/mcp/runner
32
+ pgbus/mcp/rack_app
33
+ ].freeze
34
+
35
+ module_function
36
+
37
+ # Require the `mcp` gem and the whole pgbus MCP subsystem. Idempotent.
38
+ # Raises Pgbus::Error with an actionable message if the gem is missing.
39
+ #
40
+ # Only the `require "mcp"` is wrapped — a LoadError from an internal
41
+ # `pgbus/mcp/...` path (typo, missing file) propagates verbatim so the
42
+ # real broken require shows up in the message instead of being masked
43
+ # as "add gem `mcp`".
44
+ def load!
45
+ begin
46
+ require "mcp"
47
+ rescue LoadError
48
+ raise Pgbus::Error,
49
+ "The pgbus MCP server requires the `mcp` gem. Add `gem \"mcp\"` to your Gemfile and run `bundle install`."
50
+ end
51
+
52
+ REQUIRES.each { |path| require path }
53
+ end
54
+ end
55
+ end
@@ -11,10 +11,11 @@ module Pgbus
11
11
 
12
12
  attr_reader :process_entry
13
13
 
14
- def initialize(kind:, metadata: {}, on_beat: nil)
14
+ def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil)
15
15
  @kind = kind
16
16
  @metadata = metadata
17
17
  @on_beat = on_beat
18
+ @loop_tick_supplier = loop_tick_supplier
18
19
  @timer = nil
19
20
  end
20
21
 
@@ -33,7 +34,12 @@ module Pgbus
33
34
  return unless @process_id
34
35
 
35
36
  @on_beat&.call
36
- ProcessEntry.where(id: @process_id).update_all(last_heartbeat_at: Time.current)
37
+ updates = { last_heartbeat_at: Time.current }
38
+ if @loop_tick_supplier
39
+ tick = @loop_tick_supplier.call
40
+ updates[:metadata] = @metadata.merge("loop_tick_at" => tick&.to_f)
41
+ end
42
+ ProcessEntry.where(id: @process_id).update_all(updates)
37
43
  rescue StandardError => e
38
44
  Pgbus.logger.warn { "[Pgbus] Heartbeat failed: #{e.message}" }
39
45
  end
@@ -6,6 +6,7 @@ module Pgbus
6
6
  include SignalHandler
7
7
 
8
8
  FORK_WAIT = 1 # seconds between fork checks
9
+ WATCHDOG_INTERVAL = 10 # seconds between stall checks
9
10
 
10
11
  attr_reader :config
11
12
 
@@ -13,6 +14,7 @@ module Pgbus
13
14
  @config = config
14
15
  @forks = {}
15
16
  @shutting_down = false
17
+ @last_watchdog_at = monotonic_now
16
18
  end
17
19
 
18
20
  def run
@@ -240,6 +242,7 @@ module Pgbus
240
242
 
241
243
  process_signals
242
244
  reap_children
245
+ check_stalled_workers unless @shutting_down
243
246
  interruptible_sleep(FORK_WAIT)
244
247
  end
245
248
  end
@@ -280,6 +283,43 @@ module Pgbus
280
283
  end
281
284
  end
282
285
 
286
+ def check_stalled_workers
287
+ now = monotonic_now
288
+ return if (now - @last_watchdog_at) < WATCHDOG_INTERVAL
289
+
290
+ @last_watchdog_at = now
291
+ threshold = config.stall_threshold
292
+ return unless threshold&.positive?
293
+
294
+ worker_pids = @forks.select { |_, info| info[:type] == :worker }.keys
295
+ return if worker_pids.empty?
296
+
297
+ rows = ProcessEntry.where(kind: "worker", pid: worker_pids).to_a
298
+ rows.each do |entry|
299
+ meta = entry.metadata
300
+ next unless meta.is_a?(Hash)
301
+
302
+ loop_tick = meta["loop_tick_at"]
303
+ next unless loop_tick
304
+
305
+ age = Time.current.to_f - loop_tick.to_f
306
+ next unless age > threshold
307
+
308
+ pid = entry.pid
309
+ Pgbus.logger.error do
310
+ "[Pgbus] Supervisor watchdog: worker pid=#{pid} claim loop stalled " \
311
+ "(loop_tick_at age=#{age.round(1)}s > threshold=#{threshold}s), sending SIGKILL"
312
+ end
313
+ begin
314
+ ::Process.kill("KILL", pid)
315
+ rescue Errno::ESRCH
316
+ # already gone
317
+ end
318
+ end
319
+ rescue StandardError => e
320
+ Pgbus.logger.warn { "[Pgbus] Supervisor watchdog check failed: #{e.message}" }
321
+ end
322
+
283
323
  def signal_children(sig)
284
324
  @forks.each_key do |pid|
285
325
  ::Process.kill(sig, pid)
@@ -318,6 +358,10 @@ module Pgbus
318
358
  @heartbeat.start
319
359
  end
320
360
 
361
+ def monotonic_now
362
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
363
+ end
364
+
321
365
  def shutdown
322
366
  # Wait for all children with timeout
323
367
  deadline = Time.now + 30
@@ -37,6 +37,7 @@ module Pgbus
37
37
  @jobs_processed = Concurrent::AtomicFixnum.new(0)
38
38
  @jobs_failed = Concurrent::AtomicFixnum.new(0)
39
39
  @in_flight = Concurrent::AtomicFixnum.new(0)
40
+ @loop_tick_at = Concurrent::AtomicReference.new(nil)
40
41
  @rate_counter = RateCounter.new(:processed, :failed, :dequeued)
41
42
  @started_at = Time.current
42
43
  @started_at_monotonic = monotonic_now
@@ -82,6 +83,7 @@ module Pgbus
82
83
  end
83
84
 
84
85
  loop do
86
+ stamp_loop_tick
85
87
  process_signals
86
88
  check_recycle
87
89
  refresh_wildcard_queues
@@ -492,7 +494,8 @@ module Pgbus
492
494
  queues: queues, threads: threads, pid: ::Process.pid,
493
495
  execution_mode: @execution_mode, consumer_priority: @consumer_priority
494
496
  },
495
- on_beat: -> { @rate_counter.snapshot! }
497
+ on_beat: -> { @rate_counter.snapshot! },
498
+ loop_tick_supplier: -> { @loop_tick_at.get }
496
499
  )
497
500
  @heartbeat.start
498
501
  end
@@ -512,6 +515,14 @@ module Pgbus
512
515
  def monotonic_now
513
516
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
514
517
  end
518
+
519
+ # Stamp the loop-progress beacon with a wall-clock timestamp.
520
+ # Wall clock is required because the supervisor watchdog reads
521
+ # this value from a different process (cross-fork) and the
522
+ # dashboard reads it from a different host.
523
+ def stamp_loop_tick
524
+ @loop_tick_at.set(Time.now.to_f)
525
+ end
515
526
  end
516
527
  end
517
528
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.9.4"
4
+ VERSION = "0.9.6"
5
5
  end
@@ -41,7 +41,17 @@ module Pgbus
41
41
  # Queues — query via ActiveRecord for reliability in web processes
42
42
  # (avoids PGMQ client connection issues when the web server uses a
43
43
  # different connection lifecycle than the worker processes).
44
+ # Memoized for the lifetime of this data-source instance (one per web
45
+ # request — see ApplicationController#data_source). A single page can ask
46
+ # for queue metrics more than once (e.g. the DLQ page reads both the
47
+ # message rows and the total count); without the memo each call re-runs
48
+ # the meta query + batched metrics. Mutations redirect to a fresh request
49
+ # with a new instance, so a per-request memo never serves stale data.
44
50
  def queues_with_metrics
51
+ @queues_with_metrics ||= fetch_queues_with_metrics
52
+ end
53
+
54
+ def fetch_queues_with_metrics
45
55
  queue_names = connection.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
46
56
  return [] if queue_names.empty?
47
57
 
@@ -53,6 +63,7 @@ module Pgbus
53
63
  Pgbus.logger.error { "[Pgbus::Web] Error fetching queue metrics: #{e.class}: #{e.message}" }
54
64
  []
55
65
  end
66
+ private :fetch_queues_with_metrics
56
67
 
57
68
  # name is the full PGMQ queue name (e.g. "pgbus_default") as returned
58
69
  # by queues_with_metrics. No prefix is added.
@@ -276,6 +287,16 @@ module Pgbus
276
287
  []
277
288
  end
278
289
 
290
+ def dlq_total_count
291
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
292
+ queues_with_metrics
293
+ .select { |q| q[:name].end_with?(dlq_suffix) }
294
+ .sum { |q| q[:queue_length] }
295
+ rescue StandardError => e
296
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ count: #{e.message}" }
297
+ 0
298
+ end
299
+
279
300
  def dlq_message_detail(msg_id)
280
301
  dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
281
302
  queues = queues_with_metrics.select { |q| q[:name].end_with?(dlq_suffix) }
@@ -1195,19 +1216,36 @@ module Pgbus
1195
1216
  heartbeat = row["last_heartbeat_at"]
1196
1217
  heartbeat_time = heartbeat.is_a?(String) ? Time.parse(heartbeat) : heartbeat
1197
1218
  stale = heartbeat_time && (Time.now - heartbeat_time) > Process::Heartbeat::ALIVE_THRESHOLD
1219
+ metadata = row["metadata"].is_a?(String) ? JSON.parse(row["metadata"]) : row["metadata"]
1220
+ status = derive_process_status(stale, metadata, row["kind"])
1198
1221
 
1199
1222
  {
1200
1223
  id: row["id"].to_i,
1201
1224
  kind: row["kind"],
1202
1225
  hostname: row["hostname"],
1203
1226
  pid: row["pid"].to_i,
1204
- metadata: row["metadata"].is_a?(String) ? JSON.parse(row["metadata"]) : row["metadata"],
1227
+ metadata: metadata,
1205
1228
  last_heartbeat_at: heartbeat_time,
1206
- healthy: !stale,
1229
+ healthy: status == :healthy,
1230
+ status: status,
1207
1231
  created_at: row["created_at"]
1208
1232
  }
1209
1233
  end
1210
1234
 
1235
+ def derive_process_status(stale, metadata, kind)
1236
+ return :stale if stale
1237
+ return :healthy unless kind == "worker" && metadata.is_a?(Hash)
1238
+
1239
+ loop_tick = metadata["loop_tick_at"]
1240
+ return :healthy unless loop_tick
1241
+
1242
+ threshold = Pgbus.configuration.stall_threshold
1243
+ return :healthy unless threshold&.positive?
1244
+
1245
+ age = Time.now.to_f - loop_tick.to_f
1246
+ age > threshold ? :stalled : :healthy
1247
+ end
1248
+
1211
1249
  def format_batch(record)
1212
1250
  total = record.total_jobs
1213
1251
  done = record.completed_jobs + record.discarded_jobs