pgbus 0.9.8 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +336 -25
  4. data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  5. data/lib/pgbus/active_job/adapter.rb +1 -1
  6. data/lib/pgbus/config_loader.rb +29 -2
  7. data/lib/pgbus/configuration.rb +202 -73
  8. data/lib/pgbus/doctor.rb +28 -3
  9. data/lib/pgbus/execution_pools/async_pool.rb +2 -2
  10. data/lib/pgbus/generators/config_converter.rb +7 -5
  11. data/lib/pgbus/generators/migration_detector.rb +20 -16
  12. data/lib/pgbus/instrumentation.rb +2 -1
  13. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  14. data/lib/pgbus/metrics/subscriber.rb +26 -2
  15. data/lib/pgbus/metrics.rb +3 -2
  16. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  17. data/lib/pgbus/pgmq_schema.rb +7 -2
  18. data/lib/pgbus/process/consumer.rb +141 -12
  19. data/lib/pgbus/process/supervisor.rb +33 -10
  20. data/lib/pgbus/process/worker.rb +6 -16
  21. data/lib/pgbus/recurring/schedule.rb +1 -2
  22. data/lib/pgbus/serializer.rb +4 -4
  23. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  24. data/lib/pgbus/streams/signed_name.rb +2 -2
  25. data/lib/pgbus/uniqueness.rb +11 -12
  26. data/lib/pgbus/version.rb +1 -1
  27. data/lib/pgbus/web/data_source.rb +20 -4
  28. data/lib/pgbus/web/payload_filter.rb +3 -3
  29. data/lib/pgbus/web/streamer/instance.rb +6 -4
  30. data/lib/pgbus/web/streamer/listener.rb +21 -36
  31. data/lib/pgbus.rb +46 -6
  32. metadata +4 -6
  33. data/app/models/pgbus/job_lock.rb +0 -98
  34. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
  35. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +0 -42
  36. data/lib/rubocop/pgbus.rb +0 -5
