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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c955f8fb2ac4e66fb2db388505ee83341aa6dec7c00f606162f7fba7c29cb75
4
- data.tar.gz: a31e43c6f48cb10609d73dc507e9d177d25a299ae25c6f5d5a39a045018c32e8
3
+ metadata.gz: 295404bb7b5b6d289a2bfd4f10d9e5bc6df442f8d949454cf1dd78adf620a25f
4
+ data.tar.gz: 2ee9c8e2c40f47bb900e10d2ef4c55b5e21e3cd4d997c614c628b696ac759183
5
5
  SHA512:
6
- metadata.gz: 7cd3fba720a5ab724fe4f0b5b89168cb2df4ba68e0a5a35cca725093724f8f2b2109c379a2263442111367001a52d2eda440eef8e1c2469653d2022d2a92d148
7
- data.tar.gz: 4a4e5e1c2c229c599541e61ac6ad5602dfbfcf057b346b22b0271c10a48752c41075c7166b43dd55ece7ef4a30d79ec3fd6f5cac5b898b2578ecb90c73bcc2a8
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` (are queues draining, or is a worker silently wedged?). `/readyz` runs the same `OK` / `DEGRADED` / `STALLED` verdict as the MCP `pgbus_health` tool — `STALLED` (visible backlog while workers heart-beat but don't claim) fails readiness.
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 the verdict JSON, so a probe failure is self-describing in the pod's event log.
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],
@@ -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
- # Atomically claim idempotency: INSERT ... ON CONFLICT DO NOTHING.
104
- # Returns true if this handler claimed the event (row was inserted),
105
- # false if another handler already processed it (conflict, no insert).
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
- # Uses an in-memory dedup cache to skip the DB for recently-seen events.
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 = "#{event_id}:#{self.class.name}"
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
- claimed = result.rows.any?
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
- claimed
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
- @pool.wait_for_termination(30)
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
- @health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind)
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 all children with timeout
880
- deadline = Time.now + 30
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
@@ -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 the 30s
179
- # wait_for_termination timeout in shutdown. Bounded by
180
- # config.drain_timeout so a stuck job can't wedge the loop forever.
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
- @pool.wait_for_termination(30)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.13.1"
4
+ VERSION = "0.13.2"
5
5
  end
@@ -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
- def initialize(data_source: nil)
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.1
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