pgbus 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3393ad40ec489306eff74a6504c6694aa27747e5e0d2ce533ba4b7d7e989fe93
4
- data.tar.gz: b1e7244a064bfa8fe1776a5eaa9dd7317682e098bf98133759eadc0c6402e4c8
3
+ metadata.gz: 916f349cd5f3740aca0e462acdadeb3ad7322b0ce8f0363fa78d2328ac3c7c37
4
+ data.tar.gz: 13100607efcb25262f7d4708355d600009aff7f66e961d197d00fe2013ab9a88
5
5
  SHA512:
6
- metadata.gz: 440cfbc362912153a1cfb694033e667b32559a965b5b2b8c99214d11bc06a09d717127e8616a49dc59858f6a8048619a0d4a0267398f5cccf78019ca2f0dedf3
7
- data.tar.gz: 45163ef47567b3ff8be5e4c415311f6619d11a4712ecbc970aafe7e949cc09ebd75632727ebf6b7817c124cfbb9dec0cf3c9d29c41061d37704048c9522d6851
6
+ metadata.gz: 325b359be3de92b27add05e70ac9437b813df2e464ad1ceca2fbd3fe2114c4a52471e7e5f23564765e74d6c8330068cf0d9ab9ccaddd9443d98ab69088a1ee34
7
+ data.tar.gz: 5fb4ee93c03a463cddcfa490a438ae6d267fc12a23c633a7ee82f45754fde6232a23f5da2772df45f4653efbee4018b7b336adb79f50bf4e1ccc0438c4718971
data/CHANGELOG.md CHANGED
@@ -1,7 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Changed
4
+
5
+ - **README: doctor table and configuration reference catch up to the shipped surface (issue #369).** The doctor section still said "six checks" and listed only the original six; `Pgbus::Doctor::CHECKS` has 10 (GlobalID allowlist, Broadcast queue, Primary affinity, Dedicated connections were missing — the first two are the ones most likely to warn after upgrade on nil defaults). The configuration reference table was missing ~20 shipped options operators actually hit: `allowed_global_id_models` (incl. `[]` = deny-all), streams GC (`streams_orphan_threshold` / `streams_orphan_sweep_interval` — 24h / hourly defaults that auto-drop dormant durable stream queues), retention/durable/presence/broadcast-queue knobs, `streams_pool_*` / LISTEN connection overrides, `doctor_on_boot`, `require_primary`, `connects_to`, `zombie_detection`, `connection_guc_mode`, `worker_notify_*`, `group_mode`, and neighbors. A unit drift guard pins the README doctor table to every `Doctor::CHECKS` name. Full typed reference remains on the docs site. Refs #369.
6
+
3
7
  ### Fixed
4
8
 
9
+ - **SIGTERM no longer segfaults the process: both LISTEN listeners stopped closing their PG connection from the stopping thread (issue #375).** `Process::NotifyListener#stop` (supervisor thread, on SIGTERM) closed the listener's dedicated `PG::Connection` to interrupt the blocking `wait_for_notify`, but left `@conn` pointing at it — so the listener thread unwound into `run_loop`'s `ensure` and ran `safe_unlisten_all`, exec'ing `UNLISTEN` on a connection the other thread was concurrently freeing. `PG::Connection#close` is `PQfinish`: it frees the PGconn **and its OpenSSL objects**, so `PQsendQuery` walked into freed TLS state and the whole worker died with `[BUG] Segmentation fault` — reproducibly, on essentially every container-replacement deploy against a TLS Postgres, sometimes in several forked workers at once. `rescue PG::Error` cannot catch a C-level SEGV, so the "safe" in `safe_unlisten_all` never held. **The connection is now single-owner**: the listener thread is the only thread that may exec, wait, or close on it, from build through teardown. `#stop` signals by clearing `@running` and joining — nothing more — and the listener thread closes its own connection in `run_loop`'s `ensure`. The stop is observed within one `wait_for_notify` timeout (`health_check_ms`, **250 ms** in the default configuration since workers/consumers derive it from `polling_interval`), so `#stop`'s join budget is now `health_check_ms + 5s` instead of a flat 5 s — a flat timeout could expire before a listener with a large `health_check_ms` had even one chance to observe the stop. The teardown `UNLISTEN` round-trip is **dropped entirely** rather than merely made safe: it ran immediately before the close, and closing a session deregisters every `LISTEN` server-side, so it bought nothing at any time. The identical defect in `Web::Streamer::Listener` — same close-from-`#stop`, same `UNLISTEN`-in-`ensure`, and there without even a mutex around `@conn` — is fixed the same way, so the dashboard's Puma worker stops crashing on the same deploy; its listener thread now also closes its own connection (previously only `#stop` did). Cost: shutdown can take up to one health-check cycle longer per listener — measured against a real PostgreSQL at the default `health_check_ms` of 250: **~207 ms on a fully idle queue, ~5 ms when the queue has any NOTIFY traffic** (a notification returns the wait immediately, and the loop then sees the cleared flag). 40 start/stop cycles under concurrent NOTIFY load leaked zero `LISTEN` backends. Thanks to the reporter for the crash dumps and the root-cause analysis. Refs #375.
10
+ - **The health verdict no longer emits false STALLED reports — `max_read_ct` was never populated, and the wedge signal counted queues no capsule drains (issue #367).** Two correctness defects in `Pgbus::MCP::HealthAnalyzer` (surfaced through `pgbus doctor` / `pgbus_health` / the MCP health tool). **(1)** The `all_unread?` wedge check read `:max_read_ct`, but the metrics query never selected it — so the guard always degenerated to "never claimed" and a busy-but-healthy queue caught mid-burst produced a STALLED verdict with a factually wrong "read_ct=0 (never claimed)" reason. The metrics query now exposes a per-queue **`visible_unread_length`** (`count(*) WHERE vt <= NOW() AND read_ct = 0`) and the analyzer keys the wedge off *visible, never-claimed* messages. Counting per visible message (not `max(read_ct)` over the whole table) means one retried message left in-queue after its backoff, or an in-flight message claimed by a peer, can no longer veto the signal for a pile of genuinely-unclaimed jobs. **(2)** The verdict reasoned about **every** non-DLQ/non-stream queue against the global worker fleet — but a worker can only claim from queues its capsule subscribes to, so "M workers alive but never claimed" was vacuous for a queue nobody drains (ad-hoc queues, unregistered stream queues — see #366). The analyzer now intersects the STALLED backlog with `Web::DataSource#drained_queue_names` (each configured capsule's queues, priority `_pN` sub-tables expanded via the client's queue strategy, unioned with EventBus handler queues; `nil` for a `*` wildcard = drains everything, fail-open on error). Queues nobody drains get their own DEGRADED signal — *"N queue(s) hold messages but no capsule is configured to drain them"* — instead of being folded into the worker-wedge verdict. A heart-beating-but-`:stalled` worker is still reported STALLED regardless of which queue holds the backlog. Reason strings now truncate to the first 10 queue names with `(+N more)` so a flagged fleet of hundreds of queues doesn't produce a multi-KB log line. Refs #367, #366.
11
+ - **`allowed_global_id_models` now actually guards ActiveJob arguments, not only EventBus payloads (issue #368).** The doctor warned in production that `nil` means "allow-all GlobalID arguments", but the allowlist was only enforced in `Serializer.locate_global_id` — reached from EventBus `_global_id` payloads — while the ordinary job path (`Executor` → `ActiveJob::Base.deserialize` → Rails' unrestricted `GlobalID::Locator`) never checked it. Operators who set an allowlist after following the doctor had a false sense of security; the common `SomeJob.perform_later(record)` pattern was unguarded. Job deserialization now goes through `Serializer.deserialize_job_data`, which walks `_aj_globalid` keys (including nested arrays/hashes) and reuses the same gate as EventBus when the allowlist is set; `nil` remains zero-cost allow-all. Rejected models raise `Pgbus::SerializationError` and are treated as a normal job failure. Apps with ActiveStorage attachments should include `ActiveStorage::Blob` (and related models) on the allowlist. Docs + doctor copy updated. Refs #368.
12
+ - **Dormant pre-registry stream queues no longer false-STALL the doctor or escape the orphan sweep (issue #366).** Stream queues created before the `pgbus_stream_queues` migration only register on their next broadcast — completed checkout-style flows, ended chats, and one-shot progress streams never broadcast again, so they stay unregistered forever. Health/doctor then treated their permanent `read_ct=0` visible backlog as a worker wedge (STALLED on ~hundreds of healthy stream queues), and the orphan sweep skipped them because it only iterated the registry. Detection is fingerprint-based (the archive `a_<queue>_msg_id_idx` index that only `ensure_stream_queue` creates — no name heuristics). `StreamQueue.known_names` = registry ∪ fingerprints, used by health exclusion, orphan sweep / stream-archive prune, and wildcard workers; `rake pgbus:streams:backfill_registry` persists the missing rows; doctor surfaces a DEGRADED hint pointing at the backfill when unregistered fingerprints remain; `add_stream_queues` generator post-install mentions the task. Refs #366.
5
13
  - **`ensure_stream_queue` recovers when a process-local `@queues_created` memo is stale after another process drops the physical queue.** Stream queues are created lazily and memoized per process. The dispatcher orphan stream sweep (and dashboard / manual `drop_queue`) only clears the *local* process memo when it drops an empty or aged stream queue. Peer processes — typically long-lived web workers that already published or subscribed once — keep the memo, skip `pgmq.create` on the next durable broadcast, and fail in `pgmq.enable_notify_insert` with `Queue "…" does not exist. Create it first using pgmq.create()`. That exception often surfaces from an `after_commit` Turbo/stream broadcast and turns a successful write into a 500. On that specific missing-queue `PGMQ::Errors::ConnectionError`, `ensure_stream_queue` now forgets the local queue + archive-index memo and retries create/notify once. Unrelated connection errors still raise immediately. Happy path: no extra round-trips (recovery is error-path only).
6
14
  - **`streams_pool_database_url` / `streams_pool_host` / `streams_pool_port` — route the streams PGMQ pool independently of the streamer's LISTEN connection (issue #358).** 0.12.0 started building the dedicated streams pool from `streams_connection_options` (correct for a separate streams database), but that method is also how pooler-bypass installs pin the LISTEN connection to the **direct** Postgres port (`streams_port = 5432`, the documented "workers go through PgBouncer, streamer goes direct" pattern) — so upgrading silently moved up to `streams_pool_size` connections **per process** onto the direct port, whose `max_connections` ceiling on managed Postgres is typically low. A modest fleet exhausts it (`FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute`): `StreamApp` then 500s every SSE connect and durable broadcasts fail at publish, intermittently, because the pools are lazy. Only LISTEN actually needs the direct port (it dies at transaction-pool COMMIT boundaries); the pool's broadcast INSERTs and replay reads are plain pooler-safe SQL. The new `streams_pool_*` triple routes the POOL independently — applied to the base options exactly like the `streams_*` and `worker_notify_*` groups: set `streams_pool_port` back to the pooled port and only the LISTEN pins remain direct. Default `nil` = follows `streams_connection_options`, byte-identical to 0.12.0, so separate-streams-DB installs are unchanged. Refs #358.
7
15
  - **The health verdict no longer reads durable stream queues as a wedged fleet (issue #359).** The Process-liveness signal (`pgbus doctor`, `pgbus_health`, the MCP health tool) treats *visible messages with `read_ct=0` while workers are alive* as the silent-worker-wedge signature — but durable stream delivery is a non-consuming peek, so **every** stream queue matches it permanently by design. On a streams-heavy install the verdict screamed STALLED listing hundreds of healthy stream queues, burying real wedges in noise. `HealthAnalyzer` now excludes queues registered in the `pgbus_stream_queues` registry from the operational set (exactly like DLQs) — out of the STALLED/DEGRADED reasons and the backlog totals — via a new `Web::DataSource#stream_queue_names` (loaded fresh per verdict; degrades to an empty set on pre-registry installs). Same bug class as #308/#309, same registry cure. Refs #359.
data/README.md CHANGED
@@ -1862,7 +1862,7 @@ pgbus help # Show help
1862
1862
 
1863
1863
  #### pgbus doctor
1864
1864
 
1865
- 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:
1865
+ A single preflight command that answers "is this environment healthy enough to run?" — useful as a deploy or CI gate. It runs 10 checks and never raises; a broken environment turns every probe into a failed/warned check instead of a crash:
1866
1866
 
1867
1867
  | Check | Fails (`:fail`) when | Warns (`:warn`) when |
1868
1868
  |---|---|---|
@@ -1872,6 +1872,10 @@ A single preflight command that answers "is this environment healthy enough to r
1872
1872
  | Queues | A configured queue has no PGMQ table | — |
1873
1873
  | LISTEN/NOTIFY | — | A configured queue is missing its insert trigger (falls back to polling) |
1874
1874
  | Process liveness | Verdict is `STALLED` | Verdict is `DEGRADED` |
1875
+ | GlobalID allowlist | — | `allowed_global_id_models` is `nil` (allow-all) in production |
1876
+ | Broadcast queue | — | Turbo broadcasts share the default queue in production, or `streams_broadcast_queue` is set but no worker capsule drains it |
1877
+ | Primary affinity | — | Job connection is on a read-only replica (`pg_is_in_recovery`) — a read/write-splitting pooler may be stalling jobs |
1878
+ | Dedicated connections | Streamer LISTEN and/or worker notify dedicated path cannot connect | — |
1875
1879
 
1876
1880
  ```bash
1877
1881
  pgbus doctor # prints the report; exit 1 unless every check passed
@@ -2058,12 +2062,19 @@ PostgreSQL + PGMQ
2058
2062
 
2059
2063
  ### Configuration reference
2060
2064
 
2065
+ Curated headline options for the README. The full operator reference (with types, groups, and drift-checked accessors) lives on the [Configuration](https://pgbus.zoolutions.llc/docs/configuration) docs page.
2066
+
2061
2067
  | Option | Default | Description |
2062
2068
  |--------|---------|-------------|
2063
2069
  | `database_url` | `nil` | PostgreSQL connection URL (auto-detected in Rails) |
2070
+ | `connection_params` | `nil` | Extra connection parameters merged into the pool |
2071
+ | `connects_to` | `nil` | Rails multi-database config for a dedicated pgbus database (`{ database: { writing: :pgbus } }`) |
2072
+ | `require_primary` | `false` | Reject a job connection that lands on a read-only replica (`pg_is_in_recovery`) at boot — pooler safety against a read/write splitter |
2073
+ | `connection_guc_mode` | `:options` | How database.yml GUCs (`variables:`) reach pgbus connections — `:options` (libpq startup) or `:session` (post-connect `SET`, for transaction-mode PgBouncer) |
2064
2074
  | `queue_prefix` | `"pgbus"` | Prefix for all PGMQ queue names |
2065
2075
  | `default_queue` | `"default"` | Default queue for jobs without explicit queue |
2066
2076
  | `pool_size` | `nil` (auto) | Connection pool size. Auto-tuned from worker thread counts: `sum(workers.threads) + sum(event_consumers.threads) + 2`. Set explicitly to override. |
2077
+ | `pool_timeout` | `5` | Seconds to wait for a pooled connection |
2067
2078
  | `workers` | `[{queues: ["default"], threads: 5}]` | Worker capsule definitions. String DSL (`"default: 5; critical: 10"`), Array, or `nil`. |
2068
2079
  | `event_consumers` | `nil` | Event consumer process definitions (same format as workers) |
2069
2080
  | `roles` | `nil` (all) | Supervisor role filter — usually set via CLI flags (`--workers-only` etc.) |
@@ -2077,18 +2088,25 @@ PostgreSQL + PGMQ
2077
2088
  | `max_memory_mb` | `nil` | Recycle worker when memory exceeds N MB |
2078
2089
  | `max_worker_lifetime` | `nil` | Recycle worker after N seconds. Accepts seconds or Duration. |
2079
2090
  | `listen_notify` | `true` | Use PGMQ's LISTEN/NOTIFY for instant wake-up |
2091
+ | `worker_notify_wakeup` | `nil` (follows `listen_notify`) | Dedicated LISTEN connection for worker wake-up. `nil` follows `listen_notify`; set `false` to force polling. |
2092
+ | `worker_notify_host` / `worker_notify_port` / `worker_notify_database_url` | `nil` | Override host/port/URL for the worker notify LISTEN connection (e.g. direct primary port when jobs go through PgBouncer) |
2080
2093
  | `prefetch_limit` | `nil` | Max in-flight messages per worker (nil = unlimited) |
2081
2094
  | `dispatch_interval` | `1.0` | Seconds between dispatcher maintenance ticks |
2082
2095
  | `circuit_breaker_enabled` | `true` | Enable auto-pause on consecutive failures (threshold and backoff are tuned via `Pgbus::CircuitBreaker` constants) |
2096
+ | `zombie_detection` | `true` | Detect and reclaim work from crashed workers |
2083
2097
  | `read_timeout` | `30` | Seconds before a single PGMQ read is bounded (libpq `statement_timeout` + `tcp_user_timeout` on a dedicated connection; nil disables) |
2098
+ | `drain_timeout` | `30` | Seconds to wait for in-flight jobs during graceful shutdown before abandoning them |
2099
+ | `stall_threshold` | `300` | Seconds without progress before a worker is considered stalled |
2084
2100
  | `priority_levels` | `nil` | Number of priority sub-queues (nil = disabled, 2-10) |
2085
2101
  | `default_priority` | `1` | Default priority for jobs without explicit priority |
2102
+ | `group_mode` | `nil` | Grouped-read ordering mode for a queue. Experimental — exempt from the 1.0 stability promise. |
2086
2103
  | `archive_retention` | `7.days` | How long to keep archived messages. Accepts seconds, Duration, or `nil` to disable cleanup |
2087
2104
  | `outbox_enabled` | `false` | Enable transactional outbox poller process |
2088
2105
  | `outbox_poll_interval` | `1.0` | Seconds between outbox poll cycles |
2089
2106
  | `outbox_batch_size` | `100` | Max entries per outbox poll cycle |
2090
2107
  | `outbox_retention` | `1.day` | How long to keep published outbox entries. Accepts seconds, Duration, or `nil` to disable cleanup |
2091
2108
  | `idempotency_ttl` | `7.days` | How long to keep processed event records. Accepts seconds, Duration, or `nil` to disable cleanup |
2109
+ | `allowed_global_id_models` | `nil` | Allowlist of Class/Module models permitted as GlobalID **job arguments** and EventBus payloads. `nil` = allow-all (default). `[]` = deny-all. Apps with ActiveStorage should include `ActiveStorage::Blob` (and related models). |
2092
2110
  | `base_controller_class` | `"::ActionController::Base"` | Base class for dashboard controllers (string, constantized at load time) |
2093
2111
  | `return_to_app_url` | `nil` | URL for "back to app" button in dashboard nav (nil hides the button) |
2094
2112
  | `web_auth` | `nil` | Lambda for dashboard authentication |
@@ -2098,6 +2116,22 @@ PostgreSQL + PGMQ
2098
2116
  | `stats_retention` | `30.days` | How long to keep job stats. Accepts seconds, Duration, or `nil` to disable cleanup |
2099
2117
  | `stats_flush_size` | `100` | Buffered stat entries per worker before a bulk insert flush. Positive integer. Lower = tighter SIGKILL loss window. |
2100
2118
  | `stats_flush_interval` | `5` | Seconds between periodic stat buffer flushes. Positive number. |
2119
+ | `streams_enabled` | `true` | Enable the SSE streams transport |
2120
+ | `streams_default_retention` | `300` | Default stream archive retention in **seconds** (5 minutes). Chat-style apps raise this. |
2121
+ | `streams_retention` | `{}` | Per-stream retention overrides (exact string or `Regexp` keys → seconds/Duration) |
2122
+ | `streams_orphan_threshold` | `86400` | Age (seconds) after which a durable stream queue is eligible for the orphan sweep (default **24h**). `nil` disables age-based drop. Empty queues are dropped regardless. |
2123
+ | `streams_orphan_sweep_interval` | `3600` | Seconds between orphan stream sweeps (default **hourly**). `nil` disables the sweep. |
2124
+ | `streams_durable_patterns` | `[]` | Streams (exact string or `Regexp`) that default to durable broadcast mode |
2125
+ | `streams_default_broadcast_mode` | `:ephemeral` | Default broadcast mode when no pattern matches (`:ephemeral` or `:durable`) |
2126
+ | `streams_presence_patterns` | `[]` | Streams (exact string or `Regexp`) that get connection-driven presence. Experimental. |
2127
+ | `streams_presence_member` | `nil` | Custom `->(context) { { id:, metadata: } }` extractor for presence. Experimental. |
2128
+ | `streams_broadcast_queue` | `nil` | Dedicated queue for turbo-rails async broadcast jobs. `nil` leaves them on the default queue (can wait behind long jobs). Set a name and back it with a worker capsule. |
2129
+ | `streams_pool_size` | `5` | Dedicated DB pool size for durable-stream publish + replay (isolated from the job pool) |
2130
+ | `streams_pool_timeout` | `5` | Seconds to wait for a streams-pool connection |
2131
+ | `streams_pool_autoscale` | `false` | Grow/shrink the streams pool from live Postgres headroom under saturation (opt-in) |
2132
+ | `streams_pool_max` | `nil` | Optional hard ceiling for streams-pool autoscaling |
2133
+ | `streams_database_url` / `streams_host` / `streams_port` | `nil` | Override the streamer's LISTEN connection target (e.g. direct primary port when workers use a pooler) |
2134
+ | `streams_pool_database_url` / `streams_pool_host` / `streams_pool_port` | `nil` | Override the streams **pool** target independently of LISTEN (so pool traffic can stay on the pooler while LISTEN stays direct) |
2101
2135
  | `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). |
2102
2136
  | `streams_stats_enabled` | `false` | Record stream broadcast/connect/disconnect stats (opt-in, can be high volume) |
2103
2137
  | `streams_path` | `nil` | Custom URL path for the SSE endpoint (nil = auto-detected from engine mount) |
@@ -2110,6 +2144,8 @@ PostgreSQL + PGMQ
2110
2144
  | `statsd_port` | `8125` | StatsD UDP port (used when `metrics_backend = :statsd`) |
2111
2145
  | `health_port` | `nil` | Port for standalone HTTP liveness/readiness probes served by the supervisor; nil disables |
2112
2146
  | `health_bind` | `"127.0.0.1"` | Bind address for the standalone health server |
2147
+ | `pgmq_schema_mode` | `:auto` | PGMQ schema install mode (`:auto`, `:extension`, `:embedded`) |
2148
+ | `doctor_on_boot` | `nil` | Run doctor inside the booting supervisor: `nil`/`false` = off, `:report` = log and boot, `:strict` = refuse to boot on a fatal check. Also via `--doctor` / `--doctor-strict`. |
2113
2149
  | `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. |
2114
2150
 
2115
2151
  ## Development
@@ -14,6 +14,14 @@ module Pgbus
14
14
  # is absent — `record!` no-ops and `all_names` returns an empty set, so the
15
15
  # pre-registry behavior (streams treated as job queues by maintenance) is
16
16
  # preserved rather than raising.
17
+ #
18
+ # Pre-registry dormant streams (issue #366): queues created before this
19
+ # table existed never re-broadcast, so they never call `record!`. Those
20
+ # queues still carry the archive `a_<name>_msg_id_idx` index that only
21
+ # `ensure_stream_queue` creates — `fingerprint_matched_names` discovers
22
+ # them, `known_names` unions them with the registry for classification,
23
+ # and `backfill!` (via `rake pgbus:streams:backfill_registry`) writes
24
+ # the missing rows so the registry is complete.
17
25
  class StreamQueue < BusRecord
18
26
  self.table_name = "pgbus_stream_queues"
19
27
 
@@ -22,8 +30,10 @@ module Pgbus
22
30
  # every broadcast; the caller (`ensure_stream_queue`) also memoizes
23
31
  # per-process, so the DB write happens once per stream per process.
24
32
  # Errors are swallowed — a registry hiccup must never abort a broadcast.
33
+ # Returns true on a successful write, false when the table is absent or
34
+ # the upsert failed (so callers like `backfill!` can report accurately).
25
35
  def record!(queue_name)
26
- return unless table_exists?
36
+ return false unless table_exists?
27
37
 
28
38
  upsert({ queue_name: queue_name }, unique_by: :queue_name)
29
39
  # Keep the in-process cache consistent with the write so a subsequent
@@ -35,9 +45,10 @@ module Pgbus
35
45
  # nil lets the next all_names call do a real load, which already
36
46
  # includes this row since the upsert above has committed.
37
47
  @all_names&.add(queue_name)
38
- nil
48
+ true
39
49
  rescue StandardError => e
40
50
  Pgbus.logger.debug { "[Pgbus] Failed to record stream queue #{queue_name}: #{e.message}" }
51
+ false
41
52
  end
42
53
 
43
54
  # Set of all registered physical stream queue names. Memoized so a
@@ -49,6 +60,45 @@ module Pgbus
49
60
  @all_names ||= load_names
50
61
  end
51
62
 
63
+ # Registry ∪ fingerprint-matched names. Use this for classification
64
+ # (health verdict exclusion, orphan sweep, wildcard worker exclusion)
65
+ # so dormant pre-registry streams are not treated as job queues.
66
+ # Does not write — call `backfill!` to persist the fingerprint matches.
67
+ def known_names
68
+ all_names | fingerprint_matched_names
69
+ end
70
+
71
+ # Physical queue names that carry the stream-only archive msg_id index
72
+ # (`a_<queue>_msg_id_idx`). Job queues never get this index. Safe to call
73
+ # when pgmq is absent — returns an empty set rather than raising.
74
+ def fingerprint_matched_names
75
+ Set.new(connection.select_values(<<~SQL.squish))
76
+ SELECT m.queue_name
77
+ FROM pgmq.meta m
78
+ INNER JOIN pg_indexes i
79
+ ON i.schemaname = 'pgmq'
80
+ AND i.indexname = 'a_' || m.queue_name || '_msg_id_idx'
81
+ SQL
82
+ rescue StandardError => e
83
+ Pgbus.logger.debug { "[Pgbus] Failed to discover stream queue fingerprints: #{e.message}" }
84
+ Set.new
85
+ end
86
+
87
+ # Registers fingerprint-matched queues missing from the registry.
88
+ # Idempotent. Returns the number of names successfully persisted
89
+ # (failed upserts are not counted — see `record!`). No-ops (returns 0)
90
+ # when the registry table is absent.
91
+ def backfill!
92
+ return 0 unless table_exists?
93
+
94
+ missing = fingerprint_matched_names - all_names
95
+ return 0 if missing.empty?
96
+
97
+ registered = missing.count { |name| record!(name) }
98
+ reset_cache! if registered.positive?
99
+ registered
100
+ end
101
+
52
102
  def stream?(queue_name)
53
103
  all_names.include?(queue_name)
54
104
  end
@@ -31,12 +31,18 @@ module Pgbus
31
31
  say ""
32
32
  say "Without this table, stream queues are indistinguishable from job"
33
33
  say "queues: per-stream archive retention and the orphan sweep skip them,"
34
- say "and wildcard ('*') workers can claim durable broadcasts. Existing"
34
+ say "and wildcard ('*') workers can claim durable broadcasts. Active"
35
35
  say "streams register themselves on their next broadcast after migrating."
36
+ say "Dormant durable streams (completed checkout flows, ended chats) never"
37
+ say "broadcast again, but doctor / orphan sweep / wildcard workers already"
38
+ say "see them via the archive-index fingerprint (known_names). Backfill"
39
+ say "persists those names in the registry and clears the doctor DEGRADED"
40
+ say "hint (issue #366)."
36
41
  say ""
37
42
  say "Next steps:"
38
43
  say " 1. Run: rails db:migrate#{migrate_command_suffix}"
39
- say " 2. Restart pgbus: bin/pgbus start"
44
+ say " 2. Backfill dormant streams: rake pgbus:streams:backfill_registry"
45
+ say " 3. Restart pgbus: bin/pgbus start"
40
46
  say ""
41
47
  end
42
48
 
@@ -80,7 +80,11 @@ module Pgbus
80
80
  msg_id: msg_id
81
81
  }
82
82
  Instrumentation.instrument("pgbus.executor.execute", instrument_payload) do
83
- job = ::ActiveJob::Base.deserialize(payload)
83
+ # Route through Serializer so allowed_global_id_models gates job
84
+ # arguments the same way it gates EventBus `_global_id` payloads
85
+ # (issue #368). Pass this executor's config so an injected allowlist
86
+ # is not silently ignored in favour of Pgbus.configuration.
87
+ job = Serializer.deserialize_job_data(payload, configuration: config)
84
88
  Pgbus.logger.debug { "[Pgbus::Executor] running #{tag} job_class=#{job_class}" }
85
89
  execute_job(job)
86
90
  Pgbus.logger.debug { "[Pgbus::Executor] perform_returned #{tag} job_class=#{job_class}" }
@@ -69,9 +69,13 @@ module Pgbus
69
69
  attr_accessor :outbox_enabled, :outbox_poll_interval, :outbox_batch_size
70
70
  attr_reader :outbox_retention # rubocop:disable Style/AccessorGrouping
71
71
 
72
- # Event bus
72
+ # GlobalID allowlist for job arguments (`_aj_globalid`) and EventBus
73
+ # payloads (`_global_id`). nil = allow-all; [] = deny-all; Array of
74
+ # Class/Module = only those models may resolve. Enforced in
75
+ # Serializer (issue #368 for the job path).
73
76
  attr_accessor :allowed_global_id_models
74
- attr_reader :idempotency_ttl # rubocop:disable Style/AccessorGrouping
77
+ # Event bus
78
+ attr_reader :idempotency_ttl
75
79
 
76
80
  # Logging
77
81
  attr_accessor :logger
data/lib/pgbus/doctor.rb CHANGED
@@ -258,15 +258,17 @@ module Pgbus
258
258
  end
259
259
 
260
260
  # 7. GlobalID allowlist — security. allowed_global_id_models = nil means
261
- # "allow ANY model as a GlobalID event/job argument", which lets a crafted
262
- # payload deserialize arbitrary AR models. It's the default for upgrade
263
- # continuity, so this is a warning (never a failure), and only in production
264
- # where the blast radius is real.
261
+ # "allow ANY model as a GlobalID EventBus payload or ActiveJob argument",
262
+ # which lets a crafted queue message deserialize arbitrary AR models
263
+ # (issue #368 closed the job-path hole; both paths share the gate). It's
264
+ # the default for upgrade continuity, so this is a warning (never a
265
+ # failure), and only in production where the blast radius is real.
265
266
  def check_allowed_global_id_models
266
267
  if @config.allowed_global_id_models.nil? && production?
267
268
  return Check.new(name: "GlobalID allowlist", status: :warn,
268
269
  detail: "allowed_global_id_models is nil (allow-all) in production — " \
269
- "set an explicit allowlist of models permitted as GlobalID arguments")
270
+ "set an explicit allowlist of models permitted as GlobalID " \
271
+ "job arguments and EventBus payloads")
270
272
  end
271
273
 
272
274
  Check.new(name: "GlobalID allowlist", status: :ok,
@@ -23,6 +23,18 @@ module Pgbus
23
23
  # heartbeats while doing no work — exactly the case we must catch.
24
24
  WORKER_KIND = "worker"
25
25
 
26
+ # Process kinds that claim from queues: workers drain job queues, consumers
27
+ # drain EventBus handler queues. `drained_queue_names` unions both kinds'
28
+ # queues, so the wedge signal must count both kinds as liveness evidence —
29
+ # otherwise a consumer-only fleet with a wedged handler queue is missed
30
+ # (the worker set is empty and the branch never runs).
31
+ DRAINING_KINDS = %w[worker consumer].freeze
32
+
33
+ # Cap on how many queue names a reason string lists before truncating to
34
+ # "(+N more)" — keeps a flagged fleet of hundreds of queues from producing
35
+ # a multi-KB log line (issue #367 minor).
36
+ NAME_LIMIT = 10
37
+
26
38
  def initialize(data_source)
27
39
  @data_source = data_source
28
40
  end
@@ -48,8 +60,14 @@ module Pgbus
48
60
  backlog = non_dlq.select { |q| q[:queue_visible_length].to_i.positive? }
49
61
  active_backlog = backlog.reject { |q| q[:paused] }
50
62
 
51
- stalled = stalled_reasons(active_backlog, processes)
52
- degraded = degraded_reasons(queues, backlog, processes, health, health_error)
63
+ # Split the active backlog by whether some configured capsule or handler
64
+ # drains the queue (issue #367 defect 2). Live workers say nothing about
65
+ # a queue they can never claim from, so only the drained side can be the
66
+ # silent-worker-wedge; the undrained side is its own DEGRADED hazard.
67
+ drained, undrained = partition_by_drain(active_backlog)
68
+
69
+ stalled = stalled_reasons(active_backlog, drained, processes)
70
+ degraded = degraded_reasons(queues, backlog, undrained, processes, health, health_error)
53
71
 
54
72
  status = if stalled.any?
55
73
  "STALLED"
@@ -73,6 +91,18 @@ module Pgbus
73
91
  queue[:name].to_s.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
74
92
  end
75
93
 
94
+ # Partition a backlog into [drained, undrained] using the set of queues
95
+ # some configured capsule or EventBus handler drains. A nil drained set
96
+ # means a wildcard capsule drains everything, so nothing is undrained.
97
+ def partition_by_drain(backlog)
98
+ return [backlog, []] if backlog.empty?
99
+
100
+ drained_names = @data_source.drained_queue_names
101
+ return [backlog, []] if drained_names.nil?
102
+
103
+ backlog.partition { |q| drained_names.include?(q[:name].to_s) }
104
+ end
105
+
76
106
  # Returns [health_hash, error_or_nil]. We never raise — pgbus_health
77
107
  # must always produce a verdict — but we DO surface the failure so the
78
108
  # caller knows the verdict was built from partial data, and we log it
@@ -84,31 +114,42 @@ module Pgbus
84
114
  [{}, e]
85
115
  end
86
116
 
87
- # STALLED detection: an active (non-paused) backed-up queue while a
88
- # worker is in the :stalled state, or backed up with live-but-idle
89
- # workers present.
90
- def stalled_reasons(backlog, processes)
91
- return [] if backlog.empty?
92
-
93
- workers = processes.select { |p| p[:kind] == WORKER_KIND }
94
- return [] if workers.empty?
95
-
96
- stalled_workers = workers.select { |w| w[:status].to_s == "stalled" }
97
- live_workers = workers.select { |w| %w[healthy stalled].include?(w[:status].to_s) }
117
+ # STALLED detection. Two distinct wedge signatures, with different scopes:
118
+ #
119
+ # * A worker self-reporting :stalled (heart-beating but claim loop frozen)
120
+ # is a wedge regardless of WHICH queue holds the backlog — the worker is
121
+ # broken. So this branch reasons against the FULL active backlog. This
122
+ # is the specific #179 signature and stays worker-scoped.
123
+ # * Live-but-idle draining processes with a never-claimed backlog is only
124
+ # a wedge for queues those processes can actually claim from — so this
125
+ # branch reasons against the DRAINED backlog only (issue #367 defect 2).
126
+ # Liveness is the evidence, and `drained_queue_names` covers both
127
+ # worker-drained job queues and consumer-drained handler queues, so a
128
+ # live process of either draining kind counts (a consumer-only fleet
129
+ # must still catch a wedged handler queue).
130
+ def stalled_reasons(active_backlog, drained_backlog, processes)
131
+ stalled_workers = processes.select { |p| p[:kind] == WORKER_KIND && p[:status].to_s == "stalled" }
132
+ live_drainers = processes.select do |p|
133
+ DRAINING_KINDS.include?(p[:kind].to_s) && %w[healthy stalled].include?(p[:status].to_s)
134
+ end
98
135
 
99
136
  reasons = []
100
- if stalled_workers.any?
137
+ if stalled_workers.any? && active_backlog.any?
101
138
  reasons << "#{stalled_workers.size} worker(s) stalled (heart-beating but claim loop not advancing) " \
102
- "while #{backlog.size} queue(s) have visible backlog: #{backlog_names(backlog)}"
103
- elsif live_workers.any? && all_unread?(backlog)
104
- reasons << "#{backlog.size} queue(s) have visible messages with read_ct=0 (never claimed) " \
105
- "while #{live_workers.size} worker(s) are alive: #{backlog_names(backlog)}"
139
+ "while #{active_backlog.size} queue(s) have visible backlog: #{backlog_names(active_backlog)}"
140
+ elsif live_drainers.any? && drained_backlog.any? && any_unread?(drained_backlog)
141
+ unread = drained_backlog.select { |q| unread?(q) }
142
+ reasons << "#{unread.size} queue(s) have visible messages with read_ct=0 (never claimed) " \
143
+ "while #{live_drainers.size} draining process(es) are alive: #{backlog_names(unread)}"
106
144
  end
107
145
  reasons
108
146
  end
109
147
 
110
148
  # DEGRADED detection: conditions worth surfacing that are not the wedge.
111
- def degraded_reasons(queues, backlog, processes, health, health_error)
149
+ # +undrained+ is the active backlog on queues no configured capsule/handler
150
+ # drains (issue #367 defect 2) — a real hazard (nothing will empty them),
151
+ # but not the worker wedge, so it is DEGRADED rather than STALLED.
152
+ def degraded_reasons(queues, backlog, undrained, processes, health, health_error)
112
153
  reasons = []
113
154
 
114
155
  reasons << "queue health stats unavailable: #{health_error.class}: #{health_error.message}" if health_error
@@ -119,24 +160,71 @@ module Pgbus
119
160
  paused_backlog = backlog.select { |q| q[:paused] }
120
161
  reasons << "#{paused_backlog.size} paused queue(s) holding a backlog: #{backlog_names(paused_backlog)}" if paused_backlog.any?
121
162
 
163
+ # Every active undrained queue is the "nobody drains this" hazard: it
164
+ # holds a visible backlog and no configured capsule/handler will empty
165
+ # it. Past reads don't make it safe — if nothing drains it now, whatever
166
+ # read those messages is gone. (`undrained` is already the visible,
167
+ # non-paused backlog on queues no capsule drains.)
168
+ if undrained.any?
169
+ reasons << "#{undrained.size} queue(s) hold messages but no capsule is " \
170
+ "configured to drain them: #{backlog_names(undrained)}"
171
+ end
172
+
122
173
  dlq = queues.select { |q| dlq?(q) && q[:queue_length].to_i.positive? }
123
174
  reasons << "#{dlq.size} dead-letter queue(s) hold messages" if dlq.any?
124
175
 
125
176
  age = health[:oldest_transaction_age_sec]
126
177
  reasons << "oldest open transaction is #{age}s old (MVCC horizon pinning risk)" if age && age > 300
127
178
 
179
+ unregistered_hint = unregistered_stream_hint
180
+ reasons << unregistered_hint if unregistered_hint
181
+
128
182
  reasons
129
183
  end
130
184
 
131
- # The strongest wedge signal: visible messages that have never been read.
132
- # When queue metrics don't expose read_ct we conservatively treat the
133
- # backlog as unread (the wedge default) so we don't miss the condition.
134
- def all_unread?(backlog)
135
- backlog.all? { |q| !q.key?(:max_read_ct) || q[:max_read_ct].to_i.zero? }
185
+ # Issue #366: fingerprint-matched streams excluded from the wedge signal
186
+ # still need the registry for orphan sweep bookkeeping and durable
187
+ # wildcard safety. Surface a DEGRADED hint so operators run the backfill.
188
+ def unregistered_stream_hint
189
+ return unless @data_source.respond_to?(:unregistered_stream_queue_count)
190
+
191
+ count = @data_source.unregistered_stream_queue_count
192
+ return if count.to_i <= 0
193
+
194
+ "#{count} unregistered stream queue(s) detected (dormant pre-registry durable " \
195
+ "streams). Run: rake pgbus:streams:backfill_registry"
196
+ rescue StandardError => e
197
+ Pgbus.logger.debug { "[Pgbus::MCP] unregistered stream hint failed: #{e.class}: #{e.message}" }
198
+ nil
199
+ end
200
+
201
+ # True when any queue in the backlog holds a visible, never-claimed
202
+ # message — the wedge signal.
203
+ def any_unread?(backlog)
204
+ backlog.any? { |q| unread?(q) }
205
+ end
206
+
207
+ # True when a queue has at least one visible (claimable) message with
208
+ # read_ct=0. This counts per visible message (issue #367 review), so one
209
+ # retried or in-flight message (read_ct>0) can no longer veto the signal
210
+ # for a pile of genuinely-unclaimed jobs the way max(read_ct) did.
211
+ #
212
+ # When metrics don't expose the count (an old hash / a stubbed test) we
213
+ # conservatively treat the queue as unread so we never miss the wedge.
214
+ def unread?(queue)
215
+ return true unless queue.key?(:visible_unread_length)
216
+
217
+ queue[:visible_unread_length].to_i.positive?
136
218
  end
137
219
 
220
+ # Join backlog queue names for a reason string, truncated so a flagged
221
+ # fleet of hundreds of queues doesn't produce a multi-KB log line
222
+ # (issue #367 minor). Lists the first NAME_LIMIT, then "(+N more)".
138
223
  def backlog_names(queues)
139
- queues.map { |q| q[:name] }.join(", ")
224
+ names = queues.map { |q| q[:name] }
225
+ shown = names.first(NAME_LIMIT).join(", ")
226
+ overflow = names.size - NAME_LIMIT
227
+ overflow.positive? ? "#{shown} (+#{overflow} more)" : shown
140
228
  end
141
229
 
142
230
  def build_summary(queues, non_dlq, processes, health)
@@ -637,12 +637,14 @@ module Pgbus
637
637
 
638
638
  # Fresh set of physical stream-queue names for this maintenance pass.
639
639
  # Reset first so a stream created since the last hourly pass is picked
640
- # up without a process restart. Empty on unmigrated installs (registry
641
- # table absent) callers then treat every queue as a job queue, which
642
- # is the pre-registry behavior.
640
+ # up without a process restart. Includes fingerprint-matched dormant
641
+ # streams that never re-registered after the registry migration
642
+ # (issue #366) so orphan sweep / stream-archive prune can still see
643
+ # them. Empty only when neither the registry nor any fingerprint
644
+ # matches exist — callers then treat every queue as a job queue.
643
645
  def current_stream_queue_names
644
646
  Pgbus::StreamQueue.reset_cache!
645
- Pgbus::StreamQueue.all_names
647
+ Pgbus::StreamQueue.known_names
646
648
  end
647
649
 
648
650
  def cleanup_recurring_executions
@@ -30,12 +30,24 @@ module Pgbus
30
30
  # blocking IO call where the mutex MUST NOT be held), so wait_once reads
31
31
  # the connection out of the mutex first and operates on a local. Reconnect
32
32
  # publishes the new connection + channel set under the mutex.
33
+ #
34
+ # The mutex only makes the ivar READ safe. PG::Connection itself is not
35
+ # thread-safe, so the connection is single-owner: the listener thread is
36
+ # the ONLY thread that may exec, wait, or close on it, from build through
37
+ # teardown. #stop signals by clearing @running and joining — it never
38
+ # touches the connection, because #close is PQfinish and freeing the
39
+ # PGconn under a concurrent libpq call is a process-killing SEGV, not a
40
+ # rescuable PG::Error (issue #375).
33
41
  class NotifyListener
34
42
  CHANNEL_PREFIX = "pgmq.q_"
35
43
  CHANNEL_SUFFIX = ".INSERT"
36
44
 
37
45
  RECONNECT_BACKOFF_SECONDS = 0.5
38
46
 
47
+ # Grace added to one health-check cycle when #stop joins the listener
48
+ # thread. See #stop_join_timeout.
49
+ STOP_JOIN_GRACE_SECONDS = 5
50
+
39
51
  def initialize(physical_queues:, on_wake:, connection_options:,
40
52
  health_check_ms: 1000, logger: Pgbus.logger)
41
53
  @physical_queues = Array(physical_queues)
@@ -80,22 +92,20 @@ module Pgbus
80
92
  end
81
93
 
82
94
  def stop
83
- conn_to_close = nil
84
95
  @state_mutex.synchronize do
85
96
  return self unless @running
86
97
 
87
98
  @running = false
88
- conn_to_close = @conn
89
99
  end
90
100
  @commands << [:stop]
91
- # Interrupt the blocking wait by closing the socket; the rescue in
92
- # wait_once sees @running == false and exits cleanly.
93
- begin
94
- conn_to_close&.close if conn_to_close.respond_to?(:close)
95
- rescue StandardError
96
- nil
97
- end
98
- @thread&.join(5)
101
+ # Deliberately does NOT touch @conn. PG::Connection is not thread-safe
102
+ # and #close is PQfinish: it frees the PGconn and its OpenSSL objects
103
+ # out from under whatever libpq call the listener thread is making on
104
+ # the same connection. That is a use-after-free — a process-killing
105
+ # SEGV, not a rescuable PG::Error (issue #375). The listener thread is
106
+ # the sole owner of @conn for its entire life and closes it in
107
+ # run_loop's ensure; clearing @running above is the whole stop signal.
108
+ @thread&.join(stop_join_timeout)
99
109
  @thread = nil
100
110
  self
101
111
  end
@@ -153,9 +163,26 @@ module Pgbus
153
163
  # Clear @running so #start can spawn a fresh thread after a fatal exit
154
164
  # (e.g. build_connection raising at boot). Without this, the dead
155
165
  # thread's @running stays true and #start returns early forever.
156
- @state_mutex.synchronize { @running = false }
157
- safe_unlisten_all
158
- safe_close
166
+ #
167
+ # The LISTEN set is dropped as bookkeeping only — no UNLISTEN
168
+ # round-trip. We close on the next line and closing the session
169
+ # deregisters every LISTEN server-side, so the exec bought nothing
170
+ # while being the statement that raced #stop into a SEGV (issue #375).
171
+ #
172
+ # Capturing @conn in the SAME critical section that clears @running is
173
+ # what makes restart safe. #start is public and guarded only by
174
+ # @running, so a caller watching running? may spawn a fresh thread the
175
+ # instant it flips. If teardown read @conn in a later critical section
176
+ # it could pick up the NEW thread's connection and PQfinish it mid-use
177
+ # — the same use-after-free, reached through restart instead of #stop.
178
+ conn = @state_mutex.synchronize do
179
+ @running = false
180
+ @listening_to.clear
181
+ c = @conn
182
+ @conn = nil
183
+ c
184
+ end
185
+ close_quietly(conn)
159
186
  end
160
187
 
161
188
  def wait_once
@@ -166,7 +193,10 @@ module Pgbus
166
193
  got_notify = conn.wait_for_notify(timeout_s) do |_channel, _pid, _payload|
167
194
  @on_wake.call
168
195
  end
169
- run_health_check(conn) unless got_notify
196
+ # Skip the keepalive when a stop landed during the wait: the loop is
197
+ # about to exit and close this connection anyway, so the round-trip
198
+ # would only add latency to shutdown.
199
+ run_health_check(conn) if !got_notify && running?
170
200
  rescue IOError, PG::Error => e
171
201
  return unless running?
172
202
 
@@ -270,14 +300,14 @@ module Pgbus
270
300
  end
271
301
  end
272
302
 
273
- def safe_unlisten_all
274
- channels, conn = @state_mutex.synchronize { [@listening_to.to_a, @conn] }
275
- channels.each do |channel|
276
- conn&.exec(%(UNLISTEN "#{channel}"))
277
- rescue PG::Error
278
- nil
279
- end
280
- @state_mutex.synchronize { @listening_to.clear }
303
+ # How long #stop waits for the listener thread to notice the cleared
304
+ # @running and finish teardown. The thread can be parked in
305
+ # wait_for_notify for one full health-check cycle before it re-checks the
306
+ # flag, so the budget is that cycle plus grace — a flat timeout would
307
+ # expire before a listener with a large health_check_ms had even one
308
+ # chance to observe the stop.
309
+ def stop_join_timeout
310
+ (@health_check_ms / 1000.0) + STOP_JOIN_GRACE_SECONDS
281
311
  end
282
312
 
283
313
  def safe_close
@@ -289,8 +319,10 @@ module Pgbus
289
319
  close_quietly(conn)
290
320
  end
291
321
 
292
- # Close an unpublished PG::Connection (one that never made it into @conn,
293
- # e.g. a half-built reconnect attempt where LISTEN raised). Best-effort.
322
+ # Close a PG::Connection we are done with a half-built reconnect
323
+ # attempt that never made it into @conn, or the connection captured out
324
+ # of @conn during teardown. Always called on the listener thread, which
325
+ # owns the connection. Best-effort.
294
326
  def close_quietly(conn)
295
327
  conn&.close if conn.respond_to?(:close)
296
328
  rescue StandardError
@@ -410,10 +410,12 @@ module Pgbus
410
410
  # Stream queues share the job namespace (pgbus_<name>) but must never
411
411
  # be adopted by a wildcard worker: a worker would claim durable
412
412
  # broadcasts, fail to deserialize them, and DLQ-move them out of the
413
- # stream's replay history. The registry is what tells them apart.
414
- # Reset first so a stream created since the last resolve is excluded.
413
+ # stream's replay history. known_names includes fingerprint-matched
414
+ # dormant pre-registry streams (issue #366) so they are excluded even
415
+ # before backfill. Reset first so a stream created since the last
416
+ # resolve is excluded.
415
417
  Pgbus::StreamQueue.reset_cache!
416
- stream_names = Pgbus::StreamQueue.all_names
418
+ stream_names = Pgbus::StreamQueue.known_names
417
419
 
418
420
  # Event-bus subscriber queues also share the job namespace (pgbus_<handler>)
419
421
  # but carry event payloads, not ActiveJob jobs. A wildcard worker that
@@ -6,6 +6,11 @@ module Pgbus
6
6
  module Serializer
7
7
  module_function
8
8
 
9
+ # ActiveJob encodes GlobalID job arguments with this key (private constant
10
+ # on ActiveJob::Arguments). Walked by the job-path allowlist gate (#368).
11
+ AJ_GLOBALID_KEY = "_aj_globalid"
12
+ private_constant :AJ_GLOBALID_KEY
13
+
9
14
  def serialize_job(active_job)
10
15
  Instrumentation.instrument("pgbus.serializer.serialize", kind: :job) do
11
16
  data = active_job.serialize
@@ -21,13 +26,25 @@ module Pgbus
21
26
  end
22
27
  end
23
28
 
24
- def deserialize_job(json_string)
29
+ def deserialize_job(json_string, configuration: Pgbus.configuration)
25
30
  Instrumentation.instrument("pgbus.serializer.deserialize", kind: :job) do
26
- data = JSON.parse(json_string)
27
- ActiveJob::Base.deserialize(data)
31
+ deserialize_job_data(JSON.parse(json_string), configuration: configuration)
28
32
  end
29
33
  end
30
34
 
35
+ # Job-hash entry point used by the executor (payload already parsed) and by
36
+ # `deserialize_job`. When `allowed_global_id_models` is configured, every
37
+ # `_aj_globalid` in the tree is checked before Rails' unrestricted
38
+ # GlobalID::Locator runs (issue #368). Nil allowlist = zero-cost allow-all.
39
+ # Prefer the caller's `configuration` (e.g. Executor's injected config) so a
40
+ # non-global allowlist is not silently ignored.
41
+ def deserialize_job_data(data, configuration: Pgbus.configuration)
42
+ assert_job_global_ids_allowed!(data, configuration: configuration)
43
+ # Top-level constant: bare ActiveJob::Base can resolve to
44
+ # Pgbus::ActiveJob::Base under Zeitwerk's Pgbus::ActiveJob namespace.
45
+ ::ActiveJob::Base.deserialize(data)
46
+ end
47
+
31
48
  def serialize_event(event)
32
49
  payload = event.respond_to?(:to_global_id) ? { "_global_id" => event.to_global_id.to_s } : event
33
50
  JSON.generate({
@@ -37,11 +54,13 @@ module Pgbus
37
54
  })
38
55
  end
39
56
 
40
- def deserialize_event(json_string)
57
+ def deserialize_event(json_string, configuration: Pgbus.configuration)
41
58
  data = JSON.parse(json_string)
42
59
  payload = data["payload"]
43
60
 
44
- data["payload"] = locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
61
+ if payload.is_a?(Hash) && payload["_global_id"]
62
+ data["payload"] = locate_global_id(payload["_global_id"], configuration: configuration)
63
+ end
45
64
 
46
65
  Event.new(
47
66
  event_id: data["event_id"],
@@ -53,11 +72,19 @@ module Pgbus
53
72
  # Locate a GlobalID with optional type restriction.
54
73
  # When allowed_global_id_models is configured, only those model classes
55
74
  # can be resolved — prevents loading arbitrary objects from crafted payloads.
56
- def locate_global_id(gid_string)
75
+ # Shared by EventBus payloads (`_global_id`) and job arguments (`_aj_globalid`).
76
+ def locate_global_id(gid_string, configuration: Pgbus.configuration)
77
+ gid = assert_allowed_global_id!(gid_string, configuration: configuration)
78
+ GlobalID::Locator.locate(gid)
79
+ end
80
+
81
+ # Raises SerializationError unless the GlobalID's model is permitted.
82
+ # Returns the parsed GlobalID on success (so locate can skip re-parse).
83
+ def assert_allowed_global_id!(gid_string, configuration: Pgbus.configuration)
57
84
  gid = GlobalID.parse(gid_string)
58
85
  raise Pgbus::SerializationError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
59
86
 
60
- allowed = Pgbus.configuration.allowed_global_id_models
87
+ allowed = configuration.allowed_global_id_models
61
88
  if allowed && allowed.empty?
62
89
  raise Pgbus::SerializationError,
63
90
  "GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
@@ -74,7 +101,26 @@ module Pgbus
74
101
  "Add it to Pgbus.configuration.allowed_global_id_models to permit deserialization."
75
102
  end
76
103
 
77
- GlobalID::Locator.locate(gid)
104
+ gid
105
+ end
106
+
107
+ # Walk a job payload (or any nested structure) and enforce the allowlist on
108
+ # every ActiveJob `_aj_globalid` value. No-op when allowlist is nil.
109
+ def assert_job_global_ids_allowed!(data, configuration: Pgbus.configuration)
110
+ return if configuration.allowed_global_id_models.nil?
111
+
112
+ walk_job_global_ids(data, configuration)
113
+ end
114
+
115
+ def walk_job_global_ids(value, configuration)
116
+ case value
117
+ when Hash
118
+ assert_allowed_global_id!(value[AJ_GLOBALID_KEY], configuration: configuration) if value.key?(AJ_GLOBALID_KEY)
119
+ value.each_value { |child| walk_job_global_ids(child, configuration) }
120
+ when Array
121
+ value.each { |child| walk_job_global_ids(child, configuration) }
122
+ end
78
123
  end
124
+ private_class_method :walk_job_global_ids
79
125
  end
80
126
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.12.2"
4
+ VERSION = "0.12.4"
5
5
  end
@@ -65,17 +65,60 @@ module Pgbus
65
65
  end
66
66
  private :fetch_queues_with_metrics
67
67
 
68
- # Physical queue names registered as stream queues (the
69
- # pgbus_stream_queues registry). Stream delivery is a non-consuming
70
- # peek, so stream messages sit visible with read_ct=0 forever — health
71
- # verdicts must not read that as a wedge (issue #359). Loaded fresh per
72
- # call (reset + one query): verdicts run on coarse intervals and a
73
- # long-lived process must see streams registered since the last check.
74
- # Degrades to an empty Set when the registry table is absent
75
- # (pre-migration installs) or unreadable StreamQueue swallows both.
68
+ # Physical queue names known to back streams: the pgbus_stream_queues
69
+ # registry plus any dormant pre-registry queues discovered by the
70
+ # archive msg_id index fingerprint (issue #366). Stream delivery is a
71
+ # non-consuming peek, so stream messages sit visible with read_ct=0
72
+ # forever health verdicts must not read that as a wedge (issue #359).
73
+ # Loaded fresh per call (reset + registry + fingerprint queries):
74
+ # verdicts run on coarse intervals and a long-lived process must see
75
+ # streams registered since the last check. Degrades to an empty Set
76
+ # when the registry/pgmq schema is absent or unreadable.
76
77
  def stream_queue_names
77
78
  StreamQueue.reset_cache!
78
- StreamQueue.all_names
79
+ StreamQueue.known_names
80
+ end
81
+
82
+ # Fingerprint-matched stream queues missing from the registry (issue #366).
83
+ # Used by HealthAnalyzer to surface a DEGRADED hint pointing at
84
+ # `rake pgbus:streams:backfill_registry`. Zero when fully backfilled or
85
+ # when the registry table is absent.
86
+ def unregistered_stream_queue_count
87
+ return 0 unless StreamQueue.table_exists?
88
+
89
+ (StreamQueue.fingerprint_matched_names - StreamQueue.all_names).size
90
+ rescue StandardError => e
91
+ Pgbus.logger.debug { "[Pgbus::Web] unregistered_stream_queue_count failed: #{e.message}" }
92
+ 0
93
+ end
94
+
95
+ # The set of physical queue names some configured worker capsule or
96
+ # EventBus handler is set up to drain. Used by the HealthAnalyzer to keep
97
+ # the silent-worker-wedge verdict from flagging queues nobody drains
98
+ # (ad-hoc queues, unregistered stream queues — issue #367, #366): live
99
+ # workers prove nothing about a queue they can never claim from.
100
+ #
101
+ # Returns nil when a capsule uses the "*" wildcard — that capsule drains
102
+ # every job queue (Worker#resolve_wildcard_queues), so there is nothing to
103
+ # intersect against. Otherwise returns a Set of physical names: each
104
+ # capsule's explicit logical queues expanded to the physical tables that
105
+ # actually exist (via the client's queue strategy, so a priority queue's
106
+ # _p0.._pN sub-tables are matched, not the bare prefixed name priority mode
107
+ # never creates), unioned with the EventBus handler queues consumers drain.
108
+ def drained_queue_names
109
+ capsules = Array(Pgbus.configuration.workers)
110
+ capsule_queues = capsules.flat_map { |c| c[:queues] || c["queues"] || [] }
111
+ return nil if capsule_queues.include?("*")
112
+
113
+ physical = capsule_queues.flat_map { |q| @client.physical_queue_names(q) }
114
+ (physical + handler_queue_physical_names).to_set
115
+ rescue StandardError => e
116
+ # Fail open: nil means "a wildcard drains everything", which restores the
117
+ # pre-#367 behavior of intersecting against the whole backlog. A raise
118
+ # here (e.g. a malformed capsule queue name) must never break the
119
+ # HealthAnalyzer's "always produces a verdict" contract.
120
+ Pgbus.logger.debug { "[Pgbus::Web] Error computing drained queues: #{e.class}: #{e.message}" }
121
+ nil
79
122
  end
80
123
 
81
124
  # name is the full PGMQ queue name (e.g. "pgbus_default") as returned
@@ -1132,7 +1175,9 @@ module Pgbus
1132
1175
  (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW()) AS queue_visible_length,
1133
1176
  (SELECT EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int FROM pgmq.#{qtable}) AS newest_msg_age_sec,
1134
1177
  (SELECT EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int FROM pgmq.#{qtable}) AS oldest_msg_age_sec,
1135
- (SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM pgmq.#{seq_name}) AS total_messages
1178
+ (SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM pgmq.#{seq_name}) AS total_messages,
1179
+ (SELECT max(read_ct) FROM pgmq.#{qtable}) AS max_read_ct,
1180
+ (SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW() AND read_ct = 0) AS visible_unread_length
1136
1181
  SQL
1137
1182
  rescue StandardError => e
1138
1183
  Pgbus.logger.debug { "[Pgbus::Web] Skipping queue metrics for #{name}: #{e.message}" }
@@ -1150,7 +1195,9 @@ module Pgbus
1150
1195
  queue_visible_length: row["queue_visible_length"].to_i,
1151
1196
  oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
1152
1197
  newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
1153
- total_messages: row["total_messages"].to_i
1198
+ total_messages: row["total_messages"].to_i,
1199
+ max_read_ct: row["max_read_ct"]&.to_i,
1200
+ visible_unread_length: row["visible_unread_length"].to_i
1154
1201
  }
1155
1202
  end
1156
1203
  rescue StandardError => e
@@ -1168,7 +1215,9 @@ module Pgbus
1168
1215
  count(*) AS queue_length,
1169
1216
  count(CASE WHEN vt <= NOW() THEN 1 END) AS queue_visible_length,
1170
1217
  EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int AS newest_msg_age_sec,
1171
- EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int AS oldest_msg_age_sec
1218
+ EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int AS oldest_msg_age_sec,
1219
+ max(read_ct) AS max_read_ct,
1220
+ count(CASE WHEN vt <= NOW() AND read_ct = 0 THEN 1 END) AS visible_unread_length
1172
1221
  FROM pgmq.#{qtable}
1173
1222
  ),
1174
1223
  all_metrics AS (
@@ -1180,6 +1229,8 @@ module Pgbus
1180
1229
  q_summary.queue_visible_length,
1181
1230
  q_summary.newest_msg_age_sec,
1182
1231
  q_summary.oldest_msg_age_sec,
1232
+ q_summary.max_read_ct,
1233
+ q_summary.visible_unread_length,
1183
1234
  all_metrics.total_messages
1184
1235
  FROM q_summary, all_metrics
1185
1236
  SQL
@@ -1192,7 +1243,9 @@ module Pgbus
1192
1243
  queue_visible_length: row["queue_visible_length"].to_i,
1193
1244
  oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
1194
1245
  newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
1195
- total_messages: row["total_messages"].to_i
1246
+ total_messages: row["total_messages"].to_i,
1247
+ max_read_ct: row["max_read_ct"]&.to_i,
1248
+ visible_unread_length: row["visible_unread_length"].to_i
1196
1249
  }
1197
1250
  rescue StandardError => e
1198
1251
  Pgbus.logger.error { "[Pgbus::Web] Error fetching metrics for #{queue_name}: #{e.class}: #{e.message}" }
@@ -15,7 +15,12 @@ module Pgbus
15
15
  # only running `wait_for_notify` — all LISTEN/UNLISTEN SQL goes
16
16
  # through a command queue that the listener thread drains between
17
17
  # notifies
18
- # - #stop joins the thread cleanly
18
+ # - #stop clears @running and joins; it never touches the connection
19
+ # - the connection is SINGLE-OWNER: the listener thread is the only
20
+ # thread that may exec, wait, or close on it, from construction
21
+ # through teardown. PG::Connection is not thread-safe and #close is
22
+ # PQfinish — freeing the PGconn under a concurrent libpq call is a
23
+ # process-killing SEGV, not a rescuable PG::Error (issue #375)
19
24
  #
20
25
  # Health check: `wait_for_notify(timeout)` returns nil on timeout. When
21
26
  # it does, the listener runs `SELECT 1` as a TCP keepalive. If that
@@ -41,6 +46,10 @@ module Pgbus
41
46
 
42
47
  RECONNECT_BACKOFF_SECONDS = 0.5
43
48
 
49
+ # Grace added to one health-check cycle when #stop joins the listener
50
+ # thread. See #stop_join_timeout.
51
+ STOP_JOIN_GRACE_SECONDS = 5
52
+
44
53
  attr_reader :listening_to
45
54
 
46
55
  # @param connection_factory [#call] builds a FRESH PG connection on each
@@ -98,18 +107,16 @@ module Pgbus
98
107
 
99
108
  @running = false
100
109
  @commands << [:stop]
101
- # Interrupt the blocking wait_for_notify by closing the PG
102
- # connection. Without this, the listener thread would sit
103
- # inside wait_for_notify until a NOTIFY arrived, which may
104
- # never happen. Closing the socket raises PG::Error inside
105
- # wait_for_notify; our rescue clause sees @running == false
106
- # on the next iteration and exits cleanly.
107
- begin
108
- @conn.close if @conn.respond_to?(:close)
109
- rescue StandardError
110
- # best effort — connection may already be gone
111
- end
112
- @thread&.join(5)
110
+ # Deliberately does NOT touch @conn. PG::Connection is not
111
+ # thread-safe and #close is PQfinish: it frees the PGconn and its
112
+ # OpenSSL objects out from under whatever libpq call the listener
113
+ # thread is making on the same connection. That is a use-after-free —
114
+ # a process-killing SEGV, not a rescuable PG::Error (issue #375).
115
+ # The listener thread is the sole owner of @conn and closes it in
116
+ # run_loop's ensure; clearing @running above is the whole stop signal.
117
+ # It is observed within one wait_for_notify timeout (health_check_ms),
118
+ # which is what the join budget below is sized against.
119
+ @thread&.join(stop_join_timeout)
113
120
  @thread = nil
114
121
  self
115
122
  end
@@ -148,13 +155,18 @@ module Pgbus
148
155
 
149
156
  timeout_s = @health_check_ms / 1000.0
150
157
  begin
151
- @conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
158
+ got_notify = @conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
152
159
  handle_notify(channel, payload)
153
- end || run_health_check
160
+ end
161
+ # Skip the keepalive when a stop landed during the wait: the loop
162
+ # is about to exit and close this connection anyway, so the
163
+ # round-trip would only add latency to shutdown.
164
+ run_health_check if !got_notify && @running
154
165
  rescue IOError => e
155
- # #stop closes the PG connection to interrupt
156
- # wait_for_notify, which raises IOError ("stream closed
157
- # in another thread"). This is expected exit cleanly.
166
+ # #stop no longer closes the connection to interrupt the wait
167
+ # (issue #375), so an IOError here is a genuine socket failure,
168
+ # not the expected shutdown signal. Reconnect unless we're
169
+ # stopping, in which case exit cleanly.
158
170
  break unless @running
159
171
 
160
172
  @logger.warn { "[Pgbus::Streamer::Listener] IO error (#{e.class}: #{e.message}) — reconnecting" }
@@ -167,7 +179,15 @@ module Pgbus
167
179
  end
168
180
  end
169
181
  ensure
170
- safe_unlisten_all
182
+ # Bookkeeping only — no UNLISTEN round-trip. We close the connection
183
+ # on the next line and closing the session deregisters every LISTEN
184
+ # server-side, so the exec bought nothing while being the statement
185
+ # that raced #stop's PQfinish into a SEGV (issue #375).
186
+ @listening_to.clear
187
+ # The listener thread owns this connection, so the listener thread is
188
+ # the one that closes it — #stop only clears @running.
189
+ close_quietly(@conn)
190
+ @conn = nil
171
191
  end
172
192
 
173
193
  def drain_commands
@@ -204,6 +224,16 @@ module Pgbus
204
224
  (@health_check_ms / 1000.0) + 1.0
205
225
  end
206
226
 
227
+ # How long #stop waits for the listener thread to notice the cleared
228
+ # @running and finish teardown. The thread can be parked in
229
+ # wait_for_notify for one full health-check cycle before it re-checks
230
+ # the flag, so the budget is that cycle plus grace — a flat timeout
231
+ # would expire before a listener with a large health_check_ms had even
232
+ # one chance to observe the stop.
233
+ def stop_join_timeout
234
+ (@health_check_ms / 1000.0) + STOP_JOIN_GRACE_SECONDS
235
+ end
236
+
207
237
  def do_listen(queue_name)
208
238
  channel = channel_for(queue_name)
209
239
  return if @listening_to.include?(channel)
@@ -322,17 +352,6 @@ module Pgbus
322
352
  nil
323
353
  end
324
354
 
325
- def safe_unlisten_all
326
- @listening_to.each do |channel|
327
- # @conn may be nil if #stop interrupted the reconnect loop between
328
- # closing the old connection and publishing a new one.
329
- @conn&.exec(%(UNLISTEN "#{channel}"))
330
- rescue PG::Error
331
- # connection may be dead; nothing we can do
332
- end
333
- @listening_to.clear
334
- end
335
-
336
355
  def channel_for(queue_name)
337
356
  "#{CHANNEL_PREFIX}#{queue_name}#{CHANNEL_SUFFIX}"
338
357
  end
@@ -2,6 +2,36 @@
2
2
 
3
3
  namespace :pgbus do
4
4
  namespace :streams do
5
+ desc "Register pre-registry durable stream queues into pgbus_stream_queues " \
6
+ "(issue #366). Detects queues by the archive msg_id index fingerprint " \
7
+ "that only ensure_stream_queue creates. Idempotent."
8
+ task backfill_registry: :environment do
9
+ unless Pgbus::StreamQueue.table_exists?
10
+ abort "pgbus_stream_queues table is missing. " \
11
+ "Run: rails generate pgbus:add_stream_queues && rails db:migrate " \
12
+ "(rails db:migrate:pgbus when config.connects_to uses a separate database)"
13
+ end
14
+
15
+ matched = Pgbus::StreamQueue.fingerprint_matched_names
16
+ already = Pgbus::StreamQueue.all_names
17
+ already_matched = matched & already
18
+ missing = matched - already
19
+
20
+ puts "Fingerprint-matched stream queues: #{matched.size}"
21
+ puts "Already registered (of matched): #{already_matched.size}"
22
+ puts "Missing from registry: #{missing.size}"
23
+
24
+ if missing.empty?
25
+ puts "Nothing to backfill."
26
+ next
27
+ end
28
+
29
+ count = Pgbus::StreamQueue.backfill!
30
+ puts "Registered #{count} stream queue(s)."
31
+ puts "Warning: #{missing.size - count} write(s) failed — re-run after fixing DB errors." if count < missing.size
32
+ missing.sort.each { |name| puts " + #{name}" }
33
+ end
34
+
5
35
  desc "Fail if any pgbus controller or web component includes ActionController::Live"
6
36
  task :lint_no_live do
7
37
  # ActionController::Live has well-documented interactions with Puma
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.2
4
+ version: 0.12.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson