pgbus 0.11.0 → 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: 44b8e6016eb94d129ba3f90831ee6e3f8eb213c928e61766c35fa4641f0a518b
4
- data.tar.gz: d35475deb1eaaf59e893c7096b10bc85498482f5940fc099e5add48af54aecbd
3
+ metadata.gz: 727dd6a7515414a2a426857ea19cc8cf33b8c5ca2311ecde116bf643114e8d85
4
+ data.tar.gz: c68f0e3c246b554059160e901e96d61f80c5a5a9aa1716d6e4787f84cfdbe655
5
5
  SHA512:
6
- metadata.gz: db2122fe97f2b77dd6812e5e9a5264850b4eb84ff3c6094cc2c0e11a148989fa3ae502664e040da26b1ea1bf11b764c2b1f9659a58f9d4a6b663be54c1027cba
7
- data.tar.gz: 597210a0238131f0a7912b0cf135dc4d89ce66ab830354dca3b3805dbc70a9ccbd2106830dea85b17c68be73fb79868ff9c43d27813e84e6f457dfbe996107d3
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.
@@ -32,6 +33,8 @@
32
33
 
33
34
  ### Fixed
34
35
 
36
+ - **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.
37
+ - **`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.
35
38
  - **`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.
36
39
  - **The "Dashboard is accessible without authentication" warning is no longer a false positive when a gating `base_controller_class` is set (issue #334).** The warning fired whenever `web_auth` was nil, ignoring that a non-default `base_controller_class` (e.g. an `AdminController` with its own `before_action`) already gates the dashboard — so apps set a redundant `web_auth` lambda purely to silence the log line. The warning now stays quiet when `base_controller_class` is anything other than the default `::ActionController::Base`. Refs #334.
37
40
  - **`retry_on` on an `:until_executed` job no longer dead-letters the job instead of retrying (issue #333).** The uniqueness key is released only on success or DLQ, but ActiveJob's `retry_on` re-enqueues from *inside* `perform_now` (after incrementing `executions`), while the executor still holds the key. That retry re-enqueue hit the job's own still-held key, was rejected as a duplicate (`JobNotUnique` under `on_conflict: :reject`), and the original message dead-lettered — so a job with both `ensures_uniqueness :until_executed` and `retry_on` lost its retry and DLQ'd on the first transient failure. A retry re-enqueue (`executions > 0`) is now recognized as the same logical job re-acquiring its own key and is allowed through; the existing key row correctly stays held until the job finally succeeds or dead-letters. Cross-job uniqueness (a genuine fresh duplicate, `executions == 0`) is still rejected. Proven end-to-end with a DB-gated integration test (fail-once-then-succeed runs to success exactly once, no `JobNotUnique`, no DLQ, key cleaned up). Refs #333.
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:
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus failed events unique index added!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Failed jobs will now be tracked in the dashboard"
34
34
  say ""
35
35
  end
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus job stats table installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Stats collection is enabled by default"
34
34
  say " 3. View insights at /pgbus/insights"
35
35
  say ""
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus job stats latency columns installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Queue latency and retry metrics are now tracked automatically"
34
34
  say " 3. View latency insights at /pgbus/insights"
35
35
  say ""
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus job stats queue index added!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. The Insights 'latency by queue' aggregation will now use the index"
34
34
  say " instead of sequentially scanning pgbus_job_stats. Install this on"
35
35
  say " heavy-traffic deployments with a large job stats retention window."
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus outbox installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Enable in config: config.outbox_enabled = true"
34
34
  say " 3. Restart pgbus: bin/pgbus start"
35
35
  say ""
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus presence installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Use in your code:"
34
34
  say " Pgbus.stream(@room).presence.join(member_id: current_user.id.to_s)"
35
35
  say " Pgbus.stream(@room).presence.members"
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus queue states table installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Restart pgbus: bin/pgbus start"
34
34
  say ""
35
35
  end
@@ -33,7 +33,7 @@ module Pgbus
33
33
  say "Pgbus recurring jobs installed!", :green
34
34
  say ""
35
35
  say "Next steps:"
36
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
37
37
  say " 2. Edit config/recurring.yml to define your recurring tasks"
38
38
  say " 3. Restart pgbus: bin/pgbus start"
39
39
  say ""
@@ -35,7 +35,7 @@ module Pgbus
35
35
  say "streams register themselves on their next broadcast after migrating."
36
36
  say ""
37
37
  say "Next steps:"
38
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
38
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
39
39
  say " 2. Restart pgbus: bin/pgbus start"
40
40
  say ""
41
41
  end
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus stream stats table installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Opt in by setting `config.streams_stats_enabled = true` in your"
34
34
  say " pgbus initializer (disabled by default — stream event volume can"
35
35
  say " be high, so stats recording is off unless you ask for it)."
@@ -29,7 +29,7 @@ module Pgbus
29
29
  say "Pgbus uniqueness keys table installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
33
  say " 2. Add `ensures_uniqueness` to your job classes (DSL is auto-included)"
34
34
  say " 3. Restart pgbus: bin/pgbus start"
35
35
  say ""
@@ -99,7 +99,7 @@ module Pgbus
99
99
 
100
100
  say ""
101
101
  say "Next steps:"
102
- say " 1. Run: rails db:migrate#{":#{database_name}" if separate_database?}"
102
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
103
103
  say " 2. Edit config/initializers/pgbus.rb to configure workers"
104
104
  say " 3. Start processing: bin/pgbus start"
105
105
  say ""
@@ -115,8 +115,11 @@ module Pgbus
115
115
  options[:pgmq_schema_mode]
116
116
  end
117
117
 
118
+ # An explicit --database wins; otherwise fall back to a database already
119
+ # configured via connects_to so a bare re-install targets the right DB
120
+ # and its setup hints/output name it (issue #344).
118
121
  def database_name
119
- options[:database]
122
+ effective_database_name
120
123
  end
121
124
  end
122
125
  end
@@ -34,7 +34,7 @@ module Pgbus
34
34
  say " 3. Drop the old pgbus_job_locks table (8 columns, 3 indexes)"
35
35
  say ""
36
36
  say "Next steps:"
37
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
37
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
38
38
  say " 2. Restart pgbus: bin/pgbus start"
39
39
  say ""
40
40
  end
@@ -1,27 +1,93 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pgbus/generators/database_target_detector"
4
+
3
5
  module Pgbus
4
6
  module Generators
5
7
  # Shared migration path logic for all pgbus generators.
6
8
  #
7
- # When --database is passed, uses Rails' built-in db_migrate_path which
8
- # reads migrations_paths from database.yml. This is the standard Rails
9
- # multi-database mechanism and respects custom paths like db/pgbus_migrate/.
9
+ # A separate pgbus database can be selected two ways:
10
+ #
11
+ # 1. --database=pgbus explicitly on the generator invocation, or
12
+ # 2. the host app has already configured Pgbus with a dedicated database
13
+ # (config.connects_to = { database: { writing: :pgbus } }).
10
14
  #
11
- # Falls back to db/migrate/ when no --database is set (single-database mode).
15
+ # When either applies, migrations go to the separate-database path (e.g.
16
+ # db/pgbus_migrate) and the post-install output names the db:migrate:<name>
17
+ # task. When neither applies, everything falls back to db/migrate.
18
+ #
19
+ # Auto-detecting from connects_to (case 2) fixes issue #344: a bare
20
+ # `rails g pgbus:add_*` in an app configured for a separate database used to
21
+ # silently write to db/migrate and run against the WRONG database.
12
22
  module MigrationPath
13
23
  private
14
24
 
15
25
  def pgbus_migrate_path
16
- if separate_database?
26
+ return "db/migrate" unless separate_database?
27
+
28
+ # --database was passed: Rails' db_migrate_path reads options[:database]
29
+ # and resolves migrations_paths from database.yml. When the database was
30
+ # instead auto-detected from connects_to, options[:database] is nil, so
31
+ # db_migrate_path can't see it — resolve the path for the detected
32
+ # database ourselves.
33
+ if options[:database].present?
17
34
  db_migrate_path
18
35
  else
19
- "db/migrate"
36
+ resolve_detected_migrate_path
20
37
  end
21
38
  end
22
39
 
23
40
  def separate_database?
24
- options[:database].present?
41
+ effective_database_name.present?
42
+ end
43
+
44
+ # The database name that migrations should target: an explicit --database
45
+ # wins; otherwise auto-detect from the app's connects_to configuration.
46
+ def effective_database_name
47
+ return @effective_database_name if defined?(@effective_database_name)
48
+
49
+ @effective_database_name =
50
+ if options[:database].present?
51
+ options[:database]
52
+ else
53
+ DatabaseTargetDetector.new(destination_root: destination_root).detect
54
+ end
55
+ end
56
+
57
+ # The `:name` suffix appended to `rails db:migrate` in post-install
58
+ # output. Empty string for single-database mode so the line reads a plain
59
+ # `rails db:migrate`.
60
+ def migrate_command_suffix
61
+ separate_database? ? ":#{effective_database_name}" : ""
62
+ end
63
+
64
+ # Migrations path for an auto-detected separate database. Rails' own
65
+ # db_migrate_path is keyed on options[:database], which is nil here, so
66
+ # look up migrations_paths for the detected database directly and fall
67
+ # back to the db/pgbus_migrate convention if it isn't in database.yml.
68
+ def resolve_detected_migrate_path
69
+ migrations_path_for(effective_database_name) || "db/pgbus_migrate"
70
+ end
71
+
72
+ def migrations_path_for(database_name)
73
+ return nil unless defined?(::ActiveRecord::Base) && defined?(::Rails) && ::Rails.respond_to?(:env)
74
+
75
+ config = ::ActiveRecord::Base.configurations.configs_for(
76
+ env_name: ::Rails.env,
77
+ name: database_name
78
+ )
79
+ Array(config&.migrations_paths).first
80
+ rescue StandardError => e
81
+ # A missing config for `database_name` isn't an error — configs_for
82
+ # returns nil and we fall back to the db/pgbus_migrate convention. But a
83
+ # genuine failure here (malformed database.yml, an AR API change) would
84
+ # otherwise be invisible, so surface it via the generator's own output
85
+ # the way update_generator#resolve_connection does — not Pgbus.logger
86
+ # (unavailable/inappropriate at generate time; pgbus_failed_events is the
87
+ # runtime job-failure table, not a generator diagnostics sink).
88
+ say " ! could not resolve migrations_paths for #{database_name.inspect}: " \
89
+ "#{e.class}: #{e.message} (falling back to db/pgbus_migrate)", :yellow
90
+ nil
25
91
  end
26
92
  end
27
93
  end
@@ -33,7 +33,7 @@ module Pgbus
33
33
  say "automatically receive these settings."
34
34
  say ""
35
35
  say "Next steps:"
36
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
37
37
  say " 2. Restart pgbus: bin/pgbus start"
38
38
  say ""
39
39
  end
@@ -36,7 +36,7 @@ module Pgbus
36
36
  say "this setting."
37
37
  say ""
38
38
  say "Next steps:"
39
- say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
39
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
40
40
  say " 2. Restart pgbus: bin/pgbus start"
41
41
  say ""
42
42
  end
@@ -31,7 +31,7 @@ module Pgbus
31
31
  say ""
32
32
  say "Next steps:"
33
33
  say " 1. Review the migration in db/#{separate_database? ? "pgbus_migrate" : "migrate"}/"
34
- say " 2. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
34
+ say " 2. Run: rails db:migrate#{migrate_command_suffix}"
35
35
  say ""
36
36
  end
37
37
 
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)"
@@ -1439,9 +1475,15 @@ module Pgbus
1439
1475
  # Rails 7.1+ db_config.configuration_hash returns the full config
1440
1476
  config_hash = db_config.configuration_hash
1441
1477
 
1478
+ # Do NOT default host/port here. An absent `host:` in database.yml is a
1479
+ # Unix-socket connection; AR connects via libpq's socket default, so pgmq's
1480
+ # raw connections must too. Forcing TCP localhost:5432 diverges from AR on
1481
+ # socket-based configs (issue #343). `.compact` drops the absent keys and
1482
+ # libpq applies its own defaults (PGHOST / default socket dir). A
1483
+ # present-but-nil port is preserved as nil (also dropped by compact).
1442
1484
  base = {
1443
- host: config_hash[:host] || "localhost",
1444
- port: (config_hash[:port] || 5432).to_i,
1485
+ host: config_hash[:host],
1486
+ port: config_hash[:port]&.to_i,
1445
1487
  dbname: config_hash[:database],
1446
1488
  user: config_hash[:username],
1447
1489
  password: config_hash[:password]
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.0"
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.0
4
+ version: 0.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson