pgbus 0.13.1 → 0.13.2
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 +8 -0
- data/README.md +77 -3
- data/app/models/pgbus/processed_event.rb +45 -0
- data/exe/pgbus-health +9 -0
- data/lib/generators/pgbus/add_processed_event_completion_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_processed_event_completion.rb.erb +19 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +3 -0
- data/lib/pgbus/configuration.rb +43 -0
- data/lib/pgbus/event_bus/handler.rb +49 -7
- data/lib/pgbus/generators/migration_detector.rb +13 -0
- data/lib/pgbus/health_probe.rb +132 -0
- data/lib/pgbus/process/consumer.rb +4 -1
- data/lib/pgbus/process/readiness_snapshot.rb +30 -0
- data/lib/pgbus/process/supervisor.rb +64 -3
- data/lib/pgbus/process/worker.rb +14 -4
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/health_app.rb +23 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 295404bb7b5b6d289a2bfd4f10d9e5bc6df442f8d949454cf1dd78adf620a25f
|
|
4
|
+
data.tar.gz: 2ee9c8e2c40f47bb900e10d2ef4c55b5e21e3cd4d997c614c628b696ac759183
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 184484168fd458dbdb09b98d12ae5d906ebb510a05abc626fd3812ee4cfebb5cd575c090b8014b9524a9aa19f65e99d1737df50b191ce79367e77858f72c5024
|
|
7
|
+
data.tar.gz: 92170d5077128d507968989f8d9d789bdc9afa7b4ccf92ecdc3b39b357a82608de39a83b4e9f2f899078466714bfb30f3f0c357a9dc977f35c9c21254c8b7dbd
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
### Added
|
|
4
4
|
|
|
5
|
+
- **Health-checked rolling restarts for the job container (issue #386).** ⚠️ **Behavior change on the standalone `/readyz`.** The supervisor's `health_port` server previously answered `/readyz` with the cluster-wide HealthAnalyzer verdict — so during a rolling deploy a freshly-booted container could pass an orchestrator's health gate on the strength of the *old* container's still-heartbeating workers, and the old container (with all its capacity) was stopped before the new one had forked a single child. The standalone `/readyz` is now **container-local**: 200 only when *this* supervisor verified its connection, bootstrapped queues, forked every configured child, and all of them are currently alive — with 503 bodies `BOOTING` (pre-boot), `DEGRADED` (a child died and is waiting out crash-restart backoff — precisely the state a deploy gate must fail on, keeping the old container running), and `DRAINING` (stop signal received). No database access on the probe path; the supervisor publishes an immutable snapshot per monitor pass and the accept thread reads it. The Rails-mounted `Pgbus::Web::HealthApp` keeps the cluster-wide verdict unchanged. Alongside it: **`pgbus-health`**, a shipped executable probe for docker `HEALTHCHECK` blocks (plain Ruby + stdlib sockets, loads neither Bundler nor the gem — cheap at 1–5s intervals, works in curl-less images; exit 0/1/2 = healthy/unhealthy/usage), and a README "Rolling restarts (Kamal, docker)" guide covering the healthcheck block, stop-timeout alignment, overlap-window duplicate-supervisor safety, and the `read_ct`-vs-deploy-kill DLQ caveat. Refs #386.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Shutdown budgets are now alignable end-to-end (issue #386).** New `config.shutdown_timeout` bounds how long the supervisor waits for children after forwarding TERM before escalating to SIGKILL — previously a hardcoded 30s, which silently SIGKILLed workers mid-drain the moment `drain_timeout` was raised past it. Default derives `drain_timeout + 5` so the deadline tracks the drain window automatically; an explicit value below `drain_timeout` logs a boot warning. `Consumer#shutdown`'s pool wait (its only drain bound) now follows `config.drain_timeout` instead of a hardcoded 30s, and `Worker#shutdown`'s post-drain residual wait drops from a second full 30s window to 5s — the drain loop already waited `drain_timeout`, and a job still running has proven it won't finish. Rule of thumb: orchestrator stop grace period > `shutdown_timeout` > `drain_timeout`. Refs #386.
|
|
10
|
+
|
|
5
11
|
- **Streams: one LISTEN connection per web host — `streams_listen_scope` (issue #382).** ⚠️ **Default behavior change.** Previously every Puma worker lazily opened its own dedicated streams LISTEN connection on first SSE use, so a web host pinned one direct connection per worker. Under the new default (`streams_listen_scope = :master`) the `pgbus_streams` Puma plugin runs a **MasterHub** in the preforking master: ONE `Web::Streamer::Listener` on the refcounted union of every worker's stream channels, fanning wakes — **including ephemeral payloads** — out to workers over a Unix domain socket with length-prefixed frames (`Streamer::HubProtocol`). Workers connect lazily (nothing is inherited across fork) and the synchronous `ensure_listening` ack contract is preserved cross-process: a sub is registered before LISTEN executes and acked only after, so the no-lost-broadcast guarantee holds. Backpressure follows the streams rules: durable wakes are droppable at a per-worker cap (they self-heal via `read_after`), **ephemeral wakes are never dropped** — a worker that stops draining is evicted, which triggers its own fallback. **Fallback is per-worker listeners, not loss**: whenever the hub is absent or dies (no `preload_app!`, single-mode Puma, crash, eviction) each worker's `FailoverListener` swaps in a real per-worker `Listener` and re-LISTENs its recorded subscriptions — connection footprint balloons back to pre-#382 levels (census-visible) but no broadcast semantics change; the worker stays local until it recycles. Measured (local PG, n=50): the master→worker hop is noise-level free — single-broadcast SSE roundtrip p50 16.00ms via the hub vs 16.93ms per-worker. **`:master` effectively requires `preload_app!`** (the hub waits for the app's pgbus initializer; without it the deadline expires quietly and workers stay per-worker). **Rollback:** `config.streams_listen_scope = :process`. Refs #382, builds on the #381 patterns.
|
|
6
12
|
|
|
7
13
|
- **Host-level shared LISTEN: `worker_notify_scope` — the supervisor now owns ONE direct LISTEN connection for the whole host (issue #381).** ⚠️ **Default behavior change.** Previously every worker fork and every consumer fork opened its own dedicated LISTEN connection (`NotifyListener`), so a host's direct-connection footprint scaled with fork count — on transaction-pool PgBouncer platforms those connections come out of the scarcest slice of `max_connections`, and a 5-capsule + 2-consumer host pinned 7. Under the new default (`config.worker_notify_scope = :supervisor`) the supervisor runs a single `NotifyHub`: one `NotifyListener` on the union of every capsule's and consumer's queue channels (wildcards via the shared resolver, consumer sets via the registry), fanning wakes out to forks over per-fork pipes (`W` wake / `H` healthy / `P` degraded bytes; a fork whose pipe reports degraded or reaches EOF falls back to fast polling exactly like a failed local listener). Footprint drops to **1 direct LISTEN connection per job host**, verified by integration test: routing is per-fork (an insert wakes only the forks reading that queue, wildcard capsules unconditionally), and `pg_terminate_backend` on the shared connection is survived — reconnect, re-LISTEN, wakes flow again. **Rollback:** `config.worker_notify_scope = :fork` restores the previous per-fork listeners byte-for-byte. Dedicated LISTEN connections are now census-tagged `application_name=pgbus-listen` so `pg_stat_activity` can count them. Refs #381.
|
|
@@ -16,6 +22,8 @@
|
|
|
16
22
|
|
|
17
23
|
### Fixed
|
|
18
24
|
|
|
25
|
+
- **`idempotent!` handlers no longer silently drop an execution when the consumer is killed mid-handler — the idempotency claim is now two-phase (issue #385).** `EventBus::Handler#process!` inserted the `pgbus_processed_events` claim row *before* `handle(event)` ran, so a SIGKILL between claim and completion (supervisor watchdog, container runtime after its stop timeout, OOM — i.e. every container-replacement deploy's worst case) turned the redelivered message into a skip: the mechanism meant to dedup *duplicate* executions converted a crash *during* the one permitted execution into at-most-once. The claim now lands with a NULL `completed_at` (*pending*) and is stamped completed only after `handle` returns; on redelivery a pending claim **re-runs** the handler while a completed claim still skips. The in-memory dedup cache only ever records completed executions, and a `handle` that raises leaves the claim pending so VT redelivery retries it. The only double-execution window is a still-alive handler running past its visibility timeout — the same at-least-once window every non-idempotent handler already has. **Upgrading:** new installs get the column from `pgbus:install`; existing installs run `rails generate pgbus:add_processed_event_completion` (also picked up by `pgbus:update`; `--database` supported) — legacy rows are backfilled as completed so history is not retroactively re-run. Until the migration runs, an upgraded gem detects the missing column once and falls back to the legacy single-phase claim with a warning naming the generator. Refs #385.
|
|
26
|
+
|
|
19
27
|
- **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.
|
|
20
28
|
- **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.
|
|
21
29
|
- **`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.
|
data/README.md
CHANGED
|
@@ -221,6 +221,20 @@ Pgbus::EventBus::Registry.instance.subscribe(
|
|
|
221
221
|
)
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
+
`idempotent!` uses a **two-phase claim**: a *pending* row in
|
|
225
|
+
`pgbus_processed_events` is inserted before `handle` runs, and only stamped
|
|
226
|
+
`completed_at` after `handle` returns. Deduplication applies to **completed**
|
|
227
|
+
executions only — if the consumer process is killed mid-handler (deploy,
|
|
228
|
+
OOM, supervisor watchdog), the redelivered message finds the pending claim
|
|
229
|
+
and **re-runs the handler** instead of silently skipping it. The semantics
|
|
230
|
+
are at-least-once with dedup of completed executions: the only
|
|
231
|
+
double-execution window is a handler still running past its visibility
|
|
232
|
+
timeout — the same window every non-idempotent handler already has.
|
|
233
|
+
Installs created before this feature need the upgrade migration:
|
|
234
|
+
`rails generate pgbus:add_processed_event_completion` (supports
|
|
235
|
+
`--database`); until it runs, idempotent handlers fall back to the old
|
|
236
|
+
single-phase claim and log a warning.
|
|
237
|
+
|
|
224
238
|
### 4. Start workers
|
|
225
239
|
|
|
226
240
|
```bash
|
|
@@ -1103,14 +1117,18 @@ For the **HTTP** transport, point the client at the mounted URL with a streamabl
|
|
|
1103
1117
|
|
|
1104
1118
|
### Health endpoints (liveness / readiness)
|
|
1105
1119
|
|
|
1106
|
-
For orchestrators like Kubernetes, Pgbus exposes two HTTP probes: `/livez` (is the serving process up?) and `/readyz
|
|
1120
|
+
For orchestrators like Kubernetes, Pgbus exposes two HTTP probes: `/livez` (is the serving process up?) and `/readyz`. Readiness means different things in the two places the probes are served:
|
|
1121
|
+
|
|
1122
|
+
- **Mounted in Rails** (`Pgbus::Web::HealthApp`): `/readyz` runs the cluster-wide `OK` / `DEGRADED` / `STALLED` verdict, same as the MCP `pgbus_health` tool — `STALLED` (visible backlog while workers heart-beat but don't claim) fails readiness.
|
|
1123
|
+
- **Standalone from the supervisor** (`health_port`): `/readyz` is **container-local** — did *this* supervisor finish booting, and are all the children *it* forked alive? That is the signal a rolling deploy's health gate needs; the cluster verdict would let a brand-new container pass on the strength of the *old* container's workers.
|
|
1107
1124
|
|
|
1108
1125
|
| Path | Method | 200 | 503 | Touches DB |
|
|
1109
1126
|
|---|---|---|---|---|
|
|
1110
1127
|
| `/livez` | GET | always (`ok`) | never | no |
|
|
1111
|
-
| `/readyz` | GET | verdict `OK` or `DEGRADED` | verdict `STALLED`, or DB unreachable (`{"status":"ERROR"}`) | yes |
|
|
1128
|
+
| `/readyz` (mounted) | GET | verdict `OK` or `DEGRADED` | verdict `STALLED`, or DB unreachable (`{"status":"ERROR"}`) | yes |
|
|
1129
|
+
| `/readyz` (supervisor) | GET | `OK` — booted, all children live | `BOOTING`, `DEGRADED` (child down), `DRAINING` (stopping) | no |
|
|
1112
1130
|
|
|
1113
|
-
Unknown paths return `404`; non-`GET` methods return `405`. The `/readyz` body is
|
|
1131
|
+
Unknown paths return `404`; non-`GET` methods return `405`. The `/readyz` body is JSON, so a probe failure is self-describing in the pod's event log.
|
|
1114
1132
|
|
|
1115
1133
|
#### Mount in your Rails app
|
|
1116
1134
|
|
|
@@ -1151,6 +1169,61 @@ readinessProbe:
|
|
|
1151
1169
|
httpGet: { path: /readyz, port: 9394 }
|
|
1152
1170
|
```
|
|
1153
1171
|
|
|
1172
|
+
The supervisor's `/readyz` answers from its own state, never the database:
|
|
1173
|
+
|
|
1174
|
+
```json
|
|
1175
|
+
{ "status": "OK", "expected": 3, "live": 3 }
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
- `BOOTING` (503) until the connection is verified, queues are bootstrapped, and every configured child has been forked. `expected` is stamped at that instant.
|
|
1179
|
+
- `OK` (200) while all expected children are in the fork table. A clean worker recycle never dips the count — the snapshot refreshes after reap-and-restart each monitor pass.
|
|
1180
|
+
- `DEGRADED` (503) when a child died and is waiting out crash-restart backoff. During a rolling deploy this is the desired failure mode: a crash-looping replacement never goes ready, so the old container keeps running.
|
|
1181
|
+
- `DRAINING` (503) the moment a stop signal arrives.
|
|
1182
|
+
|
|
1183
|
+
#### `pgbus-health`: container HEALTHCHECK probe
|
|
1184
|
+
|
|
1185
|
+
`pgbus-health` ships with the gem: a dependency-free probe (plain Ruby + stdlib sockets — no Bundler, no Rails, nothing else loaded) that GETs `127.0.0.1:<port>/readyz` and exits `0` on 200, `1` on anything else, `2` on usage errors. Cheap enough for a 1–5s `HEALTHCHECK` interval, and it works in images without curl:
|
|
1186
|
+
|
|
1187
|
+
```bash
|
|
1188
|
+
pgbus-health --port 9394 # or PGBUS_HEALTH_PORT=9394 pgbus-health
|
|
1189
|
+
pgbus-health --port 9394 --path /livez --timeout 2
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
### Rolling restarts (Kamal, docker)
|
|
1193
|
+
|
|
1194
|
+
Kamal distributions with per-role health checks (for example the [`dash` branch](https://github.com/mhenrixon/kamal)) can rolling-restart a non-proxied job role: start the new container, poll its docker `HEALTHCHECK` until healthy, and only then `docker stop` the old one. Wire the pgbus container into that gate:
|
|
1195
|
+
|
|
1196
|
+
```yaml
|
|
1197
|
+
# config/deploy.yml
|
|
1198
|
+
servers:
|
|
1199
|
+
job:
|
|
1200
|
+
hosts: [...]
|
|
1201
|
+
cmd: bin/pgbus start
|
|
1202
|
+
healthcheck:
|
|
1203
|
+
cmd: bin/pgbus-health --port 9394
|
|
1204
|
+
interval: 5s
|
|
1205
|
+
start_period: 30s # cover Rails boot + queue bootstrap
|
|
1206
|
+
stop_timeout: 45 # must exceed pgbus shutdown_timeout (see below)
|
|
1207
|
+
env:
|
|
1208
|
+
clear:
|
|
1209
|
+
PGBUS_HEALTH_PORT: 9394
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
(`bundle binstubs pgbus` generates `bin/pgbus-health`; adjust the path if your image invokes gem executables differently.)
|
|
1213
|
+
|
|
1214
|
+
**The shutdown timeline.** On `docker stop`, SIGTERM reaches the supervisor and readiness flips to `DRAINING`; children stop claiming work and drain in-flight jobs for up to `drain_timeout` (default 30s); the supervisor waits `shutdown_timeout` (default `drain_timeout + 5`) before SIGKILLing stragglers. Align the three knobs outside-in:
|
|
1215
|
+
|
|
1216
|
+
```text
|
|
1217
|
+
orchestrator stop_timeout > pgbus shutdown_timeout > pgbus drain_timeout
|
|
1218
|
+
45s 35s (derived) 30s
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
If the orchestrator's stop grace period is *shorter* than `shutdown_timeout`, docker SIGKILLs the whole tree mid-drain and the graceful path never gets to finish. Raising `drain_timeout` raises the derived `shutdown_timeout` automatically; raise `stop_timeout` to match.
|
|
1222
|
+
|
|
1223
|
+
**The overlap window is safe by construction.** Between "new container healthy" and "old container stopped", two supervisors run against the same database. Nothing double-fires: queue claims use `FOR UPDATE SKIP LOCKED`, `single_active_consumer` queues arbitrate via session-level advisory locks (released the instant a killed process's connection dies), two live recurring schedulers dedup on the `(task_key, run_at)` unique record, and dispatcher maintenance is idempotent. "One scheduler per deployment" is a steady-state rule; a deploy window may briefly violate it without consequence.
|
|
1224
|
+
|
|
1225
|
+
**What a hard kill still costs.** Jobs killed past the drain window are redelivered after their visibility timeout (at-least-once holds) — but PGMQ's `read_ct` increments exactly like a logical failure, so a long-running job that straddles *repeated* deploy kills can be pushed to the DLQ without its code ever raising. `zombie_detection` logs exactly this pattern (`read_ct > 1` with no recorded failure). Keep jobs shorter than `drain_timeout`, or raise it (and `stop_timeout`) for queues that can't be. For `idempotent!` event handlers there is a separate crash-window caveat tracked in [#385](https://github.com/mhenrixon/pgbus/issues/385).
|
|
1226
|
+
|
|
1154
1227
|
### Boot diagnostics banner
|
|
1155
1228
|
|
|
1156
1229
|
`Supervisor#run` logs a one-block banner right after the heartbeat starts and before queues bootstrap, so a misconfigured deployment states its actual settings instead of forcing an operator to attach a console. Every line is `"[Pgbus] boot:"`-prefixed and renders cleanly under both the `:text` and `:json` log formatters:
|
|
@@ -2097,6 +2170,7 @@ Curated headline options for the README. The full operator reference (with types
|
|
|
2097
2170
|
| `zombie_detection` | `true` | Detect and reclaim work from crashed workers |
|
|
2098
2171
|
| `read_timeout` | `30` | Seconds before a single PGMQ read is bounded (libpq `statement_timeout` + `tcp_user_timeout` on a dedicated connection; nil disables) |
|
|
2099
2172
|
| `drain_timeout` | `30` | Seconds to wait for in-flight jobs during graceful shutdown before abandoning them |
|
|
2173
|
+
| `shutdown_timeout` | `drain_timeout + 5` | Seconds the supervisor waits for children after TERM before SIGKILL; an orchestrator's stop grace period must exceed it |
|
|
2100
2174
|
| `stall_threshold` | `300` | Seconds without progress before a worker is considered stalled |
|
|
2101
2175
|
| `priority_levels` | `nil` | Number of priority sub-queues (nil = disabled, 2-10) |
|
|
2102
2176
|
| `default_priority` | `1` | Default priority for jobs without explicit priority |
|
|
@@ -5,5 +5,50 @@ module Pgbus
|
|
|
5
5
|
self.table_name = "pgbus_processed_events"
|
|
6
6
|
|
|
7
7
|
scope :expired, ->(before) { where("processed_at < ?", before) }
|
|
8
|
+
|
|
9
|
+
@completion_column_mutex = Mutex.new
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Whether pgbus_processed_events has the completed_at column that backs
|
|
13
|
+
# the two-phase idempotency claim (issue #385). Detected once per process
|
|
14
|
+
# (memoized under a mutex) so the schema probe never lands on the
|
|
15
|
+
# per-event hot path. An upgraded gem running against a not-yet-migrated
|
|
16
|
+
# table gets `false` plus a one-time warning pointing at the upgrade
|
|
17
|
+
# generator — Handler then falls back to the legacy single-phase claim.
|
|
18
|
+
#
|
|
19
|
+
# A detection error (e.g. the database is briefly unreachable) is NOT
|
|
20
|
+
# memoized: it propagates to the caller — where the event's normal
|
|
21
|
+
# failure path leaves the message for VT redelivery — and the next
|
|
22
|
+
# delivery probes again.
|
|
23
|
+
def completion_column?
|
|
24
|
+
detected = @completion_column
|
|
25
|
+
return detected unless detected.nil?
|
|
26
|
+
|
|
27
|
+
@completion_column_mutex.synchronize do
|
|
28
|
+
@completion_column = detect_completion_column if @completion_column.nil?
|
|
29
|
+
@completion_column
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Test seam: clear the memoized detection so specs can exercise both
|
|
34
|
+
# schema shapes in one process.
|
|
35
|
+
def reset_completion_column_check!
|
|
36
|
+
@completion_column_mutex.synchronize { @completion_column = nil }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def detect_completion_column
|
|
42
|
+
supported = column_names.include?("completed_at")
|
|
43
|
+
unless supported
|
|
44
|
+
Pgbus.logger.warn do
|
|
45
|
+
"[Pgbus] pgbus_processed_events is missing the completed_at column; idempotent handlers " \
|
|
46
|
+
"fall back to single-phase claims (a crash mid-handler can skip work on redelivery). " \
|
|
47
|
+
"Run `rails generate pgbus:add_processed_event_completion` and migrate."
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
supported
|
|
51
|
+
end
|
|
52
|
+
end
|
|
8
53
|
end
|
|
9
54
|
end
|
data/exe/pgbus-health
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Container HEALTHCHECK probe (issue #386). Deliberately loads ONLY the
|
|
5
|
+
# probe file — never the pgbus gem, Bundler, or Rails — because a docker
|
|
6
|
+
# HEALTHCHECK runs this every few seconds.
|
|
7
|
+
require_relative "../lib/pgbus/health_probe"
|
|
8
|
+
|
|
9
|
+
exit Pgbus::HealthProbe.run(ARGV)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "migration_path"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
module Generators
|
|
9
|
+
class AddProcessedEventCompletionGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationPath
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Add completed_at to pgbus_processed_events for two-phase idempotency claims " \
|
|
16
|
+
"(crash mid-handler re-runs instead of silently skipping)"
|
|
17
|
+
|
|
18
|
+
class_option :database,
|
|
19
|
+
type: :string,
|
|
20
|
+
default: nil,
|
|
21
|
+
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
22
|
+
|
|
23
|
+
def create_migration_file
|
|
24
|
+
migration_template "add_processed_event_completion.rb.erb",
|
|
25
|
+
File.join(pgbus_migrate_path, "add_pgbus_processed_event_completion.rb")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def display_post_install
|
|
29
|
+
say ""
|
|
30
|
+
say "Pgbus two-phase idempotency claim migration installed!", :green
|
|
31
|
+
say ""
|
|
32
|
+
say "Next steps:"
|
|
33
|
+
say " 1. Run: rails db:migrate#{migrate_command_suffix}"
|
|
34
|
+
say " 2. Restart pgbus: bin/pgbus start"
|
|
35
|
+
say ""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def migration_version
|
|
41
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class AddPgbusProcessedEventCompletion < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
add_column :pgbus_processed_events, :completed_at, :datetime
|
|
4
|
+
|
|
5
|
+
# Backfill legacy rows as completed: a row written by the single-phase
|
|
6
|
+
# code means handle() was at least started — treating it as completed
|
|
7
|
+
# preserves the old skip behavior instead of retroactively re-running
|
|
8
|
+
# history on the next delivery of an old event.
|
|
9
|
+
execute <<~SQL
|
|
10
|
+
UPDATE pgbus_processed_events
|
|
11
|
+
SET completed_at = processed_at
|
|
12
|
+
WHERE completed_at IS NULL
|
|
13
|
+
SQL
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def down
|
|
17
|
+
remove_column :pgbus_processed_events, :completed_at
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -30,6 +30,9 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
30
30
|
t.string :event_id, null: false
|
|
31
31
|
t.string :handler_class, null: false
|
|
32
32
|
t.datetime :processed_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
33
|
+
# Two-phase idempotency claim: NULL = claimed but not finished (a crash
|
|
34
|
+
# mid-handler re-runs on redelivery); set = completed, dedup applies.
|
|
35
|
+
t.datetime :completed_at
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
add_index :pgbus_processed_events, [:event_id, :handler_class],
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -35,6 +35,16 @@ module Pgbus
|
|
|
35
35
|
# wait, so recycling/deploy never wedges on a permanently-stuck job.
|
|
36
36
|
attr_accessor :stall_threshold, :read_timeout, :drain_timeout
|
|
37
37
|
|
|
38
|
+
# shutdown_timeout bounds how long the supervisor waits for its children
|
|
39
|
+
# after forwarding TERM before escalating to SIGKILL. nil (default) derives
|
|
40
|
+
# drain_timeout + SHUTDOWN_TIMEOUT_MARGIN, so raising drain_timeout keeps
|
|
41
|
+
# the supervisor's deadline above the workers' drain window. An orchestrator
|
|
42
|
+
# stop grace period (Kamal stop_timeout, Kubernetes terminationGracePeriod)
|
|
43
|
+
# should exceed this value, or docker SIGKILLs the whole tree first.
|
|
44
|
+
attr_writer :shutdown_timeout
|
|
45
|
+
|
|
46
|
+
SHUTDOWN_TIMEOUT_MARGIN = 5
|
|
47
|
+
|
|
38
48
|
# Dispatcher settings
|
|
39
49
|
attr_accessor :dispatch_interval
|
|
40
50
|
|
|
@@ -238,6 +248,7 @@ module Pgbus
|
|
|
238
248
|
@stall_threshold = 90
|
|
239
249
|
@read_timeout = 30
|
|
240
250
|
@drain_timeout = 30
|
|
251
|
+
@shutdown_timeout = nil
|
|
241
252
|
|
|
242
253
|
@dispatch_interval = 1.0
|
|
243
254
|
|
|
@@ -712,6 +723,8 @@ module Pgbus
|
|
|
712
723
|
end
|
|
713
724
|
raise Pgbus::ConfigurationError, "drain_timeout must be > 0" unless drain_timeout.is_a?(Numeric) && drain_timeout.positive?
|
|
714
725
|
|
|
726
|
+
validate_shutdown_timeout!
|
|
727
|
+
|
|
715
728
|
unless stats_flush_size.is_a?(Integer) && stats_flush_size.positive?
|
|
716
729
|
raise Pgbus::ConfigurationError, "stats_flush_size must be a positive integer"
|
|
717
730
|
end
|
|
@@ -765,6 +778,30 @@ module Pgbus
|
|
|
765
778
|
self
|
|
766
779
|
end
|
|
767
780
|
|
|
781
|
+
# An explicit shutdown_timeout must be a positive number; nil keeps the
|
|
782
|
+
# derived drain_timeout + margin default. A value below drain_timeout is
|
|
783
|
+
# legal but self-defeating (the supervisor SIGKILLs workers mid-drain), so
|
|
784
|
+
# it warns instead of raising.
|
|
785
|
+
def validate_shutdown_timeout!
|
|
786
|
+
explicit = @shutdown_timeout
|
|
787
|
+
# Finite real only: Float::INFINITY would blow up Supervisor#shutdown's
|
|
788
|
+
# `Time.now + shutdown_timeout` before any child cleanup ran, and a
|
|
789
|
+
# Complex would crash `positive?` — reject both here, at boot.
|
|
790
|
+
valid = explicit.is_a?(Numeric) && explicit.real? && explicit.finite? && explicit.positive?
|
|
791
|
+
unless explicit.nil? || valid
|
|
792
|
+
raise Pgbus::ConfigurationError,
|
|
793
|
+
"shutdown_timeout must be a positive finite number or nil " \
|
|
794
|
+
"(defaults to drain_timeout + #{SHUTDOWN_TIMEOUT_MARGIN})"
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
return unless explicit && explicit < drain_timeout
|
|
798
|
+
|
|
799
|
+
Pgbus.logger.warn do
|
|
800
|
+
"[Pgbus] shutdown_timeout (#{explicit}s) is below drain_timeout (#{drain_timeout}s) — " \
|
|
801
|
+
"the supervisor will SIGKILL workers before their drain window ends"
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
768
805
|
# Pre-1.0 surface-freeze: reject malformed values for core job-path keys at
|
|
769
806
|
# boot rather than failing deep in a worker/dispatcher/poller/scheduler
|
|
770
807
|
# thread, per-enqueue, or by silently corrupting queue names / leaving the
|
|
@@ -1237,6 +1274,12 @@ module Pgbus
|
|
|
1237
1274
|
# because only one runs at a time per reactor thread.
|
|
1238
1275
|
ASYNC_POOL_CONNECTIONS = 3
|
|
1239
1276
|
|
|
1277
|
+
# Resolved supervisor SIGKILL deadline: the explicit value when set,
|
|
1278
|
+
# otherwise drain_timeout + SHUTDOWN_TIMEOUT_MARGIN (see attr_writer docs).
|
|
1279
|
+
def shutdown_timeout
|
|
1280
|
+
@shutdown_timeout || (drain_timeout + SHUTDOWN_TIMEOUT_MARGIN)
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1240
1283
|
def resolved_pool_size
|
|
1241
1284
|
return pool_size if pool_size
|
|
1242
1285
|
|
|
@@ -45,6 +45,7 @@ module Pgbus
|
|
|
45
45
|
Instrumentation.instrument("pgbus.event_processed", instrument_payload) do
|
|
46
46
|
handle(event)
|
|
47
47
|
end
|
|
48
|
+
complete_claim!(event.event_id) if self.class.idempotent?
|
|
48
49
|
:handled
|
|
49
50
|
rescue StandardError => e
|
|
50
51
|
instrument(
|
|
@@ -100,13 +101,25 @@ module Pgbus
|
|
|
100
101
|
ActiveSupport::Notifications.instrument(event_name, payload)
|
|
101
102
|
end
|
|
102
103
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
104
|
+
# Two-phase idempotency claim (issue #385). Phase 1: atomically claim
|
|
105
|
+
# via INSERT ... ON CONFLICT DO NOTHING with completed_at NULL — a
|
|
106
|
+
# *pending* claim. Returns true when this delivery should run handle:
|
|
106
107
|
#
|
|
107
|
-
#
|
|
108
|
+
# - insert won → fresh claim
|
|
109
|
+
# - insert lost, completed_at NULL → a prior attempt claimed but was
|
|
110
|
+
# killed before finishing (SIGKILL mid-handler); re-run so the crash
|
|
111
|
+
# doesn't silently drop the execution. Safe: PGMQ's VT means the
|
|
112
|
+
# prior holder is dead or wedged past its timeout — the same
|
|
113
|
+
# at-least-once window every non-idempotent handler has.
|
|
114
|
+
#
|
|
115
|
+
# Returns false (skip) only for a *completed* execution. Phase 2 is
|
|
116
|
+
# complete_claim! after handle returns; only completed executions enter
|
|
117
|
+
# the in-memory dedup cache.
|
|
118
|
+
#
|
|
119
|
+
# Legacy fallback: without the completed_at column (upgraded gem,
|
|
120
|
+
# not-yet-migrated table) this degrades to the old single-phase claim.
|
|
108
121
|
def claim_idempotency?(event_id)
|
|
109
|
-
cache_key =
|
|
122
|
+
cache_key = dedup_key(event_id)
|
|
110
123
|
return false if self.class.dedup_cache.seen?(cache_key)
|
|
111
124
|
|
|
112
125
|
result = ProcessedEvent.insert(
|
|
@@ -114,9 +127,38 @@ module Pgbus
|
|
|
114
127
|
unique_by: %i[event_id handler_class]
|
|
115
128
|
)
|
|
116
129
|
|
|
117
|
-
|
|
130
|
+
unless ProcessedEvent.completion_column?
|
|
131
|
+
self.class.dedup_cache.mark!(cache_key)
|
|
132
|
+
return result.rows.any?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return true if result.rows.any?
|
|
136
|
+
|
|
137
|
+
completed_at = ProcessedEvent
|
|
138
|
+
.where(event_id: event_id, handler_class: self.class.name)
|
|
139
|
+
.pick(:completed_at)
|
|
140
|
+
return true if completed_at.nil? # pending claim (or purged row) → re-run
|
|
141
|
+
|
|
118
142
|
self.class.dedup_cache.mark!(cache_key)
|
|
119
|
-
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Phase 2: stamp the claim completed and only then admit it to the
|
|
147
|
+
# dedup cache. Skipped on legacy schemas (single-phase claims are
|
|
148
|
+
# already cached at claim time). If this write fails, process!'s rescue
|
|
149
|
+
# re-raises, the consumer leaves the message for VT redelivery, and the
|
|
150
|
+
# still-pending claim re-runs — at-least-once, never a silent drop.
|
|
151
|
+
def complete_claim!(event_id)
|
|
152
|
+
return unless ProcessedEvent.completion_column?
|
|
153
|
+
|
|
154
|
+
ProcessedEvent
|
|
155
|
+
.where(event_id: event_id, handler_class: self.class.name)
|
|
156
|
+
.update_all(completed_at: Time.now.utc)
|
|
157
|
+
self.class.dedup_cache.mark!(dedup_key(event_id))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def dedup_key(event_id)
|
|
161
|
+
"#{event_id}:#{self.class.name}"
|
|
120
162
|
end
|
|
121
163
|
end
|
|
122
164
|
end
|
|
@@ -77,6 +77,7 @@ module Pgbus
|
|
|
77
77
|
add_outbox: "pgbus:add_outbox",
|
|
78
78
|
add_recurring: "pgbus:add_recurring",
|
|
79
79
|
add_failed_events_index: "pgbus:add_failed_events_index",
|
|
80
|
+
add_processed_event_completion: "pgbus:add_processed_event_completion",
|
|
80
81
|
tune_autovacuum: "pgbus:tune_autovacuum",
|
|
81
82
|
tune_fillfactor: "pgbus:tune_fillfactor"
|
|
82
83
|
}.freeze
|
|
@@ -95,6 +96,7 @@ module Pgbus
|
|
|
95
96
|
add_outbox: "outbox entries table (transactional outbox)",
|
|
96
97
|
add_recurring: "recurring tasks + executions tables",
|
|
97
98
|
add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
|
|
99
|
+
add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
|
|
98
100
|
tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables",
|
|
99
101
|
tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)"
|
|
100
102
|
}.freeze
|
|
@@ -120,6 +122,7 @@ module Pgbus
|
|
|
120
122
|
*outbox_migrations,
|
|
121
123
|
*recurring_migrations,
|
|
122
124
|
*failed_events_index_migrations,
|
|
125
|
+
*processed_event_completion_migrations,
|
|
123
126
|
*autovacuum_migrations,
|
|
124
127
|
*fillfactor_migrations
|
|
125
128
|
]
|
|
@@ -210,6 +213,16 @@ module Pgbus
|
|
|
210
213
|
[:add_failed_events_index]
|
|
211
214
|
end
|
|
212
215
|
|
|
216
|
+
# completed_at backs the two-phase idempotency claim (issue #385).
|
|
217
|
+
# Without it, idempotent handlers fall back to single-phase claims and
|
|
218
|
+
# a crash mid-handler silently drops the execution on redelivery.
|
|
219
|
+
def processed_event_completion_migrations
|
|
220
|
+
return [] unless table_exists?("pgbus_processed_events")
|
|
221
|
+
return [] if column_names("pgbus_processed_events").include?("completed_at")
|
|
222
|
+
|
|
223
|
+
[:add_processed_event_completion]
|
|
224
|
+
end
|
|
225
|
+
|
|
213
226
|
# Autovacuum tuning: check if any PGMQ queue table already has
|
|
214
227
|
# custom autovacuum settings applied. If not, queue the migration.
|
|
215
228
|
def autovacuum_migrations
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# Dependency-free readiness probe for container HEALTHCHECKs (issue #386).
|
|
7
|
+
#
|
|
8
|
+
# exe/pgbus-health loads this file via require_relative and nothing else:
|
|
9
|
+
# a docker HEALTHCHECK runs the probe every few seconds, so it must never
|
|
10
|
+
# drag in Bundler, Zeitwerk, Rails, or the rest of the gem. Only Ruby's
|
|
11
|
+
# bundled socket library is allowed here.
|
|
12
|
+
#
|
|
13
|
+
# healthcheck:
|
|
14
|
+
# cmd: bin/pgbus-health # port from PGBUS_HEALTH_PORT
|
|
15
|
+
# cmd: bin/pgbus-health --port 9394 --path /livez
|
|
16
|
+
#
|
|
17
|
+
# Exit codes: 0 healthy (HTTP 2xx), 1 unhealthy (non-2xx, refused, timeout),
|
|
18
|
+
# 2 usage error (no/invalid port).
|
|
19
|
+
class HealthProbe
|
|
20
|
+
EXIT_OK = 0
|
|
21
|
+
EXIT_UNHEALTHY = 1
|
|
22
|
+
EXIT_USAGE = 2
|
|
23
|
+
|
|
24
|
+
DEFAULT_PATH = "/readyz"
|
|
25
|
+
DEFAULT_TIMEOUT = 2.0
|
|
26
|
+
HOST = "127.0.0.1"
|
|
27
|
+
|
|
28
|
+
USAGE = "usage: pgbus-health [--port PORT] [--path PATH] [--timeout SECONDS]\n " \
|
|
29
|
+
"port falls back to the PGBUS_HEALTH_PORT environment variable\n"
|
|
30
|
+
|
|
31
|
+
def self.run(argv, env: ENV, out: $stdout, err: $stderr)
|
|
32
|
+
new(argv, env: env, out: out, err: err).run
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(argv, env: ENV, out: $stdout, err: $stderr)
|
|
36
|
+
@out = out
|
|
37
|
+
@err = err
|
|
38
|
+
@path = DEFAULT_PATH
|
|
39
|
+
@timeout = DEFAULT_TIMEOUT
|
|
40
|
+
@port = env["PGBUS_HEALTH_PORT"]
|
|
41
|
+
@usage_error = false
|
|
42
|
+
parse(argv)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
return usage_failure if @usage_error
|
|
47
|
+
|
|
48
|
+
port = Integer(@port, exception: false)
|
|
49
|
+
# Out-of-range ports would reach Socket.tcp and raise SocketError — a
|
|
50
|
+
# backtrace where a HEALTHCHECK needs a deterministic exit code.
|
|
51
|
+
return usage_failure unless port&.between?(1, 65_535)
|
|
52
|
+
|
|
53
|
+
probe(port)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Hand-rolled flag parsing: three flags do not justify optparse in a
|
|
59
|
+
# script whose reason to exist is loading nothing.
|
|
60
|
+
def parse(argv)
|
|
61
|
+
args = argv.dup
|
|
62
|
+
until args.empty?
|
|
63
|
+
flag = args.shift
|
|
64
|
+
value = args.shift
|
|
65
|
+
return @usage_error = true if value.nil?
|
|
66
|
+
|
|
67
|
+
case flag
|
|
68
|
+
when "--port" then @port = value
|
|
69
|
+
when "--path" then @path = value
|
|
70
|
+
when "--timeout"
|
|
71
|
+
# A typo'd timeout must be a usage error, not `to_f`'s silent 0.0 —
|
|
72
|
+
# a zero deadline reports the container unhealthy on every probe.
|
|
73
|
+
timeout = Float(value, exception: false)
|
|
74
|
+
return @usage_error = true unless timeout&.positive?
|
|
75
|
+
|
|
76
|
+
@timeout = timeout
|
|
77
|
+
else
|
|
78
|
+
return @usage_error = true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage_failure
|
|
84
|
+
@err.write(USAGE)
|
|
85
|
+
EXIT_USAGE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def probe(port)
|
|
89
|
+
status = http_status(port)
|
|
90
|
+
healthy = status&.between?(200, 299)
|
|
91
|
+
@out.write("pgbus-health: #{@path} -> #{status || "no response"}\n")
|
|
92
|
+
healthy ? EXIT_OK : EXIT_UNHEALTHY
|
|
93
|
+
rescue SystemCallError, IOError, SocketError => e
|
|
94
|
+
@err.write("pgbus-health: #{@path} -> #{e.class}: #{e.message}\n")
|
|
95
|
+
EXIT_UNHEALTHY
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Minimal HTTP/1.0 exchange: send the request, read just the status line.
|
|
99
|
+
# The deadline covers connect and read together.
|
|
100
|
+
def http_status(port)
|
|
101
|
+
deadline = monotonic_now + @timeout
|
|
102
|
+
Socket.tcp(HOST, port, connect_timeout: @timeout) do |sock|
|
|
103
|
+
sock.write("GET #{@path} HTTP/1.0\r\nHost: #{HOST}\r\nConnection: close\r\n\r\n")
|
|
104
|
+
line = read_status_line(sock, deadline)
|
|
105
|
+
code = line&.split(" ", 3)&.fetch(1, nil)
|
|
106
|
+
Integer(code, exception: false)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_status_line(sock, deadline)
|
|
111
|
+
buffer = +""
|
|
112
|
+
until buffer.include?("\n")
|
|
113
|
+
remaining = deadline - monotonic_now
|
|
114
|
+
return nil if remaining <= 0 || !sock.wait_readable(remaining)
|
|
115
|
+
|
|
116
|
+
chunk = sock.read_nonblock(1024, exception: false)
|
|
117
|
+
return nil if chunk.nil? # EOF before a full status line
|
|
118
|
+
next if chunk == :wait_readable # spurious wakeup — re-wait on the deadline
|
|
119
|
+
|
|
120
|
+
buffer << chunk
|
|
121
|
+
end
|
|
122
|
+
buffer[/\A[^\r\n]*/]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ::Process, not Process — inside the Pgbus namespace the bare constant
|
|
126
|
+
# resolves to Pgbus::Process (the process model), which is also why this
|
|
127
|
+
# file must never be renamed into that namespace.
|
|
128
|
+
def monotonic_now
|
|
129
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -480,7 +480,10 @@ module Pgbus
|
|
|
480
480
|
def shutdown
|
|
481
481
|
stop_wake_source
|
|
482
482
|
@pool.shutdown
|
|
483
|
-
|
|
483
|
+
# The consumer has no quiesce-gated drain loop like Worker's, so this
|
|
484
|
+
# wait IS its drain window — bound it by the same knob workers use
|
|
485
|
+
# instead of a hardcoded 30s (issue #386).
|
|
486
|
+
@pool.wait_for_termination(config.drain_timeout)
|
|
484
487
|
@stat_buffer&.stop
|
|
485
488
|
@heartbeat&.stop
|
|
486
489
|
restore_signals
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Immutable container-local readiness state, published by the supervisor
|
|
6
|
+
# (one atomic swap per monitor pass) and read by the standalone health
|
|
7
|
+
# server's accept thread — the immutability is what makes the cross-thread
|
|
8
|
+
# handoff safe without a lock (issue #386).
|
|
9
|
+
#
|
|
10
|
+
# `expected` is the child count forked by boot_processes; `live` is the
|
|
11
|
+
# current fork-table size. A child sitting in crash-restart backoff keeps
|
|
12
|
+
# `live < expected`, which is exactly the signal a rolling deploy's health
|
|
13
|
+
# gate needs to fail on: the replacement container never goes ready, and
|
|
14
|
+
# the orchestrator keeps the old container running.
|
|
15
|
+
ReadinessSnapshot = Data.define(:booted, :shutting_down, :expected, :live) do
|
|
16
|
+
def ready?
|
|
17
|
+
booted && !shutting_down && live >= expected
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# DRAINING wins over BOOTING: a supervisor told to stop mid-boot is
|
|
21
|
+
# leaving, not arriving, and must never look like it will become ready.
|
|
22
|
+
def status
|
|
23
|
+
return "DRAINING" if shutting_down
|
|
24
|
+
return "BOOTING" unless booted
|
|
25
|
+
|
|
26
|
+
ready? ? "OK" : "DEGRADED"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -44,6 +44,16 @@ module Pgbus
|
|
|
44
44
|
@pending_restarts = pending_restarts
|
|
45
45
|
@crash_counts = Hash.new(0)
|
|
46
46
|
@notify_hub = notify_hub
|
|
47
|
+
@intended_children = 0
|
|
48
|
+
@readiness = Concurrent::AtomicReference.new(
|
|
49
|
+
ReadinessSnapshot.new(booted: false, shutting_down: shutting_down, expected: 0, live: forks.size)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The current container-local readiness state. Safe to call from any
|
|
54
|
+
# thread (the health server's accept thread reads it per probe).
|
|
55
|
+
def readiness_snapshot
|
|
56
|
+
@readiness.get
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
def shutting_down?
|
|
@@ -97,6 +107,7 @@ module Pgbus
|
|
|
97
107
|
start_notify_hub
|
|
98
108
|
|
|
99
109
|
boot_processes
|
|
110
|
+
mark_booted
|
|
100
111
|
monitor_loop
|
|
101
112
|
ensure
|
|
102
113
|
shutdown
|
|
@@ -105,17 +116,51 @@ module Pgbus
|
|
|
105
116
|
def graceful_shutdown
|
|
106
117
|
Pgbus.logger.info { "[Pgbus] Supervisor: graceful shutdown requested" }
|
|
107
118
|
@shutting_down = true
|
|
119
|
+
refresh_readiness
|
|
108
120
|
signal_children("TERM")
|
|
109
121
|
end
|
|
110
122
|
|
|
111
123
|
def immediate_shutdown
|
|
112
124
|
Pgbus.logger.warn { "[Pgbus] Supervisor: immediate shutdown requested" }
|
|
113
125
|
@shutting_down = true
|
|
126
|
+
refresh_readiness
|
|
114
127
|
signal_children("QUIT")
|
|
115
128
|
end
|
|
116
129
|
|
|
117
130
|
private
|
|
118
131
|
|
|
132
|
+
# Boot is complete: connection verified, queues bootstrapped, every
|
|
133
|
+
# configured child fork ATTEMPTED. The baseline is the larger of the
|
|
134
|
+
# intended-attempt count and the fork-table size: a boot-time fork
|
|
135
|
+
# failure (EAGAIN/ENOMEM, logged-and-swallowed in fork_*) leaves
|
|
136
|
+
# intended > live, so the readiness gate reports DEGRADED instead of
|
|
137
|
+
# blessing a container that is missing workers. Roles that legitimately
|
|
138
|
+
# declined to boot (scheduler with no recurring tasks) never reach a
|
|
139
|
+
# fork_* method and are counted by neither side.
|
|
140
|
+
def mark_booted
|
|
141
|
+
@booted = true
|
|
142
|
+
@expected_children = [@intended_children, @forks.size].max
|
|
143
|
+
refresh_readiness
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Count a child the configuration intends this boot to run. Called at
|
|
147
|
+
# the top of every fork_* method — before the fork can fail — and only
|
|
148
|
+
# pre-boot, so restart_child's re-forks never inflate the baseline.
|
|
149
|
+
def note_intended_child
|
|
150
|
+
@intended_children += 1 unless @booted
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Publish a fresh snapshot; the swapped-in Data is immutable, so the
|
|
154
|
+
# health server's accept thread always reads a consistent state.
|
|
155
|
+
def refresh_readiness
|
|
156
|
+
@readiness.set(
|
|
157
|
+
ReadinessSnapshot.new(
|
|
158
|
+
booted: !!@booted, shutting_down: @shutting_down,
|
|
159
|
+
expected: @expected_children || 0, live: @forks.size
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
119
164
|
# Log a single boot diagnostics banner: the settings that actually
|
|
120
165
|
# determine whether this deployment works. One consecutive block of
|
|
121
166
|
# "[Pgbus] boot:"-prefixed info lines so it reads cleanly under both the
|
|
@@ -253,6 +298,7 @@ module Pgbus
|
|
|
253
298
|
end
|
|
254
299
|
|
|
255
300
|
def fork_worker(worker_config, slot: nil)
|
|
301
|
+
note_intended_child
|
|
256
302
|
queues = worker_config[:queues] || [config.default_queue]
|
|
257
303
|
threads = worker_config[:threads] || 5
|
|
258
304
|
single_active = worker_config[:single_active_consumer] || false
|
|
@@ -337,6 +383,7 @@ module Pgbus
|
|
|
337
383
|
end
|
|
338
384
|
|
|
339
385
|
def fork_dispatcher
|
|
386
|
+
note_intended_child
|
|
340
387
|
pid = fork do
|
|
341
388
|
restore_signals
|
|
342
389
|
setup_child_process
|
|
@@ -364,6 +411,7 @@ module Pgbus
|
|
|
364
411
|
end
|
|
365
412
|
|
|
366
413
|
def fork_scheduler
|
|
414
|
+
note_intended_child
|
|
367
415
|
pid = fork do
|
|
368
416
|
restore_signals
|
|
369
417
|
setup_child_process
|
|
@@ -427,6 +475,7 @@ module Pgbus
|
|
|
427
475
|
end
|
|
428
476
|
|
|
429
477
|
def fork_consumer(consumer_config, slot: nil)
|
|
478
|
+
note_intended_child
|
|
430
479
|
# Array() so a consumer entry without :topics can't NoMethodError the
|
|
431
480
|
# supervisor on the topics.join log lines below.
|
|
432
481
|
topics = Array(consumer_config[:topics])
|
|
@@ -504,6 +553,7 @@ module Pgbus
|
|
|
504
553
|
end
|
|
505
554
|
|
|
506
555
|
def fork_outbox_poller
|
|
556
|
+
note_intended_child
|
|
507
557
|
pid = fork do
|
|
508
558
|
restore_signals
|
|
509
559
|
setup_child_process
|
|
@@ -537,6 +587,9 @@ module Pgbus
|
|
|
537
587
|
# refresh, and fork status broadcast (issue #381).
|
|
538
588
|
@notify_hub&.tick
|
|
539
589
|
end
|
|
590
|
+
# After reap + restarts so a clean recycle (reaped and re-forked in
|
|
591
|
+
# the same pass) never dips the published live count (issue #386).
|
|
592
|
+
refresh_readiness
|
|
540
593
|
interruptible_sleep(FORK_WAIT)
|
|
541
594
|
end
|
|
542
595
|
end
|
|
@@ -821,7 +874,12 @@ module Pgbus
|
|
|
821
874
|
def start_health_server
|
|
822
875
|
return unless config.health_port
|
|
823
876
|
|
|
824
|
-
|
|
877
|
+
# The standalone server answers /readyz from THIS supervisor's
|
|
878
|
+
# container-local snapshot — a rolling deploy's health gate must
|
|
879
|
+
# measure the new container, not the fleet-wide verdict a sibling
|
|
880
|
+
# container's workers can satisfy (issue #386).
|
|
881
|
+
app = Pgbus::Web::HealthApp.new(local_readiness: -> { readiness_snapshot })
|
|
882
|
+
@health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind, app: app)
|
|
825
883
|
@health_server.start
|
|
826
884
|
end
|
|
827
885
|
|
|
@@ -876,8 +934,11 @@ module Pgbus
|
|
|
876
934
|
end
|
|
877
935
|
|
|
878
936
|
def shutdown
|
|
879
|
-
# Wait for
|
|
880
|
-
|
|
937
|
+
# Wait for children to drain and exit, bounded by config.shutdown_timeout
|
|
938
|
+
# (default drain_timeout + 5) so raising the drain window can never
|
|
939
|
+
# mean SIGKILLing workers mid-drain. An orchestrator's stop grace
|
|
940
|
+
# period should exceed this value (issue #386).
|
|
941
|
+
deadline = Time.now + config.shutdown_timeout
|
|
881
942
|
|
|
882
943
|
until @forks.empty? || Time.now > deadline
|
|
883
944
|
reap_children
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -155,6 +155,12 @@ module Pgbus
|
|
|
155
155
|
NOTIFY_RETRY_BASE_SECONDS = 5
|
|
156
156
|
NOTIFY_RETRY_MAX_SECONDS = 300
|
|
157
157
|
|
|
158
|
+
# Residual pool wait in #shutdown, AFTER the drain loop already spent up
|
|
159
|
+
# to config.drain_timeout on in-flight jobs. Short by design: a job still
|
|
160
|
+
# running has proven it won't finish, and this wait competes with the
|
|
161
|
+
# supervisor's shutdown_timeout deadline (issue #386).
|
|
162
|
+
POOL_TERMINATION_WAIT = 5
|
|
163
|
+
|
|
158
164
|
def run
|
|
159
165
|
setup_signals
|
|
160
166
|
start_heartbeat
|
|
@@ -175,9 +181,9 @@ module Pgbus
|
|
|
175
181
|
|
|
176
182
|
break if @lifecycle.stopped?
|
|
177
183
|
# quiesced? (all slots free), not idle? (any slot free) — exiting
|
|
178
|
-
# with work still in flight abandons those jobs to
|
|
179
|
-
#
|
|
180
|
-
#
|
|
184
|
+
# with work still in flight abandons those jobs to shutdown's short
|
|
185
|
+
# POOL_TERMINATION_WAIT residual. Bounded by config.drain_timeout so
|
|
186
|
+
# a stuck job can't wedge the loop forever.
|
|
181
187
|
break if @lifecycle.draining? && (@pool.quiesced? || drain_deadline_exceeded?)
|
|
182
188
|
|
|
183
189
|
claim_and_execute if @lifecycle.can_process?
|
|
@@ -792,7 +798,11 @@ module Pgbus
|
|
|
792
798
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
793
799
|
stop_wake_source
|
|
794
800
|
@pool.shutdown
|
|
795
|
-
|
|
801
|
+
# Residual wait only: the drain loop already waited up to
|
|
802
|
+
# config.drain_timeout for in-flight jobs. A job still running here has
|
|
803
|
+
# proven it won't finish; waiting another full window would push the
|
|
804
|
+
# worker past the supervisor's shutdown_timeout deadline (issue #386).
|
|
805
|
+
@pool.wait_for_termination(POOL_TERMINATION_WAIT)
|
|
796
806
|
@stat_buffer&.stop
|
|
797
807
|
@queue_lock&.unlock_all
|
|
798
808
|
@heartbeat&.stop
|
data/lib/pgbus/version.rb
CHANGED
data/lib/pgbus/web/health_app.rb
CHANGED
|
@@ -49,8 +49,15 @@ module Pgbus
|
|
|
49
49
|
# @param data_source [Pgbus::Web::DataSource, nil] read layer for /readyz.
|
|
50
50
|
# nil (the default) builds a fresh DataSource per readiness check, which
|
|
51
51
|
# avoids serving stale metrics from a long-lived app's memoized instance.
|
|
52
|
-
|
|
52
|
+
# @param local_readiness [#call, nil] when set, /readyz answers from this
|
|
53
|
+
# callable's {Process::ReadinessSnapshot} instead of the cluster-wide
|
|
54
|
+
# analyzer — the supervisor's standalone HealthServer passes its own
|
|
55
|
+
# snapshot so a rolling deploy's health gate measures THIS container,
|
|
56
|
+
# not the fleet (issue #386). The Rails-mounted app leaves it nil and
|
|
57
|
+
# keeps the cluster verdict.
|
|
58
|
+
def initialize(data_source: nil, local_readiness: nil)
|
|
53
59
|
@data_source = data_source
|
|
60
|
+
@local_readiness = local_readiness
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
def call(env)
|
|
@@ -69,6 +76,8 @@ module Pgbus
|
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
def readyz
|
|
79
|
+
return local_readyz if @local_readiness
|
|
80
|
+
|
|
72
81
|
# HealthAnalyzer lives in the MCP namespace, which is excluded from
|
|
73
82
|
# Zeitwerk (its *tools* subclass the optional `mcp` gem). The analyzer
|
|
74
83
|
# itself has no gem dependency, so require just that one file — the
|
|
@@ -83,6 +92,19 @@ module Pgbus
|
|
|
83
92
|
[503, JSON_HEADERS.dup, [{ status: "ERROR", error: e.message }.to_json]]
|
|
84
93
|
end
|
|
85
94
|
|
|
95
|
+
# Container-local readiness: no database, no analyzer — just the
|
|
96
|
+
# supervisor's published snapshot. The error path mirrors the cluster
|
|
97
|
+
# readyz: 503 ERROR, logged, never swallowed.
|
|
98
|
+
def local_readyz
|
|
99
|
+
snapshot = @local_readiness.call
|
|
100
|
+
status = snapshot.ready? ? 200 : 503
|
|
101
|
+
body = { status: snapshot.status, expected: snapshot.expected, live: snapshot.live }
|
|
102
|
+
[status, JSON_HEADERS.dup, [body.to_json]]
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Pgbus.logger.error { "[Pgbus::Web::HealthApp] local readiness check failed: #{e.class}: #{e.message}" }
|
|
105
|
+
[503, JSON_HEADERS.dup, [{ status: "ERROR", error: e.message }.to_json]]
|
|
106
|
+
end
|
|
107
|
+
|
|
86
108
|
# Reuse an injected DataSource (tests, an app that wants one shared
|
|
87
109
|
# instance); otherwise build a fresh one each check so per-instance
|
|
88
110
|
# memoization can never serve stale queue/process metrics.
|
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.13.
|
|
4
|
+
version: 0.13.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -114,6 +114,7 @@ email:
|
|
|
114
114
|
- mikael@mhenrixon.com
|
|
115
115
|
executables:
|
|
116
116
|
- pgbus
|
|
117
|
+
- pgbus-health
|
|
117
118
|
extensions: []
|
|
118
119
|
extra_rdoc_files: []
|
|
119
120
|
files:
|
|
@@ -207,6 +208,7 @@ files:
|
|
|
207
208
|
- config/locales/sv.yml
|
|
208
209
|
- config/routes.rb
|
|
209
210
|
- exe/pgbus
|
|
211
|
+
- exe/pgbus-health
|
|
210
212
|
- lib/active_job/queue_adapters/pgbus_adapter.rb
|
|
211
213
|
- lib/generators/pgbus/add_failed_events_index_generator.rb
|
|
212
214
|
- lib/generators/pgbus/add_job_stats_generator.rb
|
|
@@ -214,6 +216,7 @@ files:
|
|
|
214
216
|
- lib/generators/pgbus/add_job_stats_queue_index_generator.rb
|
|
215
217
|
- lib/generators/pgbus/add_outbox_generator.rb
|
|
216
218
|
- lib/generators/pgbus/add_presence_generator.rb
|
|
219
|
+
- lib/generators/pgbus/add_processed_event_completion_generator.rb
|
|
217
220
|
- lib/generators/pgbus/add_queue_states_generator.rb
|
|
218
221
|
- lib/generators/pgbus/add_recurring_generator.rb
|
|
219
222
|
- lib/generators/pgbus/add_stream_queues_generator.rb
|
|
@@ -228,6 +231,7 @@ files:
|
|
|
228
231
|
- lib/generators/pgbus/templates/add_job_stats_queue_index.rb.erb
|
|
229
232
|
- lib/generators/pgbus/templates/add_outbox.rb.erb
|
|
230
233
|
- lib/generators/pgbus/templates/add_presence.rb.erb
|
|
234
|
+
- lib/generators/pgbus/templates/add_processed_event_completion.rb.erb
|
|
231
235
|
- lib/generators/pgbus/templates/add_queue_states.rb.erb
|
|
232
236
|
- lib/generators/pgbus/templates/add_recurring_tables.rb.erb
|
|
233
237
|
- lib/generators/pgbus/templates/add_stream_queues.rb.erb
|
|
@@ -282,6 +286,7 @@ files:
|
|
|
282
286
|
- lib/pgbus/failed_event_recorder.rb
|
|
283
287
|
- lib/pgbus/generators/database_target_detector.rb
|
|
284
288
|
- lib/pgbus/generators/migration_detector.rb
|
|
289
|
+
- lib/pgbus/health_probe.rb
|
|
285
290
|
- lib/pgbus/instrumentation.rb
|
|
286
291
|
- lib/pgbus/integrations/appsignal.rb
|
|
287
292
|
- lib/pgbus/integrations/appsignal/dashboard.json
|
|
@@ -333,6 +338,7 @@ files:
|
|
|
333
338
|
- lib/pgbus/process/notify_probe.rb
|
|
334
339
|
- lib/pgbus/process/primary_validator.rb
|
|
335
340
|
- lib/pgbus/process/queue_lock.rb
|
|
341
|
+
- lib/pgbus/process/readiness_snapshot.rb
|
|
336
342
|
- lib/pgbus/process/signal_handler.rb
|
|
337
343
|
- lib/pgbus/process/supervisor.rb
|
|
338
344
|
- lib/pgbus/process/wake_pipe.rb
|