pgbus 0.11.1 → 0.11.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a52dc949281b6f2f1e9c847d38709fcc2723badd12370486faeb68b40ed55c42
4
- data.tar.gz: 70a75a14bfe2225f63846314cbb60c0942e86b966c14b20e43eec74afc63adef
3
+ metadata.gz: df6f1cbda597599aaad006242fbb0b964edaa8fa599c413dfde949fc3b7e19a2
4
+ data.tar.gz: 4a57b939f12238bf4aac8fc601ee5cd529fd1311e6c6994dacfb55f925545e4b
5
5
  SHA512:
6
- metadata.gz: dc59f389bf2b5e20b2e75a7a2c47301c242ea0b5857a240e5a59db3177effe34cbe694903d4396b07ef3ca67699f578a5d6b6d95f61d4318169ac477f237b526
7
- data.tar.gz: a5a6a076c6cadb857c8690ef7d1363934c7f73bd76e8d91a87a936a91d05c4146c12bb67b5637204995d0ddef540833a8983ca4dd3d5ef1034a7b88473aa1e61
6
+ metadata.gz: 336b503f39a11252b461b98c124617d16f252a20e8ef343ea48d637d8552a6048ff8c0d9465d95fcb7c737616010da63669a8db3eacb9971d79b6ae9286ed432
7
+ data.tar.gz: 33d5b40c21ef5213f4014f27577e19c617ee0e71420dc3c3432c4e3d35f941cafbe0e6a41d33bac42cc9ec2eb65a159d3685f1cce28ff44e97a14ca9749134ab
data/CHANGELOG.md CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  ### Added
12
12
 
