pgbus 0.9.4 → 0.9.5

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +115 -0
  3. data/app/helpers/pgbus/application_helper.rb +12 -2
  4. data/app/views/pgbus/dashboard/_processes_table.html.erb +1 -1
  5. data/app/views/pgbus/processes/_processes_table.html.erb +1 -1
  6. data/config/locales/da.yml +1 -0
  7. data/config/locales/de.yml +1 -0
  8. data/config/locales/en.yml +1 -0
  9. data/config/locales/es.yml +1 -0
  10. data/config/locales/fi.yml +1 -0
  11. data/config/locales/fr.yml +1 -0
  12. data/config/locales/it.yml +1 -0
  13. data/config/locales/ja.yml +1 -0
  14. data/config/locales/nb.yml +1 -0
  15. data/config/locales/nl.yml +1 -0
  16. data/config/locales/pt.yml +1 -0
  17. data/config/locales/sv.yml +1 -0
  18. data/lib/pgbus/cli.rb +16 -0
  19. data/lib/pgbus/client.rb +34 -7
  20. data/lib/pgbus/configuration.rb +16 -0
  21. data/lib/pgbus/mcp/base_tool.rb +79 -0
  22. data/lib/pgbus/mcp/health_analyzer.rb +159 -0
  23. data/lib/pgbus/mcp/rack_app.rb +97 -0
  24. data/lib/pgbus/mcp/redactor.rb +72 -0
  25. data/lib/pgbus/mcp/runner.rb +73 -0
  26. data/lib/pgbus/mcp/server.rb +62 -0
  27. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +72 -0
  28. data/lib/pgbus/mcp/tools/dlq_tool.rb +48 -0
  29. data/lib/pgbus/mcp/tools/health_tool.rb +33 -0
  30. data/lib/pgbus/mcp/tools/job_detail_tool.rb +42 -0
  31. data/lib/pgbus/mcp/tools/jobs_tool.rb +56 -0
  32. data/lib/pgbus/mcp/tools/locks_tool.rb +28 -0
  33. data/lib/pgbus/mcp/tools/processes_tool.rb +32 -0
  34. data/lib/pgbus/mcp/tools/queue_detail_tool.rb +46 -0
  35. data/lib/pgbus/mcp/tools/queues_tool.rb +28 -0
  36. data/lib/pgbus/mcp/tools/recurring_tool.rb +27 -0
  37. data/lib/pgbus/mcp/tools/stats_tool.rb +40 -0
  38. data/lib/pgbus/mcp/tools/throughput_tool.rb +35 -0
  39. data/lib/pgbus/mcp.rb +55 -0
  40. data/lib/pgbus/process/heartbeat.rb +8 -2
  41. data/lib/pgbus/process/supervisor.rb +44 -0
  42. data/lib/pgbus/process/worker.rb +12 -1
  43. data/lib/pgbus/version.rb +1 -1
  44. data/lib/pgbus/web/data_source.rb +19 -2
  45. data/lib/pgbus.rb +15 -1
  46. metadata +20 -1
@@ -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.5"
5
5
  end
@@ -1195,19 +1195,36 @@ module Pgbus
1195
1195
  heartbeat = row["last_heartbeat_at"]
1196
1196
  heartbeat_time = heartbeat.is_a?(String) ? Time.parse(heartbeat) : heartbeat
1197
1197
  stale = heartbeat_time && (Time.now - heartbeat_time) > Process::Heartbeat::ALIVE_THRESHOLD
1198
+ metadata = row["metadata"].is_a?(String) ? JSON.parse(row["metadata"]) : row["metadata"]
1199
+ status = derive_process_status(stale, metadata, row["kind"])
1198
1200
 
1199
1201
  {
1200
1202
  id: row["id"].to_i,
1201
1203
  kind: row["kind"],
1202
1204
  hostname: row["hostname"],
1203
1205
  pid: row["pid"].to_i,
1204
- metadata: row["metadata"].is_a?(String) ? JSON.parse(row["metadata"]) : row["metadata"],
1206
+ metadata: metadata,
1205
1207
  last_heartbeat_at: heartbeat_time,
1206
- healthy: !stale,
1208
+ healthy: status == :healthy,
1209
+ status: status,
1207
1210
  created_at: row["created_at"]
1208
1211
  }
1209
1212
  end
1210
1213
 
