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.
- checksums.yaml +4 -4
- data/README.md +115 -0
- data/Rakefile +56 -3
- data/app/controllers/pgbus/dead_letter_controller.rb +5 -1
- data/app/helpers/pgbus/application_helper.rb +12 -2
- data/app/views/pgbus/dashboard/_processes_table.html.erb +1 -1
- data/app/views/pgbus/dashboard/_recent_failures.html.erb +1 -1
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +1 -0
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +33 -1
- data/app/views/pgbus/insights/show.html.erb +1 -1
- data/app/views/pgbus/jobs/_failed_table.html.erb +1 -1
- data/app/views/pgbus/processes/_processes_table.html.erb +1 -1
- data/app/views/pgbus/queues/show.html.erb +3 -0
- data/config/locales/da.yml +8 -0
- data/config/locales/de.yml +8 -0
- data/config/locales/en.yml +8 -0
- data/config/locales/es.yml +8 -0
- data/config/locales/fi.yml +8 -0
- data/config/locales/fr.yml +8 -0
- data/config/locales/it.yml +8 -0
- data/config/locales/ja.yml +8 -0
- data/config/locales/nb.yml +8 -0
- data/config/locales/nl.yml +8 -0
- data/config/locales/pt.yml +8 -0
- data/config/locales/sv.yml +8 -0
- data/lib/pgbus/cli.rb +16 -0
- data/lib/pgbus/client.rb +34 -7
- data/lib/pgbus/configuration.rb +17 -1
- data/lib/pgbus/integrations/appsignal/dashboard.json +353 -0
- data/lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json +224 -85
- data/lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json +154 -63
- data/lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json +218 -79
- data/lib/pgbus/integrations/appsignal/probe.rb +17 -2
- data/lib/pgbus/integrations/appsignal.rb +17 -0
- data/lib/pgbus/mcp/base_tool.rb +79 -0
- data/lib/pgbus/mcp/health_analyzer.rb +159 -0
- data/lib/pgbus/mcp/rack_app.rb +97 -0
- data/lib/pgbus/mcp/redactor.rb +72 -0
- data/lib/pgbus/mcp/runner.rb +73 -0
- data/lib/pgbus/mcp/server.rb +62 -0
- data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +72 -0
- data/lib/pgbus/mcp/tools/dlq_tool.rb +48 -0
- data/lib/pgbus/mcp/tools/health_tool.rb +33 -0
- data/lib/pgbus/mcp/tools/job_detail_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/jobs_tool.rb +56 -0
- data/lib/pgbus/mcp/tools/locks_tool.rb +28 -0
- data/lib/pgbus/mcp/tools/processes_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/queue_detail_tool.rb +46 -0
- data/lib/pgbus/mcp/tools/queues_tool.rb +28 -0
- data/lib/pgbus/mcp/tools/recurring_tool.rb +27 -0
- data/lib/pgbus/mcp/tools/stats_tool.rb +40 -0
- data/lib/pgbus/mcp/tools/throughput_tool.rb +35 -0
- data/lib/pgbus/mcp.rb +55 -0
- data/lib/pgbus/process/heartbeat.rb +8 -2
- data/lib/pgbus/process/supervisor.rb +44 -0
- data/lib/pgbus/process/worker.rb +12 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +40 -2
- data/lib/pgbus.rb +15 -1
- 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
|
-
|
|
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
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -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
|
@@ -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:
|
|
1227
|
+
metadata: metadata,
|
|
1205
1228
|
last_heartbeat_at: heartbeat_time,
|
|
1206
|
-
healthy:
|
|
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
|