13
+ - **`pgbus dashboard` — print the vendored AppSignal dashboards as import-ready JSON.** The gem ships four AppSignal dashboard definitions (the main one submitted to appsignal/public_config#80 plus the health/streams/throughput extras), but they're stored in the automated-dashboard format — a `metric_keys` trigger wrapping the `dashboard` object — which AppSignal's "Import dashboard" dialog doesn't accept, so trying one on a real app meant hand-unwrapping JSON. `pgbus dashboard [main|health|streams|throughput]` prints the inner dashboard object ready to paste into the import dialog (`pgbus dashboard health | pbcopy`); `pgbus dashboard --list` enumerates the available names and titles.
14
+ - **`config.doctor_on_boot` (+ `pgbus start --doctor` / `--doctor-strict`) — run the doctor preflight inside the booting supervisor, one Rails boot instead of two (issue #347).** The recommended deploy preflight was `bin/pgbus doctor || true` in the container entrypoint before `bin/pgbus start` — but both commands `require config/environment`, so the job container boots the full Rails app **twice** on every deploy (cost scaling with app boot time), and running doctor pre-supervisor makes the `Process liveness` check false-fail (no workers exist yet), which is exactly why the entrypoint has to swallow the exit code with `|| true` — losing any chance to gate on a genuinely-fatal finding. Now `config.doctor_on_boot = :report` (or `pgbus start --doctor`) runs the checks in the already-booted supervisor, after the DB is verified reachable and queues are bootstrapped but **before any worker is forked**: one boot, and the worker-dependent `Process liveness` check is skipped (it has no workers to observe yet and would false-fail on stale prior-generation rows during a redeploy). `config.doctor_on_boot = :strict` (or `--doctor-strict`) additionally **refuses to boot** — raising before forking anything, so the `ensure shutdown` path tears down the heartbeat/health server and no child starts — but *only* on a genuinely-fatal, non-transient check: a `Configuration` failure (a real config bug) or an **absent** PGMQ schema. It deliberately does **not** abort on `Queues`/`Database` failures — those are the transients the lenient queue bootstrap is built to ride out (children crash-and-backoff until the DB recovers), and `verify_connection!` already gated a hard-down DB moments earlier; making them strict-fatal would take down a whole fleet's cold boot in lockstep on a momentarily-saturated primary. Default `nil`/`false` = off (byte-identical to today; the supervisor constructs no `Doctor` and does zero extra work). The standalone `pgbus doctor` command and its exit-code semantics are unchanged. Refs #347.
13
15
  - **`Pgbus::Streams::PhlexHelpers` — a Phlex-includable `pgbus_stream_from`, and `Pgbus.stream_name_budget` (issue #334).** Two small helpers that stop consuming apps reimplementing pgbus glue. (1) `pgbus_stream_from` is a Rails view helper, so a Phlex component couldn't call it without hand-registering an output-helper macro (phlex-rails ships no `Phlex::Rails::Helpers::PgbusStreamFrom`). `include Pgbus::Streams::PhlexHelpers` now bridges it exactly like phlex-rails' own `TurboStreamFrom`. phlex-rails stays an **optional** dependency — the module references its `HelperMacros` and is loaded on demand (`require "pgbus/streams/phlex_helpers"`), never eagerly. (2) `Pgbus.stream_name_budget` exposes the maximum stream-name length (the pgmq queue-name cap minus the queue prefix) so apps size/truncate stream identifiers up front instead of hand-computing `MAX_QUEUE_NAME_LENGTH - queue_prefix.length - 1` at every call site. Refs #334.
14
16
  - **`EventBus::Registry#setup_all!(safe: true)` — a boot/rake-safe way to eagerly set up event subscribers (issue #334).** Calling `setup_all!` opens a PGMQ connection per subscriber (to create its queue + bind its topic), which is wrong during a `db:`/schema rake task (a live connection blocks `DROP DATABASE`) and crashes boot if the database isn't up yet — so apps wrapped it in a hand-rolled multi-class rescue plus a rake-task skip. `setup_all!(safe: true)` bakes that in: it skips entirely in a schema/asset rake context and swallows a connection error with a warning instead of raising. The default `setup_all!` is unchanged (raises). Refs #334.
15
17
  - **Connection-pooler safety (`config.require_primary`, `config.connection_guc_mode`) — pgbus is now correct-and-loud behind a transaction/read-write-splitting pooler instead of silently stalling (issue #332).** Three linked fixes. (1) **Primary affinity:** a read/write-splitting pooler (pgdog/pgcat) can route pgmq's VOLATILE `read`/`archive`/`delete` to a read replica, where workers read nothing (`read_ct=0`) and jobs stop with a **healthy heartbeat** — the supervisor watchdog keys on loop advancement, not read progress, so nothing surfaced the stall. The job read path had zero primary-affinity defense (only the LISTEN/streamer paths validated the primary via `PrimaryValidator`). Setting `config.require_primary = true` (default **false** — a single-primary deployment is byte-identical) makes `verify_connection!` reject a connection that lands on a replica (`pg_is_in_recovery() => t`) at supervisor boot, raising a clear `ConfigurationError` instead of forking children that read nothing. (2) **GUC forwarding:** pgbus's AR-config extraction dropped database.yml's `variables:` block, so `client_min_messages` (and any GUC) never reached pgmq's dedicated raw connections (NOTICE flooding that three consuming apps hand-patched via `connection_params`). GUCs are now forwarded, on both the AR-extracted and explicit-`connection_params` paths, by `config.connection_guc_mode`: `:options` (default) bakes them into the libpq `options` STARTUP param (byte-compatible with today for the read-timeout `statement_timeout`, now also carrying `variables:`), while `:session` applies them via post-connect `SET` on a fresh connection instead — for a transaction-mode PgBouncer that **rejects** the `options` startup param with a FATAL. `pgbus doctor` gains a ninth check ("Primary affinity") that warns (never fails) when the job connection is currently on a replica, naming the direct-port remediation. DB-gated integration tests prove both GUC modes round-trip a forwarded GUC and that `require_primary` verifies on a real primary. Refs #332.
@@ -32,6 +34,7 @@
32
34
 
33
35
  ### Fixed
34
36
 
37
+ - **AppSignal main dashboard: job and event lines now split by `job_class` and `routing_key`.** Applied the upstream review suggestions committed on appsignal/public_config#80 to the vendored `dashboard.json`: the "Job status per queue" panel gains a `job_class` wildcard tag filter and label (`%job_class% - %queue% - %status%`), and "Event handler status" gains `routing_key` (`%handler% - %status% - %routing_key%`). Without the wildcard tag entry a `%placeholder%` in the line label never resolves, so lines from different job classes / routing keys were collapsed together. The subscriber already emitted both tags; only the dashboard definition lagged.
35
38
  - **Migration generators now route to the pgbus database when `connects_to` is configured, even without an explicit `--database` (issue #344).** `rails g pgbus:add_stream_queues` (and every sibling generator via `Generators::MigrationPath`) keyed the separate-database decision purely on the `--database` flag. An app that had already set `config.connects_to = { database: { writing: :pgbus } }` (so its pgbus migrations live in `db/pgbus_migrate/` per database.yml's `migrations_paths:`) still got the migration written to the **primary** DB's `db/migrate/` on a bare invocation — where it then ran against the wrong database — and the "Next steps" output told the operator to run `rails db:migrate` instead of `rails db:migrate:pgbus`. `MigrationPath` now auto-detects the target database from `connects_to` (via the existing `DatabaseTargetDetector`: runtime config → initializer scan → application.rb scan) when `--database` is absent; an explicit `--database` still wins. The detected name drives both the migration path (resolving `migrations_paths` for that database, falling back to the `db/pgbus_migrate` convention) and the `db:migrate:<name>` suffix in every generator's post-install output. An app with no separate database is unaffected. Refs #344.
36
39
  - **`extract_ar_connection_hash` no longer forces `host: "localhost"` / `port: 5432` on a socket-based (host-less) database.yml — pgmq's connections now match ActiveRecord's on Unix-socket dev setups (issue #343).** A local database.yml with no `host:` is a Unix-socket connection: ActiveRecord connects via libpq's default socket (`PGHOST` / the default socket dir), but pgbus's AR-config extraction defaulted the absent `host`/`port` to TCP `localhost:5432`, silently diverging from AR and pointing pgmq's dedicated raw connections at a *different* server (or nothing) on any machine where the socket dir isn't `localhost`. Apps on socket-based dev DBs therefore couldn't use the AR-extraction path at all and had to pin an explicit `connection_params` Hash whose only real job was *not* defaulting `host` (cosmos carried an `after_initialize` block for exactly this). The two fallbacks are dropped: `host`/`port` now pass through as-is and `.compact` removes them when absent, so libpq applies its own socket defaults and matches AR. A config that *does* set `host`/`port` is byte-identical to before. Refs #343.
37
40
  - **`config.log_format` no longer breaks `ActiveSupport::TaggedLogging` — tags are preserved instead of dropped, and `logger.tagged` no longer raises (issue #334).** TaggedLogging works by extending the logger's formatter instance with a module that prepends the current tags. Installing a fresh pgbus formatter (`LogFormatter::JSON`/`Text`) stripped that extension, so `logger.tagged("req-42") { ... }` dropped the tag — and, worse, raised `NoMethodError: undefined method 'tagged'` on the pgbus formatter (a deploy-breaker several apps hit, then worked around with a dedicated `$stdout` logger). `log_format=` now re-extends the replacement formatter with `ActiveSupport::TaggedLogging::Formatter` when the previous one carried it, so tagging keeps working. Refs #334.
data/README.md CHANGED
@@ -820,15 +820,23 @@ end
820
820
 
821
821
  #### Dashboards
822
822
 
823
- Three importable AppSignal dashboards ship with the gem:
823
+ Four importable AppSignal dashboards ship with the gem:
824
824
 
825
- | File | Purpose |
826
- |------|---------|
827
- | `lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json` | Jobs/sec, perform-duration percentiles, send/read counts |
828
- | `lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json` | Queue depth, oldest message age, DLQ, dead tuples, MVCC horizon, worker recycles |
829
- | `lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json` | Broadcasts, fanout, active SSE connections, outbox, recurring tasks |
825
+ | Name | File | Purpose |
826
+ |------|------|---------|
827
+ | `main` | `lib/pgbus/integrations/appsignal/dashboard.json` | All-in-one overview (the automated dashboard submitted to appsignal/public_config) |
828
+ | `throughput` | `lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json` | Jobs/sec, perform-duration percentiles, send/read counts |
829
+ | `health` | `lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json` | Queue depth, oldest message age, DLQ, dead tuples, MVCC horizon, worker recycles |
830
+ | `streams` | `lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json` | Broadcasts, fanout, active SSE connections, outbox, recurring tasks |
831
+
832
+ The files are stored in AppSignal's automated-dashboard format (a `metric_keys` trigger wrapping
833
+ the dashboard object); `pgbus dashboard <name>` unwraps one into the JSON that AppSignal's
834
+ "New dashboard" → "Import dashboard" dialog accepts:
830
835
 
831
- Import via the AppSignal dashboard UI ("New dashboard" → "Import JSON") or the AppSignal API.
836
+ ```bash
837
+ pgbus dashboard --list # available names and titles
838
+ pgbus dashboard health | pbcopy # paste into the import dialog
839
+ ```
832
840
 
833
841
  ### Metrics adapter (Prometheus / StatsD)
834
842
 
@@ -1872,6 +1880,25 @@ rake pgbus:doctor # identical checks, for a Rake-based deploy pipeline
1872
1880
 
1873
1881
  The report ends with a resolved-config summary (queue prefix, pool size, roles, capsules) with any password redacted from `database_url` / `connection_params`. Warnings do not fail the exit code — only a `:fail` result does.
1874
1882
 
1883
+ ##### Doctor preflight at boot (single Rails boot)
1884
+
1885
+ Running `pgbus doctor || true` in a container entrypoint before `pgbus start` boots the full Rails app **twice** on every deploy, and the pre-supervisor `Process liveness` check false-fails (no workers exist yet) — which is why the `|| true` is needed, swallowing any genuinely-fatal finding too. Instead, run the preflight *inside* the booting supervisor:
1886
+
1887
+ ```bash
1888
+ pgbus start --doctor # run the preflight, log the report, always boot
1889
+ pgbus start --doctor-strict # refuse to boot on a fatal check (exit non-zero, fork nothing)
1890
+ ```
1891
+
1892
+ or set it in the initializer:
1893
+
1894
+ ```ruby
1895
+ Pgbus.configure do |c|
1896
+ c.doctor_on_boot = :report # or :strict; nil/false (default) is off
1897
+ end
1898
+ ```
1899
+
1900
+ The boot preflight runs after the DB is verified reachable and queues are bootstrapped, but **before any worker is forked** — one Rails boot, and the `Process liveness` check is skipped (nothing to observe yet, so it can't false-fail). `:strict` aborts the boot only on a genuinely-fatal, non-transient check — a `Configuration` failure or an **absent** PGMQ schema. A transient DB blip that the lenient queue bootstrap is designed to ride out (surfacing as a `Queues` or `Database` failure) is reported but never aborts, so a fleet-wide cold boot against a momentarily-saturated primary doesn't fail every pod in lockstep.
1901
+
1875
1902
  #### pgbus dlq
1876
1903
 
1877
1904
  Dead-letter inspect/drain operations from a headless deployment or incident runbook, routed through `Web::DataSource` so retry/discard semantics are identical to the dashboard (origin-queue re-enqueue, transactional produce+delete, lock release on discard) with zero raw SQL and no direct PGMQ calls:
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ module CLI
7
+ # Prints the vendored AppSignal dashboard definitions as import-ready
8
+ # JSON. The files ship in the automated-dashboard format used by
9
+ # appsignal/public_config (a `metric_keys` trigger wrapping a
10
+ # `dashboard` object); AppSignal's "Import dashboard" dialog wants the
11
+ # inner object only, so this command unwraps it:
12
+ #
13
+ # pgbus dashboard # main dashboard
14
+ # pgbus dashboard health # one of the extra dashboards
15
+ # pgbus dashboard --list # available names and titles
16
+ module Dashboard
17
+ module_function
18
+
19
+ def start(args)
20
+ name = args.first || "main"
21
+ return list if ["--list", "list"].include?(name)
22
+
23
+ path = available[name]
24
+ unless path
25
+ warn "Unknown dashboard: #{name.inspect} (available: #{available.keys.join(", ")})"
26
+ exit 1
27
+ end
28
+
29
+ puts JSON.pretty_generate(definition(path))
30
+ end
31
+
32
+ def list
33
+ available.each do |name, path|
34
+ puts format("%-12s %s", name, definition(path)["title"])
35
+ end
36
+ end
37
+
38
+ # The main dashboard plus the extras, keyed by their short CLI name
39
+ # (pgbus_health.json => "health").
40
+ def available
41
+ require "pgbus/integrations/appsignal"
42
+
43
+ extras = Dir[File.join(Integrations::Appsignal::DASHBOARDS_DIR, "*.json")].to_h do |path|
44
+ [File.basename(path, ".json").delete_prefix("pgbus_"), path]
45
+ end
46
+ { "main" => Integrations::Appsignal::DASHBOARD_PATH }.merge(extras)
47
+ end
48
+
49
+ def definition(path)
50
+ JSON.parse(File.read(path)).fetch("dashboard")
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/pgbus/cli.rb CHANGED
@@ -18,6 +18,8 @@ module Pgbus
18
18
  list_queues
19
19
  when "dlq"
20
20
  DLQ.start(args[1..] || [])
21
+ when "dashboard"
22
+ Dashboard.start(args[1..] || [])
21
23
  when "mcp"
22
24
  start_mcp_server
23
25
  when "doctor"
@@ -51,6 +53,16 @@ module Pgbus
51
53
  Pgbus.configuration.execution_mode = options[:execution_mode].to_sym if options[:execution_mode]
52
54
  apply_capsule_filter(options[:capsule]) if options[:capsule]
53
55
  apply_role_filter(options)
56
+ apply_doctor_flag(options)
57
+ end
58
+
59
+ # --doctor runs the boot preflight in report mode; --doctor-strict aborts
60
+ # the boot on a fatal finding. Both set config.doctor_on_boot, which the
61
+ # supervisor reads at boot (issue #347). Strict wins if both are passed — it
62
+ # is the stronger gate, so a user asking for both clearly wants strict.
63
+ def apply_doctor_flag(options)
64
+ Pgbus.configuration.doctor_on_boot = :report if options[:doctor]
65
+ Pgbus.configuration.doctor_on_boot = :strict if options[:doctor_strict]
54
66
  end
55
67
 
56
68
  def parse_start_options(args)
@@ -81,6 +93,14 @@ module Pgbus
81
93
  opts.on("--execution-mode MODE", "Execution mode: threads (default) or async") do |v|
82
94
  options[:execution_mode] = v
83
95
  end
96
+
97
+ opts.on("--doctor", "Run doctor preflight checks at boot and log the report (issue #347)") do
98
+ options[:doctor] = true
99
+ end
100
+
101
+ opts.on("--doctor-strict", "Run doctor preflight and refuse to boot on a fatal finding") do
102
+ options[:doctor_strict] = true
103
+ end
84
104
  end.parse!(args.dup)
85
105
  options
86
106
  end
@@ -178,6 +198,8 @@ module Pgbus
178
198
  queues List queues with metrics
179
199
  dlq Inspect and drain dead-letter queues
180
200
  (list/show/retry/retry-all/purge)
201
+ dashboard Print an AppSignal dashboard as import-ready JSON
202
+ (main/health/streams/throughput; --list to enumerate)
181
203
  mcp Start the read-only MCP diagnostic server over stdio
182
204
  doctor Run environment diagnostics (config, DB, PGMQ, queues,
183
205
  LISTEN/NOTIFY, process liveness); exits 1 on any failure
@@ -199,6 +221,12 @@ module Pgbus
199
221
  pattern)
200
222
  --execution-mode Execution mode: threads (default) or async
201
223
  (fiber-based, lower connection usage)
224
+ --doctor Run doctor preflight checks in the booting
225
+ supervisor and log the report (one Rails boot
226
+ instead of `pgbus doctor` + `pgbus start`)
227
+ --doctor-strict Like --doctor, but refuse to boot (exit non-zero,
228
+ fork nothing) if a fatal check fails (bad config
229
+ or an absent PGMQ schema)
202
230
 
203
231
  Environment for `mcp`:
204
232
  PGBUS_MCP_TOKEN If set, PGBUS_MCP_AUTH_TOKEN must match for
data/lib/pgbus/client.rb CHANGED
@@ -1099,6 +1099,14 @@ module Pgbus
1099
1099
  ].freeze
1100
1100
  private_constant :STALE_CONNECTION_PATTERNS
1101
1101
 
1102
+ # Compiled once: matches an error message containing any stale-connection
1103
+ # pattern as a substring, case-insensitively (replacing the previous
1104
+ # `message.downcase` + substring scan). Regexp.union escapes the literal
1105
+ # parens in "pqsocket() ...", so they match literally.
1106
+ STALE_CONNECTION_PATTERN =
1107
+ Regexp.new(Regexp.union(STALE_CONNECTION_PATTERNS).source, Regexp::IGNORECASE).freeze
1108
+ private_constant :STALE_CONNECTION_PATTERN
1109
+
1102
1110
  # How many times a matched stale-connection error is retried before it
1103
1111
  # propagates. Two attempts (not one) so a transient window — a PgBouncer
1104
1112
  # restart or a brief failover — that outlasts the first immediate retry
@@ -1482,8 +1490,11 @@ module Pgbus
1482
1490
  end
1483
1491
 
1484
1492
  def stale_connection_error?(error)
1485
- message = error.message.to_s.downcase
1486
- STALE_CONNECTION_PATTERNS.any? { |pattern| message.include?(pattern) }
1493
+ # Substring match against any stale-connection pattern. Deliberately a
1494
+ # single Regexp rather than `STALE_CONNECTION_PATTERNS.any? { |p| msg.include?(p) }`:
1495
+ # Style/ArrayIntersect misfires on that shape and "corrects" it to
1496
+ # `patterns.intersect?(msg)`, which raises TypeError (String isn't an Array).
1497
+ STALE_CONNECTION_PATTERN.match?(error.message.to_s)
1487
1498
  end
1488
1499
 
1489
1500
  # Substring pgmq-ruby uses when a pool checkout times out — a
@@ -89,6 +89,19 @@ module Pgbus
89
89
  # PGMQ schema installation mode (:auto, :extension, :embedded)
90
90
  attr_reader :pgmq_schema_mode
91
91
 
92
+ # Run doctor preflight checks inside the supervisor at boot (issue #347),
93
+ # so an entrypoint need not boot Rails twice (once for `pgbus doctor`, once
94
+ # for `pgbus start`). nil/false (default) — off, byte-identical to today.
95
+ # :report — run the preflight and log the report, always continue booting.
96
+ # :strict — additionally REFUSE to boot (raise before forking any worker)
97
+ # when a genuinely-fatal, non-transient check fails. The strict
98
+ # set is deliberately narrow (Configuration, PGMQ schema) so a
99
+ # transient boot-time DB blip — which the lenient queue bootstrap
100
+ # is designed to ride out via child crash-and-backoff — never
101
+ # aborts a whole fleet's cold boot in lockstep. Set via the
102
+ # `--doctor` / `--doctor-strict` start flags too.
103
+ attr_reader :doctor_on_boot
104
+
92
105
  # Event consumers
93
106
  attr_reader :event_consumers
94
107
 
@@ -248,6 +261,7 @@ module Pgbus
248
261
  @worker_notify_database_url = nil
249
262
 
250
263
  @pgmq_schema_mode = :auto
264
+ @doctor_on_boot = nil
251
265
 
252
266
  @event_consumers = nil
253
267
 
@@ -566,6 +580,28 @@ module Pgbus
566
580
  @pgmq_schema_mode = mode
567
581
  end
568
582
 
583
+ VALID_DOCTOR_ON_BOOT_MODES = [nil, :report, :strict].freeze
584
+
585
+ # nil/false are both "off"; a String is coerced to a Symbol so the CLI flags
586
+ # and YAML-ish configs work. Validated at assignment time (like the other
587
+ # enum options), not in validate!.
588
+ def doctor_on_boot=(mode)
589
+ coerced = case mode
590
+ when nil, false then nil
591
+ when Symbol then mode
592
+ when String then mode.to_sym
593
+ else
594
+ raise Pgbus::ConfigurationError,
595
+ "Invalid doctor_on_boot type: #{mode.class}. Must be nil, false, String, or Symbol"
596
+ end
597
+ unless VALID_DOCTOR_ON_BOOT_MODES.include?(coerced)
598
+ raise Pgbus::ConfigurationError,
599
+ "Invalid doctor_on_boot: #{coerced.inspect}. Must be nil/false (off), :report, or :strict"
600
+ end
601
+
602
+ @doctor_on_boot = coerced
603
+ end
604
+
569
605
  def validate!
570
606
  if pool_size && !(pool_size.is_a?(Numeric) && pool_size.positive?)
571
607
  raise Pgbus::ConfigurationError, "pool_size must be a positive number or nil (auto-tune)"
data/lib/pgbus/doctor.rb CHANGED
@@ -25,26 +25,47 @@ module Pgbus
25
25
 
26
26
  STATUS_ICON = { ok: "✓", warn: "!", fail: "✗" }.freeze
27
27
 
28
+ # The ordered check suite, name → method. The name strings are the public,
29
+ # stable identity of each check (used in the report and to select subsets).
30
+ CHECKS = {
31
+ "Configuration" => :check_configuration,
32
+ "Database" => :check_database,
33
+ "PGMQ schema" => :check_pgmq_schema,
34
+ "Queues" => :check_queues,
35
+ "LISTEN/NOTIFY" => :check_notify,
36
+ "Process liveness" => :check_processes,
37
+ "GlobalID allowlist" => :check_allowed_global_id_models,
38
+ "Broadcast queue" => :check_broadcast_queue,
39
+ "Primary affinity" => :check_primary
40
+ }.freeze
41
+
42
+ # Process liveness reads the pgbus_processes table (via HealthAnalyzer), so
43
+ # it is only meaningful once workers have registered. The supervisor boot
44
+ # preflight (issue #347) runs BEFORE forking any worker, so it excludes this
45
+ # check — otherwise stale prior-generation worker rows plus a visible backlog
46
+ # would produce a false STALLED verdict on a redeploy.
47
+ BOOT_SKIP = ["Process liveness"].freeze
48
+
49
+ # The subset of checks whose :fail is genuinely deploy-fatal AND not a
50
+ # transient the supervisor is designed to ride out. Only these abort a
51
+ # `:strict` boot. Configuration#validate! failing is a real config bug;
52
+ # an absent PGMQ schema means the migrations never ran. Deliberately NOT
53
+ # Queues/Database: the lenient queue bootstrap swallows a boot-time DB blip
54
+ # so children crash-and-backoff and recover, and verify_connection! already
55
+ # gated a hard-down DB before the preflight — making either strict-fatal
56
+ # would turn a tolerated transient into a fleet-wide lockstep boot abort.
57
+ STRICT_FATAL = ["Configuration", "PGMQ schema"].freeze
58
+
28
59
  def initialize(config: Pgbus.configuration, client: Pgbus.client, data_source: nil)
29
60
  @config = config
30
61
  @client = client
31
- @data_source = data_source || Pgbus::Web::DataSource.new(client: client)
62
+ @data_source = data_source
32
63
  end
33
64
 
34
65
  # Run all checks and return an array of result hashes:
35
66
  # { name:, status: :ok|:warn|:fail, detail: }
36
67
  def run
37
- @run ||= [
38
- check_configuration,
39
- check_database,
40
- check_pgmq_schema,
41
- check_queues,
42
- check_notify,
43
- check_processes,
44
- check_allowed_global_id_models,
45
- check_broadcast_queue,
46
- check_primary
47
- ].map(&:to_h)
68
+ @run ||= run_checks(CHECKS.keys)
48
69
  end
49
70
 
50
71
  # True when no check failed. Warnings do not fail the run — they surface a
@@ -53,11 +74,34 @@ module Pgbus
53
74
  run.none? { |c| c[:status] == :fail }
54
75
  end
55
76
 
77
+ # --- Supervisor boot preflight (issue #347) ---
78
+
79
+ # The checks safe to run inside the booting supervisor before any worker is
80
+ # forked: everything except the worker-dependent process-liveness check.
81
+ # Runs a genuine SUBSET — check_processes is never invoked — so there is no
82
+ # pre-fork HealthAnalyzer/DataSource round-trip.
83
+ def boot_checks
84
+ @boot_checks ||= run_checks(CHECKS.keys - BOOT_SKIP)
85
+ end
86
+
87
+ # True unless a strict-fatal check (see STRICT_FATAL) failed. Warnings and
88
+ # transient-shaped failures (Queues, Database) never block a `:strict` boot.
89
+ def boot_ok?
90
+ boot_checks.none? { |c| c[:status] == :fail && STRICT_FATAL.include?(c[:name]) }
91
+ end
92
+
93
+ # Human-readable report for the boot preflight — the boot_checks subset.
94
+ def boot_report
95
+ report(boot_checks)
96
+ end
97
+
56
98
  # Human-readable report: one line per check, then a resolved-config summary
57
99
  # with passwords redacted. Suitable for stdout in the CLI and rake task.
58
- def report
100
+ # Defaults to the full run; pass a filtered result array (e.g. boot_checks)
101
+ # to render a subset.
102
+ def report(checks = run)
59
103
  lines = ["Pgbus Doctor", "=" * 40]
60
- run.each do |check|
104
+ checks.each do |check|
61
105
  icon = STATUS_ICON.fetch(check[:status], "?")
62
106
  lines << format("%<icon>s %<name>-22s %<detail>s", icon: icon, name: check[:name], detail: check[:detail])
63
107
  end
@@ -86,6 +130,21 @@ module Pgbus
86
130
 
87
131
  private
88
132
 
133
+ # Run the named checks in CHECKS order and return an array of result hashes.
134
+ # Only the requested checks are INVOKED — a check omitted from `names` does
135
+ # no work (e.g. boot_checks never calls check_processes, so no HealthAnalyzer
136
+ # round-trip pre-fork).
137
+ def run_checks(names)
138
+ CHECKS.filter_map { |name, method| send(method).to_h if names.include?(name) }
139
+ end
140
+
141
+ # The dashboard data source backing the process-liveness check. Built lazily
142
+ # so the boot preflight (which excludes that check) never constructs a
143
+ # DataSource or touches the dashboard layer at the fork boundary.
144
+ def data_source
145
+ @data_source ||= Pgbus::Web::DataSource.new(client: @client)
146
+ end
147
+
89
148
  # True in a Rails production environment. Guarded so the doctor still runs
90
149
  # outside Rails (plain Ruby, tests) without assuming Rails is loaded.
91
150
  def production?
@@ -181,7 +240,7 @@ module Pgbus
181
240
  # from the shared HealthAnalyzer. STALLED (the silent-worker-wedge) is a
182
241
  # failure; DEGRADED is a warning; OK passes.
183
242
  def check_processes
184
- verdict = Pgbus::MCP::HealthAnalyzer.new(@data_source).verdict
243
+ verdict = Pgbus::MCP::HealthAnalyzer.new(data_source).verdict
185
244
  status = verdict[:status]
186
245
  detail = Array(verdict[:reasons]).first || status
187
246
 
@@ -11,7 +11,7 @@
11
11
  "display": "LINE",
12
12
  "title": "Job status per queue",
13
13
  "description": "Processed, failed, and dead-lettered jobs per queue.",
14
- "line_label": "%queue% - %status%",
14
+ "line_label": "%job_class% - %queue% - %status%",
15
15
  "format": "number",
16
16
  "draw_null_as_zero": true,
17
17
  "metrics": [
@@ -23,6 +23,10 @@
23
23
  }
24
24
  ],
25
25
  "tags": [
26
+ {
27
+ "key": "job_class",
28
+ "value": "*"
29
+ },
26
30
  {
27
31
  "key": "queue",
28
32
  "value": "*"
@@ -232,7 +236,7 @@
232
236
  "display": "LINE",
233
237
  "title": "Event handler status",
234
238
  "description": "Processed and failed event handler invocations.",
235
- "line_label": "%handler% - %status%",
239
+ "line_label": "%handler% - %status% - %routing_key%",
236
240
  "format": "number",
237
241
  "draw_null_as_zero": true,
238
242
  "metrics": [
@@ -251,6 +255,10 @@
251
255
  {
252
256
  "key": "status",
253
257
  "value": "*"
258
+ },
259
+ {
260
+ "key": "routing_key",
261
+ "value": "*"
254
262
  }
255
263
  ]
256
264
  }
@@ -76,6 +76,14 @@ module Pgbus
76
76
  # notify_trigger_current? makes those calls cheap no-ops.
77
77
  bootstrap_queues
78
78
 
79
+ # Optional in-process doctor preflight (issue #347): run the diagnostic
80
+ # checks here — after config is loaded, the DB is verified reachable, and
81
+ # queues are bootstrapped, but BEFORE any worker is forked — so an
82
+ # entrypoint gets a single Rails boot instead of `pgbus doctor` + `pgbus
83
+ # start`. :strict aborts the boot (raising, so nothing forks) on a
84
+ # genuinely-fatal finding; :report only logs. Off by default.
85
+ run_doctor_preflight unless config.doctor_on_boot.nil?
86
+
79
87
  boot_processes
80
88
  monitor_loop
81
89
  ensure
@@ -683,6 +691,33 @@ module Pgbus
683
691
  ErrorReporter.report(e, { action: "bootstrap_queues" })
684
692
  end
685
693
 
694
+ # In-process doctor preflight (issue #347). Runs the boot-safe subset of
695
+ # doctor checks (everything except the worker-dependent process-liveness
696
+ # check, which has no workers to observe yet) against the supervisor's own
697
+ # config and the already-verified shared client, and logs the report.
698
+ #
699
+ # In :strict mode, a genuinely-fatal check (Doctor::STRICT_FATAL —
700
+ # Configuration or an absent PGMQ schema) aborts the boot by raising
701
+ # Pgbus::ConfigurationError. Because this runs before boot_processes, no
702
+ # child is forked, and `run`'s `ensure shutdown` tears down the heartbeat
703
+ # and health server — the same fail-fast path verify_connection! uses.
704
+ # A transient-shaped failure (Queues, Database) is reported but never
705
+ # aborts: the lenient bootstrap above is built to let children ride out a
706
+ # boot-time DB blip, and a strict abort there would take down a whole
707
+ # fleet's cold boot in lockstep.
708
+ def run_doctor_preflight
709
+ doctor = Pgbus::Doctor.new(config: config, client: Pgbus.client)
710
+ report = doctor.boot_report
711
+ Pgbus.logger.info { "[Pgbus] doctor preflight (#{config.doctor_on_boot}):\n#{report}" }
712
+
713
+ return unless config.doctor_on_boot == :strict
714
+ return if doctor.boot_ok?
715
+
716
+ raise Pgbus::ConfigurationError,
717
+ "doctor preflight failed a fatal check (doctor_on_boot: :strict) — refusing to boot. " \
718
+ "See the report above."
719
+ end
720
+
686
721
  def load_rails_app
687
722
  return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
688
723
 
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.11.1"
4
+ VERSION = "0.11.3"
5
5
  end
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.11.1
4
+ version: 0.11.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -253,6 +253,7 @@ files:
253
253
  - lib/pgbus/bus_record.rb
254
254
  - lib/pgbus/circuit_breaker.rb
255
255
  - lib/pgbus/cli.rb
256
+ - lib/pgbus/cli/dashboard.rb
256
257
  - lib/pgbus/cli/dlq.rb
257
258
  - lib/pgbus/client.rb
258
259
  - lib/pgbus/client/connection_health.rb