pgbus 0.12.2 → 0.12.3
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 +7 -0
- data/README.md +37 -1
- data/app/models/pgbus/stream_queue.rb +52 -2
- data/lib/generators/pgbus/add_stream_queues_generator.rb +8 -2
- data/lib/pgbus/active_job/executor.rb +5 -1
- data/lib/pgbus/configuration.rb +6 -2
- data/lib/pgbus/doctor.rb +7 -5
- data/lib/pgbus/mcp/health_analyzer.rb +113 -25
- data/lib/pgbus/process/dispatcher.rb +6 -4
- data/lib/pgbus/process/worker.rb +5 -3
- data/lib/pgbus/serializer.rb +54 -8
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +66 -13
- data/lib/tasks/pgbus_streams.rake +30 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b79edc460be04508034f7c4b34ba889f33493ab88878b3a272cf23d7f75d43ee
|
|
4
|
+
data.tar.gz: 9a000c729dfa32c5e3e6bb8b8df751fe75e159ff0a2160afee104b201e4f78e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ca04a2bb7436c7fc08f953b5fa19cc5130bb36de9d678287ffc49768bd6e3f142266a547e4ca852377b0fd66ce15cf18f1eac34857f75365dd62f9c7c10a754
|
|
7
|
+
data.tar.gz: 101a052d947241a6e35b80093846c20bfaec42454e40767073ad6cde8e6f64e09f3ae1ef879bd4686482c8b5e355d4770ddcba7ed61672465e7867b9382925f4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
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
|
+
- **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.
|
|
10
|
+
- **`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.
|
|
11
|
+
- **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
12
|
- **`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
13
|
- **`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
14
|
- **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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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}" }
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
|
262
|
-
#
|
|
263
|
-
#
|
|
264
|
-
#
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 #{
|
|
103
|
-
elsif
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
def
|
|
135
|
-
|
|
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] }
|
|
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.
|
|
641
|
-
#
|
|
642
|
-
#
|
|
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.
|
|
647
|
+
Pgbus::StreamQueue.known_names
|
|
646
648
|
end
|
|
647
649
|
|
|
648
650
|
def cleanup_recurring_executions
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -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.
|
|
414
|
-
#
|
|
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.
|
|
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
|
data/lib/pgbus/serializer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
@@ -65,17 +65,60 @@ module Pgbus
|
|
|
65
65
|
end
|
|
66
66
|
private :fetch_queues_with_metrics
|
|
67
67
|
|
|
68
|
-
# Physical queue names
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
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.
|
|
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}" }
|
|
@@ -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
|