pgbus 0.11.1 → 0.11.2

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: 727dd6a7515414a2a426857ea19cc8cf33b8c5ca2311ecde116bf643114e8d85
4
+ data.tar.gz: c68f0e3c246b554059160e901e96d61f80c5a5a9aa1716d6e4787f84cfdbe655
5
5
  SHA512:
6
- metadata.gz: dc59f389bf2b5e20b2e75a7a2c47301c242ea0b5857a240e5a59db3177effe34cbe694903d4396b07ef3ca67699f578a5d6b6d95f61d4318169ac477f237b526
7
- data.tar.gz: a5a6a076c6cadb857c8690ef7d1363934c7f73bd76e8d91a87a936a91d05c4146c12bb67b5637204995d0ddef540833a8983ca4dd3d5ef1034a7b88473aa1e61
6
+ metadata.gz: f515f94c6e585a038ab468ca30dc49a284c9666ddb0116998532ce2afdf602ac8309972fbe79606ad055b73bd95da418210778cf80a9e3062288855d67a3b746
7
+ data.tar.gz: dd748949d2330236bfbc46b94c04ff907268e067830a2c4b91b3ff6b811324b2383d3c46bc950add9511c98355b39b39d9f3a58c03a3fed361838c866b24bf1c
data/CHANGELOG.md CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  ### Added
12
12
 
13
+ - **`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
14
  - **`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
15
  - **`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
16
  - **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.
data/README.md CHANGED
@@ -1872,6 +1872,25 @@ rake pgbus:doctor # identical checks, for a Rake-based deploy pipeline
1872
1872
 
1873
1873
  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
1874
 
1875
+ ##### Doctor preflight at boot (single Rails boot)
1876
+
1877
+ 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:
1878
+
1879
+ ```bash
1880
+ pgbus start --doctor # run the preflight, log the report, always boot
1881
+ pgbus start --doctor-strict # refuse to boot on a fatal check (exit non-zero, fork nothing)
1882
+ ```
1883
+
1884
+ or set it in the initializer:
1885
+
1886
+ ```ruby
1887
+ Pgbus.configure do |c|
1888
+ c.doctor_on_boot = :report # or :strict; nil/false (default) is off
1889
+ end
1890
+ ```
1891
+
1892
+ 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.
1893
+
1875
1894
  #### pgbus dlq
1876
1895
 
1877
1896
  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:
data/lib/pgbus/cli.rb CHANGED
@@ -51,6 +51,16 @@ module Pgbus
51
51
  Pgbus.configuration.execution_mode = options[:execution_mode].to_sym if options[:execution_mode]
52
52
  apply_capsule_filter(options[:capsule]) if options[:capsule]
53
53
  apply_role_filter(options)
54
+ apply_doctor_flag(options)
55
+ end
56
+
57
+ # --doctor runs the boot preflight in report mode; --doctor-strict aborts
58
+ # the boot on a fatal finding. Both set config.doctor_on_boot, which the
59
+ # supervisor reads at boot (issue #347). Strict wins if both are passed — it
60
+ # is the stronger gate, so a user asking for both clearly wants strict.
61
+ def apply_doctor_flag(options)
62
+ Pgbus.configuration.doctor_on_boot = :report if options[:doctor]
63
+ Pgbus.configuration.doctor_on_boot = :strict if options[:doctor_strict]
54
64
  end
55
65
 
56
66
  def parse_start_options(args)
@@ -81,6 +91,14 @@ module Pgbus
81
91
  opts.on("--execution-mode MODE", "Execution mode: threads (default) or async") do |v|
82
92
  options[:execution_mode] = v
83
93
  end
94
+
95
+ opts.on("--doctor", "Run doctor preflight checks at boot and log the report (issue #347)") do
96
+ options[:doctor] = true
97
+ end
98
+
99
+ opts.on("--doctor-strict", "Run doctor preflight and refuse to boot on a fatal finding") do
100
+ options[:doctor_strict] = true
101
+ end
84
102
  end.parse!(args.dup)
85
103
  options
86
104
  end
@@ -199,6 +217,12 @@ module Pgbus
199
217
  pattern)
200
218
  --execution-mode Execution mode: threads (default) or async
201
219
  (fiber-based, lower connection usage)
220
+ --doctor Run doctor preflight checks in the booting
221
+ supervisor and log the report (one Rails boot
222
+ instead of `pgbus doctor` + `pgbus start`)
223
+ --doctor-strict Like --doctor, but refuse to boot (exit non-zero,
224
+ fork nothing) if a fatal check fails (bad config
225
+ or an absent PGMQ schema)
202
226
 
203
227
  Environment for `mcp`:
204
228
  PGBUS_MCP_TOKEN If set, PGBUS_MCP_AUTH_TOKEN must match for
@@ -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
 
@@ -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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson