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.
- checksums.yaml +4 -4
- data/README.md +115 -0
- data/app/helpers/pgbus/application_helper.rb +12 -2
- data/app/views/pgbus/dashboard/_processes_table.html.erb +1 -1
- data/app/views/pgbus/processes/_processes_table.html.erb +1 -1
- data/config/locales/da.yml +1 -0
- data/config/locales/de.yml +1 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/locales/fi.yml +1 -0
- data/config/locales/fr.yml +1 -0
- data/config/locales/it.yml +1 -0
- data/config/locales/ja.yml +1 -0
- data/config/locales/nb.yml +1 -0
- data/config/locales/nl.yml +1 -0
- data/config/locales/pt.yml +1 -0
- data/config/locales/sv.yml +1 -0
- data/lib/pgbus/cli.rb +16 -0
- data/lib/pgbus/client.rb +34 -7
- data/lib/pgbus/configuration.rb +16 -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 +19 -2
- data/lib/pgbus.rb +15 -1
- 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
|
-
|
|
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
|
@@ -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:
|
|
1206
|
+
metadata: metadata,
|
|
1205
1207
|
last_heartbeat_at: heartbeat_time,
|
|
1206
|
-
healthy:
|
|
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
|
+
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
|