1214
+ def derive_process_status(stale, metadata, kind)
1215
+ return :stale if stale
1216
+ return :healthy unless kind == "worker" && metadata.is_a?(Hash)
1217
+
1218
+ loop_tick = metadata["loop_tick_at"]
1219
+ return :healthy unless loop_tick
1220
+
1221
+ threshold = Pgbus.configuration.stall_threshold
1222
+ return :healthy unless threshold&.positive?
1223
+
1224
+ age = Time.now.to_f - loop_tick.to_f
1225
+ age > threshold ? :stalled : :healthy
1226
+ end
1227
+
1211
1228
  def format_batch(record)
1212
1229
  total = record.total_jobs
1213
1230
  done = record.completed_jobs + record.discarded_jobs
data/lib/pgbus.rb CHANGED
@@ -18,6 +18,7 @@ module Pgbus
18
18
  class ConcurrencyLimitExceeded < Error; end
19
19
  class JobNotUnique < Error; end
20
20
  class SchemaNotReady < Error; end
21
+ class ReadTimeoutError < Error; end
21
22
 
22
23
  class << self
23
24
  # Process-global flag set by Worker#graceful_shutdown so the adapter
@@ -35,11 +36,19 @@ module Pgbus
35
36
  "pgbus" => "Pgbus",
36
37
  "cli" => "CLI",
37
38
  "dsl" => "DSL",
38
- "capsule_dsl" => "CapsuleDSL"
39
+ "capsule_dsl" => "CapsuleDSL",
40
+ "mcp" => "MCP"
39
41
  )
40
42
  loader.ignore("#{__dir__}/generators")
41
43
  loader.ignore("#{__dir__}/active_job")
42
44
  loader.ignore("#{__dir__}/pgbus/testing")
45
+ # The MCP diagnostic server is optional — its tool classes subclass
46
+ # MCP::Tool from the (optional) `mcp` gem. Keeping it out of Zeitwerk
47
+ # means we never reference the gem's constants at autoload time;
48
+ # `Pgbus::MCP.load!` (called by the `pgbus mcp` CLI command) requires
49
+ # the gem and loads the subsystem explicitly.
50
+ loader.ignore("#{__dir__}/pgbus/mcp")
51
+ loader.ignore("#{__dir__}/pgbus/mcp.rb")
43
52
  # Vendor integrations are loaded conditionally (when the vendor gem
44
53
  # is present) by lib/pgbus/engine.rb. Keeping them out of Zeitwerk
45
54
  # means we don't reference vendor constants at autoload time.
@@ -174,3 +183,8 @@ end
174
183
 
175
184
  require "active_job/queue_adapters/pgbus_adapter" if defined?(ActiveJob)
176
185
  require "pgbus/engine" if defined?(Rails::Engine)
186
+
187
+ # Define the optional MCP namespace and its `load!` entrypoint. This file is
188
+ # excluded from Zeitwerk (it references the optional `mcp` gem only inside
189
+ # load!), so require it explicitly here. Requiring it does NOT load the gem.
190
+ require "pgbus/mcp"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.4
4
+ version: 0.9.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -285,6 +285,25 @@ files:
285
285
  - lib/pgbus/integrations/appsignal/probe.rb
286
286
  - lib/pgbus/integrations/appsignal/subscriber.rb
287
287
  - lib/pgbus/log_formatter.rb
288
+ - lib/pgbus/mcp.rb
289
+ - lib/pgbus/mcp/base_tool.rb
290
+ - lib/pgbus/mcp/health_analyzer.rb
291
+ - lib/pgbus/mcp/rack_app.rb
292
+ - lib/pgbus/mcp/redactor.rb
293
+ - lib/pgbus/mcp/runner.rb
294
+ - lib/pgbus/mcp/server.rb
295
+ - lib/pgbus/mcp/tools/dlq_detail_tool.rb
296
+ - lib/pgbus/mcp/tools/dlq_tool.rb
297
+ - lib/pgbus/mcp/tools/health_tool.rb
298
+ - lib/pgbus/mcp/tools/job_detail_tool.rb
299
+ - lib/pgbus/mcp/tools/jobs_tool.rb
300
+ - lib/pgbus/mcp/tools/locks_tool.rb
301
+ - lib/pgbus/mcp/tools/processes_tool.rb
302
+ - lib/pgbus/mcp/tools/queue_detail_tool.rb
303
+ - lib/pgbus/mcp/tools/queues_tool.rb
304
+ - lib/pgbus/mcp/tools/recurring_tool.rb
305
+ - lib/pgbus/mcp/tools/stats_tool.rb
306
+ - lib/pgbus/mcp/tools/throughput_tool.rb
288
307
  - lib/pgbus/outbox.rb
289
308
  - lib/pgbus/outbox/poller.rb
290
309
  - lib/pgbus/pgmq_schema.rb