data/README.md CHANGED
@@ -23,6 +23,8 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
23
23
  - [Job uniqueness](#job-uniqueness)
24
24
  - [Concurrency controls](#concurrency-controls)
25
25
  - [Circuit breaker and queue pause/resume](#circuit-breaker-and-queue-pauseresume)
26
+ - [Client-level circuit breaker (database-down)](#client-level-circuit-breaker-database-down)
27
+ - [Read timeouts (libpq-native)](#read-timeouts-libpq-native)
26
28
  - [Prefetch flow control](#prefetch-flow-control)
27
29
  - [Worker recycling](#worker-recycling)
28
30
  - [Retry backoff](#retry-backoff)
@@ -36,9 +38,11 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
36
38
  - [Archive compaction](#archive-compaction)
37
39
  - [Observability](#observability)
38
40
  - [Error reporting](#error-reporting)
41
+ - [Connection pool metrics](#connection-pool-metrics)
39
42
  - [Structured logging](#structured-logging)
40
43
  - [Queue health monitoring](#queue-health-monitoring)
41
44
  - [Health endpoints (liveness / readiness)](#health-endpoints-liveness--readiness)
45
+ - [Boot diagnostics banner](#boot-diagnostics-banner)
42
46
  - [Real-time broadcasts](#real-time-broadcasts-turbo-streams-replacement)
43
47
  - [Testing](#testing)
44
48
  - [RSpec setup](#rspec-setup)
@@ -132,12 +136,16 @@ end
132
136
 
133
137
  The capsule string DSL is the shortest form for the common case. Use `c.capsule` when you need named capsules with advanced options like `single_active_consumer` or `consumer_priority`. See [Routing and ordering](#routing-and-ordering) for the full set.
134
138
 
139
+ Configuration is validated eagerly: `Pgbus.configure` runs `Configuration#validate!` right after your block yields, so an invalid value (`visibility_timeout = 0`, for example) raises `Pgbus::ConfigurationError` at boot instead of failing later inside a worker. Set `c.eager_validation = false` for the rare setup that intentionally holds a transiently-invalid config across sequential `configure` calls.
140
+
135
141
  > **Upgrading from an older pgbus?** Run `rails generate pgbus:update`. It does two things in one pass:
136
142
  >
137
143
  > - Converts any legacy `config/pgbus.yml` to a Ruby initializer at `config/initializers/pgbus.rb` (skipped if the initializer already exists).
138
144
  > - Inspects your live database and adds any missing pgbus migrations to `db/migrate` (or `db/pgbus_migrate` if you use `connects_to`). The generator detects your separate-database config automatically from `Pgbus.configuration.connects_to` or by scanning the initializer / `config/application.rb`, so you don't have to re-specify `--database=pgbus` every time.
139
145
  >
140
146
  > Useful flags: `--dry-run` (print the plan without creating files), `--skip-config`, `--skip-migrations`, `--quiet`. Running it on a database with no pgbus tables at all will redirect you to `pgbus:install` instead of stacking individual add_* migrations.
147
+ >
148
+ > For the full step-by-step procedure (including the vendored PGMQ schema check and post-upgrade verification with `pgbus doctor`) plus per-version breaking changes, see [Upgrading pgbus](https://pgbus.zoolutions.llc/docs/upgrading-pgbus).
141
149
 
142
150
  ### 2. Use as ActiveJob backend
143
151
 
@@ -170,19 +178,23 @@ Publish events with AMQP-style topic routing:
170
178
 
171
179
  ```ruby
172
180
  # Publish an event
173
- Pgbus::EventBus::Publisher.publish(
181
+ Pgbus.publish(
174
182
  "orders.created",
175
183
  { order_id: order.id, total: order.total }
176
184
  )
177
185
 
178
186
  # Publish with delay
179
- Pgbus::EventBus::Publisher.publish_later(
187
+ Pgbus.publish_later(
180
188
  "invoices.due",
181
189
  { invoice_id: invoice.id },
182
190
  delay: 30.days
183
191
  )
184
192
  ```
185
193
 
194
+ `Pgbus.publish` / `Pgbus.publish_later` are top-level shortcuts for
195
+ `Pgbus::EventBus::Publisher.publish` / `.publish_later` (symmetric with
196
+ `Pgbus.stream`). The long form still works.
197
+
186
198
  Subscribe with handlers:
187
199
 
188
200
  ```ruby
@@ -297,36 +309,33 @@ end
297
309
 
298
310
  #### Lock lifecycle
299
311
 
300
- The lock is **never released by a timer**. It is held as long as the job exists in the system:
312
+ The lock is **never released by a timer**. It is held as long as the job's message exists in the queue:
301
313
 
302
314
  ```text
303
- Enqueue ──→ pgbus_job_locks (state: queued, owner_pid: nil)
315
+ Enqueue ──→ pgbus_uniqueness_keys (lock_key, queue_name, msg_id)
304
316
 
305
- Worker picks up job
306
-
307
-
308
- claim_for_execution! (state: executing, owner_pid: PID)
317
+ Worker picks up and runs the job
309
318
 
310
319
  ┌───────┴───────┐
311
320
  ▼ ▼
312
321
  Success Crash
313
- release! (lock orphaned)
314
- (row deleted)
322
+ release! (lock orphaned
323
+ (row deleted) message still tracked
324
+ by lock_key/queue_name/msg_id)
325
+
315
326
 
316
327
  Reaper checks:
317
- Is owner_pid in pgbus_processes
318
- with fresh heartbeat?
328
+ Does the referenced message
329
+ still exist in the PGMQ queue?
319
330
 
320
331
  ┌─────┴─────┐
321
- No Yes
332
+ Gone Present
322
333
  ▼ ▼
323
334
  release! (keep lock,
324
- (orphaned) job is running)
335
+ (orphaned) job still in flight)
325
336
  ```
326
337
 
327
- **Crash recovery** works through the reaper (runs every 5 minutes in the dispatcher). It cross-references `owner_pid` in `pgbus_job_locks` against `pgbus_processes` heartbeats. If the owning worker has no fresh heartbeat, the lock is orphaned and releasedthe PGMQ message's visibility timeout will expire and the job will be retried by another worker.
328
-
329
- A last-resort TTL (default 24 hours) handles the case where the entire pgbus supervisor is dead and the reaper itself can't run.
338
+ **Crash recovery** works through the reaper (runs periodically in the dispatcher's `cleanup_job_locks`). It checks whether the message referenced by each lock (`queue_name` + `msg_id`) still exists in the PGMQ queue via `Client#message_exists?`. If the message is gone (delivered, expired, or the queue was truncated) and the lock is older than `2 * visibility_timeout` to avoid racing a lock whose `send_message` hasn't committed yet the lock is released. A lock backed by a message still in the queue is never touched, even if it looks old, so a recurring job that fails and retries can hold its lock for hours.
330
339
 
331
340
  #### Uniqueness vs concurrency controls
332
341
 
@@ -348,8 +357,8 @@ A last-resort TTL (default 24 hours) handles the case where the entire pgbus sup
348
357
  #### Setup
349
358
 
350
359
  ```bash
351
- rails generate pgbus:add_job_locks # Add the migration
352
- rails generate pgbus:add_job_locks --database=pgbus # For separate database
360
+ rails generate pgbus:add_uniqueness_keys # Add the migration
361
+ rails generate pgbus:add_uniqueness_keys --database=pgbus # For separate database
353
362
  ```
354
363
 
355
364
  ### Concurrency controls
@@ -456,6 +465,49 @@ rails generate pgbus:add_queue_states # Add the queue_states migration
456
465
  rails generate pgbus:add_queue_states --database=pgbus # For separate database
457
466
  ```
458
467
 
468
+ ### Client-level circuit breaker (database-down)
469
+
470
+ The circuit breaker above (`Pgbus::CircuitBreaker`) is **per-queue** and persists its pause state in the database — useless when the database itself is down, since its `check_paused` rescues and returns `false`, tripping nothing. `Pgbus::Client::ConnectionHealth` is a **separate, in-memory, process-local** latch owned by `Pgbus::Client` for exactly that failure mode. Do not confuse the two:
471
+
472
+ | | `Pgbus::CircuitBreaker` | `Client::ConnectionHealth` |
473
+ |---|---|---|
474
+ | Scope | Per queue | Per client (whole process) |
475
+ | Trips on | Job execution failures | Consecutive `PGMQ::Errors::ConnectionError` |
476
+ | State lives in | `pgbus_queue_states` (DB) | In-process memory (Mutex-guarded) |
477
+ | Survives restart | Yes | No — resets on process start |
478
+ | Purpose | Isolate a queue whose job code keeps failing | Stop hammering a database that is down |
479
+ | Configuration | `circuit_breaker_enabled` + constants | None — constants only |
480
+
481
+ `ConnectionHealth` trips open after 5 consecutive connection errors across *any* operation. Once open, the read paths (`read_message`, `read_batch`, `read_multi`, `read_batch_prioritized`, `read_grouped*`, `read_with_poll`) fail fast with a new `Pgbus::ConnectionCircuitOpenError` **without checking out a pool connection**. A single half-open probe is admitted after a monotonic backoff (1s base, doubling per re-open, capped at 60s); its success closes and resets the breaker, its failure re-opens it with a doubled window. Enqueues (`send_message`/`send_batch`) are **never** short-circuited — callers must see enqueue failures. `Worker#fetch_messages` rescues `ConnectionCircuitOpenError` before its generic rescue and idles the poll with no `ErrorReporter` call, so a whole-fleet outage produces two log lines total (a `warn` on open, an `info` on close) instead of one per worker per poll.
482
+
483
+ There is no configuration for this breaker — like `Pgbus::CircuitBreaker`'s thresholds, the values rarely need tuning.
484
+
485
+ ### Read timeouts (libpq-native)
486
+
487
+ `config.read_timeout` (default `30` seconds) caps how long a single PGMQ read can block. Reads used to be bounded with Ruby `Timeout.timeout`, which interrupts via `Thread#raise` — that can fire mid-libpq-call and leave a pooled connection corrupted for the next checkout. On a **dedicated connection** (`database_url` or `connection_params`), pgbus now bakes two libpq-native bounds into the connection at boot instead:
488
+
489
+ | Bound | How | Effect |
490
+ |---|---|---|
491
+ | Server-side | `statement_timeout` via `options=-c statement_timeout=<read_timeout>ms` | Postgres cleanly cancels an overrunning query, surfaced as `Pgbus::ReadTimeoutError` |
492
+ | Client-side | `tcp_user_timeout` + `keepalives` (sized `read_timeout + 5s`) | A dead/hung socket makes libpq raise `PG::ConnectionBad` synchronously — no `Thread#raise`, no buffer corruption |
493
+
494
+ The client-side bound only applies on Linux with libpq ≥ 12 (older libpq rejects the `tcp_user_timeout` conninfo keyword; non-Linux hosts no-op it) — detected automatically via `Socket.const_defined?(:TCP_USER_TIMEOUT)` and `PG.library_version`, no configuration needed. Ruby `Timeout` remains only as a narrow last-resort fallback on a dedicated connection where libpq can't bound the socket (non-Linux hosts, or libpq < 12).
495
+
496
+ **The shared-AR `Proc` connection path** (`-> { ActiveRecord::Base.connection.raw_connection }`) gets neither bound automatically — pgbus doesn't own that socket. Configure the same libpq timeouts yourself in `database.yml`; ActiveRecord passes them straight through:
497
+
498
+ ```yaml
499
+ # config/database.yml
500
+ production:
501
+ primary:
502
+ <<: *default
503
+ variables:
504
+ statement_timeout: 30000 # ms — match config.read_timeout
505
+ # tcp_user_timeout / keepalives: set via the connection URL or driver/OS
506
+ # defaults; ActiveRecord passes libpq conninfo options straight through.
507
+ ```
508
+
509
+ A custom RuboCop cop, `Pgbus/NoRubyTimeout`, bans `Timeout.timeout` project-wide to prevent this class of bug from regressing.
510
+
459
511
  ### Prefetch flow control
460
512
 
461
513
  Cap the number of in-flight (claimed but unfinished) messages per worker:
@@ -731,6 +783,25 @@ Reporters are wired into all critical rescue paths: job execution failures, work
731
783
 
732
784
  `ErrorReporter.report` is guaranteed to never raise — if a reporter or the logger itself throws, the error is swallowed silently. This preserves fault-tolerance invariants at every rescue site.
733
785
 
786
+ ### Error hierarchy
787
+
788
+ Every operational error Pgbus raises descends from `Pgbus::Error`, so a single rescue catches them all:
789
+
790
+ ```ruby
791
+ begin
792
+ Pgbus.configure { |c| c.visibility_timeout = 0 }
793
+ rescue Pgbus::Error => e
794
+ # ConfigurationError, EnqueueError, ExecutionPoolError, SerializationError,
795
+ # QueueNotFoundError, SchemaNotReady, ... all land here
796
+ end
797
+ ```
798
+
799
+ The named subclasses (a partial list): `Pgbus::ConfigurationError` (`validate!` and config setters), `Pgbus::SerializationError` (payload / GlobalID rejection), `Pgbus::EnqueueError` (batch enqueue integrity), `Pgbus::ExecutionPoolError` (pool shutting down / at capacity), `Pgbus::QueueNotFoundError`, `Pgbus::DeadLetterError`, `Pgbus::JobNotUnique`, `Pgbus::SchemaNotReady`, `Pgbus::ReadTimeoutError`, and `Pgbus::ConnectionCircuitOpenError`.
800
+
801
+ **One deliberate exception to the rule:** errors that reject a malformed *argument shape* stay `ArgumentError` subclasses, because that's what `ArgumentError` means — `Pgbus::Streams::StreamNameTooLong`, `Pgbus::Streams::Cursor::InvalidCursor`, and `Pgbus::Configuration::CapsuleDSL::ParseError`. Rescue those with `ArgumentError` (or the specific class), not `Pgbus::Error`.
802
+
803
+ If you upgraded from 0.9.x and previously did `rescue ArgumentError` around `Pgbus.configure`, switch it to `rescue Pgbus::Error` — config validation now raises `Pgbus::ConfigurationError`, which is not an `ArgumentError`.
804
+
734
805
  ### AppSignal integration
735
806
 
736
807
  When the `appsignal` gem is loaded in your app, Pgbus auto-installs a subscriber and a minutely probe that report into AppSignal:
@@ -792,7 +863,8 @@ c.metrics_backend = MyOpenTelemetryBackend.new
792
863
  | `pgbus_stream_broadcast_count` | counter | `stream`, `deferred` |
793
864
  | `pgbus_outbox_published` | counter | `kind` |
794
865
  | `pgbus_recurring_enqueued` | counter | `task`, `class_name` |
795
- | `pgbus_worker_recycled` | counter | `reason` |
866
+ | `pgbus_worker_recycled` | counter | `reason`, `kind` (`worker`/`consumer`) |
867
+ | `pgbus_pool_size` / `pgbus_pool_available` | gauge | `hostname` |
796
868
 
797
869
  A backend that raises (registry bug, StatsD socket down) is logged and swallowed — a metrics failure never propagates into the thread that emitted the event.
798
870
 
@@ -820,7 +892,24 @@ ActiveSupport::Notifications.subscribe(/^pgbus\./) do |name, start, finish, _id,
820
892
  end
821
893
  ```
822
894
 
823
- Events emitted: `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
895
+ Metrics-relevant events emitted (the subset the metrics subscriber maps; see `lib/pgbus/instrumentation.rb` for the full `pgbus.*` catalog): `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.client.pool`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`, `pgbus.consumer.recycle`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
896
+
897
+ ### Connection pool metrics
898
+
899
+ The PGMQ connection pool was previously invisible — pgmq-ruby exposes `size`/`available` counters, but nothing read them, so the first sign of an undersized or leaking pool was an opaque `PGMQ::Errors::ConnectionError: Connection pool timeout`. `Pgbus::Client#pool_stats` surfaces the live counters:
900
+
901
+ ```ruby
902
+ Pgbus.client.pool_stats
903
+ # => { size: 10, available: 3, pool_timeout: 5 }
904
+ ```
905
+
906
+ It's purely observational (rescues internally to `{}`), so reading the pool can never break job processing, and it works on both the dedicated-pool path and the shared-`Proc` connection path (`size` reports `1` there). The worker heartbeat emits a `pgbus.client.pool` instrumentation event once per beat (never on a per-job hot path) carrying that payload, and the AppSignal minutely probe reports `pgbus_pool_size` / `pgbus_pool_available` gauges tagged by hostname (the pool is per-process, unlike the cluster-wide queue/summary gauges).
907
+
908
+ A pool-timeout error is re-raised as the same `PGMQ::Errors::ConnectionError` class (so `with_stale_connection_retry` semantics and non-retryability are unchanged) with the live pool state and an actionable hint appended:
909
+
910
+ ```
911
+ Connection pool timeout (pool {size: 10, available: 0, pool_timeout: 5}) — raise Pgbus.configuration.pool_size or reduce worker threads
912
+ ```
824
913
 
825
914
  ### Structured logging
826
915
 
@@ -1055,6 +1144,24 @@ readinessProbe:
1055
1144
  httpGet: { path: /readyz, port: 9394 }
1056
1145
  ```
1057
1146
 
1147
+ ### Boot diagnostics banner
1148
+
1149
+ `Supervisor#run` logs a one-block banner right after the heartbeat starts and before queues bootstrap, so a misconfigured deployment states its actual settings instead of forcing an operator to attach a console. Every line is `"[Pgbus] boot:"`-prefixed and renders cleanly under both the `:text` and `:json` log formatters:
1150
+
1151
+ ```
1152
+ [Pgbus] boot: pgbus 0.9.8 pid=42317
1153
+ [Pgbus] boot: connection=host/dbname pool=12
1154
+ [Pgbus] boot: pgmq_schema_mode=auto pgmq_version=1.4.0
1155
+ [Pgbus] boot: listen_notify=true worker_notify_wakeup=true
1156
+ [Pgbus] boot: roles=workers,dispatcher,scheduler
1157
+ [Pgbus] boot: capsule=critical queues=critical threads=5 mode=threads
1158
+ [Pgbus] boot: capsule=default queues=default,mailers threads=10 mode=threads
1159
+ ```
1160
+
1161
+ It states: the pgbus version; the connection target reduced to `host/dbname` (never the password) across all three `connection_options` forms (`database_url` string, `connection_params` hash, AR-derived hash); the resolved pool size; `pgmq_schema_mode` and the installed PGMQ version (best-effort — `unknown` on any error); `listen_notify` and `worker_notify_wakeup?`; the roles that will actually boot (honoring `config.roles`); and one line per worker capsule (name, queues, threads, execution mode) and per event consumer (topics, threads).
1162
+
1163
+ Every DB-dependent field is wrapped so a transient failure degrades that field to `unknown` — the banner can never abort boot.
1164
+
1058
1165
  ## Real-time broadcasts (turbo-streams replacement)
1059
1166
 
1060
1167
  Pgbus ships a drop-in replacement for turbo-rails' `turbo_stream_from` helper that fixes several well-known ActionCable correctness bugs by using PGMQ message IDs as a replay cursor. Same API as turbo-rails. No Redis. No ActionCable. No lost messages on reconnect.
@@ -1172,6 +1279,20 @@ Pgbus.stream_key(chat, :messages) # => "ai_chat_a3f8c1e9d2b47610_messages"
1172
1279
 
1173
1280
  The budget is computed from `config.queue_prefix` at call time so prefix overrides adjust automatically. If a stream name exceeds the budget, `Pgbus::Streams::StreamNameTooLong` is raised immediately with the offending name, computed budget, and a pointer to `Pgbus.stream_key` — before PGMQ is ever touched.
1174
1281
 
1282
+ #### `stream_key` idempotency
1283
+
1284
+ A single `String` argument to `Pgbus.stream_key` is treated as an already-built pgbus stream key and returned unchanged (after the queue-name budget check), instead of tripping the colon-separator guard. This lets a consumer hold one `stream_key` value and pass it to both `pgbus_stream_from` and the broadcaster without a second call raising `ArgumentError`:
1285
+
1286
+ ```ruby
1287
+ key = Pgbus.stream_key(chat, :messages) # => "ai_chat_a3f8c1e9d2b47610:messages"
1288
+
1289
+ # Both calls accept the same pre-built key without raising:
1290
+ pgbus_stream_from(key)
1291
+ Pgbus.stream(key).broadcast(html)
1292
+ ```
1293
+
1294
+ `Pgbus.stream_key!(key)` accepts a pre-built key explicitly (`String` required, budget still enforced) for call sites that want to be explicit that no re-keying should happen. The guard still fires for the genuinely ambiguous multi-fragment join (`stream_key("a:b", :c)` — colliding with `stream_key("a", "b:c")`), and a `Symbol`/record fragment containing a colon is still rejected (a colon there never came from `stream_key`).
1295
+
1175
1296
  ### Transactional broadcasts
1176
1297
 
1177
1298
  **This is the feature no other Rails real-time stack can offer.** A broadcast issued inside an open ActiveRecord transaction is deferred until the transaction commits. If it rolls back, the broadcast silently drops — clients never see the change that the database never persisted.
@@ -1210,6 +1331,29 @@ The replay cap is applied server-side: the helper computes `since_id = max(0, cu
1210
1331
 
1211
1332
  How much history is actually available depends on the stream's retention setting (`streams_retention` or `streams_default_retention`, both in seconds). A chat stream configured with `streams_retention = { /^chat_/ => 7.days }` will replay up to seven days of history with `replay: :all`; a notification stream with the 5-minute default will only go back five minutes.
1212
1333
 
1334
+ ### `msg_id` reconciliation for optimistic UI
1335
+
1336
+ Every delivered frame carries its monotonic PGMQ `msg_id` as the SSE `id:` line — the same watermark that powers reconnect replay. `<pgbus-stream-source>` surfaces it to the client two ways: the standard `message` `MessageEvent` sets `lastEventId` to the msg_id (Turbo ignores it; a reactive runtime listening for `message` reads the revision with no pgbus-specific API), and a `pgbus:message` `CustomEvent` carries `{ msgId, data }` (`msgId` is a `Number` when numeric; a negative value marks an ephemeral frame that bypassed PGMQ rather than a durable, archived one).
1337
+
1338
+ **The reconciliation recipe:** track the highest applied `msgId` per render target; when a frame arrives, skip the morph if you've already applied a newer revision for that target. This stops a late echo — a broadcast that was in flight when a newer one landed — from clobbering a newer optimistic edit:
1339
+
1340
+ ```js
1341
+ const appliedRevision = new Map() // target -> highest applied msgId
1342
+
1343
+ document.addEventListener("pgbus:message", (event) => {
1344
+ const { msgId, data } = event.detail
1345
+ const target = extractTargetFrom(data) // however your markup encodes it
1346
+
1347
+ const highest = appliedRevision.get(target) ?? -Infinity
1348
+ if (msgId != null && msgId < highest) return // stale — skip the morph
1349
+
1350
+ appliedRevision.set(target, msgId)
1351
+ applyMorph(target, data)
1352
+ })
1353
+ ```
1354
+
1355
+ This complements `exclude:` (below): `exclude:` handles the actor (never receives its own echo at all); msg_id reconciliation handles out-of-order delivery for everyone else.
1356
+
1213
1357
  ### Server-side audience filtering
1214
1358
 
1215
1359
  Some broadcasts shouldn't reach every subscriber on a stream. Pgbus supports per-connection filtering via a registry of named predicates evaluated against each connection's authorize-hook context:
@@ -1247,6 +1391,95 @@ Failure semantics:
1247
1391
 
1248
1392
  The filter registry is process-local. Each Puma worker (or Falcon reactor) has its own copy populated at boot. Filter predicates run **on the subscriber side** — the predicate itself can't be serialized through PGMQ, so the broadcast carries only the label name.
1249
1393
 
1394
+ ### Actor-echo suppression (`exclude:`)
1395
+
1396
+ An actor who just triggered a change already applied it via the HTTP response of their own action. If the resulting broadcast reaches their own SSE connection too, it double-applies — re-running animations or clobbering an optimistic edit. Pass `exclude:` with a connection id to skip delivery to that one connection; everyone else still gets the broadcast:
1397
+
1398
+ ```ruby
1399
+ Pgbus.stream(room).broadcast(html, exclude: connection_id)
1400
+ ```
1401
+
1402
+ Every server-minted SSE connection exposes its id to the page: right after the open handshake, pgbus sends a `pgbus:connected` frame carrying the connection id, and `<pgbus-stream-source>` captures it onto its `connection-id` attribute and re-dispatches it as a `pgbus:connected` event (also present in `pgbus:open`'s detail). The page reads that id and sends it back as the `X-Pgbus-Connection` header on the action request that triggers the broadcast:
1403
+
1404
+ ```js
1405
+ document.addEventListener("pgbus:connected", (event) => {
1406
+ document.querySelector("meta[name='pgbus-connection-id']")
1407
+ ?.setAttribute("content", event.detail.connectionId)
1408
+ })
1409
+
1410
+ // On the next fetch/form submit:
1411
+ fetch(url, {
1412
+ method: "POST",
1413
+ headers: { "X-Pgbus-Connection": connectionIdMetaTag() }
1414
+ })
1415
+ ```
1416
+
1417
+ ```ruby
1418
+ def create
1419
+ @message = @room.messages.create!(message_params)
1420
+ @room.broadcast_append_to(:messages, exclude: request.headers["X-Pgbus-Connection"])
1421
+ end
1422
+ ```
1423
+
1424
+ A nil or blank `exclude:` is a no-op — the common path for background jobs and other server-initiated broadcasts with no originating connection. Reuses the existing per-connection delivery (Filters) path.
1425
+
1426
+ ### `broadcast_render` — render and broadcast in one call
1427
+
1428
+ `Stream#broadcast_render` renders a Phlex component, a ViewComponent, or a pre-rendered HTML string into a complete `<turbo-stream>` action tag and broadcasts it atomically — removing the off-request render + tag-building boilerplate (and the easy-to-get-wrong view context) from every call site:
1429
+
1430
+ ```ruby
1431
+ Pgbus.stream("chat", room).broadcast_render(
1432
+ renderable: Chat::Message.new(chat_message: msg),
1433
+ action: :append,
1434
+ target: "chat-messages-#{room}",
1435
+ exclude: connection_id # composes with #exclude
1436
+ )
1437
+ ```
1438
+
1439
+ `action` defaults to `:replace`; `target:` is required. The renderable is resolved via `String` → `#call` (Phlex) → `#render_in` (ViewComponent; called with a `nil` view context since there's no controller off-request) → `#to_s`. Content-less actions (`remove`) emit no `<template>` wrapper. `exclude:`, `visible_to:`, `durable:`, `event:`, and `coalesce:` all forward to `#broadcast` unchanged, so actor-echo suppression and audience filtering compose. A component that needs URL helpers or a full view context should be rendered by the app and the resulting string passed as `renderable:`.
1440
+
1441
+ ### Typed SSE event names (`event:`)
1442
+
1443
+ A broadcast can set the SSE `event:` field while keeping the payload a Turbo Stream, so clients route on a typed name instead of sniffing the HTML:
1444
+
1445
+ ```ruby
1446
+ Pgbus.stream(name).broadcast(html, event: "presence")
1447
+ Pgbus.stream(name).broadcast_render(renderable: component, target: "cursor", event: "reactive")
1448
+ ```
1449
+
1450
+ The default (`nil` or `"turbo-stream"`) is omitted from the JSONB payload to avoid redundancy, but is still set on the SSE frame's `event:` line (falling back to `turbo-stream`), so default consumers still get the standard `message`/turbo-stream path.
1451
+
1452
+ On the client, `<pgbus-stream-source>` dispatches a typed broadcast two ways: a generic `pgbus:event` (`{ event, data, msgId }`) for one listener that handles every typed event, and a named `pgbus:<event>` (`{ data, msgId }`) for `addEventListener("pgbus:presence", …)` ergonomics:
1453
+
1454
+ ```js
1455
+ document.addEventListener("pgbus:presence", (event) => {
1456
+ const { data, msgId } = event.detail
1457
+ // ...
1458
+ })
1459
+ ```
1460
+
1461
+ Native `EventSource` (the reconnect path) only invokes listeners registered by name, so declare every typed event name you use on the element's `listen-events` attribute (comma- or space-separated) — otherwise a typed broadcast is silently dropped after a reconnect, even though it worked on the first connection (which uses `fetch()` and routes any event generically):
1462
+
1463
+ ```erb
1464
+ <%= pgbus_stream_from @room, "listen-events": "presence reactive" %>
1465
+ ```
1466
+
1467
+ ### `coalesce:` — publish-side debounce
1468
+
1469
+ A chatty component — a live cursor, a typing indicator, a progress bar — can fan out many small broadcasts per second. Pass `coalesce:` (a window in milliseconds, or `true` for the 50ms default) together with `target:` to batch broadcasts per `(stream, target)` and publish only the *latest* frame within the window:
1470
+
1471
+ ```ruby
1472
+ Pgbus.stream(name).broadcast_render(
1473
+ renderable: CursorPosition.new(x:, y:),
1474
+ target: "cursor-#{user_id}",
1475
+ coalesce: true # or coalesce: 100 for a 100ms window
1476
+ )
1477
+ ```
1478
+
1479
+ Superseded frames never hit the bus at all — no PGMQ insert, no NOTIFY, no fan-out. This is last-write-wins, so it's only safe for **idempotent replace/update of a stable target** (exactly the high-frequency case above) — never for actions where every intermediate frame matters (an `append` to a running log, for instance).
1480
+
1481
+ Semantics: the first submit for a `(stream, target)` schedules the flush one window later; every subsequent submit within that window only overwrites the buffered payload. Latency is bounded to one window (trailing-edge-with-max-wait, not a resettable debounce). The flush re-enters the normal broadcast path, so a coalesced frame still composes with `visible_to:`, `exclude:`, `event:`, and `durable:`. Coalescing is process-wide and in-memory — behind multiple Puma workers or Falcon processes, each process debounces its own submissions independently.
1482
+
1250
1483
  ### Presence
1251
1484
 
1252
1485
  Pgbus tracks who is currently subscribed to a stream via a `pgbus_presence_members` table. This is the standard "X people are in this room" feature that chat apps and collaboration tools need:
@@ -1295,12 +1528,35 @@ Pgbus.stream(@room).presence.sweep!(older_than: 60.seconds.ago)
1295
1528
 
1296
1529
  The sweep uses `DELETE ... RETURNING` so multiple workers running it concurrently won't double-emit leave events.
1297
1530
 
1298
- **Deliberately left to the application**:
1531
+ **Deliberately left to the application (manual API)**:
1299
1532
 
1300
- - Join/leave is explicit, not connection-driven. The controller decides who is "present" — a connected SSE client is not always a present user (think tab-in-background, multi-tab dedup).
1533
+ - Join/leave is explicit, not connection-driven, unless you opt into connection-driven presence below. The controller decides who is "present" — a connected SSE client is not always a present user (think tab-in-background, multi-tab dedup).
1301
1534
  - The stale-member sweep is manual. Run it from a cron, an ActiveJob, or your existing heartbeat — pgbus does not assume one over the others.
1302
1535
  - The DOM markup for join/leave is whatever your `join`/`leave` block returns. Pgbus does not impose a fixed presence schema on `<pgbus-stream-source>`.
1303
1536
 
1537
+ #### Connection-driven presence (opt-in)
1538
+
1539
+ Streams matching `config.streams_presence_patterns` (an exact string or a `Regexp`, mirroring `streams_durable_patterns`) automatically join a member when an SSE connection opens, leave when it closes, and refresh `last_seen_at` on every keepalive heartbeat tick — no explicit `join`/`leave`/sweeper calls required:
1540
+
1541
+ ```ruby
1542
+ Pgbus.configure do |c|
1543
+ c.streams_presence_patterns = [/^room:/, "lobby"]
1544
+ end
1545
+ ```
1546
+
1547
+ Identity comes from the connection's authorize-hook context (the value your `StreamApp` `authorize:` callable returns). The built-in extractor handles the common shapes without any configuration: a `Hash` with `:member_id` (or `:id`) and optional `:metadata`, or any object responding to `#id` (e.g. a `User` model). For anything else, provide a custom extractor — a `->(context) { { id:, metadata: } }` callable returning `nil` for a context with no derivable identity (anonymous connections are simply skipped, not an error):
1548
+
1549
+ ```ruby
1550
+ Pgbus.configure do |c|
1551
+ c.streams_presence_patterns = [/^room:/]
1552
+ c.streams_presence_member = ->(user) {
1553
+ { id: user.id, metadata: { name: user.name, avatar: user.avatar_url } } if user
1554
+ }
1555
+ end
1556
+ ```
1557
+
1558
+ Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched touch per tick. Presence failures are logged and swallowed so a presence-table hiccup can't knock a live SSE connection out of the registry.
1559
+
1304
1560
  ### Stream stats (opt-in)
1305
1561
 
1306
1562
  Pgbus can record one row in `pgbus_stream_stats` per broadcast, connect, and disconnect so the `/pgbus/insights` dashboard shows stream throughput alongside job throughput. This is **disabled by default** because stream event volume can dwarf job volume in chat-style apps — enable it deliberately when you want the observability.
@@ -1562,10 +1818,48 @@ Day-to-day running of Pgbus: starting and stopping processes, observing what is
1562
1818
  pgbus start # Start supervisor with workers + dispatcher + scheduler
1563
1819
  pgbus status # Show running processes
1564
1820
  pgbus queues # List queues with depth/metrics
1821
+ pgbus dlq # Inspect and drain dead-letter queues (see below)
1822
+ pgbus doctor # Preflight diagnostics; exits 1 on any failed check (see below)
1823
+ pgbus mcp # Start the read-only MCP diagnostic server over stdio
1565
1824
  pgbus version # Print version
1566
1825
  pgbus help # Show help
1567
1826
  ```
1568
1827
 
1828
+ #### pgbus doctor
1829
+
1830
+ A single preflight command that answers "is this environment healthy enough to run?" — useful as a deploy or CI gate. It runs six checks and never raises; a broken environment turns every probe into a failed/warned check instead of a crash:
1831
+
1832
+ | Check | Fails (`:fail`) when | Warns (`:warn`) when |
1833
+ |---|---|---|
1834
+ | Configuration | `Configuration#validate!` raises | — |
1835
+ | Database | Unreachable (`SELECT 1` via `Client#ping`) | — |
1836
+ | PGMQ schema | Schema not installed | Installed but untracked, or behind the vendored version |
1837
+ | Queues | A configured queue has no PGMQ table | — |
1838
+ | LISTEN/NOTIFY | — | A configured queue is missing its insert trigger (falls back to polling) |
1839
+ | Process liveness | Verdict is `STALLED` | Verdict is `DEGRADED` |
1840
+
1841
+ ```bash
1842
+ pgbus doctor # prints the report; exit 1 unless every check passed
1843
+ rake pgbus:doctor # identical checks, for a Rake-based deploy pipeline
1844
+ ```
1845
+
1846
+ 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.
1847
+
1848
+ #### pgbus dlq
1849
+
1850
+ 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:
1851
+
1852
+ | Subcommand | Does |
1853
+ |---|---|
1854
+ | `pgbus dlq list [--page N] [--per-page N]` | Table of msg_id / DLQ queue / origin queue / read_ct / enqueued_at, plus a total count. Never prints payloads. |
1855
+ | `pgbus dlq show MSG_ID` | Message metadata plus the payload, filtered through the dashboard's sensitive-data filter. |
1856
+ | `pgbus dlq retry MSG_ID` | Re-enqueue one message to its origin queue. |
1857
+ | `pgbus dlq retry-all` | Re-enqueue every dead-letter message; prints the count. |
1858
+ | `pgbus dlq purge MSG_ID` | Discard a single message. |
1859
+ | `pgbus dlq purge --all --yes` | Discard every dead-letter message. Omitting `--yes` makes no changes and exits 1. |
1860
+
1861
+ An unknown `msg_id` or an unknown subcommand exits 1.
1862
+
1569
1863
  #### Role flags (split deployments)
1570
1864
 
1571
1865
  By default, `pgbus start` boots every role in one supervisor (workers, dispatcher, scheduler, event consumers, outbox poller). For containerized deployments where each role lives in a separate process, use the role flags:
@@ -1597,7 +1891,7 @@ The dashboard is a mountable Rails engine at `/pgbus` with:
1597
1891
  - **Queues** — per-queue metrics, purge/pause/resume/delete actions
1598
1892
  - **Jobs** — enqueued and failed jobs, retry/discard actions
1599
1893
  - **Dead letter** — DLQ messages with retry/discard, bulk actions
1600
- - **Processes** — active workers/dispatcher/consumers with heartbeat status
1894
+ - **Processes** — active workers/dispatcher/consumers with heartbeat status **and per-worker throughput** (e.g. `12.4/s processed · 0.2/s failed`)
1601
1895
  - **Events** — registered subscribers and processed events
1602
1896
  - **Outbox** — transactional outbox entries pending publication
1603
1897
  - **Locks** — active job uniqueness locks with state (queued/executing), owner PID@hostname, age
@@ -1605,6 +1899,8 @@ The dashboard is a mountable Rails engine at `/pgbus` with:
1605
1899
 
1606
1900
  All tables use Turbo Frames for periodic auto-refresh without page reloads. Destructive actions use styled confirmation dialogs (not browser `confirm()`), and flash messages appear as auto-dismissing toast notifications.
1607
1901
 
1902
+ Each worker snapshots its live in-process rate counter (dequeued/processed/failed) into its heartbeat metadata on every beat, so the Processes panel shows a cluster-wide, near-real-time throughput view with no extra query. Zero rates are omitted from the rendered string. Non-worker processes (dispatcher, scheduler, consumers) carry no rate data and render only their static boot metadata, unchanged.
1903
+
1608
1904
  #### Queue management
1609
1905
 
1610
1906
  The queues page lets you manage PGMQ queues directly:
@@ -1655,7 +1951,7 @@ Pgbus uses these tables (created via PGMQ and migrations):
1655
1951
  | `pgbus_semaphores` | Concurrency control counting semaphores |
1656
1952
  | `pgbus_blocked_executions` | Jobs waiting for a concurrency semaphore slot |
1657
1953
  | `pgbus_batches` | Batch tracking with job counters and callback config |
1658
- | `pgbus_job_locks` | Job uniqueness locks (state, owner_pid, reaper correlation) |
1954
+ | `pgbus_uniqueness_keys` | Job uniqueness locks (lock_key, queue_name, msg_id) |
1659
1955
  | `pgbus_job_stats` | Job execution metrics (class, queue, status, duration) |
1660
1956
  | `pgbus_queue_states` | Queue pause/resume and circuit breaker state |
1661
1957
  | `pgbus_outbox_entries` | Transactional outbox entries pending publication |
@@ -1730,6 +2026,7 @@ PostgreSQL + PGMQ
1730
2026
  | `prefetch_limit` | `nil` | Max in-flight messages per worker (nil = unlimited) |
1731
2027
  | `dispatch_interval` | `1.0` | Seconds between dispatcher maintenance ticks |
1732
2028
  | `circuit_breaker_enabled` | `true` | Enable auto-pause on consecutive failures (threshold and backoff are tuned via `Pgbus::CircuitBreaker` constants) |
2029
+ | `read_timeout` | `30` | Seconds before a single PGMQ read is bounded (libpq `statement_timeout` + `tcp_user_timeout` on a dedicated connection; nil disables) |
1733
2030
  | `priority_levels` | `nil` | Number of priority sub-queues (nil = disabled, 2-10) |
1734
2031
  | `default_priority` | `1` | Default priority for jobs without explicit priority |
1735
2032
  | `archive_retention` | `7.days` | How long to keep archived messages. Accepts seconds, Duration, or `nil` to disable cleanup |
@@ -1757,6 +2054,9 @@ PostgreSQL + PGMQ
1757
2054
  | `metrics_backend` | `nil` | Generic metrics adapter: `nil` (off), `:prometheus`, `:statsd`, or a `Pgbus::Metrics::Backend` instance |
1758
2055
  | `statsd_host` | `"127.0.0.1"` | StatsD UDP host (used when `metrics_backend = :statsd`) |
1759
2056
  | `statsd_port` | `8125` | StatsD UDP port (used when `metrics_backend = :statsd`) |
2057
+ | `health_port` | `nil` | Port for standalone HTTP liveness/readiness probes served by the supervisor; nil disables |
2058
+ | `health_bind` | `"127.0.0.1"` | Bind address for the standalone health server |
2059
+ | `eager_validation` | `true` | Run `Configuration#validate!` automatically after `Pgbus.configure` / `ConfigLoader.apply`; an invalid value raises `Pgbus::ConfigurationError` at boot. Set `false` to suppress and validate manually. |
1760
2060
 
1761
2061
  ## Development
1762
2062
 
@@ -1783,6 +2083,17 @@ PGBUS_DATABASE_URL=postgres://user@host/db bundle exec rake bench:streams
1783
2083
 
1784
2084
  The harness measures single-broadcast roundtrip latency, burst throughput, fanout to many clients, and concurrent connect under thundering herd. See `benchmarks/streams_bench.rb`.
1785
2085
 
2086
+ ### Dependency watch
2087
+
2088
+ A nightly GitHub Action (`.github/workflows/dependency-watch.yml`) compares the
2089
+ vendored PGMQ SQL (`lib/pgbus/pgmq_schema/pgmq_v*.sql`) and the locked
2090
+ `pgmq-ruby` gem version against their upstream releases, opening a GitHub issue
2091
+ on drift (deduplicated by exact title, so repeat runs are a no-op). It never
2092
+ auto-vendors SQL or auto-bumps the gem -- it only notifies. Set the optional
2093
+ `SLACK_WEBHOOK_URL` repository secret to also post drift notifications to
2094
+ Slack; the workflow logs and skips that step gracefully when the secret is
2095
+ unset.
2096
+
1786
2097
  ## License
1787
2098
 
1788
2099
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -6,13 +6,13 @@ require_relative "migration_path"
6
6
 
7
7
  module Pgbus
8
8
  module Generators
9
- class AddJobLocksGenerator < Rails::Generators::Base
9
+ class AddUniquenessKeysGenerator < Rails::Generators::Base
10
10
  include ActiveRecord::Generators::Migration
11
11
  include MigrationPath
12
12
 
13
13
  source_root File.expand_path("templates", __dir__)
14
14
 
15
- desc "Add job locks table for uniqueness guarantees"
15
+ desc "Add uniqueness keys table for ensures_uniqueness support"
16
16
 
17
17
  class_option :database,
18
18
  type: :string,
@@ -20,13 +20,13 @@ module Pgbus
20
20
  desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
21
21
 
22
22
  def create_migration_file
23
- migration_template "add_job_locks.rb.erb",
24
- File.join(pgbus_migrate_path, "add_pgbus_job_locks.rb")
23
+ migration_template "add_uniqueness_keys.rb.erb",
24
+ File.join(pgbus_migrate_path, "add_pgbus_uniqueness_keys.rb")
25
25
  end
26
26
 
27
27
  def display_post_install
28
28
  say ""
29
- say "Pgbus job locks table installed!", :green
29
+ say "Pgbus uniqueness keys table installed!", :green
30
30
  say ""
31
31
  say "Next steps:"
32
32
  say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
@@ -151,7 +151,7 @@ module Pgbus
151
151
  msg_ids = Pgbus.client.send_batch(queue, payloads)
152
152
 
153
153
  unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
154
- raise "Pgbus batch enqueue failed: expected #{jobs.size} ids, got #{msg_ids&.size || 0}"
154
+ raise Pgbus::EnqueueError, "Pgbus batch enqueue failed: expected #{jobs.size} ids, got #{msg_ids&.size || 0}"
155
155
  end
156
156
 
157
157
  jobs.zip(msg_ids).each { |job, id| job.provider_job_id = id }
@@ -16,14 +16,41 @@ module Pgbus
16
16
  # alone can't tell them apart, so a flat file silently lost its typo
17
17
  # warnings whenever no env section happened to match its keys.
18
18
  if sectioned?(parsed)
19
- apply(parsed.fetch(env)) if parsed.key?(env)
19
+ if parsed.key?(env)
20
+ apply(parsed.fetch(env))
21
+ else
22
+ warn_missing_env_section(parsed, env)
23
+ end
20
24
  else
21
25
  apply(parsed)
22
26
  end
23
27
  end
24
28
 
29
+ # A file is "sectioned" (top-level keys are env names, e.g. development:/
30
+ # production:) rather than "flat" (top-level keys are Configuration
31
+ # setters) when every top-level value is a Hash AND none of the
32
+ # top-level keys is itself a real Configuration setter. The setter check
33
+ # is what distinguishes the two: env section names (production, staging,
34
+ # ...) are never real setters, so a legitimate flat file whose settings
35
+ # all happen to be Hash-valued (connection_params:, streams_retention:,
36
+ # recurring_tasks:, ...) is correctly classified as flat instead of being
37
+ # misread as sectioned-with-no-matching-env and silently skipped.
25
38
  def sectioned?(parsed)
26
- parsed.is_a?(Hash) && parsed.any? && parsed.values.all?(Hash)
39
+ return false unless parsed.is_a?(Hash) && parsed.any? && parsed.values.all?(Hash)
40
+
41
+ config = Pgbus.configuration
42
+ parsed.keys.none? { |key| config.respond_to?(:"#{key}=") }
43
+ end
44
+
45
+ # A sectioned file with no section for the current env has nothing to
46
+ # apply for this process — that's a real gap (typo'd env name, or a
47
+ # config file that just doesn't cover this deployment), so it must be
48
+ # loud instead of a silent no-op.
49
+ def warn_missing_env_section(parsed, env)
50
+ Pgbus.configuration.logger.warn do
51
+ "[Pgbus] No configuration section for env #{env.inspect} — " \
52
+ "available sections: #{parsed.keys.inspect}. Nothing was applied."
53
+ end
27
54
  end
28
55
 
29
56
  def apply(hash, warn_unknown: true)