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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f659edeb84f108e166ae2ea981e5eb3e4fabfde1542769a777d9d707aba349d
4
- data.tar.gz: 2abf0072cdc0f414670367e4a59881003285c43f895618d1ec63b1b7fca5e27a
3
+ metadata.gz: 4add7dbee0e372ddbfcb644190f4cb1a564e89c3809ecc0a5c16b6f0ff36a6b9
4
+ data.tar.gz: c5a028268a5966b2d20849789bdca929451425c7f85ebf98347e298fd91de95f
5
5
  SHA512:
6
- metadata.gz: c527aa3c60ae917b2589cd23b99f14348fa4eef752ae004b2ffc3a90d248e5c09bc68ee06ddfde3d335c469192dd821551b56bb9b6fb259f6f1b961a61567d9e
7
- data.tar.gz: 6f66d58dfe49bca18d3b3a1379e2c458c75954cec3c7fb1ddd23fcfa05e495b00db4d3646d42e32e40b202135e5bb7e51a18d6287ab897e7605fd87250c93391
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(healthy)
33
- if healthy
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) %>
@@ -235,6 +235,7 @@ da:
235
235
  status_badge:
236
236
  healthy: Sund
237
237
  stale: Forældet
238
+ stalled: Gået i stå
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ de:
235
235
  status_badge:
236
236
  healthy: Gesund
237
237
  stale: Veraltet
238
+ stalled: Blockiert
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ en:
235
235
  status_badge:
236
236
  healthy: Healthy
237
237
  stale: Stale
238
+ stalled: Stalled
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ es:
235
235
  status_badge:
236
236
  healthy: Saludable
237
237
  stale: Obsoleto
238
+ stalled: Bloqueado
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ fi:
235
235
  status_badge:
236
236
  healthy: Toimiva
237
237
  stale: Vanha
238
+ stalled: Juuttunut
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ fr:
235
235
  status_badge:
236
236
  healthy: Sain
237
237
  stale: Obsolète
238
+ stalled: Bloqué
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ it:
235
235
  status_badge:
236
236
  healthy: Sano
237
237
  stale: Obsoleto
238
+ stalled: Bloccato
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ ja:
235
235
  status_badge:
236
236
  healthy: 正常
237
237
  stale: 期限切れ
238
+ stalled: 停滞
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ nb:
235
235
  status_badge:
236
236
  healthy: Sunn
237
237
  stale: Utdatert
238
+ stalled: Fastlåst
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ nl:
235
235
  status_badge:
236
236
  healthy: Gezond
237
237
  stale: Verouderd
238
+ stalled: Vastgelopen
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ pt:
235
235
  status_badge:
236
236
  healthy: Saudável
237
237
  stale: Obsoleto
238
+ stalled: Bloqueado
238
239
  insights:
239
240
  show:
240
241
  charts:
@@ -235,6 +235,7 @@ sv:
235
235
  status_badge:
236
236
  healthy: Hälsosam
237
237
  stale: Föråldrad
238
+ stalled: Fastlåst
238
239
  insights:
239
240
  show:
240
241
  charts:
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
- @pgmq.read_multi(full_names, vt: vt || config.visibility_timeout, qty: qty, limit: limit)
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
@@ -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