pgbus 0.9.10 → 0.9.11
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 +22 -7
- data/README.md +34 -7
- data/Rakefile +22 -1
- data/app/models/pgbus/stream_queue.rb +87 -0
- data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
- data/lib/generators/pgbus/install_generator.rb +3 -3
- data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
- data/lib/generators/pgbus/update_generator.rb +6 -66
- data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
- data/lib/pgbus/client/read_after.rb +7 -6
- data/lib/pgbus/client/resizable_pool.rb +160 -0
- data/lib/pgbus/client.rb +200 -1
- data/lib/pgbus/configuration.rb +170 -9
- data/lib/pgbus/doctor.rb +49 -2
- data/lib/pgbus/engine.rb +30 -3
- data/lib/pgbus/generators/migration_detector.rb +7 -0
- data/lib/pgbus/process/dispatcher.rb +89 -19
- data/lib/pgbus/process/worker.rb +9 -0
- data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
- data/lib/pgbus/streams/pool_trigger.rb +124 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
- data/lib/pgbus/streams.rb +2 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/streamer/connection.rb +22 -6
- data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
- data/lib/pgbus/web/streamer/instance.rb +75 -5
- data/lib/pgbus/web/streamer/io_writer.rb +11 -5
- data/lib/pgbus/web/streamer/listener.rb +61 -1
- data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
- metadata +9 -4
- data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
- data/lib/pgbus/config_loader.rb +0 -73
- data/lib/pgbus/generators/config_converter.rb +0 -345
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56605f0cc86326e66b73d13d9090bbcdc522b9ee6e571dd19f62f02aadce3dac
|
|
4
|
+
data.tar.gz: e53deb0e75aec959f4c0bf54d2d969d52899d57c59ca59e0441f989d897745b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3bdbd54ebc2c5d414d5e85212adee43bea5645d4422397e1b5c41e41a63b19fb7695cbd32da7096c3455c3ff6f8a4afab2a7ad510d443d9b5cc561abdd01043a
|
|
7
|
+
data.tar.gz: 4cb472bbaad4c5052f732cfe452c07fc725ac739afceca8cd4e58a05a9785950e64b1efe050f6d97a245cd94fe0b85218ee1f616b753d552e113a498aa135289
|
data/CHANGELOG.md
CHANGED
|
@@ -2,18 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
### Breaking Changes
|
|
4
4
|
|
|
5
|
-
- **
|
|
5
|
+
- **YAML config (`config/pgbus.yml`) is removed for 1.0 — the Ruby initializer is the only config surface.** Ruby was always the real config surface (every production setup is a `Pgbus.configure` initializer); `config/pgbus.yml` was only ever a legacy convenience, and pre-1.0 is the last low-risk moment to drop it before the config shape becomes a compatibility promise. Concretely: the engine no longer loads `config/pgbus.yml` at boot (the `pgbus.configure` initializer and `Pgbus::ConfigLoader` are deleted); `rails generate pgbus:install` generates `config/initializers/pgbus.rb` (done for 1.0, see below); and `rails generate pgbus:update` no longer converts YAML → initializer (`Pgbus::Generators::ConfigConverter`, its `--source`/`--destination`/`--skip-config` options, and the `pgbus.yml.erb` template are deleted) — `pgbus:update` now only detects and installs missing migrations. **If you still have a `config/pgbus.yml`, it is no longer loaded** — the engine warns once at boot (pointing at the docs) so the inert file doesn't quietly diverge from your real config. Port its settings into `config/initializers/pgbus.rb` as a `Pgbus.configure` block and delete the YAML; the last release that auto-converts it via `pgbus:update` is 0.9.x, so run that conversion (or port by hand) **before** upgrading to 1.0. The recurring-tasks YAML (`config/recurring.yml`, `Pgbus::Recurring::ConfigLoader`) is a separate surface and is unchanged. Refs #317.
|
|
6
|
+
- **The error hierarchy is unified under `Pgbus::Error` for the 1.0 API freeze.** Several call sites raised bare stdlib errors that no `rescue Pgbus::Error` could catch, freezing an inconsistent contract into 1.0. Now: **`Configuration#validate!` and every config setter raise `Pgbus::ConfigurationError`** (was `ArgumentError`) — this is the one that can break existing code: a `rescue ArgumentError` around `Pgbus.configure` or a boot-time config read will no longer catch config failures; switch to `rescue Pgbus::Error` (or `rescue Pgbus::ConfigurationError`). The ActiveJob adapter's `perform_all_later` msg_id-count mismatch now raises **`Pgbus::EnqueueError`** (new, was `RuntimeError`); `ExecutionPools::AsyncPool` shutdown/at-capacity raises now use **`Pgbus::ExecutionPoolError`** (new, was `RuntimeError`); `Serializer#locate_global_id` GlobalID rejections raise **`Pgbus::SerializationError`** (was `ArgumentError`). Three error classes that bypassed the base — `PgmqSchema::VersionNotFoundError` and `Streams::SignedName::InvalidSignedName`/`MissingSecret` — are re-parented under `Pgbus::Error`. Three classes that reject a malformed *argument shape* — `Configuration::CapsuleDSL::ParseError`, `Streams::Cursor::InvalidCursor`, `Streams::StreamNameTooLong` — **deliberately stay `ArgumentError` subclasses**; the policy ("argument-shape errors are `ArgumentError`, operational errors are `Pgbus::Error`") is documented in `lib/pgbus.rb` and pinned by `spec/pgbus/error_hierarchy_spec.rb`. Messages are unchanged. Refs #282.
|
|
6
7
|
|
|
7
8
|
### Added
|
|
8
9
|
|
|
10
|
+
- **Job-burst tuning guidance + gate benchmark (`rake bench:job_burst`), and a decision NOT to autoscale the job pool (issue #323 phase 3).** Following the issue's measure-first mandate, `benchmarks/job_burst_bench.rb` sweeps worker thread count under a job burst in two connection regimes (pool matched to threads vs. pool fixed small). Measured finding: a job holds a pgmq connection only for the `read_batch` + `archive` round-trip (not the job body), so **the DB connection pool is the hard burst ceiling, not the thread count** — in the fixed-pool regime `peak_busy` never exceeds `pool_size` no matter how many threads run (extra threads just queue on checkout, latency climbs, throughput plateaus), while matching the pool to the thread count scales throughput ~linearly (≈8× from 2→16). The conclusion — issue #323's explicitly sanctioned outcome — is that **elastic job threads on a fixed connection pool cannot help**, so pgbus does *not* autoscale the job execution pool; the fix for job bursts is **static headroom**: raise `threads` (and let `pool_size` follow, or keep it ≥ `threads`). Documented in the Performance & tuning docs. The streams-pool autoscaling below stays the elastic story where it *does* pay off (the streams pool's connection cost genuinely scales with its size). Refs #323.
|
|
11
|
+
- **Fan-out writer throughput gate benchmark (`writer_burst_bench.rb`), and a decision NOT to make the writer pool elastic (issue #323 phase 1).** Following the issue's measure-first mandate, `benchmarks/writer_burst_bench.rb` drives a sustained fan-out burst (a fleet of connections × many wakes) through the `OutboundPump` while sweeping `streams_writer_threads`, across fast/typical socket-write profiles. Measured finding: throughput scales ~linearly with writer count (≈6× from 2→16) — but that is generic parallelism (N workers finish N independent blocking writes ~N× faster), which argues for the **existing static `streams_writer_threads` knob**, not for elastic writers: a fixed-N sweep can't demonstrate the *spikiness* that would make a static count wasteful enough to justify dynamic resize, and the genuinely harmful case (a slow/congested client) is already **shed** by the #320/#322 fan-out deadline + mark-dead path rather than absorbed by scaling writers. Given the elastic-writer rewrite is the issue's highest-risk phase (it would touch the ordering/cursor invariant #321's whole review exists to protect — mis-sizing risks out-of-order SSE frames), the conclusion — issue #323's sanctioned outcome — is that pgbus does **not** autoscale the writer pool; the fan-out-throughput knob is **static `streams_writer_threads`, sized for your peak fan-out fleet**. Documented in the Performance & tuning docs. Refs #323.
|
|
12
|
+
- **Self-tuning elastic streams-pool autoscaling (`config.streams_pool_autoscale`, opt-in, default off).** The dedicated streams pool (durable-broadcast publish + dispatcher replay reads) is a fixed `streams_pool_size` (default 5); under a genuine burst of SSE clients it saturates, and a saturated pool *serialises* replay reads (it doesn't error — the checkout waits; see #324), slowing fan-out. Setting `config.streams_pool_autoscale = true` runs a **periodic maintenance check** (default every 5 minutes, `config.streams_pool_autoscale_interval`) that **grows the streams pool into a fair share of live Postgres connection headroom** while it's saturated and **shrinks it back to `streams_pool_size`** when the burst passes — with no connection-count target to tune: every threshold derives from live `max_connections`. The check adds **no extra connection and no extra thread** — it's a lightweight `pg_stat_activity` query that runs on the streamer's *existing idle LISTEN connection* (a pghero-`capture_query_stats`-style periodic task, run on the listener thread in its health-check window). It counts peer stream processes by a per-process `application_name` tag (`config.streams_application_name`, default `"pgbus_streams"`), and each process grows only into its own fair share of free connections (`free / peers`, claiming ¼, bounded by a per-grow step and half the remaining headroom) so N forked processes provably can't collectively exhaust the database — even in a synchronized cold-boot herd. If the database runs *critically* low on free connections, every process **emergency-shrinks to baseline immediately**, overriding the saturation signal (protecting the DB wins). `config.streams_pool_max` is an **optional** hard per-process ceiling (`nil` = the dynamic fair share is the cap). No per-sample hysteresis or cooldown is needed — the slow cadence is itself the debounce: one grow (or shrink) step per check, and a sustained burst converges over a few checks. Built on the #325 hot-swap primitive; the decision object is a pure function (no thread, no connection), DB-free unit-tested (scripted headroom, incl. convergence-across-checks and emergency-shrink), with a DB-gated integration test proving real grow/shrink/emergency and a `rake bench:pool_autoscale` benchmark (check cost ~0.3 ms; grow-under-saturation `3→7→10`). **Two prerequisite fixes ship with it:** streams-pool connections are now tagged with a per-process `application_name`, and the streams pool is now built from `streams_connection_options` (honoring `streams_database_url`/host/port) rather than the job-database options — the latter a latent wrong-DB bug in the existing streams pool that only surfaced with a separate streams database. Default-off = byte-identical (the Listener runs no maintenance). A pure-*publisher* worker process (no streamer, only `send_stream_message`) autoscales too: each publish opportunistically triggers the same check, throttled to at most once per `streams_pool_autoscale_interval` across all publisher threads (a lock-free compare-and-set claims the window). The publish thread does only that CAS and then hands the work to a background single-worker executor — it never runs the `pg_stat_activity` query or the resize inline (off the hot path), and a quiet process spawns no thread. The query runs through the *job* pool (so it never competes with the streams pool it grows), and the publisher decision is **grow-only**: it grows under its own publish pressure and emergency-shrinks on DB exhaustion, but leaves normal idle shrink to the streamer/consumer that sees the sustained-idle picture. Fail-soft throughout, so a broadcast never fails on a telemetry query. For steady load, a larger static `streams_pool_size` remains the simpler choice — autoscaling earns its keep under genuinely bursty streams traffic. `PoolAutoscaler` lives in `Pgbus::Streams::` (it is a streams-pool controller, not web-specific). Refs #323.
|
|
13
|
+
- **Execution-mode connection benchmark + DB-pool sizing guidance (`rake bench:execution_modes`).** A fair, reusable harness (`benchmarks/support/execution_mode_harness.rb` + `benchmarks/execution_modes_bench.rb`) that runs the *same* offered load through the threads and async execution pools and measures how many real Postgres connections each actually holds (`peak_busy = pool size − available`, sampled through the real pool checkout), so operators can size the DB pool per `execution_mode`. It settles the "async saves connections" question with data: a job holds a pgmq connection only for the `read_batch` + `archive` round-trip (not the job body), so **async's connection-density win is real only for I/O-light work** (a few connections serve many fibers) — for **DB-bound work async is connection-bound like threads**, and a too-small pool *serialises* (throughput collapse, ~3.4× in the measured cell) rather than erroring, because `pool_timeout` (5 s) dwarfs a typical checkout. New `docs/performance.md` section "Execution mode and DB pool sizing" carries the measured numbers and a sizing table. The harness's fairness/struct/measurement logic is unit-tested with fakes (no DB, runs in CI); the numbers come from a manual `PGBUS_DATABASE_URL` run (run-and-report, never a CI gate). Groundwork for the elastic-pools research in #323; a `pgbus doctor` mode/pool-mismatch hint is deferred to a follow-up. Refs #323.
|
|
14
|
+
- **Off-thread durable-stream fanout writer (`config.streams_writer_threads`, `config.streams_writer_buffer_limit`) — the full head-of-line cure the #315 deadline only bounded.** The #315 deadline caps each slow-client fanout stall at `streams_fanout_write_deadline_ms` (250ms), but the single dispatcher thread still writes to every client serially, so K slow clients still cost ~`K × 250ms`. Setting `streams_writer_threads > 0` moves the **durable** fanout socket write off the dispatcher into N writer threads (each connection pinned to one worker by `id.hash % N`, preserving per-connection frame order and its per-io mutex). The dispatcher hands off the write and moves on; the pump reports back the highest msg_id it actually committed via an ack queue, and the dispatcher — still the **sole owner** of the read cursor — advances it only on that ack, so a blocked or partial write never advances the cursor past a frame that didn't reach the socket. A failed write posts a `DisconnectMessage` so the dispatcher scrubs the connection deterministically on its own thread (even on an otherwise-quiet stream). Measured (`streams_writer_offload_bench.rb`, dispatcher `handle_durable_wake` wall time, 50 fast + K slow clients @ 50ms): inline scales linearly (K=20 → **1071 ms**) while offload stays flat (K=20 → **0.09 ms**) — fast clients no longer wait behind slow ones. This is a **system-level latency win, not a per-write speedup** (total bytes written are unchanged); the dispatcher does slightly more per-wake bookkeeping under offload, which is why it is **opt-in and default-off** (`streams_writer_threads = 0` = byte-for-byte the pre-#321 inline behavior). **Ephemeral frames always stay inline** regardless of this setting — they have no archive to replay, so they must not risk an async drop (the pump raises if one is ever routed to it; issue #321 B1). `streams_writer_buffer_limit` (default `0` = unbounded) caps a connection's outbound buffer, dropping its oldest durable frame on overflow — safe because durable frames are re-read from the archive on reconnect; it's an OOM guard for a pathologically slow-but-alive client, not a delivery guarantee. Both knobs are dispatcher-internal tuning (not part of the 1.0 public-config surface). Refs #321 (follow-up to #315).
|
|
15
|
+
- **Bounded dispatcher fanout head-of-line blocking (`config.streams_fanout_write_deadline_ms`, `config.streams_dispatch_queue_limit`).** One dispatcher thread per web-server process fans out every broadcast to every connected SSE client **serially**, writing to each socket in turn. A slow client's write blocked that thread up to the full `streams_write_deadline_ms` (5s) before the client was marked dead — and every connection queued behind it in the same wake waited, so K slow clients could stack a `K × 5s` stall on all other browsers on the worker. Fanout writes now use a separate, shorter `streams_fanout_write_deadline_ms` (default **250 ms**), bounding the worst-case serial stall to `K × 250 ms` (~20× lower); a slow-but-alive client that misses the window is marked dead and reconnects, replaying the gap from the durable archive via its `Last-Event-ID` (or a fresh re-render for ephemeral streams, which have no archive). **Connect-replay** writes keep the full `streams_write_deadline_ms` so a new client catching up a large backlog isn't evicted. The happy path (all-fast clients) is unchanged — the `deadline_ms` threading is a zero-allocation keyword pass-through (bench: 585k vs 583k i/s, within noise; 0 retained/op). `streams_dispatch_queue_limit` (default `0` = unbounded) optionally caps distinct-stream wake backlog: at the cap the Listener drops durable wakes (never ephemeral or connect/disconnect), safe because the next durable wake for a stream re-reads from the min cursor. Both knobs are dispatcher-internal tuning (not part of the 1.0 public-config surface). A per-connection writer offload — moving the socket write off the dispatcher thread entirely — is deferred to a follow-up (it needs an ack/replay path for ephemeral frames first). Refs #315.
|
|
16
|
+
- **Dedicated streams DB connection pool isolates durable-stream publish + replay from the job pool (`config.streams_pool_size`, `config.streams_pool_timeout`).** Both stream hot paths used to ride on the single job pool sized for worker threads: `Client#send_stream_message` (the broadcast INSERT) drew from `@pgmq`, so under a saturated job pool a broadcast blocked on pool checkout up to `pool_timeout` (~5s) — the browser waited on jobs at the *publish* step; and the dispatcher's per-wake replay read (`read_after` / `stream_current_msg_id` / `stream_oldest_msg_id`) went through `with_raw_connection`, which on the String/Hash config does a **full `PG.connect` + close per call** (TCP + auth + TLS) on the single dispatcher thread inside the fanout loop. Both now use a dedicated `@streams_pgmq` pool (own `PGMQ::Client` → own `connection_pool`), sized independently of worker thread counts via `streams_pool_size` (default 5) / `streams_pool_timeout` (default 5). A new `Client#with_streams_connection` checks out a persistent pooled connection for the replay reads; `Client#streams_pool_stats` exposes its counters alongside `pool_stats`. On the shared-ActiveRecord (Proc) connection path no second pool is created — libpq isn't thread-safe, so `@streams_pgmq` aliases `@pgmq` and streams keep using the single serialized connection. Because every forked process opens its own streams pool on the dedicated path, budget Postgres/PgBouncer `max_connections` for `resolved_pool_size + streams_pool_size` per process (both pools are lazy). Measured (real DB, 50-row stream, `read_after`): pooled replay reads run **~23× faster** than the old fresh-connect-per-call path (5.2k i/s / 191 μs vs 226 i/s / 4.42 ms), removing the per-wake connection-setup cost; this is a client-layer connection-setup win, not end-to-end broadcast-to-browser latency (which is dominated by the PGMQ round-trip + LISTEN/NOTIFY + socket write). Refs #315.
|
|
17
|
+
- **`config.streams_broadcast_queue` isolates turbo-rails broadcast jobs from long-running work, and new installs get it by default.** The default `broadcasts_to`/`broadcasts_refreshes` macros enqueue turbo-rails' render+broadcast as ActiveJobs (`Turbo::Streams::ActionBroadcastJob` and siblings), which ship with no `queue_as` and so land on the **default** queue — meaning a browser SSE update can wait behind long-running jobs before it's rendered and broadcast (the delivery side is isolated, but the enqueue-render hop is not). Set `config.streams_broadcast_queue = "realtime"` and pgbus routes those three Turbo job classes to that queue at engine boot; back it with a dedicated worker capsule to give broadcasts their own thread pool. **`rails generate pgbus:install` now ships this setup by default** — the generated `config/initializers/pgbus.rb` sets `c.streams_broadcast_queue = "realtime"` and a dedicated `realtime` worker capsule (see the install-output change below). The **code default stays `nil`**, so a programmatic `Pgbus.configure` and existing installs are unchanged. `pgbus doctor` gains an eighth check that warns (never fails): in a Rails production env when streams+turbo-rails are on but no broadcast queue is set (latency), and — everywhere — when `streams_broadcast_queue` is set but **no worker capsule drains it** (broadcasts would pile up unread and never reach the browser). Refs #311.
|
|
18
|
+
- **Stream queue registry (`pgbus_stream_queues` table + `Pgbus::StreamQueue`) so stream queues are distinguishable from job queues.** Stream and job queues share the `#{queue_prefix}_` namespace, so nothing could tell them apart by name — the root cause behind #308 and #309. `ensure_stream_queue` now records each stream's physical queue name in the registry on first broadcast (idempotent, per-process memoized, and a no-op on installs that have not migrated). Fresh installs get the table via `pgbus:install`; existing installs add it with `rails generate pgbus:add_stream_queues` (also auto-detected and offered by `pgbus:update`). Refs #308.
|
|
9
19
|
- **`Pgbus.publish` / `Pgbus.publish_later` top-level shortcuts and a `config.drain_timeout` key (1.0 API freeze).** The event bus now has the same top-level ergonomics as streams: `Pgbus.publish(routing_key, payload, headers:, delay:)` and `Pgbus.publish_later(routing_key, payload, delay:, headers:)` delegate to `Pgbus::EventBus::Publisher` (long form still works), so tutorials no longer fossilize the fully-qualified call. `config.drain_timeout` (validated positive number, default 30) replaces the hardcoded `Worker::DRAIN_TIMEOUT = 30` constant — the graceful-shutdown drain deadline is now operator-tunable, so jobs that legitimately run longer than 30s aren't silently abandoned on every deploy/recycle. `pgbus doctor` gains a seventh check that warns (never fails) when `allowed_global_id_models` is `nil` (allow-all) in a Rails production env — the default is kept for upgrade continuity but is security-relevant. Refs #283.
|
|
10
20
|
|
|
11
21
|
### Changed
|
|
12
22
|
|
|
13
|
-
- **
|
|
23
|
+
- **Fanout socket writes now use a 250ms deadline (was the full 5s `streams_write_deadline_ms`), so a transiently-slow SSE client is marked dead ~20× sooner during broadcast fanout.** This is the behavior change behind the head-of-line-blocking fix (see Added, #315): a client that can't absorb a broadcast frame within `streams_fanout_write_deadline_ms` (default 250ms) is dropped and reconnects, replaying the missed frames from the durable archive (durable streams) or getting a fresh re-render (ephemeral). Connect-time replay is unaffected (keeps the full deadline). To restore the previous timing, set `config.streams_fanout_write_deadline_ms = config.streams_write_deadline_ms`. Refs #315.
|
|
24
|
+
- **The generated `config/initializers/pgbus.rb` documents every config group and bakes in realtime broadcast isolation.** The initializer that `rails generate pgbus:install` now writes (see Breaking Changes for the YAML removal) documents each config group inline — queues, pool, retries, recycling, dispatcher, event consumers — with the gem defaults shown as commented reference, and it sets the #311 realtime broadcast isolation by default: `c.streams_broadcast_queue = "realtime"` plus a dedicated `c.capsule :realtime` that drains it (so `pgbus doctor` stays quiet — no undrained-queue footgun). The **code default in `configuration.rb` stays `nil`**, so a programmatic `Pgbus.configure` and existing installs are unchanged; the recommended setup lives in the generated Ruby. Refs #317.
|
|
25
|
+
- **1.0 API freeze: config renames as deprecated aliases, and removal of provably-dead surface.** Renaming now is cheap; renaming after 1.0 is a major-version break, so the semver-audit renames land as **deprecated aliases** (old name works, warns once, removed in 2.0): `skip_recurring` → `recurring_enabled` (positive polarity, the only negative toggle among the `*_enabled` switches — the alias inverts the boolean); `dashboard_filter_parameters`/`dashboard_filter_sensitive` → `web_filter_parameters`/`web_filter_sensitive` (unify on the incumbent `web_` prefix); and `recurring_tasks_file` (singular), which now warns once when set alongside the plural `recurring_tasks_files` instead of being silently ignored. Both spellings of each alias resolve to the same setter. **Removed** (each verified zero-caller in the audit): the `lock_ttl:` keyword on `ensures_uniqueness` (validated and stored in metadata but read by nothing — passing it now raises `ArgumentError` naming the removal and the upgrade guide); the `pgbus:add_job_locks` generator and the `Pgbus::JobLock` model (new installs use `pgbus:add_uniqueness_keys`; `pgbus:migrate_job_locks` still retires the legacy `pgbus_job_locks` table); the internal `with_pgbus_durable` streams helper (use `with_pgbus_broadcast_opts(durable:)`); and the streamer's `reconnect_via_reset` fallback (`connection_factory` is now required and always injected, so reconnect always rebuilds a fresh connection). `log_format=` no longer clobbers a custom logger's formatter, and `streams_presence_patterns`/`streams_presence_member`, `group_mode`, and `streams_falcon_streaming_body` are annotated **experimental** (exempt from the 1.0 stability promise). The `workers=` Array form is documented as permanent (not "legacy") — it is the only way to express N identical anonymous capsules. Refs #283.
|
|
14
26
|
|
|
15
27
|
### Fixed
|
|
16
28
|
|
|
29
|
+
- **The dispatcher no longer logs a scary `Concurrency cleanup failed: ... terminating connection due to administrator command` WARN when an idle DB connection is killed between maintenance cycles.** The dispatcher is a long-lived loop that queries the DB on coarse intervals (concurrency cleanup every 300s, most tasks hourly) and, until now, kept its pooled ActiveRecord connection leased across the idle sleep. When the server terminated that idle backend — routine in **local development** (a Postgres restart, `rails db:migrate` calling `pg_terminate_backend` on idle backends, foreman/overmind restarting the DB) — the next cycle reused the dead socket and *every* maintenance task raised `PG::ConnectionBad` with PostgreSQL's `terminating connection due to administrator command` text, surfaced as an alarming WARN even though the process was healthy and self-heals on the next tick. Two changes: (1) `run_maintenance` now returns every leased AR connection to the pool after each cycle (`clear_active_connections!`, mirroring the Streamer's existing `release_ar_connections`), so an idle backend is never reused — the next cycle checks out afresh and AR's `verify!`-on-checkout transparently discards a dead socket and reconnects; (2) every maintenance task's failure log now routes through `log_maintenance_failure`, which recognizes the terminated-idle-connection signal and logs a calm INFO ("reconnecting on the next cycle") instead of a WARN. Non-connection failures still WARN unchanged, and maintenance backoff is unaffected (a self-healing cycle resets the failure streak on the next successful reconnect).
|
|
30
|
+
- **`priority_levels > 1` no longer silently breaks durable streams.** With job priority enabled, `QueueFactory.for` selects `PriorityStrategy`, and `Client#send_message` routes *every* send — including durable stream broadcasts (which pass no `priority:`) — to a `_p<default_priority>` sub-queue. But the streamer LISTENs and replays exclusively on the **bare** `pgmq.q_<stream>` queue (`read_after`, `notify_stream`), so the broadcast never reached the browser; on a fresh DB, `ensure_stream_queue` also raised `PG::UndefinedTable` because `ensure_queue` created only `_p0.._pN`, never the bare table the NOTIFY trigger and archive index attach to. Durable broadcasts now go through a dedicated `Client#send_stream_message` that always targets the bare queue, and `ensure_stream_queue` creates the bare queue directly via `ensure_single_queue` — both independent of the priority strategy, mirroring the bare-name read path. Streams are delivered by a non-consuming peek; priority sub-queues are meaningless for them. Ephemeral broadcasts (the default mode) were never affected. Refs #310.
|
|
31
|
+
- **Stream maintenance and wildcard workers now correctly identify stream queues (finishes the #306/#307 orphan-sweep fix and closes a durable-stream data-loss bug).** `streams_queue_prefix` (default `pgbus_stream`) was consulted only by the dispatcher's `prune_stream_archives` and `sweep_orphan_streams`, but no code ever *named* a queue with it — real stream queues are `#{queue_prefix}_<name>` (`pgbus_<name>`). So both filters matched **zero** real stream queues: per-stream retention never ran, and the age-based orphan drop shipped in #307 was inert. Worse, a *job* queue whose logical name started with `stream_` (physical `pgbus_stream_x`) *did* match and could be dropped. All stream detection now goes through the new `pgbus_stream_queues` registry (see Added): `prune_stream_archives` and `sweep_orphan_streams` iterate registered streams (reviving #306/#307), `compact_archives` skips registered streams so each stream archive is compacted by exactly one path at its per-stream retention (not the 7-day job default), and — closing #309 — a `queues: ['*']` worker's `resolve_wildcard_queues` now excludes registered stream queues, so wildcard workers no longer claim durable broadcasts, fail to deserialize them, and DLQ-move them out of the stream's replay history. `streams_queue_prefix` is retained for backward compatibility but is now inert (documented). Refs #308, #309.
|
|
17
32
|
- **Durable stream `q_` queues no longer leak — the orphan sweep is now age-based, as its `streams_orphan_threshold` config always implied.** A durable stream's live `pgmq.q_<name>` table is never drained by normal delivery: the streamer peeks (`read_after` UNIONs `q_`+`a_` without claiming rows), so `queue_length` stays `> 0` for any durable stream ever broadcast to, and retention only prunes `a_` (which stays empty for streams). The orphan sweep dropped only *empty* queues, so it never collected a durable stream — a permanent leak of the queue table, archive table, indexes, and a `pgmq.meta` row, at worst one leaked queue *per request* for per-render/one-shot durable keys. `streams_orphan_threshold` (default 24h) was named and defaulted as an age but wired only as an on/off gate. `sweep_orphan_streams` now reads each queue's `pgmq.meta.created_at` (no schema change) and drops when the queue is empty **or** has aged past the threshold. Idempotent re-creation (`ensure_stream_queue`) plus the existing missing-queue tolerance in `read_after` keep it safe; a genuinely long-lived stream only loses replay history past the threshold, well clear of normal reconnect windows. A `nil` `created_at` is treated as young (never dropped on age). Refs #306.
|
|
18
33
|
- **`Pgbus::MCP.rack_app` now returns a clean 401 on auth failure instead of a 500.** The `RackApp` auth gate returned a *frozen* `UNAUTHORIZED` response triple; once mounted in Rails, response-mutating middleware downstream (`Rack::TempfileReaper` assigns `response[2]`, `Rack::ETag` adds a header) raised `FrozenError`, so every request with a missing/mismatched `PGBUS_MCP_TOKEN` surfaced as an opaque 500 in the host app's error tracker rather than the intended 401. The gate now builds a fresh, mutable array and headers hash per call (only the JSON body string stays memoized+frozen). A request-level spec drives the app through the real `TempfileReaper`/`ETag` stack to guard against regression. Refs #304.
|
|
19
34
|
|
|
@@ -49,13 +64,13 @@
|
|
|
49
64
|
|
|
50
65
|
- **Observability: HTTP liveness and readiness endpoints for orchestrators.** Pgbus had no HTTP health surface: the `OK`/`DEGRADED`/`STALLED` verdict in `Pgbus::MCP::HealthAnalyzer` was reachable only through the MCP JSON-RPC tool or the auth-gated dashboard, and the supervisor process (which forks and watches workers) had no HTTP surface at all — so a Kubernetes kubelet could not distinguish "supervisor alive" from "workers silently wedged". New `Pgbus::Web::HealthApp` (`lib/pgbus/web/health_app.rb`) is a plain Rack app exposing `GET /livez` (→ `200 text/plain "ok"`, unconditionally, **no** database access — the serving process is up) and `GET /readyz` (builds a `Web::DataSource`, runs `HealthAnalyzer#verdict`: `OK`/`DEGRADED` → `200`, `STALLED` → `503`, body = the verdict JSON; any `StandardError` such as an unreachable DB → `503 {"status":"ERROR"}`, logged via `Pgbus.logger` and never swallowed). Unknown paths return `404`; non-`GET` returns `405`. `DEGRADED` deliberately stays ready — only the silent-wedge `STALLED` signal fails readiness. Mount it in a web pod: `mount Pgbus::Web::HealthApp.new => "/pgbus/health"`. For worker pods that run the supervisor (no Puma to mount into), set `config.health_port` (Integer, default `nil` = disabled) and `config.health_bind` (default `"127.0.0.1"`) and `Supervisor#run` serves both paths standalone via `Pgbus::Web::HealthServer` (`lib/pgbus/web/health_server.rb`) — a single accept-loop thread over a `TCPServer` that parses only the HTTP request line, synthesizes a minimal Rack env, and dispatches to the same `HealthApp#call`, so routing and the verdict→status mapping live in exactly one place. The analyzer is required lazily (it has no `mcp`-gem dependency), so the standalone server works without the optional `mcp` gem installed. Refs #218.
|
|
51
66
|
|
|
52
|
-
- **Observability: generic metrics adapter with Prometheus and StatsD backends.** `lib/pgbus/instrumentation.rb` documents 16 `pgbus.*` `ActiveSupport::Notifications` events, but the only shipped consumer was the AppSignal integration (`lib/pgbus/integrations/appsignal/subscriber.rb` subscribes 13 of them) — teams on Prometheus, Datadog, or plain StatsD got no metrics without hand-writing subscribers. New `Pgbus::Metrics` namespace (Zeitwerk-managed under `lib/pgbus/metrics/`, unlike the explicitly-required `integrations/`) adds a vendor-neutral second consumer: `Metrics::Backend` (interface — `increment(name, value = 1, tags = {})`, `gauge(name, value, tags = {})`, `histogram(name, value, tags = {})` — plus a `Null` no-op subclass); `Metrics::Subscriber` (mirrors the AppSignal subscriber's 13 subscriptions and identical `pgbus_`-prefixed metric names/tags — `pgbus_queue_job_count` with `status`, `pgbus_job_duration_ms`, `pgbus_messages_sent`/`pgbus_messages_read`, `pgbus_event_count`/`pgbus_event_duration_ms`, `pgbus_outbox_published`, `pgbus_recurring_enqueued`, `pgbus_worker_recycled`, `pgbus_stream_broadcast_count` — forwarding to the configured backend, every handler wrapped in a `safely` rescue+log so a raising backend never propagates into the producer thread); `Metrics::Backends::Prometheus` (dependency-free, Mutex-guarded in-process registry, histograms as sum+count summaries, text-exposition rendering with label escaping); `Metrics::PrometheusExporter` (a self-contained Rack app rendering exposition format v0.0.4, mountable like `StreamApp`, `503` when the configured backend is not Prometheus); and `Metrics::Backends::Statsd` (`UDPSocket` datagrams — `|c`/`|g`/`|ms` with DogStatsD `|#tag:value` tags, no gem dependency). Config keys in `lib/pgbus/configuration.rb`: `metrics_backend` (`nil` default | `:prometheus` | `:statsd` | a `Backend` instance — validated eagerly in `validate!`), `statsd_host` (`"127.0.0.1"`), `statsd_port` (`8125`)
|
|
67
|
+
- **Observability: generic metrics adapter with Prometheus and StatsD backends.** `lib/pgbus/instrumentation.rb` documents 16 `pgbus.*` `ActiveSupport::Notifications` events, but the only shipped consumer was the AppSignal integration (`lib/pgbus/integrations/appsignal/subscriber.rb` subscribes 13 of them) — teams on Prometheus, Datadog, or plain StatsD got no metrics without hand-writing subscribers. New `Pgbus::Metrics` namespace (Zeitwerk-managed under `lib/pgbus/metrics/`, unlike the explicitly-required `integrations/`) adds a vendor-neutral second consumer: `Metrics::Backend` (interface — `increment(name, value = 1, tags = {})`, `gauge(name, value, tags = {})`, `histogram(name, value, tags = {})` — plus a `Null` no-op subclass); `Metrics::Subscriber` (mirrors the AppSignal subscriber's 13 subscriptions and identical `pgbus_`-prefixed metric names/tags — `pgbus_queue_job_count` with `status`, `pgbus_job_duration_ms`, `pgbus_messages_sent`/`pgbus_messages_read`, `pgbus_event_count`/`pgbus_event_duration_ms`, `pgbus_outbox_published`, `pgbus_recurring_enqueued`, `pgbus_worker_recycled`, `pgbus_stream_broadcast_count` — forwarding to the configured backend, every handler wrapped in a `safely` rescue+log so a raising backend never propagates into the producer thread); `Metrics::Backends::Prometheus` (dependency-free, Mutex-guarded in-process registry, histograms as sum+count summaries, text-exposition rendering with label escaping); `Metrics::PrometheusExporter` (a self-contained Rack app rendering exposition format v0.0.4, mountable like `StreamApp`, `503` when the configured backend is not Prometheus); and `Metrics::Backends::Statsd` (`UDPSocket` datagrams — `|c`/`|g`/`|ms` with DogStatsD `|#tag:value` tags, no gem dependency). Config keys in `lib/pgbus/configuration.rb`: `metrics_backend` (`nil` default | `:prometheus` | `:statsd` | a `Backend` instance — validated eagerly in `validate!`), `statsd_host` (`"127.0.0.1"`), `statsd_port` (`8125`). The subscriber installs from a new `pgbus.metrics` engine initializer that mirrors the AppSignal one — `next if metrics_backend.nil?` so the default installs no subscription (zero overhead). AppSignal is untouched; both integrations can run simultaneously. README documents configuration, the emitted metrics, and mounting the exporter. Refs #219.
|
|
53
68
|
|
|
54
69
|
- **DX: `pgbus dlq` — dead-letter management from the CLI.** Dead-letter inspect/drain operations existed only behind the mounted web dashboard (`Web::DataSource#dlq_messages`/`#dlq_message_detail`/`#retry_dlq_message`/`#discard_dlq_message`/`#retry_all_dlq`/`#discard_all_dlq`), so headless deployments and incident runbooks could not inspect or drain a DLQ without ad-hoc SQL — which project rules forbid. New `Pgbus::CLI::DLQ` (`lib/pgbus/cli/dlq.rb`), dispatched from `CLI.start`, adds five subcommands that route through `Web::DataSource` so retry semantics stay identical to the dashboard (origin-queue re-enqueue by stripping `DEAD_LETTER_SUFFIX`, transactional produce+delete, lock release on discard) with zero raw SQL and no direct PGMQ calls: `pgbus dlq list [--page N] [--per-page N]` (table of msg_id, DLQ queue, origin queue, read_ct, enqueued_at plus a total-count footer — never payloads), `pgbus dlq show MSG_ID` (message metadata with the payload passed through `Web::PayloadFilter.filter_json` so the dashboard's sensitive-data filtering applies identically), `pgbus dlq retry MSG_ID` (resolve the DLQ queue via `dlq_message_detail`, then `retry_dlq_message`), `pgbus dlq retry-all` (prints the re-enqueued count), and `pgbus dlq purge MSG_ID` / `pgbus dlq purge --all --yes` (`--all` without `--yes` makes no changes and exits 1). An unknown msg_id and an unknown subcommand both exit 1. Refs #216.
|
|
55
70
|
|
|
56
71
|
- **DX: boot diagnostics banner at supervisor start.** `Supervisor#run` logged only `"[Pgbus] Supervisor starting pid=…"` — the settings that decide whether a deployment actually works (which database it connected to, LISTEN/NOTIFY vs polling, the resolved pool size, which roles and capsules boot) were never stated, so a misconfigured deployment that sat idle forced operators to attach a console and inspect `Pgbus.configuration` by hand. `Supervisor#run` now emits a one-block banner (a run of `"[Pgbus] boot:"`-prefixed `info` lines that render cleanly under both the `:text` and `:json` log formatters) right after `start_heartbeat` and before `bootstrap_queues`: `Pgbus::VERSION`; the connection target reduced to `host/dbname` (never the password) across all three `connection_options` forms — `database_url` string, `connection_params` hash, and AR-derived hash; `config.resolved_pool_size`; `config.pgmq_schema_mode` and the installed PGMQ version (best-effort via `Client#pgmq_schema_version`, `unknown` on any error); `config.listen_notify` and `config.worker_notify_wakeup?`; the roles that will actually boot honoring `config.roles`; and one line per worker capsule (name or `anonymous`, queues, threads, execution mode via `config.execution_mode_for`) and per event consumer (topics, threads). Every DB-dependent field is wrapped so a transient failure degrades that field to `unknown` — the banner can never abort boot. Refs #213.
|
|
57
72
|
|
|
58
|
-
- **DX: configuration is now validated eagerly at boot.** `Configuration#validate!` had no call site in `lib/` — `Pgbus.configure` just yielded
|
|
73
|
+
- **DX: configuration is now validated eagerly at boot.** `Configuration#validate!` had no call site in `lib/` — `Pgbus.configure` just yielded, so an invalid value like `visibility_timeout = 0` sat dormant until a worker code path consumed it, failing at runtime far from the misconfiguration (the opposite of the fail-loud-at-boot behavior `roles=` and the duration setters already had). `Pgbus.configure` now runs `configuration.validate!` after the yield, so a bad value aborts Rails boot with an error naming the offending key. `validate!` stays DB-free (pure value checks), so eager validation adds no boot-time DB dependency; defaults are valid, so partial and sequential `configure` blocks pass. A new `attr_accessor :eager_validation` (default `true`) is the escape hatch for exotic setups that intentionally hold a transiently-invalid config: set `eager_validation = false` before or inside a `configure` block to suppress the automatic call, and explicit `validate!` still works. **Backward-incompatible in one direction:** an invalid-but-previously-unread config now raises at boot instead of silently later; that is the intended change, and `eager_validation = false` restores the old behavior. (The 1.0 YAML-config removal later dropped the `config/pgbus.yml` load path referenced by the original version of this note.) Refs #215.
|
|
59
74
|
|
|
60
75
|
- **DX: `pgbus doctor` — a single preflight command for deploy/CI gating.** Nothing answered "is this pgbus environment healthy enough to run?" in one place: config validity was never checked at boot (`Configuration#validate!` was never called), DB reachability only surfaced when the supervisor forked children that crash-looped, PGMQ version lived in `rake pgbus:pgmq:status`, and the process-liveness verdict was reachable only through the MCP stdio server (which needs the optional `mcp` gem). New `Pgbus::Doctor#run` runs six checks — each a `{ name:, status: :ok|:warn|:fail, detail: }` — and `#success?`: (1) `Configuration#validate!`, (2) DB connectivity via a new public `Client#ping` (`SELECT 1` through `with_raw_connection`), (3) PGMQ schema presence and installed-vs-vendored version via a new `Client#pgmq_schema_version` (the same tracking-table read `pgbus:pgmq:status` uses, kept inside the Client so no raw SQL escapes it), (4) queue existence — configured queues (via `Client#configured_queues`, prefixed through `config.queue_name`) diffed against `Client#list_queues`, (5) LISTEN/NOTIFY liveness per configured queue via a new `Client#notify_enabled?`, and (6) process liveness via `Pgbus::MCP::HealthAnalyzer` (required directly — it only needs a `Web::DataSource`, not the `mcp` gem). Doctor never touches PGMQ/PostgreSQL directly and never raises: a broken environment turns every probe into a `:fail` result rather than a crash. `#report` prints one line per check plus a resolved-config summary (queue_prefix, default_queue, pgmq_schema_mode, resolved_pool_size, roles, capsules) with passwords redacted from `database_url`/`connection_params`. Wired into `pgbus doctor` (exit 1 unless `success?`, so it gates a deploy or CI run) and `rake pgbus:doctor`. Refs #212.
|
|
61
76
|
|
|
@@ -83,11 +98,11 @@
|
|
|
83
98
|
|
|
84
99
|
- **Streams: `Pgbus.stream_key` is idempotent for an already-built key, plus `Pgbus.stream_key!`.** A single `String` argument is now treated as a pre-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 `turbo_stream_from` and the broadcaster without `stream_key("chat:lobby")` raising `ArgumentError`. The guard still fires for the genuinely ambiguous multi-fragment join (`stream_key("a:b", :c)`), and `Symbol`/record fragments with colons are still rejected (a colon there never came from `stream_key`). `Pgbus.stream_key!(key)` accepts a pre-built key explicitly (String required, budget enforced). Refs #167.
|
|
85
100
|
|
|
86
|
-
- **Observability: StatBuffer flushes on drain entry and its flush thresholds are now configurable.** `Pgbus::StatBuffer` accumulated up to `DEFAULT_FLUSH_SIZE = 100` entries / `DEFAULT_FLUSH_INTERVAL = 5`s of job stats in memory, flushed only by the worker's periodic `flush_if_due` tick and the drain-loop `stop`. When the supervisor watchdog `SIGKILL`s a stalled worker — which cannot be trapped — everything buffered since the last flush was silently lost, and the drain phase's up-to-30s wait widened that window. `Worker#graceful_shutdown` and `Worker#check_recycle` now call `@stat_buffer&.flush` immediately after `transition_to(:draining)`, so entering drain on either a graceful `TERM` or a recycle threshold (`max_jobs`/`max_memory`/`max_lifetime`) persists the buffer before the watchdog can intervene. Both run on the main loop thread (signals are dispatched via `process_signals`, never in trap context), so the DB write is safe; `flush` is thread-safe and no-ops on an empty buffer and when stats are disabled (`@stat_buffer` is nil). New config keys `stats_flush_size` (default `100`, positive integer) and `stats_flush_interval` (default `5`, positive number) on `Configuration` are validated in `validate
|
|
101
|
+
- **Observability: StatBuffer flushes on drain entry and its flush thresholds are now configurable.** `Pgbus::StatBuffer` accumulated up to `DEFAULT_FLUSH_SIZE = 100` entries / `DEFAULT_FLUSH_INTERVAL = 5`s of job stats in memory, flushed only by the worker's periodic `flush_if_due` tick and the drain-loop `stop`. When the supervisor watchdog `SIGKILL`s a stalled worker — which cannot be trapped — everything buffered since the last flush was silently lost, and the drain phase's up-to-30s wait widened that window. `Worker#graceful_shutdown` and `Worker#check_recycle` now call `@stat_buffer&.flush` immediately after `transition_to(:draining)`, so entering drain on either a graceful `TERM` or a recycle threshold (`max_jobs`/`max_memory`/`max_lifetime`) persists the buffer before the watchdog can intervene. Both run on the main loop thread (signals are dispatched via `process_signals`, never in trap context), so the DB write is safe; `flush` is thread-safe and no-ops on an empty buffer and when stats are disabled (`@stat_buffer` is nil). New config keys `stats_flush_size` (default `100`, positive integer) and `stats_flush_interval` (default `5`, positive number) on `Configuration` are validated in `validate!` and passed into `StatBuffer.new` by the worker — tune them down for a tighter loss window on high-throughput deployments or up to reduce insert frequency. The residual loss window (≤ `stats_flush_interval` seconds / `stats_flush_size` entries lost only on an un-trappable `SIGKILL`, affecting insights accuracy only — never a job payload) is documented in the README stats section. Refs #220.
|
|
87
102
|
|
|
88
103
|
### Changed
|
|
89
104
|
|
|
90
|
-
- **DX: worker and event-consumer config keys are normalized once, at assignment.** Worker/consumer config hashes arrived with symbol keys (the Ruby DSL and `capsule`) or
|
|
105
|
+
- **DX: worker and event-consumer config keys are normalized once, at assignment.** Worker/consumer config hashes arrived with symbol keys (the Ruby DSL and `capsule`) or, from an explicit `Array` literal, string keys, so every read site did a dual `w[:x] || w["x"]` lookup — and any new option read without the string fallback silently ignored a string-keyed value, a recurring bug class. `Configuration#workers=` now maps each `Array`-form entry through a private `normalize_entry` (`transform_keys(&:to_sym)`; entries are flat hashes, no deep recursion) and raises `ArgumentError` for a non-Hash entry; `event_consumers` becomes an `attr_reader` plus a matching `event_consumers=` setter (nil passes through). The string/DSL path already yielded symbols, so it is unaffected. With both setters normalizing, the string fallbacks were deleted from every downstream site — `supervisor` (`fork_worker`, `fork_consumer`), `client` (`collect_configured_queues`), `configuration` (`execution_mode_for`, `validate!`, `capsule_name`, `validate_no_queue_overlap!`, `sum_thread_counts`), `doctor` (`capsule_summary`), and `cli` (`apply_capsule_filter`) — which now use symbol access only. The CLI `--queues`/`--capsule` overrides route through `workers=` too, so they get normalization for free. Out of scope: the DB `metadata` JSON read in `consumer_priority` (Postgres always yields string keys there, so no symbol fallback exists to remove). Refs #214.
|
|
91
106
|
|
|
92
107
|
### Breaking Changes
|
|
93
108
|
|
|
@@ -114,7 +129,7 @@
|
|
|
114
129
|
- **Defensive retry on stale pooled pgmq connections in the enqueue path.** `Pgbus::Client#send_message`, `#send_batch`, and `#publish_to_topic` now retry once when `@pgmq.produce*` raises `PGMQ::Errors::ConnectionError` with a message indicating the pooled `PG::Connection` was killed beneath pgmq-ruby — typically by PgBouncer hitting `server_idle_timeout` / `client_idle_timeout`, an admin disconnect, or a TCP RST. Observed in production as `PQsocket() can't get socket descriptor` on the first produce following an idle window. pgmq-ruby's `auto_reconnect` recovers on the *next* pool checkout, so a single retry is sufficient; non-stale errors (pool timeout, misconfiguration, unreachable database) still propagate unchanged. Upstream pgmq-ruby fix for the underlying misclassification is in-flight at mensfeld/pgmq-ruby#94.
|
|
115
130
|
- **Stale-connection retry now backs off and attempts twice.** The single immediate retry above recovers one dead pooled socket, but during a PgBouncer restart or a brief failover window the immediate second attempt lands in the same unavailable window and the error propagated — failing an enqueue the caller may never retry. `Pgbus::Client#with_stale_connection_retry` now retries a matched stale error up to `STALE_RETRY_ATTEMPTS` (2) times with a short backoff between attempts (`STALE_RETRY_DELAYS = [0.1, 0.5]`), giving a transient window time to clear. Safety is unchanged: the pattern list still matches only pre-flight/idle-socket errors (no SQL was sent), so retrying non-idempotent ops stays safe. The backoff runs error-path only — never on the success path — and the sleep is issued outside `synchronized`, so `@pgmq_mutex` is never held during a delay on the shared-connection path. No happy-path throughput or allocation change (`client_bench` identical before/after). Refs #196.
|
|
116
131
|
- **Stalled dispatcher and scheduler are no longer reported healthy on the processes page.** `Web::DataSource#derive_process_status` evaluated the loop beacon only for workers, returning `:healthy` for every other kind, and neither the dispatcher (`{ pid }`) nor the scheduler (`{ pid, tasks }`) heartbeat carried a beacon. Because heartbeats run on an independent `Concurrent::TimerTask` thread, a dispatcher wedged inside `run_maintenance` (e.g. a stuck vacuum in `run_table_maintenance`) or a scheduler stuck in `tick` kept heartbeating and showed healthy forever while doing no work. Both processes now stamp a wall-clock loop beacon (`@loop_tick_at`, a `Concurrent::AtomicReference`) at the top of each main-loop iteration and pass `loop_tick_supplier:` to their heartbeat, mirroring `Worker#stamp_loop_tick`; the heartbeat persists it as `metadata.loop_tick_at` on every beat. `derive_process_status` now evaluates the beacon for `"dispatcher"` and `"scheduler"` too, with kind-aware thresholds that account for each loop's sleep — workers keep `config.stall_threshold` (90s), dispatcher uses `stall_threshold + config.dispatch_interval`, scheduler uses `stall_threshold + config.recurring_schedule_interval` — so a beacon older than the threshold renders the `:stalled` badge. A missing beacon (an older process during a rolling deploy) stays `:healthy`, and `stall_threshold = nil` disables the check as before. Worker stall detection is unchanged. Refs #222.
|
|
117
|
-
- **Config hardening:
|
|
132
|
+
- **Config hardening: silent-failure holes closed.** `Configuration#validate!` now validates `statsd_host`/`statsd_port`/`health_bind` unconditionally alongside the other keys added in the same window — a typo'd host or an out-of-range port previously passed boot and only failed later when the UDP socket opened or the health server bound. (This entry originally also documented two YAML-config hardening fixes — `ConfigLoader.sectioned?` misclassification and a `ConfigConverter::DEPRECATED_SETTINGS` `pool_size` drop — but the entire `config/pgbus.yml` surface was removed in 1.0, so those no longer apply.) Refs #276.
|
|
118
133
|
|
|
119
134
|
## [0.9.7] - 2026-06-29
|
|
120
135
|
|
data/README.md
CHANGED
|
@@ -138,12 +138,11 @@ The capsule string DSL is the shortest form for the common case. Use `c.capsule`
|
|
|
138
138
|
|
|
139
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
140
|
|
|
141
|
-
> **Upgrading from an older pgbus?** Run `rails generate pgbus:update`. It
|
|
141
|
+
> **Upgrading from an older pgbus?** Run `rails generate pgbus:update`. It 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.
|
|
142
142
|
>
|
|
143
|
-
> -
|
|
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.
|
|
143
|
+
> Useful flags: `--dry-run` (print the plan without creating files), `--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.
|
|
145
144
|
>
|
|
146
|
-
>
|
|
145
|
+
> **YAML config was removed in 1.0.** `config/pgbus.yml` is no longer loaded at boot; if one is present, pgbus warns once at boot that it's inert. Port its settings into `config/initializers/pgbus.rb` as a `Pgbus.configure` block (see the example above) and delete the YAML — the last release to auto-convert it was 0.9.x via `pgbus:update`.
|
|
147
146
|
>
|
|
148
147
|
> 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).
|
|
149
148
|
|
|
@@ -1214,7 +1213,6 @@ Without the plugin, Puma closes hijacked SSE sockets abruptly during graceful re
|
|
|
1214
1213
|
```ruby
|
|
1215
1214
|
Pgbus.configure do |c|
|
|
1216
1215
|
c.streams_enabled = true # default
|
|
1217
|
-
c.streams_queue_prefix = "pgbus_stream"
|
|
1218
1216
|
c.streams_default_retention = 5 * 60 # 5 minutes
|
|
1219
1217
|
c.streams_retention = { # per-stream overrides
|
|
1220
1218
|
/^chat_/ => 7 * 24 * 3600, # 7 days for chat history
|
|
@@ -1226,9 +1224,38 @@ Pgbus.configure do |c|
|
|
|
1226
1224
|
c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive + ensure_listening ack budget
|
|
1227
1225
|
c.streams_write_deadline_ms = 5_000 # write_nonblock deadline
|
|
1228
1226
|
c.streams_falcon_streaming_body = false # opt-in: Falcon-native streaming body
|
|
1227
|
+
c.streams_broadcast_queue = nil # dedicated queue for turbo-rails broadcast jobs (see below)
|
|
1229
1228
|
end
|
|
1230
1229
|
```
|
|
1231
1230
|
|
|
1231
|
+
#### Realtime broadcast isolation
|
|
1232
|
+
|
|
1233
|
+
The default `broadcasts_to` / `broadcasts_refreshes` model macros use turbo-rails' `broadcast_*_later_to` helpers, which enqueue the render+broadcast as a **background job** on the default queue. Delivery is isolated (the streamer runs in the web process with its own LISTEN connection), but the *enqueue-render hop* is not: under worker saturation, a broadcast job waits behind long-running jobs, so the browser sees the update only after a worker thread frees up.
|
|
1234
|
+
|
|
1235
|
+
**New installs get this out of the box:** `rails generate pgbus:install` ships `config/initializers/pgbus.rb` with `c.streams_broadcast_queue = "realtime"` and a dedicated `realtime` worker capsule. The code default is `nil` (so a programmatic `Pgbus.configure` and existing installs are unchanged) — the generated initializer is where the recommended setup lives.
|
|
1236
|
+
|
|
1237
|
+
Two ways to keep broadcasts off the critical path:
|
|
1238
|
+
|
|
1239
|
+
1. **Dedicated broadcast queue (recommended for `broadcasts_to`).** Route turbo-rails' broadcast jobs to their own queue and back it with a dedicated worker capsule:
|
|
1240
|
+
|
|
1241
|
+
```ruby
|
|
1242
|
+
c.streams_broadcast_queue = "realtime"
|
|
1243
|
+
c.workers = [
|
|
1244
|
+
{ queues: ["realtime"], threads: 3 }, # broadcasts get their own pool
|
|
1245
|
+
{ queues: ["*"], threads: 10 } # everything else
|
|
1246
|
+
]
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
pgbus applies the queue to `Turbo::Streams::ActionBroadcastJob`, `BroadcastJob`, and `BroadcastStreamJob` at boot. **The queue is only useful if a worker drains it** — `pgbus doctor` warns if `streams_broadcast_queue` is set but no capsule reads it (broadcasts would pile up unread), and warns in production if you use streams with turbo-rails but leave it unset.
|
|
1250
|
+
|
|
1251
|
+
2. **Synchronous broadcast (`durable:`).** Passing any non-nil `durable:` to the macros renders and broadcasts in the request thread — no queue hop at all — at the cost of the render happening on the web request:
|
|
1252
|
+
|
|
1253
|
+
```ruby
|
|
1254
|
+
class Message < ApplicationRecord
|
|
1255
|
+
broadcasts_to :room, durable: true # sync: renders + broadcasts inline
|
|
1256
|
+
end
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1232
1259
|
#### Falcon-native streaming body (opt-in)
|
|
1233
1260
|
|
|
1234
1261
|
When running on Falcon, enable native streaming body support for better integration with Falcon's fiber scheduler:
|
|
@@ -1243,7 +1270,7 @@ Without the flag (default), Falcon uses the same `rack.hijack` path as Puma via
|
|
|
1243
1270
|
|
|
1244
1271
|
### How it works
|
|
1245
1272
|
|
|
1246
|
-
Stream broadcasts are stored in PGMQ queues
|
|
1273
|
+
Stream broadcasts are stored in PGMQ queues named `#{queue_prefix}_<stream>` (e.g. `pgbus_chat_42`), the same namespace as job queues; the `pgbus_stream_queues` registry records which of those queues back streams so maintenance and wildcard workers can tell them apart. Each broadcast is assigned a monotonic `msg_id` by PGMQ. The `pgbus_stream_from` helper captures the current `MAX(msg_id)` at render time and embeds it in the HTML as `since-id`. When the SSE client connects, it sends that cursor as `?since=` on the first request and as `Last-Event-ID` on reconnects. The streamer replays from `pgmq.q_*` (live) UNION `pgmq.a_*` (archive) for any `msg_id > cursor`, then switches to LISTEN/NOTIFY for the live path. There is no message identity gap between the render and the subscribe — the cursor model guarantees every broadcast is delivered exactly once, in order, even across reconnects.
|
|
1247
1274
|
|
|
1248
1275
|
One Puma worker (or Falcon reactor) hosts one `Pgbus::Web::Streamer::Instance` singleton with three threads (Listener / Dispatcher / Heartbeat) and one dedicated PG connection for LISTEN. Hijacked SSE sockets are held outside the web server's thread pool on Puma (confirmed by an integration test that fires 20 concurrent hijacked connections and observes them complete in parallel on an 8-thread Puma server, [puma/puma#1009](https://github.com/puma/puma/issues/1009)) and inside a fiber on Falcon (one fiber per hijacked connection, scheduler-backed non-blocking IO).
|
|
1249
1276
|
|
|
@@ -2056,7 +2083,7 @@ PostgreSQL + PGMQ
|
|
|
2056
2083
|
| `statsd_port` | `8125` | StatsD UDP port (used when `metrics_backend = :statsd`) |
|
|
2057
2084
|
| `health_port` | `nil` | Port for standalone HTTP liveness/readiness probes served by the supervisor; nil disables |
|
|
2058
2085
|
| `health_bind` | `"127.0.0.1"` | Bind address for the standalone health server |
|
|
2059
|
-
| `eager_validation` | `true` | Run `Configuration#validate!` automatically after `Pgbus.configure`
|
|
2086
|
+
| `eager_validation` | `true` | Run `Configuration#validate!` automatically after the `Pgbus.configure` block yields; an invalid value raises `Pgbus::ConfigurationError` at boot. Set `false` to suppress and validate manually. |
|
|
2060
2087
|
|
|
2061
2088
|
## Development
|
|
2062
2089
|
|
data/Rakefile
CHANGED
|
@@ -23,7 +23,8 @@ namespace :bench do
|
|
|
23
23
|
bench_dir = "benchmarks"
|
|
24
24
|
# Benches that need a real PostgreSQL/PGMQ (or boot Puma) — excluded from the
|
|
25
25
|
# no-DB unit suite that bench:all runs in CI.
|
|
26
|
-
db_benches = %w[connection_pool_bench integration_bench streams_bench
|
|
26
|
+
db_benches = %w[connection_pool_bench integration_bench streams_bench streams_read_pool_bench
|
|
27
|
+
execution_modes_bench pool_swap_bench pool_autoscale_bench job_burst_bench].freeze
|
|
27
28
|
# The unit suite is every *_bench.rb that doesn't need a database, derived
|
|
28
29
|
# from the directory so a new unit bench is picked up automatically (kept in
|
|
29
30
|
# sync with bench:one, which globs the same files).
|
|
@@ -63,6 +64,26 @@ namespace :bench do
|
|
|
63
64
|
ruby "benchmarks/streams_bench.rb"
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
desc "Run execution-mode connection benchmark (threads vs async pool usage; requires PGBUS_DATABASE_URL)"
|
|
68
|
+
task :execution_modes do
|
|
69
|
+
ruby "benchmarks/execution_modes_bench.rb"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
desc "Run streams-pool hot-swap benchmark (zero-loss/zero-leak/cost under load; requires PGBUS_DATABASE_URL)"
|
|
73
|
+
task :pool_swap do
|
|
74
|
+
ruby "benchmarks/pool_swap_bench.rb"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
desc "Run streams-pool autoscaler benchmark (tick cost + grow-under-load; requires PGBUS_DATABASE_URL)"
|
|
78
|
+
task :pool_autoscale do
|
|
79
|
+
ruby "benchmarks/pool_autoscale_bench.rb"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
desc "Run job-burst gate benchmark (#323 phase 3: is the job pool or DB pool the burst limiter?; requires PGBUS_DATABASE_URL)"
|
|
83
|
+
task :job_burst do
|
|
84
|
+
ruby "benchmarks/job_burst_bench.rb"
|
|
85
|
+
end
|
|
86
|
+
|
|
66
87
|
desc "Run a single benchmark: rake bench:one[client_bench]"
|
|
67
88
|
task :one, [:name] do |_t, args|
|
|
68
89
|
name = args[:name] or abort "Usage: rake bench:one[serialization_bench|client_bench|...]"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# Registry of physical PGMQ queue names that back streams (as opposed to
|
|
5
|
+
# job queues). Stream and job queues share the same `#{queue_prefix}_`
|
|
6
|
+
# namespace (see Configuration#queue_name), so there is no reliable way to
|
|
7
|
+
# tell them apart by name alone. `ensure_stream_queue` records each stream
|
|
8
|
+
# queue here on first broadcast/subscribe; consumers that iterate
|
|
9
|
+
# `pgmq.meta` (the dispatcher's stream-archive prune and orphan sweep,
|
|
10
|
+
# `compact_archives`, and the worker's wildcard resolution) use this
|
|
11
|
+
# registry to classify a queue as a stream.
|
|
12
|
+
#
|
|
13
|
+
# Degrades safely: on an install that has not run the migration, the table
|
|
14
|
+
# is absent — `record!` no-ops and `all_names` returns an empty set, so the
|
|
15
|
+
# pre-registry behavior (streams treated as job queues by maintenance) is
|
|
16
|
+
# preserved rather than raising.
|
|
17
|
+
class StreamQueue < BusRecord
|
|
18
|
+
self.table_name = "pgbus_stream_queues"
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Upserts the physical queue name. Idempotent and cheap to call on
|
|
22
|
+
# every broadcast; the caller (`ensure_stream_queue`) also memoizes
|
|
23
|
+
# per-process, so the DB write happens once per stream per process.
|
|
24
|
+
# Errors are swallowed — a registry hiccup must never abort a broadcast.
|
|
25
|
+
def record!(queue_name)
|
|
26
|
+
return unless table_exists?
|
|
27
|
+
|
|
28
|
+
upsert({ queue_name: queue_name }, unique_by: :queue_name)
|
|
29
|
+
# Keep the in-process cache consistent with the write so a subsequent
|
|
30
|
+
# stream? check reflects this registration without a re-query. Only
|
|
31
|
+
# update an ALREADY-LOADED cache — if @all_names is still nil (this
|
|
32
|
+
# process hasn't queried the registry yet), seeding it here would
|
|
33
|
+
# fabricate a one-entry set and silently hide every other
|
|
34
|
+
# already-registered stream until the next reset_cache!. Leaving it
|
|
35
|
+
# nil lets the next all_names call do a real load, which already
|
|
36
|
+
# includes this row since the upsert above has committed.
|
|
37
|
+
@all_names&.add(queue_name)
|
|
38
|
+
nil
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Pgbus.logger.debug { "[Pgbus] Failed to record stream queue #{queue_name}: #{e.message}" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Set of all registered physical stream queue names. Memoized so a
|
|
44
|
+
# dispatcher maintenance pass or a wildcard re-resolve does one query,
|
|
45
|
+
# not one per queue. Callers that need freshness (a long-lived process
|
|
46
|
+
# picking up newly-created streams) call `reset_cache!` at the top of
|
|
47
|
+
# their loop.
|
|
48
|
+
def all_names
|
|
49
|
+
@all_names ||= load_names
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stream?(queue_name)
|
|
53
|
+
all_names.include?(queue_name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Drops the memoized set so the next `all_names`/`stream?` re-queries.
|
|
57
|
+
# Called at the start of each dispatcher maintenance pass and each
|
|
58
|
+
# wildcard resolution so freshly-created streams are picked up without
|
|
59
|
+
# a process restart.
|
|
60
|
+
def reset_cache!
|
|
61
|
+
@all_names = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Memoized like StreamStat: a successful probe sticks; a transient
|
|
65
|
+
# error returns false without caching, so the next call retries.
|
|
66
|
+
def table_exists?
|
|
67
|
+
return @table_exists if defined?(@table_exists) && @table_exists
|
|
68
|
+
|
|
69
|
+
@table_exists = connection.table_exists?(table_name)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
Pgbus.logger.debug { "[Pgbus] Failed to check stream queue table: #{e.message}" }
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def load_names
|
|
78
|
+
return Set.new unless table_exists?
|
|
79
|
+
|
|
80
|
+
Set.new(all.pluck(:queue_name))
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
Pgbus.logger.debug { "[Pgbus] Failed to load stream queues: #{e.message}" }
|
|
83
|
+
Set.new
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "migration_path"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
module Generators
|
|
9
|
+
class AddStreamQueuesGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationPath
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Add stream queue registry so maintenance and wildcard workers can " \
|
|
16
|
+
"tell stream queues apart from job queues (issues #308, #309)"
|
|
17
|
+
|
|
18
|
+
class_option :database,
|
|
19
|
+
type: :string,
|
|
20
|
+
default: nil,
|
|
21
|
+
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
22
|
+
|
|
23
|
+
def create_migration_file
|
|
24
|
+
migration_template "add_stream_queues.rb.erb",
|
|
25
|
+
File.join(pgbus_migrate_path, "add_pgbus_stream_queues.rb")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def display_post_install
|
|
29
|
+
say ""
|
|
30
|
+
say "Pgbus stream queue registry installed!", :green
|
|
31
|
+
say ""
|
|
32
|
+
say "Without this table, stream queues are indistinguishable from job"
|
|
33
|
+
say "queues: per-stream archive retention and the orphan sweep skip them,"
|
|
34
|
+
say "and wildcard ('*') workers can claim durable broadcasts. Existing"
|
|
35
|
+
say "streams register themselves on their next broadcast after migrating."
|
|
36
|
+
say ""
|
|
37
|
+
say "Next steps:"
|
|
38
|
+
say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
|
|
39
|
+
say " 2. Restart pgbus: bin/pgbus start"
|
|
40
|
+
say ""
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def migration_version
|
|
46
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -12,7 +12,7 @@ module Pgbus
|
|
|
12
12
|
|
|
13
13
|
source_root File.expand_path("templates", __dir__)
|
|
14
14
|
|
|
15
|
-
desc "Install Pgbus: create migration,
|
|
15
|
+
desc "Install Pgbus: create migration, initializer, and binstub"
|
|
16
16
|
|
|
17
17
|
class_option :pgmq_schema_mode,
|
|
18
18
|
type: :string,
|
|
@@ -32,7 +32,7 @@ module Pgbus
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def create_config_file
|
|
35
|
-
template "
|
|
35
|
+
template "initializer.rb.erb", "config/initializers/pgbus.rb"
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def create_binstub
|
|
@@ -100,7 +100,7 @@ module Pgbus
|
|
|
100
100
|
say ""
|
|
101
101
|
say "Next steps:"
|
|
102
102
|
say " 1. Run: rails db:migrate#{":#{database_name}" if separate_database?}"
|
|
103
|
-
say " 2. Edit config/pgbus.
|
|
103
|
+
say " 2. Edit config/initializers/pgbus.rb to configure workers"
|
|
104
104
|
say " 3. Start processing: bin/pgbus start"
|
|
105
105
|
say ""
|
|
106
106
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AddPgbusStreamQueues < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :pgbus_stream_queues do |t|
|
|
4
|
+
t.string :queue_name, null: false
|
|
5
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
add_index :pgbus_stream_queues, :queue_name,
|
|
9
|
+
unique: true, name: "idx_pgbus_stream_queues_queue_name"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Pgbus configuration — https://github.com/mhenrixon/pgbus
|
|
4
|
+
#
|
|
5
|
+
# This is the real config surface. Every setting has a sensible default, so an
|
|
6
|
+
# empty block gives you a working install; uncomment and edit what you need.
|
|
7
|
+
# Values below that are commented out show the gem default for reference.
|
|
8
|
+
Pgbus.configure do |c|
|
|
9
|
+
# --- Queues -------------------------------------------------------------
|
|
10
|
+
# c.queue_prefix = "pgbus" # prefix for every PGMQ queue
|
|
11
|
+
# c.default_queue = "default" # queue used when a job names none
|
|
12
|
+
|
|
13
|
+
# --- Connection pool ----------------------------------------------------
|
|
14
|
+
# pool_size auto-tunes from your worker/consumer thread counts:
|
|
15
|
+
# sum(workers.threads) + sum(event_consumers.threads) + 2
|
|
16
|
+
# Set it explicitly only to force a tighter or looser pool than that.
|
|
17
|
+
# c.pool_size = 5
|
|
18
|
+
# c.pool_timeout = 5
|
|
19
|
+
|
|
20
|
+
# --- Wake-up + visibility ----------------------------------------------
|
|
21
|
+
# c.listen_notify = true # LISTEN/NOTIFY for instant job wake-up
|
|
22
|
+
# c.visibility_timeout = 30 # seconds a message stays invisible after a read
|
|
23
|
+
|
|
24
|
+
# --- Retries + idempotency ---------------------------------------------
|
|
25
|
+
# c.max_retries = 5 # reads before a message goes to its DLQ
|
|
26
|
+
# c.idempotency_ttl = 7.days # event dedup TTL
|
|
27
|
+
|
|
28
|
+
# --- Workers ------------------------------------------------------------
|
|
29
|
+
# Named capsules, each draining an explicit, non-overlapping set of queues.
|
|
30
|
+
# Tune the queue names and thread counts to your app.
|
|
31
|
+
#
|
|
32
|
+
# `c.workers = []` first clears the built-in default capsule
|
|
33
|
+
# (queues: %w[default], threads: 5) that pgbus seeds for a zero-config
|
|
34
|
+
# install; without it the named capsules below would be *appended* and the
|
|
35
|
+
# "default" queue would be drained twice.
|
|
36
|
+
c.workers = []
|
|
37
|
+
c.capsule :default, queues: %w[critical default], threads: 5
|
|
38
|
+
c.capsule :low, queues: %w[low], threads: 2
|
|
39
|
+
|
|
40
|
+
# --- Worker recycling (prevents memory bloat) --------------------------
|
|
41
|
+
c.max_jobs_per_worker = 10_000
|
|
42
|
+
c.max_memory_mb = 512
|
|
43
|
+
# c.max_worker_lifetime = 3600 # seconds
|
|
44
|
+
|
|
45
|
+
# --- Dispatcher (maintenance tasks) ------------------------------------
|
|
46
|
+
# c.dispatch_interval = 1.0
|
|
47
|
+
|
|
48
|
+
# --- Realtime broadcast isolation (turbo-rails) ------------------------
|
|
49
|
+
# Route turbo-rails' async render+broadcast jobs to a dedicated queue so a
|
|
50
|
+
# browser SSE update never waits behind long-running jobs (#311). The
|
|
51
|
+
# `realtime` capsule below drains it — keep both, or drop both, together.
|
|
52
|
+
# `pgbus doctor` warns if this queue has no worker to drain it.
|
|
53
|
+
c.streams_broadcast_queue = "realtime"
|
|
54
|
+
c.capsule :realtime, queues: %w[realtime], threads: 3
|
|
55
|
+
|
|
56
|
+
# --- Event consumers (event bus) ---------------------------------------
|
|
57
|
+
# Uncomment to consume routing-key topics off the bus.
|
|
58
|
+
# c.event_consumers = [
|
|
59
|
+
# { topics: ["orders.#"], threads: 3 },
|
|
60
|
+
# { topics: ["notifications.#"], threads: 1 }
|
|
61
|
+
# ]
|
|
62
|
+
end
|
|
@@ -147,6 +147,19 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
147
147
|
add_index :pgbus_recurring_executions, :run_at,
|
|
148
148
|
name: "idx_pgbus_recurring_executions_cleanup"
|
|
149
149
|
|
|
150
|
+
# Stream queue registry — records which PGMQ queues back streams (vs jobs).
|
|
151
|
+
# Stream and job queues share the pgbus_ namespace, so maintenance
|
|
152
|
+
# (per-stream retention, orphan sweep) and wildcard workers rely on this
|
|
153
|
+
# table to tell them apart. Populated by ensure_stream_queue on first
|
|
154
|
+
# broadcast. See issues #308, #309.
|
|
155
|
+
create_table :pgbus_stream_queues do |t|
|
|
156
|
+
t.string :queue_name, null: false
|
|
157
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
add_index :pgbus_stream_queues, :queue_name,
|
|
161
|
+
unique: true, name: "idx_pgbus_stream_queues_queue_name"
|
|
162
|
+
|
|
150
163
|
# Create default queues via PGMQ
|
|
151
164
|
execute "SELECT pgmq.create('pgbus_default')"
|
|
152
165
|
execute "SELECT pgmq.create('pgbus_default_dlq')"
|
|
@@ -167,6 +180,7 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
167
180
|
execute "SELECT pgmq.drop_queue('pgbus_default_dlq')"
|
|
168
181
|
execute "SELECT pgmq.drop_queue('pgbus_default')"
|
|
169
182
|
|
|
183
|
+
drop_table :pgbus_stream_queues
|
|
170
184
|
drop_table :pgbus_recurring_executions
|
|
171
185
|
drop_table :pgbus_recurring_tasks
|
|
172
186
|
drop_table :pgbus_batches
|