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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +329 -0
- data/README.md +336 -25
- data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
- data/lib/pgbus/active_job/adapter.rb +1 -1
- data/lib/pgbus/config_loader.rb +29 -2
- data/lib/pgbus/configuration.rb +202 -73
- data/lib/pgbus/doctor.rb +28 -3
- data/lib/pgbus/execution_pools/async_pool.rb +2 -2
- data/lib/pgbus/generators/config_converter.rb +7 -5
- data/lib/pgbus/generators/migration_detector.rb +20 -16
- data/lib/pgbus/instrumentation.rb +2 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
- data/lib/pgbus/metrics/subscriber.rb +26 -2
- data/lib/pgbus/metrics.rb +3 -2
- data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
- data/lib/pgbus/pgmq_schema.rb +7 -2
- data/lib/pgbus/process/consumer.rb +141 -12
- data/lib/pgbus/process/supervisor.rb +33 -10
- data/lib/pgbus/process/worker.rb +6 -16
- data/lib/pgbus/recurring/schedule.rb +1 -2
- data/lib/pgbus/serializer.rb +4 -4
- data/lib/pgbus/streams/broadcastable_override.rb +0 -8
- data/lib/pgbus/streams/signed_name.rb +2 -2
- data/lib/pgbus/uniqueness.rb +11 -12
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +20 -4
- data/lib/pgbus/web/payload_filter.rb +3 -3
- data/lib/pgbus/web/streamer/instance.rb +6 -4
- data/lib/pgbus/web/streamer/listener.rb +21 -36
- data/lib/pgbus.rb +46 -6
- metadata +4 -6
- data/app/models/pgbus/job_lock.rb +0 -98
- data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
- data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +0 -42
- 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
|
|
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
|
|
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
|
|
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 ──→
|
|
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
|
-
|
|
318
|
-
|
|
328
|
+
Does the referenced message
|
|
329
|
+
still exist in the PGMQ queue?
|
|
319
330
|
│
|
|
320
331
|
┌─────┴─────┐
|
|
321
|
-
|
|
332
|
+
Gone Present
|
|
322
333
|
▼ ▼
|
|
323
334
|
release! (keep lock,
|
|
324
|
-
(orphaned) job
|
|
335
|
+
(orphaned) job still in flight)
|
|
325
336
|
```
|
|
326
337
|
|
|
327
|
-
**Crash recovery** works through the reaper (runs
|
|
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:
|
|
352
|
-
rails generate pgbus:
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
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 "
|
|
24
|
-
File.join(pgbus_migrate_path, "
|
|
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
|
|
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 }
|
data/lib/pgbus/config_loader.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|