pgbus 0.9.7 โ 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 +367 -0
- data/README.md +454 -25
- data/Rakefile +10 -1
- data/app/helpers/pgbus/application_helper.rb +37 -0
- data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
- data/config/locales/da.yml +4 -0
- data/config/locales/de.yml +4 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +4 -0
- data/config/locales/fi.yml +4 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/it.yml +4 -0
- data/config/locales/ja.yml +4 -0
- data/config/locales/nb.yml +4 -0
- data/config/locales/nl.yml +4 -0
- data/config/locales/pt.yml +4 -0
- data/config/locales/sv.yml +4 -0
- 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/active_job/executor.rb +25 -4
- data/lib/pgbus/cli/dlq.rb +164 -0
- data/lib/pgbus/cli.rb +18 -1
- data/lib/pgbus/client/connection_health.rb +194 -0
- data/lib/pgbus/client.rb +592 -73
- data/lib/pgbus/config_loader.rb +50 -4
- data/lib/pgbus/configuration.rb +294 -79
- data/lib/pgbus/dedup_cache.rb +8 -0
- data/lib/pgbus/doctor.rb +275 -0
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/execution_pools/async_pool.rb +9 -2
- data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
- data/lib/pgbus/generators/config_converter.rb +7 -5
- data/lib/pgbus/generators/migration_detector.rb +20 -16
- data/lib/pgbus/instrumentation.rb +3 -1
- data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
- data/lib/pgbus/metrics/backend.rb +38 -0
- data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
- data/lib/pgbus/metrics/backends/statsd.rb +64 -0
- data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
- data/lib/pgbus/metrics/subscriber.rb +214 -0
- data/lib/pgbus/metrics.rb +43 -0
- 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 +354 -18
- data/lib/pgbus/process/consumer_priority.rb +34 -0
- data/lib/pgbus/process/dispatcher.rb +265 -41
- data/lib/pgbus/process/heartbeat.rb +18 -5
- data/lib/pgbus/process/memory_usage.rb +48 -0
- data/lib/pgbus/process/notify_listener.rb +26 -7
- data/lib/pgbus/process/notify_probe.rb +96 -0
- data/lib/pgbus/process/primary_validator.rb +53 -0
- data/lib/pgbus/process/signal_handler.rb +6 -0
- data/lib/pgbus/process/supervisor.rb +423 -50
- data/lib/pgbus/process/worker.rb +288 -35
- data/lib/pgbus/recurring/schedule.rb +1 -2
- data/lib/pgbus/recurring/scheduler.rb +15 -1
- 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/streams/turbo_broadcastable.rb +7 -5
- data/lib/pgbus/table_maintenance.rb +13 -2
- data/lib/pgbus/uniqueness.rb +11 -12
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +36 -4
- data/lib/pgbus/web/health_app.rb +102 -0
- data/lib/pgbus/web/health_server.rb +144 -0
- data/lib/pgbus/web/payload_filter.rb +3 -3
- data/lib/pgbus/web/streamer/instance.rb +58 -2
- data/lib/pgbus/web/streamer/listener.rb +69 -21
- data/lib/pgbus.rb +77 -0
- data/lib/tasks/pgbus_doctor.rake +12 -0
- metadata +19 -4
- data/app/models/pgbus/job_lock.rb +0 -98
- data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
data/README.md
CHANGED
|
@@ -4,6 +4,8 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
|
|
|
4
4
|
|
|
5
5
|
**Why Pgbus?** If you already run PostgreSQL, you don't need Redis for background jobs. Pgbus gives you ActiveJob integration, AMQP-style topic routing, dead letter queues, worker memory management, and a live dashboard -- all backed by your existing database.
|
|
6
6
|
|
|
7
|
+
๐ **Documentation:** [pgbus.zoolutions.llc](https://pgbus.zoolutions.llc) โ guides, flow diagrams, and a full configuration reference. (This README stays the canonical GitHub reference.)
|
|
8
|
+
|
|
7
9
|
[](https://github.com/mhenrixon/pgbus/actions/workflows/main.yml)
|
|
8
10
|
|
|
9
11
|
## Table of contents
|
|
@@ -21,6 +23,8 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
|
|
|
21
23
|
- [Job uniqueness](#job-uniqueness)
|
|
22
24
|
- [Concurrency controls](#concurrency-controls)
|
|
23
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)
|
|
24
28
|
- [Prefetch flow control](#prefetch-flow-control)
|
|
25
29
|
- [Worker recycling](#worker-recycling)
|
|
26
30
|
- [Retry backoff](#retry-backoff)
|
|
@@ -34,8 +38,11 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
|
|
|
34
38
|
- [Archive compaction](#archive-compaction)
|
|
35
39
|
- [Observability](#observability)
|
|
36
40
|
- [Error reporting](#error-reporting)
|
|
41
|
+
- [Connection pool metrics](#connection-pool-metrics)
|
|
37
42
|
- [Structured logging](#structured-logging)
|
|
38
43
|
- [Queue health monitoring](#queue-health-monitoring)
|
|
44
|
+
- [Health endpoints (liveness / readiness)](#health-endpoints-liveness--readiness)
|
|
45
|
+
- [Boot diagnostics banner](#boot-diagnostics-banner)
|
|
39
46
|
- [Real-time broadcasts](#real-time-broadcasts-turbo-streams-replacement)
|
|
40
47
|
- [Testing](#testing)
|
|
41
48
|
- [RSpec setup](#rspec-setup)
|
|
@@ -129,12 +136,16 @@ end
|
|
|
129
136
|
|
|
130
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.
|
|
131
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
|
+
|
|
132
141
|
> **Upgrading from an older pgbus?** Run `rails generate pgbus:update`. It does two things in one pass:
|
|
133
142
|
>
|
|
134
143
|
> - Converts any legacy `config/pgbus.yml` to a Ruby initializer at `config/initializers/pgbus.rb` (skipped if the initializer already exists).
|
|
135
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.
|
|
136
145
|
>
|
|
137
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).
|
|
138
149
|
|
|
139
150
|
### 2. Use as ActiveJob backend
|
|
140
151
|
|
|
@@ -167,19 +178,23 @@ Publish events with AMQP-style topic routing:
|
|
|
167
178
|
|
|
168
179
|
```ruby
|
|
169
180
|
# Publish an event
|
|
170
|
-
Pgbus
|
|
181
|
+
Pgbus.publish(
|
|
171
182
|
"orders.created",
|
|
172
183
|
{ order_id: order.id, total: order.total }
|
|
173
184
|
)
|
|
174
185
|
|
|
175
186
|
# Publish with delay
|
|
176
|
-
Pgbus
|
|
187
|
+
Pgbus.publish_later(
|
|
177
188
|
"invoices.due",
|
|
178
189
|
{ invoice_id: invoice.id },
|
|
179
190
|
delay: 30.days
|
|
180
191
|
)
|
|
181
192
|
```
|
|
182
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
|
+
|
|
183
198
|
Subscribe with handlers:
|
|
184
199
|
|
|
185
200
|
```ruby
|
|
@@ -294,36 +309,33 @@ end
|
|
|
294
309
|
|
|
295
310
|
#### Lock lifecycle
|
|
296
311
|
|
|
297
|
-
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:
|
|
298
313
|
|
|
299
314
|
```text
|
|
300
|
-
Enqueue โโโ
|
|
301
|
-
โ
|
|
302
|
-
Worker picks up job
|
|
315
|
+
Enqueue โโโ pgbus_uniqueness_keys (lock_key, queue_name, msg_id)
|
|
303
316
|
โ
|
|
304
|
-
|
|
305
|
-
claim_for_execution! (state: executing, owner_pid: PID)
|
|
317
|
+
Worker picks up and runs the job
|
|
306
318
|
โ
|
|
307
319
|
โโโโโโโโโดโโโโโโโโ
|
|
308
320
|
โผ โผ
|
|
309
321
|
Success Crash
|
|
310
|
-
release! (lock orphaned
|
|
311
|
-
(row deleted)
|
|
322
|
+
release! (lock orphaned โ
|
|
323
|
+
(row deleted) message still tracked
|
|
324
|
+
by lock_key/queue_name/msg_id)
|
|
325
|
+
โ
|
|
312
326
|
โผ
|
|
313
327
|
Reaper checks:
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
Does the referenced message
|
|
329
|
+
still exist in the PGMQ queue?
|
|
316
330
|
โ
|
|
317
331
|
โโโโโโโดโโโโโโ
|
|
318
|
-
|
|
332
|
+
Gone Present
|
|
319
333
|
โผ โผ
|
|
320
334
|
release! (keep lock,
|
|
321
|
-
(orphaned) job
|
|
335
|
+
(orphaned) job still in flight)
|
|
322
336
|
```
|
|
323
337
|
|
|
324
|
-
**Crash recovery** works through the reaper (runs
|
|
325
|
-
|
|
326
|
-
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.
|
|
327
339
|
|
|
328
340
|
#### Uniqueness vs concurrency controls
|
|
329
341
|
|
|
@@ -345,8 +357,8 @@ A last-resort TTL (default 24 hours) handles the case where the entire pgbus sup
|
|
|
345
357
|
#### Setup
|
|
346
358
|
|
|
347
359
|
```bash
|
|
348
|
-
rails generate pgbus:
|
|
349
|
-
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
|
|
350
362
|
```
|
|
351
363
|
|
|
352
364
|
### Concurrency controls
|
|
@@ -453,6 +465,49 @@ rails generate pgbus:add_queue_states # Add the queue_states migration
|
|
|
453
465
|
rails generate pgbus:add_queue_states --database=pgbus # For separate database
|
|
454
466
|
```
|
|
455
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
|
+
|
|
456
511
|
### Prefetch flow control
|
|
457
512
|
|
|
458
513
|
Cap the number of in-flight (claimed but unfinished) messages per worker:
|
|
@@ -728,6 +783,25 @@ Reporters are wired into all critical rescue paths: job execution failures, work
|
|
|
728
783
|
|
|
729
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.
|
|
730
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
|
+
|
|
731
805
|
### AppSignal integration
|
|
732
806
|
|
|
733
807
|
When the `appsignal` gem is loaded in your app, Pgbus auto-installs a subscriber and a minutely probe that report into AppSignal:
|
|
@@ -757,9 +831,59 @@ Three importable AppSignal dashboards ship with the gem:
|
|
|
757
831
|
|
|
758
832
|
Import via the AppSignal dashboard UI ("New dashboard" โ "Import JSON") or the AppSignal API.
|
|
759
833
|
|
|
834
|
+
### Metrics adapter (Prometheus / StatsD)
|
|
835
|
+
|
|
836
|
+
AppSignal is one consumer of pgbus's `ActiveSupport::Notifications` events. For teams on Prometheus, Datadog, or plain StatsD, the built-in **metrics adapter** consumes the same events and forwards them to a backend โ no hand-written subscribers. It is **off by default** (`metrics_backend = nil` installs nothing, zero overhead) and runs independently of AppSignal, so both can be active at once.
|
|
837
|
+
|
|
838
|
+
```ruby
|
|
839
|
+
Pgbus.configure do |c|
|
|
840
|
+
c.metrics_backend = :prometheus # in-process registry, scraped via the exporter
|
|
841
|
+
# or
|
|
842
|
+
c.metrics_backend = :statsd # UDP datagrams (DogStatsD dialect)
|
|
843
|
+
c.statsd_host = "127.0.0.1" # default
|
|
844
|
+
c.statsd_port = 8125 # default
|
|
845
|
+
end
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
You can also assign a custom backend instance โ any object subclassing `Pgbus::Metrics::Backend` (implementing `increment`, `gauge`, `histogram`):
|
|
849
|
+
|
|
850
|
+
```ruby
|
|
851
|
+
c.metrics_backend = MyOpenTelemetryBackend.new
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
**Metrics emitted** (all `pgbus_`-prefixed, low-cardinality tags only):
|
|
855
|
+
|
|
856
|
+
| Metric | Type | Tags |
|
|
857
|
+
|--------|------|------|
|
|
858
|
+
| `pgbus_queue_job_count` | counter | `queue`, `job_class`, `status` (`processed`/`failed`/`dead_lettered`) |
|
|
859
|
+
| `pgbus_job_duration_ms` | histogram | `queue`, `job_class` |
|
|
860
|
+
| `pgbus_event_count` | counter | `handler`, `routing_key`, `status` |
|
|
861
|
+
| `pgbus_event_duration_ms` | histogram | `handler`, `routing_key` |
|
|
862
|
+
| `pgbus_messages_sent` / `pgbus_messages_read` | counter | `queue` |
|
|
863
|
+
| `pgbus_stream_broadcast_count` | counter | `stream`, `deferred` |
|
|
864
|
+
| `pgbus_outbox_published` | counter | `kind` |
|
|
865
|
+
| `pgbus_recurring_enqueued` | counter | `task`, `class_name` |
|
|
866
|
+
| `pgbus_worker_recycled` | counter | `reason`, `kind` (`worker`/`consumer`) |
|
|
867
|
+
| `pgbus_pool_size` / `pgbus_pool_available` | gauge | `hostname` |
|
|
868
|
+
|
|
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.
|
|
870
|
+
|
|
871
|
+
#### Mounting the Prometheus exporter
|
|
872
|
+
|
|
873
|
+
The `:prometheus` backend is an in-process registry; expose it for scraping by mounting `Pgbus::Metrics::PrometheusExporter` โ a self-contained Rack app that renders text exposition format (v0.0.4):
|
|
874
|
+
|
|
875
|
+
```ruby
|
|
876
|
+
# config/routes.rb
|
|
877
|
+
mount Pgbus::Metrics::PrometheusExporter.new => "/metrics"
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
With no argument the exporter reads `config.metrics_backend`, so a single `config.metrics_backend = :prometheus` wires both the subscriber and the exporter to the same registry. The app is plain Rack, so it also runs under any standalone Rack server (e.g. a one-line `config.ru`) โ point Prometheus at `GET /metrics`.
|
|
881
|
+
|
|
882
|
+
> The exporter returns **503** if `metrics_backend` is not a Prometheus backend (e.g. it's `:statsd` or `nil`), since there is no in-process registry to render.
|
|
883
|
+
|
|
760
884
|
#### Custom subscriptions
|
|
761
885
|
|
|
762
|
-
The
|
|
886
|
+
The metrics adapter above covers Prometheus and StatsD. For anything else (New Relic, OpenTelemetry, a bespoke sink), the events are built on `ActiveSupport::Notifications` โ subscribe directly:
|
|
763
887
|
|
|
764
888
|
```ruby
|
|
765
889
|
ActiveSupport::Notifications.subscribe(/^pgbus\./) do |name, start, finish, _id, payload|
|
|
@@ -768,7 +892,24 @@ ActiveSupport::Notifications.subscribe(/^pgbus\./) do |name, start, finish, _id,
|
|
|
768
892
|
end
|
|
769
893
|
```
|
|
770
894
|
|
|
771
|
-
|
|
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
|
+
```
|
|
772
913
|
|
|
773
914
|
### Structured logging
|
|
774
915
|
|
|
@@ -953,6 +1094,74 @@ For the **HTTP** transport, point the client at the mounted URL with a streamabl
|
|
|
953
1094
|
}
|
|
954
1095
|
```
|
|
955
1096
|
|
|
1097
|
+
### Health endpoints (liveness / readiness)
|
|
1098
|
+
|
|
1099
|
+
For orchestrators like Kubernetes, Pgbus exposes two HTTP probes: `/livez` (is the serving process up?) and `/readyz` (are queues draining, or is a worker silently wedged?). `/readyz` runs the same `OK` / `DEGRADED` / `STALLED` verdict as the MCP `pgbus_health` tool โ `STALLED` (visible backlog while workers heart-beat but don't claim) fails readiness.
|
|
1100
|
+
|
|
1101
|
+
| Path | Method | 200 | 503 | Touches DB |
|
|
1102
|
+
|---|---|---|---|---|
|
|
1103
|
+
| `/livez` | GET | always (`ok`) | never | no |
|
|
1104
|
+
| `/readyz` | GET | verdict `OK` or `DEGRADED` | verdict `STALLED`, or DB unreachable (`{"status":"ERROR"}`) | yes |
|
|
1105
|
+
|
|
1106
|
+
Unknown paths return `404`; non-`GET` methods return `405`. The `/readyz` body is the verdict JSON, so a probe failure is self-describing in the pod's event log.
|
|
1107
|
+
|
|
1108
|
+
#### Mount in your Rails app
|
|
1109
|
+
|
|
1110
|
+
`Pgbus::Web::HealthApp` is a plain Rack app โ mount it wherever your web pods already serve HTTP. It needs no auth (it exposes only aggregate health, never payloads) but keep it on an internal network:
|
|
1111
|
+
|
|
1112
|
+
```ruby
|
|
1113
|
+
# config/routes.rb
|
|
1114
|
+
mount Pgbus::Web::HealthApp.new => "/pgbus/health"
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
```yaml
|
|
1118
|
+
# kubelet probes (Deployment spec)
|
|
1119
|
+
livenessProbe:
|
|
1120
|
+
httpGet: { path: /pgbus/health/livez, port: 3000 }
|
|
1121
|
+
periodSeconds: 10
|
|
1122
|
+
readinessProbe:
|
|
1123
|
+
httpGet: { path: /pgbus/health/readyz, port: 3000 }
|
|
1124
|
+
periodSeconds: 10
|
|
1125
|
+
failureThreshold: 3
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
#### Standalone from the supervisor
|
|
1129
|
+
|
|
1130
|
+
Worker pods run `bin/pgbus` (the supervisor), not Puma โ so there is no Rails server to mount into. Set `health_port` and the supervisor serves both paths itself over a tiny TCP server (no Rails, no dashboard), letting a kubelet probe the process that actually forks and watches workers:
|
|
1131
|
+
|
|
1132
|
+
```ruby
|
|
1133
|
+
Pgbus.configure do |c|
|
|
1134
|
+
c.health_port = 9394 # nil (default) = disabled
|
|
1135
|
+
c.health_bind = "0.0.0.0" # default "127.0.0.1"
|
|
1136
|
+
end
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
```yaml
|
|
1140
|
+
# probe the supervisor pod directly
|
|
1141
|
+
livenessProbe:
|
|
1142
|
+
httpGet: { path: /livez, port: 9394 }
|
|
1143
|
+
readinessProbe:
|
|
1144
|
+
httpGet: { path: /readyz, port: 9394 }
|
|
1145
|
+
```
|
|
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
|
+
|
|
956
1165
|
## Real-time broadcasts (turbo-streams replacement)
|
|
957
1166
|
|
|
958
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.
|
|
@@ -1070,6 +1279,20 @@ Pgbus.stream_key(chat, :messages) # => "ai_chat_a3f8c1e9d2b47610_messages"
|
|
|
1070
1279
|
|
|
1071
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.
|
|
1072
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
|
+
|
|
1073
1296
|
### Transactional broadcasts
|
|
1074
1297
|
|
|
1075
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.
|
|
@@ -1108,6 +1331,29 @@ The replay cap is applied server-side: the helper computes `since_id = max(0, cu
|
|
|
1108
1331
|
|
|
1109
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.
|
|
1110
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
|
+
|
|
1111
1357
|
### Server-side audience filtering
|
|
1112
1358
|
|
|
1113
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:
|
|
@@ -1145,6 +1391,95 @@ Failure semantics:
|
|
|
1145
1391
|
|
|
1146
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.
|
|
1147
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
|
+
|
|
1148
1483
|
### Presence
|
|
1149
1484
|
|
|
1150
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:
|
|
@@ -1193,12 +1528,35 @@ Pgbus.stream(@room).presence.sweep!(older_than: 60.seconds.ago)
|
|
|
1193
1528
|
|
|
1194
1529
|
The sweep uses `DELETE ... RETURNING` so multiple workers running it concurrently won't double-emit leave events.
|
|
1195
1530
|
|
|
1196
|
-
**Deliberately left to the application**:
|
|
1531
|
+
**Deliberately left to the application (manual API)**:
|
|
1197
1532
|
|
|
1198
|
-
- 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).
|
|
1199
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.
|
|
1200
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>`.
|
|
1201
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
|
+
|
|
1202
1560
|
### Stream stats (opt-in)
|
|
1203
1561
|
|
|
1204
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.
|
|
@@ -1460,10 +1818,48 @@ Day-to-day running of Pgbus: starting and stopping processes, observing what is
|
|
|
1460
1818
|
pgbus start # Start supervisor with workers + dispatcher + scheduler
|
|
1461
1819
|
pgbus status # Show running processes
|
|
1462
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
|
|
1463
1824
|
pgbus version # Print version
|
|
1464
1825
|
pgbus help # Show help
|
|
1465
1826
|
```
|
|
1466
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
|
+
|
|
1467
1863
|
#### Role flags (split deployments)
|
|
1468
1864
|
|
|
1469
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:
|
|
@@ -1495,7 +1891,7 @@ The dashboard is a mountable Rails engine at `/pgbus` with:
|
|
|
1495
1891
|
- **Queues** โ per-queue metrics, purge/pause/resume/delete actions
|
|
1496
1892
|
- **Jobs** โ enqueued and failed jobs, retry/discard actions
|
|
1497
1893
|
- **Dead letter** โ DLQ messages with retry/discard, bulk actions
|
|
1498
|
-
- **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`)
|
|
1499
1895
|
- **Events** โ registered subscribers and processed events
|
|
1500
1896
|
- **Outbox** โ transactional outbox entries pending publication
|
|
1501
1897
|
- **Locks** โ active job uniqueness locks with state (queued/executing), owner PID@hostname, age
|
|
@@ -1503,6 +1899,8 @@ The dashboard is a mountable Rails engine at `/pgbus` with:
|
|
|
1503
1899
|
|
|
1504
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.
|
|
1505
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
|
+
|
|
1506
1904
|
#### Queue management
|
|
1507
1905
|
|
|
1508
1906
|
The queues page lets you manage PGMQ queues directly:
|
|
@@ -1528,6 +1926,17 @@ rails generate pgbus:add_job_stats --database=pgbus
|
|
|
1528
1926
|
|
|
1529
1927
|
Stats collection is enabled by default (`config.stats_enabled = true`). Old stats are cleaned up by the dispatcher based on `config.stats_retention` (default: 30 days). If the migration hasn't been run yet, stat recording is silently skipped.
|
|
1530
1928
|
|
|
1929
|
+
Stats are buffered in memory and bulk-inserted rather than written one row per job. Each worker flushes its buffer when it fills to `config.stats_flush_size` entries (default: `100`), when `config.stats_flush_interval` seconds have elapsed (default: `5`), and immediately when the worker begins draining (graceful `TERM` shutdown or a recycle threshold). Tune the thresholds down for high-throughput deployments that want a tighter loss window, or up to reduce insert frequency:
|
|
1930
|
+
|
|
1931
|
+
```ruby
|
|
1932
|
+
Pgbus.configure do |c|
|
|
1933
|
+
c.stats_flush_size = 500 # flush after 500 buffered stats
|
|
1934
|
+
c.stats_flush_interval = 2 # ...or every 2 seconds, whichever comes first
|
|
1935
|
+
end
|
|
1936
|
+
```
|
|
1937
|
+
|
|
1938
|
+
**Loss window:** stats are advisory (dashboard/insights only), never job payloads. Buffered entries are flushed on graceful shutdown and drain entry, so a clean stop loses nothing. If the supervisor watchdog `SIGKILL`s a stalled worker โ which cannot be trapped โ up to `stats_flush_interval` seconds / `stats_flush_size` entries accumulated since the last flush are lost. This affects insights accuracy only; no job is dropped.
|
|
1939
|
+
|
|
1531
1940
|
### Database tables
|
|
1532
1941
|
|
|
1533
1942
|
Pgbus uses these tables (created via PGMQ and migrations):
|
|
@@ -1542,7 +1951,7 @@ Pgbus uses these tables (created via PGMQ and migrations):
|
|
|
1542
1951
|
| `pgbus_semaphores` | Concurrency control counting semaphores |
|
|
1543
1952
|
| `pgbus_blocked_executions` | Jobs waiting for a concurrency semaphore slot |
|
|
1544
1953
|
| `pgbus_batches` | Batch tracking with job counters and callback config |
|
|
1545
|
-
| `
|
|
1954
|
+
| `pgbus_uniqueness_keys` | Job uniqueness locks (lock_key, queue_name, msg_id) |
|
|
1546
1955
|
| `pgbus_job_stats` | Job execution metrics (class, queue, status, duration) |
|
|
1547
1956
|
| `pgbus_queue_states` | Queue pause/resume and circuit breaker state |
|
|
1548
1957
|
| `pgbus_outbox_entries` | Transactional outbox entries pending publication |
|
|
@@ -1617,6 +2026,7 @@ PostgreSQL + PGMQ
|
|
|
1617
2026
|
| `prefetch_limit` | `nil` | Max in-flight messages per worker (nil = unlimited) |
|
|
1618
2027
|
| `dispatch_interval` | `1.0` | Seconds between dispatcher maintenance ticks |
|
|
1619
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) |
|
|
1620
2030
|
| `priority_levels` | `nil` | Number of priority sub-queues (nil = disabled, 2-10) |
|
|
1621
2031
|
| `default_priority` | `1` | Default priority for jobs without explicit priority |
|
|
1622
2032
|
| `archive_retention` | `7.days` | How long to keep archived messages. Accepts seconds, Duration, or `nil` to disable cleanup |
|
|
@@ -1632,6 +2042,8 @@ PostgreSQL + PGMQ
|
|
|
1632
2042
|
| `web_live_updates` | `true` | Enable Turbo Frames auto-refresh on dashboard |
|
|
1633
2043
|
| `stats_enabled` | `true` | Record job execution stats for insights dashboard |
|
|
1634
2044
|
| `stats_retention` | `30.days` | How long to keep job stats. Accepts seconds, Duration, or `nil` to disable cleanup |
|
|
2045
|
+
| `stats_flush_size` | `100` | Buffered stat entries per worker before a bulk insert flush. Positive integer. Lower = tighter SIGKILL loss window. |
|
|
2046
|
+
| `stats_flush_interval` | `5` | Seconds between periodic stat buffer flushes. Positive number. |
|
|
1635
2047
|
| `streams_test_mode` | `false` | Return a stub SSE response without hijack or background threads. Auto-enabled by `Pgbus::Testing.fake!`/`.inline!`. See [SSE streams in tests](#sse-streams-in-tests). |
|
|
1636
2048
|
| `streams_stats_enabled` | `false` | Record stream broadcast/connect/disconnect stats (opt-in, can be high volume) |
|
|
1637
2049
|
| `streams_path` | `nil` | Custom URL path for the SSE endpoint (nil = auto-detected from engine mount) |
|
|
@@ -1639,6 +2051,12 @@ PostgreSQL + PGMQ
|
|
|
1639
2051
|
| `error_reporters` | `[]` | Array of callables invoked on caught exceptions. Each receives `(exception, context_hash)`. |
|
|
1640
2052
|
| `log_format` | `:text` | Log formatter (`:text` or `:json`). Sets `logger.formatter` automatically. |
|
|
1641
2053
|
| `metrics_enabled` | `true` | Enable Prometheus-compatible metrics on the dashboard |
|
|
2054
|
+
| `metrics_backend` | `nil` | Generic metrics adapter: `nil` (off), `:prometheus`, `:statsd`, or a `Pgbus::Metrics::Backend` instance |
|
|
2055
|
+
| `statsd_host` | `"127.0.0.1"` | StatsD UDP host (used when `metrics_backend = :statsd`) |
|
|
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. |
|
|
1642
2060
|
|
|
1643
2061
|
## Development
|
|
1644
2062
|
|
|
@@ -1665,6 +2083,17 @@ PGBUS_DATABASE_URL=postgres://user@host/db bundle exec rake bench:streams
|
|
|
1665
2083
|
|
|
1666
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`.
|
|
1667
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
|
+
|
|
1668
2097
|
## License
|
|
1669
2098
|
|
|
1670
2099
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|