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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4add7dbee0e372ddbfcb644190f4cb1a564e89c3809ecc0a5c16b6f0ff36a6b9
|
|
4
|
+
data.tar.gz: c5a028268a5966b2d20849789bdca929451425c7f85ebf98347e298fd91de95f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83f391a1a0e013a708727f02255b5122dab340f57cdde1b038d77449bc534d30d73296023fafa8cd0985bd1b219a5d7269a6a3ecaba33be24bcd58e846fa1fd0
|
|
7
|
+
data.tar.gz: f426ac4fcde58e6b6b76154120a9cdfe2bc6efaccdecee72f55fd510a136dd1e0e289e87f912d3b96670b5d8b6531523b551a692c02094e9459ea60e388ed4e8
|
data/README.md
CHANGED
|
@@ -838,6 +838,121 @@ When `config.metrics_enabled = true` (default), the dashboard exposes Prometheus
|
|
|
838
838
|
| `pgbus_worker_pool_busy` | Currently busy worker threads |
|
|
839
839
|
| `pgbus_worker_pool_utilization` | Busy / capacity ratio |
|
|
840
840
|
|
|
841
|
+
### MCP diagnostic server (read-only)
|
|
842
|
+
|
|
843
|
+
Pgbus ships an optional, **read-only** [MCP](https://modelcontextprotocol.io) server so an AI agent (or any MCP client) can diagnose pgbus directly — "are queues backed up?", "is `read_ct` advancing?", "are workers heart-beating but not claiming?" — instead of hand-writing `pgmq` / `pg_stat_activity` SQL against production. It is a thin adapter over the same read layer the dashboard uses, so it adds no new database access path.
|
|
844
|
+
|
|
845
|
+
Add the optional `mcp` gem to your `Gemfile` first (`gem "mcp"`); both entry points below tell you if it's missing.
|
|
846
|
+
|
|
847
|
+
#### Choosing a deployment
|
|
848
|
+
|
|
849
|
+
There are two ways to run it, depending on **who connects and from where**:
|
|
850
|
+
|
|
851
|
+
| | **stdio** (`pgbus mcp`) | **HTTP** (mount in Rails) |
|
|
852
|
+
|---|---|---|
|
|
853
|
+
| Who connects | A local operator (Claude Desktop / Claude Code on your machine) | A remote agent or alerting system |
|
|
854
|
+
| Process model | The MCP client **spawns** a short-lived process on demand | Runs **inside your existing Rails server** — no second process |
|
|
855
|
+
| Reaches production? | Only if run where prod DB creds are available | Yes — co-located with the app, same credentials |
|
|
856
|
+
| Use it for | Hands-on, interactive debugging | Automated/remote diagnostics & alerting |
|
|
857
|
+
|
|
858
|
+
> **Don't start a second `bin/pgbus` instance for HTTP.** The HTTP transport is a Rack app — mount it in the Rails app you already deploy.
|
|
859
|
+
|
|
860
|
+
##### stdio (local operator)
|
|
861
|
+
|
|
862
|
+
```bash
|
|
863
|
+
bundle exec pgbus mcp # speaks MCP over stdio
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
##### HTTP (mount in Rails)
|
|
867
|
+
|
|
868
|
+
```ruby
|
|
869
|
+
# config/routes.rb
|
|
870
|
+
Rails.application.routes.draw do
|
|
871
|
+
# ... your routes ...
|
|
872
|
+
mount Pgbus::MCP.rack_app(token: ENV["PGBUS_MCP_TOKEN"]) => "/pgbus/mcp"
|
|
873
|
+
end
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
`Pgbus::MCP.rack_app` returns a gated Rack app. It runs the transport in **stateless + JSON-response mode**, so every request is a self-contained POST with no in-memory session — safe behind multiple Puma/Falcon workers (any worker can answer any request). Keep the endpoint on an internal network / behind your VPN; it is not meant to be internet-exposed.
|
|
877
|
+
|
|
878
|
+
Options:
|
|
879
|
+
|
|
880
|
+
| Option | Default | Meaning |
|
|
881
|
+
|--------|---------|---------|
|
|
882
|
+
| `token:` | `nil` | Shared secret. When set, requests must send `Authorization: Bearer <token>` (constant-time compared). |
|
|
883
|
+
| `auth:` | `nil` | A callable `->(rack_request) { ... }` returning truthy to allow — mirrors `config.web_auth`. Wins over `token:`. |
|
|
884
|
+
| `allow_payloads:` | `false` | When true, tools honor a per-call `include_payloads` flag (see Security). |
|
|
885
|
+
|
|
886
|
+
If you set neither `token:` nor `auth:`, pgbus logs a warning — an unauthenticated diagnostic endpoint exposes operational metadata to anyone who can reach it.
|
|
887
|
+
|
|
888
|
+
> Clients must send `Accept: application/json` and `Content-Type: application/json` on every POST, or the transport replies `406 Not Acceptable`. MCP clients do this automatically.
|
|
889
|
+
|
|
890
|
+
Need a **standalone HTTP pod** instead of mounting in your main app? The same Rack app works under any Rack server, e.g. a one-line `config.ru`:
|
|
891
|
+
|
|
892
|
+
```ruby
|
|
893
|
+
require "pgbus"
|
|
894
|
+
Pgbus::MCP.load!
|
|
895
|
+
run Pgbus::MCP.rack_app(token: ENV["PGBUS_MCP_TOKEN"])
|
|
896
|
+
# rackup -p 9293 (run it in a container that shares the app's DB config)
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
#### Tools
|
|
900
|
+
|
|
901
|
+
All tools are read-only — no tool mutates state, and there is no raw-SQL passthrough.
|
|
902
|
+
|
|
903
|
+
| Tool | Purpose |
|
|
904
|
+
|------|---------|
|
|
905
|
+
| `pgbus_health` | One-call verdict: `OK` / `DEGRADED` / `STALLED`. STALLED is the silent-worker-wedge signal (visible backlog while workers heart-beat but don't claim). Suitable for automated alerting. |
|
|
906
|
+
| `pgbus_queues` | All queues: depth, visible count, oldest-message age, paused state. |
|
|
907
|
+
| `pgbus_queue_detail` | Per-queue metrics + paused state + table health (dead tuples, bloat, vacuum age). |
|
|
908
|
+
| `pgbus_processes` | Every process with kind, pid, heartbeat age, and `healthy`/`stale`/`stalled` status. |
|
|
909
|
+
| `pgbus_jobs` / `pgbus_job_detail` | Inspect enqueued messages (`read_ct`, `vt`, `enqueued_at`). Paginated. |
|
|
910
|
+
| `pgbus_dlq` / `pgbus_dlq_detail` | Dead-letter inspection. Paginated. |
|
|
911
|
+
| `pgbus_locks` | Active uniqueness locks (the leaked-lock diagnostic). |
|
|
912
|
+
| `pgbus_throughput` / `pgbus_stats` | Recent throughput time series and status counts. |
|
|
913
|
+
| `pgbus_recurring` | Recurring task schedule + last/next run times. |
|
|
914
|
+
|
|
915
|
+
#### Security
|
|
916
|
+
|
|
917
|
+
The server is built to be safe against a production datastore:
|
|
918
|
+
|
|
919
|
+
- **Read-only by default.** No tool mutates state and no arbitrary-query tool exists.
|
|
920
|
+
- **Payloads redacted.** Message bodies, headers, and job arguments are replaced with `[redacted]` unless payloads are explicitly allowed **and** `include_payloads: true` is passed on the call. Both gates must be open. Allow payloads with `PGBUS_MCP_ALLOW_PAYLOADS=1` (stdio) or `Pgbus::MCP.rack_app(allow_payloads: true)` (HTTP).
|
|
921
|
+
- **Bounded queries.** Every list tool paginates with a row cap (`pgbus_jobs` / `pgbus_dlq` cap at 100 rows/page; time windows cap at 1440 minutes).
|
|
922
|
+
- **Reuses your DB credentials.** No new privileged path — it reads through the app's existing connection config.
|
|
923
|
+
- **Authentication.**
|
|
924
|
+
- *HTTP:* set `token:` (clients send `Authorization: Bearer <token>`) or a custom `auth:` callable; unauthenticated requests get `401`.
|
|
925
|
+
- *stdio:* the channel is local (the client spawns the process), so the gate is a boot-time precondition — set `PGBUS_MCP_TOKEN` and the server refuses to start unless `PGBUS_MCP_AUTH_TOKEN` matches (constant-time compare).
|
|
926
|
+
|
|
927
|
+
#### Client configuration
|
|
928
|
+
|
|
929
|
+
For the **stdio** transport, a minimal MCP client config (Claude Code / Claude Desktop style):
|
|
930
|
+
|
|
931
|
+
```json
|
|
932
|
+
{
|
|
933
|
+
"mcpServers": {
|
|
934
|
+
"pgbus": {
|
|
935
|
+
"command": "bundle",
|
|
936
|
+
"args": ["exec", "pgbus", "mcp"],
|
|
937
|
+
"env": { "RAILS_ENV": "production" }
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
For the **HTTP** transport, point the client at the mounted URL with a streamable-HTTP server config, sending the bearer token, e.g.:
|
|
944
|
+
|
|
945
|
+
```json
|
|
946
|
+
{
|
|
947
|
+
"mcpServers": {
|
|
948
|
+
"pgbus": {
|
|
949
|
+
"url": "https://your-app.internal/pgbus/mcp",
|
|
950
|
+
"headers": { "Authorization": "Bearer ${PGBUS_MCP_TOKEN}" }
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
```
|
|
955
|
+
|
|
841
956
|
## Real-time broadcasts (turbo-streams replacement)
|
|
842
957
|
|
|
843
958
|
Pgbus ships a drop-in replacement for turbo-rails' `turbo_stream_from` helper that fixes several well-known ActionCable correctness bugs by using PGMQ message IDs as a replay cursor. Same API as turbo-rails. No Redis. No ActionCable. No lost messages on reconnect.
|
|
@@ -29,10 +29,20 @@ module Pgbus
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def pgbus_status_badge(
|
|
33
|
-
|
|
32
|
+
def pgbus_status_badge(healthy_or_status)
|
|
33
|
+
status = case healthy_or_status
|
|
34
|
+
when true then :healthy
|
|
35
|
+
when Symbol, String then healthy_or_status.to_sym
|
|
36
|
+
else :stale
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
case status
|
|
40
|
+
when :healthy
|
|
34
41
|
tag.span(I18n.t("pgbus.helpers.status_badge.healthy"),
|
|
35
42
|
class: "inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800")
|
|
43
|
+
when :stalled
|
|
44
|
+
tag.span(I18n.t("pgbus.helpers.status_badge.stalled"),
|
|
45
|
+
class: "inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800")
|
|
36
46
|
else
|
|
37
47
|
tag.span(I18n.t("pgbus.helpers.status_badge.stale"),
|
|
38
48
|
class: "inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800")
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<td data-label="Kind" class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"><%= p[:kind] %></td>
|
|
18
18
|
<td data-label="Host" class="px-4 py-3 text-sm text-gray-500"><%= p[:hostname] %></td>
|
|
19
19
|
<td data-label="PID" class="px-4 py-3 text-sm text-gray-500"><%= p[:pid] %></td>
|
|
20
|
-
<td data-label="Status" class="px-4 py-3 text-sm"><%= pgbus_status_badge(p[:healthy]) %></td>
|
|
20
|
+
<td data-label="Status" class="px-4 py-3 text-sm"><%= pgbus_status_badge(p[:status] || p[:healthy]) %></td>
|
|
21
21
|
</tr>
|
|
22
22
|
<% end %>
|
|
23
23
|
<% if @processes.empty? %>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<td data-label="Kind" class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"><%= p[:kind] %></td>
|
|
18
18
|
<td data-label="Host" class="px-4 py-3 text-sm text-gray-700"><%= p[:hostname] %></td>
|
|
19
19
|
<td data-label="PID" class="px-4 py-3 text-sm font-mono text-gray-700"><%= p[:pid] %></td>
|
|
20
|
-
<td data-label="Status" class="px-4 py-3 text-sm"><%= pgbus_status_badge(p[:healthy]) %></td>
|
|
20
|
+
<td data-label="Status" class="px-4 py-3 text-sm"><%= pgbus_status_badge(p[:status] || p[:healthy]) %></td>
|
|
21
21
|
<td data-label="Last Heartbeat" class="px-4 py-3 text-sm text-gray-500"><%= pgbus_time_ago(p[:last_heartbeat_at]) %></td>
|
|
22
22
|
<td data-label="Metadata" class="px-4 py-3 text-sm text-gray-500 font-mono text-xs max-w-xs truncate">
|
|
23
23
|
<% if p[:metadata].is_a?(Hash) %>
|
data/config/locales/da.yml
CHANGED
data/config/locales/de.yml
CHANGED
data/config/locales/en.yml
CHANGED
data/config/locales/es.yml
CHANGED
data/config/locales/fi.yml
CHANGED
data/config/locales/fr.yml
CHANGED
data/config/locales/it.yml
CHANGED
data/config/locales/ja.yml
CHANGED
data/config/locales/nb.yml
CHANGED
data/config/locales/nl.yml
CHANGED
data/config/locales/pt.yml
CHANGED
data/config/locales/sv.yml
CHANGED
data/lib/pgbus/cli.rb
CHANGED
|
@@ -16,6 +16,8 @@ module Pgbus
|
|
|
16
16
|
show_status
|
|
17
17
|
when "queues"
|
|
18
18
|
list_queues
|
|
19
|
+
when "mcp"
|
|
20
|
+
start_mcp_server
|
|
19
21
|
when "version"
|
|
20
22
|
puts "pgbus #{Pgbus::VERSION}"
|
|
21
23
|
when "help", "--help", "-h"
|
|
@@ -132,6 +134,13 @@ module Pgbus
|
|
|
132
134
|
end
|
|
133
135
|
end
|
|
134
136
|
|
|
137
|
+
# Boots the read-only MCP diagnostic server over stdio. Loaded lazily so
|
|
138
|
+
# the optional `mcp` gem is only required when this command is invoked.
|
|
139
|
+
def start_mcp_server
|
|
140
|
+
Pgbus::MCP.load!
|
|
141
|
+
Pgbus::MCP::Runner.run
|
|
142
|
+
end
|
|
143
|
+
|
|
135
144
|
def list_queues
|
|
136
145
|
Pgbus.client.list_queues
|
|
137
146
|
metrics = Pgbus.client.metrics
|
|
@@ -154,6 +163,7 @@ module Pgbus
|
|
|
154
163
|
start Start the Pgbus supervisor (workers + dispatcher)
|
|
155
164
|
status Show running Pgbus processes
|
|
156
165
|
queues List queues with metrics
|
|
166
|
+
mcp Start the read-only MCP diagnostic server over stdio
|
|
157
167
|
version Show version
|
|
158
168
|
help Show this help
|
|
159
169
|
|
|
@@ -172,6 +182,12 @@ module Pgbus
|
|
|
172
182
|
pattern)
|
|
173
183
|
--execution-mode Execution mode: threads (default) or async
|
|
174
184
|
(fiber-based, lower connection usage)
|
|
185
|
+
|
|
186
|
+
Environment for `mcp`:
|
|
187
|
+
PGBUS_MCP_TOKEN If set, PGBUS_MCP_AUTH_TOKEN must match for
|
|
188
|
+
the server to start (optional token gate).
|
|
189
|
+
PGBUS_MCP_ALLOW_PAYLOADS Truthy to let tools return raw message
|
|
190
|
+
bodies/headers (off by default; redacted).
|
|
175
191
|
HELP
|
|
176
192
|
end
|
|
177
193
|
end
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "timeout"
|
|
4
5
|
require_relative "client/read_after"
|
|
5
6
|
require_relative "client/ensure_stream_queue"
|
|
6
7
|
require_relative "client/notify_stream"
|
|
@@ -110,7 +111,7 @@ module Pgbus
|
|
|
110
111
|
full_name = config.queue_name(queue_name)
|
|
111
112
|
Instrumentation.instrument("pgbus.client.read_message", queue: full_name) do
|
|
112
113
|
with_stale_connection_retry do
|
|
113
|
-
synchronized { @pgmq.read(full_name, vt: vt || config.visibility_timeout) }
|
|
114
|
+
synchronized { with_read_timeout { @pgmq.read(full_name, vt: vt || config.visibility_timeout) } }
|
|
114
115
|
end
|
|
115
116
|
end
|
|
116
117
|
end
|
|
@@ -119,7 +120,7 @@ module Pgbus
|
|
|
119
120
|
full_name = config.queue_name(queue_name)
|
|
120
121
|
Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
|
|
121
122
|
with_stale_connection_retry do
|
|
122
|
-
synchronized { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) }
|
|
123
|
+
synchronized { with_read_timeout { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
|
|
123
124
|
end
|
|
124
125
|
end
|
|
125
126
|
end
|
|
@@ -141,7 +142,7 @@ module Pgbus
|
|
|
141
142
|
|
|
142
143
|
msgs = Instrumentation.instrument("pgbus.client.read_batch", queue: pq_name, qty: remaining) do
|
|
143
144
|
with_stale_connection_retry do
|
|
144
|
-
synchronized { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) }
|
|
145
|
+
synchronized { with_read_timeout { @pgmq.read_batch(pq_name, vt: vt || config.visibility_timeout, qty: remaining) } }
|
|
145
146
|
end
|
|
146
147
|
end || []
|
|
147
148
|
|
|
@@ -180,7 +181,9 @@ module Pgbus
|
|
|
180
181
|
Instrumentation.instrument("pgbus.client.read_multi", queues: full_names, qty: qty, limit: limit) do
|
|
181
182
|
with_stale_connection_retry do
|
|
182
183
|
synchronized do
|
|
183
|
-
|
|
184
|
+
with_read_timeout do
|
|
185
|
+
@pgmq.read_multi(full_names, vt: vt || config.visibility_timeout, qty: qty, limit: limit)
|
|
186
|
+
end
|
|
184
187
|
end
|
|
185
188
|
end
|
|
186
189
|
end
|
|
@@ -357,7 +360,7 @@ module Pgbus
|
|
|
357
360
|
full_name = config.queue_name(queue_name)
|
|
358
361
|
Instrumentation.instrument("pgbus.client.read_grouped", queue: full_name, qty: qty) do
|
|
359
362
|
with_stale_connection_retry do
|
|
360
|
-
synchronized { @pgmq.read_grouped(full_name, vt: vt || config.visibility_timeout, qty: qty) }
|
|
363
|
+
synchronized { with_read_timeout { @pgmq.read_grouped(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
|
|
361
364
|
end
|
|
362
365
|
end
|
|
363
366
|
end
|
|
@@ -366,7 +369,7 @@ module Pgbus
|
|
|
366
369
|
full_name = config.queue_name(queue_name)
|
|
367
370
|
Instrumentation.instrument("pgbus.client.read_grouped_rr", queue: full_name, qty: qty) do
|
|
368
371
|
with_stale_connection_retry do
|
|
369
|
-
synchronized { @pgmq.read_grouped_rr(full_name, vt: vt || config.visibility_timeout, qty: qty) }
|
|
372
|
+
synchronized { with_read_timeout { @pgmq.read_grouped_rr(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
|
|
370
373
|
end
|
|
371
374
|
end
|
|
372
375
|
end
|
|
@@ -374,7 +377,7 @@ module Pgbus
|
|
|
374
377
|
def read_grouped_head(queue_name, qty:, vt: nil)
|
|
375
378
|
full_name = config.queue_name(queue_name)
|
|
376
379
|
with_stale_connection_retry do
|
|
377
|
-
synchronized { @pgmq.read_grouped_head(full_name, vt: vt || config.visibility_timeout, qty: qty) }
|
|
380
|
+
synchronized { with_read_timeout { @pgmq.read_grouped_head(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
|
|
378
381
|
end
|
|
379
382
|
end
|
|
380
383
|
|
|
@@ -696,6 +699,30 @@ module Pgbus
|
|
|
696
699
|
# connection was dead *before* pgmq-ruby tried to use it, so no SQL was
|
|
697
700
|
# ever sent. Mid-flight errors like "server closed the connection" are
|
|
698
701
|
# excluded from the pattern list for this reason.
|
|
702
|
+
# Bound a read at config.read_timeout so a dead socket raises
|
|
703
|
+
# Pgbus::ReadTimeoutError instead of blocking the worker loop forever.
|
|
704
|
+
#
|
|
705
|
+
# MUST wrap only the bare `@pgmq.read*` call, sitting *inside* both
|
|
706
|
+
# `synchronized` and `with_stale_connection_retry`:
|
|
707
|
+
#
|
|
708
|
+
# with_stale_connection_retry { synchronized { with_read_timeout { @pgmq.read* } } }
|
|
709
|
+
#
|
|
710
|
+
# Two reasons for this nesting:
|
|
711
|
+
# 1. On the shared-connection path @pgmq_mutex serializes all reads.
|
|
712
|
+
# The timeout clock must start only after the mutex is acquired,
|
|
713
|
+
# otherwise a thread queued behind another read is charged for the
|
|
714
|
+
# wait and raises a false ReadTimeoutError for a socket it never
|
|
715
|
+
# touched.
|
|
716
|
+
# 2. with_stale_connection_retry can retry once; with the timeout
|
|
717
|
+
# inside, each socket attempt gets its own full timeout budget
|
|
718
|
+
# rather than sharing one across both attempts.
|
|
719
|
+
def with_read_timeout(&)
|
|
720
|
+
timeout = config.read_timeout
|
|
721
|
+
return yield unless timeout&.positive?
|
|
722
|
+
|
|
723
|
+
Timeout.timeout(timeout, Pgbus::ReadTimeoutError, &)
|
|
724
|
+
end
|
|
725
|
+
|
|
699
726
|
def with_stale_connection_retry
|
|
700
727
|
attempts = 0
|
|
701
728
|
begin
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -26,6 +26,12 @@ module Pgbus
|
|
|
26
26
|
# Worker recycling
|
|
27
27
|
attr_accessor :max_jobs_per_worker, :max_memory_mb, :max_worker_lifetime
|
|
28
28
|
|
|
29
|
+
# Liveness probe: supervisor kills a worker whose claim loop has not
|
|
30
|
+
# advanced for longer than stall_threshold seconds (default 90).
|
|
31
|
+
# read_timeout caps how long a single PGMQ read can block (default 30s),
|
|
32
|
+
# so a dead socket raises instead of parking the loop forever.
|
|
33
|
+
attr_accessor :stall_threshold, :read_timeout
|
|
34
|
+
|
|
29
35
|
# Dispatcher settings
|
|
30
36
|
attr_accessor :dispatch_interval
|
|
31
37
|
|
|
@@ -150,6 +156,9 @@ module Pgbus
|
|
|
150
156
|
@max_memory_mb = nil
|
|
151
157
|
@max_worker_lifetime = nil
|
|
152
158
|
|
|
159
|
+
@stall_threshold = 90
|
|
160
|
+
@read_timeout = 30
|
|
161
|
+
|
|
153
162
|
@dispatch_interval = 1.0
|
|
154
163
|
|
|
155
164
|
@circuit_breaker_enabled = true
|
|
@@ -374,6 +383,13 @@ module Pgbus
|
|
|
374
383
|
raise ArgumentError, "retry_backoff_jitter must be between 0 and 1"
|
|
375
384
|
end
|
|
376
385
|
|
|
386
|
+
unless stall_threshold.nil? || (stall_threshold.is_a?(Numeric) && stall_threshold.positive?)
|
|
387
|
+
raise ArgumentError, "stall_threshold must be a positive number or nil to disable"
|
|
388
|
+
end
|
|
389
|
+
unless read_timeout.nil? || (read_timeout.is_a?(Numeric) && read_timeout.positive?)
|
|
390
|
+
raise ArgumentError, "read_timeout must be a positive number or nil to disable"
|
|
391
|
+
end
|
|
392
|
+
|
|
377
393
|
# Validate global execution_mode
|
|
378
394
|
ExecutionPools.normalize_mode(execution_mode)
|
|
379
395
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module MCP
|
|
7
|
+
# Base class for every pgbus diagnostic tool. Provides the shared
|
|
8
|
+
# read-only annotation, a JSON response helper, and access to the
|
|
9
|
+
# DataSource the tool delegates to.
|
|
10
|
+
#
|
|
11
|
+
# The DataSource is pulled from +server_context[:data_source]+ so the
|
|
12
|
+
# server can inject a configured instance (and tests can inject a double).
|
|
13
|
+
# Every subclass is read-only by contract — see issue #180 security
|
|
14
|
+
# requirements. Write/admin tools, if ever added, must live in a separate,
|
|
15
|
+
# explicitly opt-in surface and never inherit from this class.
|
|
16
|
+
class BaseTool < ::MCP::Tool
|
|
17
|
+
# Marks the tool read-only and non-destructive so MCP clients can show
|
|
18
|
+
# the right affordance and never treat a call as a mutation.
|
|
19
|
+
annotations(
|
|
20
|
+
read_only_hint: true,
|
|
21
|
+
destructive_hint: false,
|
|
22
|
+
idempotent_hint: true,
|
|
23
|
+
open_world_hint: false
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# MCP::Tool.inherited resets @annotations_value to nil on every
|
|
28
|
+
# subclass, so the read_only_hint set on BaseTool would not reach the
|
|
29
|
+
# concrete tools. Fall back to the nearest ancestor that defined
|
|
30
|
+
# annotations so all tools inherit the read-only contract without
|
|
31
|
+
# repeating it. (annotations_value is what MCP::Tool#to_h reads.)
|
|
32
|
+
def annotations_value
|
|
33
|
+
super || (superclass.respond_to?(:annotations_value) ? superclass.annotations_value : nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Pull the injected DataSource (or build a default one). Kept as a
|
|
37
|
+
# class method because MCP tool entry points (`self.call`) are class
|
|
38
|
+
# methods.
|
|
39
|
+
def data_source_from(server_context)
|
|
40
|
+
(server_context && server_context[:data_source]) || Pgbus::Web::DataSource.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether payloads may be returned for this call. Honors a per-call
|
|
44
|
+
# `include_payloads` argument only when the server was started with
|
|
45
|
+
# payloads globally allowed (`server_context[:allow_payloads]`).
|
|
46
|
+
# Defaults to false on both axes so nothing leaks by accident.
|
|
47
|
+
def payloads_allowed?(server_context, include_payloads)
|
|
48
|
+
return false unless server_context && server_context[:allow_payloads]
|
|
49
|
+
|
|
50
|
+
!!include_payloads
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Wrap any Ruby value as a single text-content JSON response, applying
|
|
54
|
+
# payload redaction at this boundary as a fail-safe. Redaction is the
|
|
55
|
+
# default: a tool returns metadata, and any payload-bearing key (at any
|
|
56
|
+
# depth) is stripped unless this call is explicitly allowed to include
|
|
57
|
+
# payloads. A tool author who forgets about redaction therefore cannot
|
|
58
|
+
# leak message bodies — they have to opt in.
|
|
59
|
+
#
|
|
60
|
+
# Pass +server_context+ and the tool's per-call +include_payloads+ flag
|
|
61
|
+
# to enable payloads; both gates must be open (see #payloads_allowed?).
|
|
62
|
+
def json_response(value, server_context: nil, include_payloads: false)
|
|
63
|
+
allow = payloads_allowed?(server_context, include_payloads)
|
|
64
|
+
redacted = Redactor.deep_redact(value, include_payloads: allow)
|
|
65
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(redacted) }])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Wrap an error message as an MCP error response (isError: true) so the
|
|
69
|
+
# client surfaces it as a tool failure rather than a normal result.
|
|
70
|
+
def error_response(message)
|
|
71
|
+
::MCP::Tool::Response.new(
|
|
72
|
+
[{ type: "text", text: message }],
|
|
73
|
+
error: true
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|