pgbus 0.21.1 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59ff2a591041f47789d41f241c5496534dbba5314ebb37a0c2fe41286f611587
4
- data.tar.gz: 9f8b3c5a4f88330b96cd6ecab5d4dfe18580d48765b3aa50873e3f54150f63b1
3
+ metadata.gz: afa142ad44c901764a77528d7e387f708321353b5323c6dafcf23577cd27556e
4
+ data.tar.gz: 3bdcb708e9547c70115ccc9babd430d038a8b0ffb483faf615842a5a96cbbdff
5
5
  SHA512:
6
- metadata.gz: 76c28daff8340ec68e0cf5b0b7f9a69ad48b1d024d65ff9af37fbc734167a8b710afca0b1fce9163783b43e022f0d8e6d4d465219aa91282b52eb69d770e969b
7
- data.tar.gz: 8f2b9d1f6c4f899c64a9dd0fa1918b64e158799e6172bb0e42cf7beac6ca191675e5df9c31e90eaa62c4f1714c6c55eb317612e8d7b6047ab3e069d5bbab6046
6
+ metadata.gz: 3204bb1c094c7765ca32942b76857774e6696070b3b1787057b06da5f185d55de3069397bb1deb1dbf7221daf22eb064dddcb7a7906471e40fddafd5cf14cb38
7
+ data.tar.gz: 28c7d2ff21d9c8f97a2db27694c1e21184c9549d5af9cbebbef7244e3690d14b13aa546f79d006eb14104189ca3246d1416a6bb1679d59cfe80353d59511e6e5
data/CHANGELOG.md CHANGED
@@ -1,7 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+
5
+ - **`config.boot_timeout` (default 120 seconds): the supervisor kills a child that never checks in (issue #587).** A fork checks in when it registers its `pgbus_processes` row on this host or writes its first liveness byte. One that has done neither `boot_timeout` seconds after the fork is logged at ERROR, sent `TTIN`, given 2 seconds, then SIGKILLed, and the normal restart path forks it again. A boot-deadline kill always counts as a crash, whatever the child's uptime, so a boot that hangs every time is re-forked with the crash-loop backoff (1 s doubling to 60 s) instead of every `boot_timeout`. Applies to workers, consumers, the dispatcher, the scheduler and the outbox poller. `nil` turns it off; anything else must be a positive number, or `validate!` raises `ConfigurationError`. When the watchdog's read of the process table fails, the boot deadline waits for a pass that can read it. Refs #587.
6
+ - **A process whose registration in `pgbus_processes` failed at start retries it on each heartbeat (issue #587).** It used to stay without a row for good after one failed `create!` (a database blip at boot); a dispatcher, scheduler or outbox poller in that state would now be killed by the boot deadline even though it runs. Refs #587.
7
+ - **`kill -TTIN <child pid>` logs every thread of a pgbus child with its backtrace at ERROR; the child keeps running (issue #587).** The watchdog sends it before every SIGKILL (boot timeout or stalled loop), so a hang is diagnosable from the log. Best effort: a child blocked in native code holding the GVL does not answer. Refs #587.
8
+ - **`config.health_unclaimed_grace` (default 60 seconds) and the per-queue metric `oldest_unread_claimable_age_sec` (issue #589).** The grace is how long a visible, never-read (`read_ct = 0`) message must have been claimable before the health verdict counts it as "never claimed while draining processes are alive". It takes a number of seconds or an `ActiveSupport::Duration`; `0` restores the old rule (any such message counts), and `validate!` raises `ConfigurationError` on nil, a negative or a non-finite value. `Web::DataSource#queues_with_metrics` and `#queue_detail` report `oldest_unread_claimable_age_sec`, the seconds since the oldest visible `read_ct = 0` message became claimable (`min(vt)`, so a delayed job's age starts when its wait expires and a retried message is left out), nil when there is none. The verdict's `summary` carries it as the oldest over the drained queues, and the STALLED reason names it (`never claimed, oldest claimable for 74s`). `Pgbus::MCP::HealthAnalyzer.new` takes an optional `grace:`. Refs #589.
9
+ - **The supervisor's standalone `/readyz` body carries `forked` (issue #587).** `forked` is the fork-table size (what `live` used to be); `live` now counts the children that have checked in. `Pgbus::Process::ReadinessSnapshot` gains `forked:`, defaulting to `live`. Refs #587.
10
+
11
+ ### Changed
12
+
13
+ - **`pgbus_failed_events` gains a `source_queue` column; run `rails generate pgbus:update` (or `pgbus:add_failed_events_source_queue`) and migrate (issue #583).** The migration adds the column, creates the unique index `idx_pgbus_failed_events_queue_msg_source` on `(queue_name, msg_id, source_queue)` and drops `idx_pgbus_failed_events_queue_msg` on `(queue_name, msg_id)`. Until it runs, pgbus logs one warning per process and records failures as before (one row per logical queue and msg_id); a running process checks for the column again every minute, so it needs no restart after the migration. Fresh `pgbus:install` creates the new shape. `pgbus:update` no longer queues `pgbus:add_failed_events_index`, which would put the old index back, and queues the new migration again if the old index is present; `pgbus:add_failed_events_index` run by name adds nothing once the new index exists. `Pgbus::FailedEventRecorder.record!`, `.exists?`, `.last_error` and `.clear!` take a required `source_queue:` keyword, the physical table the message was read from. Refs #583.
14
+
3
15
  ### Fixed
4
16
 
17
+ - **`/readyz` and `pgbus_health` no longer flap to `STALLED` on a healthy fleet (issue #589).** The verdict reported `STALLED` (and the mounted `/readyz` 503) whenever a drained queue held one visible, never-read message while a worker or consumer was alive, however briefly it had been claimable: every enqueue and every delayed job whose wait just expired is such a message until a worker's next poll, so a busy install answered 503 on a large share of checks. That branch now counts a queue only once its oldest such message has been claimable for `health_unclaimed_grace` seconds; a wedged worker still reads `STALLED` after the grace. A queue hash without the age (an older or stubbed data source) still counts at once. The worker-reported stalled branch (heart-beating, claim loop not advancing) is unchanged. Refs #589.
18
+ - **A child that hangs before its first liveness signal is killed and forked again, and `/readyz` no longer counts it as live (issue #587).** A fork stuck between `fork` and its first check-in (in an initializer, the eager load or a connection) never exited, so nothing reaped it, and the watchdog skipped it because no liveness channel had spoken; the container-local `/readyz` counted it as live because it was in the fork table, so a rolling deploy passed its health gate with a dead capsule. The boot deadline above now kills it, and `/readyz` stays at 503 `DEGRADED` (`live < expected`) until every child has checked in. A dispatcher, scheduler or outbox poller checks in through its process row, which the watchdog reads every 10 seconds, so `/readyz` turns 200 up to 10 seconds after the last one registers, and stays at 503 while the database is unreachable at boot (those children cannot be seen to be alive). The watchdog reads only this host's process rows last beaten since the fork, so a row another container wrote under the same pid, or one a SIGKILLed predecessor left behind (a container restarted in place reuses its hostname and pids), no longer vouches for a local child or hides a stalled loop. Refs #587.
19
+ - **A failed job's retry backoff applies under priority routing (issue #582).** With `priority_levels > 1` the executor set the backoff's visibility timeout on the logical queue's base table, which priority routing never creates; the call raised, was logged at debug, and the job came back after the read's visibility timeout instead of its backoff delay. The backoff now goes to the `_pN` sub-queue the worker read the message from, as the visibility heartbeat and the archive already did. Refs #582.
20
+ - **Two priority sub-queues' failures with the same msg_id keep a row each (issue #583).** Each `_pN` sub-queue numbers its messages from its own sequence, and `pgbus_failed_events` was unique on the logical queue and the msg_id, so a second job failing with the same msg_id in another sub-queue overwrote the first one's error and kept its payload: the dashboard showed one failed job where two had failed, Retry and Discard acted on the first job's message and deleted the only row, and the first job's success cleared the second one's failure. Rows now record the physical table (`source_queue`) and are unique on `(queue_name, msg_id, source_queue)`. The executor, the event consumer and the worker's zombie check read, write and clear only the row of the table the message came from. On the Jobs page a failed row joins only its own table's message and shows as an orphan once that message is gone, Retry, Discard and Discard all look the message up in the table the row names, and Discard releases the job's uniqueness lock bounded by when that table's message was sent, not by a sibling sub-queue's. A row written before the migration has no `source_queue` and is treated as before: it can belong to any table of its queue. Refs #583.
21
+
5
22
  - **The dashboard's Retry, Discard and Discard all reach a failed job's message under priority routing (issue #577).** A failed-job row records the logical queue only, and with `priority_levels > 1` the message sits in a `_pN` sub-queue, never in the base table. Retry reset the visibility timeout on `pgmq.q_pgbus_<queue>`, which priority routing never creates, and answered "Could not retry job."; Discard and Discard all archived from that same table, so they reported the job discarded while its message stayed queued and ran again. Each sub-queue also numbers its messages from its own sequence, so the msg_id alone could name another job's message in a sibling sub-queue. The dashboard now finds the sub-queue whose message has the row's msg_id and carries the job's `job_id` (`Pgbus::Client#locate_message`, which returns that physical table or nil, and `#locate_messages` for many at once, one query per table), resets or archives the message there, and leaves a same-numbered message elsewhere alone. Discard all locates a queue's messages together and archives them in one batch per table. When no sub-queue holds it, Retry enqueues a fresh copy as before. Without priority routing there is one table and nothing changes, except that a message reusing the msg_id under another `job_id` is no longer reset or archived in the job's place. Refs #577.
6
23
 
7
24
  ## [0.21.0] - 2026-10-19
data/README.md CHANGED
@@ -1260,7 +1260,7 @@ All tools below are read-only — none mutates state, and there is no raw-SQL pa
1260
1260
 
1261
1261
  | Tool | Purpose |
1262
1262
  |------|---------|
1263
- | `pgbus_health` | One-call verdict: `OK` / `DEGRADED` / `STALLED`. STALLED is the silent-worker-wedge signal (visible backlog while workers heart-beat but don't claim). Suitable for automated alerting. |
1263
+ | `pgbus_health` | One-call verdict: `OK` / `DEGRADED` / `STALLED`. STALLED is the silent-worker-wedge signal: a worker reports its claim loop stalled, or a visible backlog older than `health_unclaimed_grace` (default 60s) while workers heart-beat but don't claim. The summary carries `oldest_unread_claimable_age_sec`. Suitable for automated alerting. |
1264
1264
  | `pgbus_queues` | All queues: depth, visible count, oldest-message age, paused state. |
1265
1265
  | `pgbus_queue_detail` | Per-queue metrics + paused state + table health (dead tuples, bloat, vacuum age). |
1266
1266
  | `pgbus_processes` | Every process with kind, pid, heartbeat age, and `healthy`/`stale`/`stalled` status. |
@@ -1348,7 +1348,7 @@ For the **HTTP** transport, point the client at the mounted URL with a streamabl
1348
1348
 
1349
1349
  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:
1350
1350
 
1351
- - **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.
1351
+ - **Mounted in Rails** (`Pgbus::Web::HealthApp`): `/readyz` runs the cluster-wide `OK` / `DEGRADED` / `STALLED` verdict, same as the MCP `pgbus_health` tool — `STALLED` (a never-claimed backlog older than `health_unclaimed_grace` while workers heart-beat but don't claim) fails readiness.
1352
1352
  - **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.
1353
1353
 
1354
1354
  | Path | Method | 200 | 503 | Touches DB |
@@ -2560,6 +2560,7 @@ Curated headline options for the README. The full operator reference (with types
2560
2560
  | `statsd_port` | `8125` | StatsD UDP port (used when `metrics_backend = :statsd`) |
2561
2561
  | `health_port` | `nil` | Port for standalone HTTP liveness/readiness probes served by the supervisor; nil disables |
2562
2562
  | `health_bind` | `"127.0.0.1"` | Bind address for the standalone health server |
2563
+ | `health_unclaimed_grace` | `60` | Seconds a visible, never-read message must have been claimable before the health verdict reports it as never claimed (`STALLED`); `0` counts any such message |
2563
2564
  | `pgmq_schema_mode` | `:auto` | PGMQ schema install mode (`:auto`, `:extension`, `:embedded`) |
2564
2565
  | `doctor_on_boot` | `nil` | Run doctor inside the booting supervisor: `nil`/`false` = off, `:report` = log and boot, `:strict` = refuse to boot on a fatal check. Also via `--doctor` / `--doctor-strict`. |
2565
2566
  | `eager_validation` | `true` | Run `Configuration#validate!` automatically after the `Pgbus.configure` block yields; an invalid value raises `Pgbus::ConfigurationError` at boot. Set `false` to suppress and validate manually. |
@@ -0,0 +1,44 @@
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 AddFailedEventsSourceQueueGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+ include MigrationPath
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Add source_queue to pgbus_failed_events so each priority sub-queue's failure keeps its own row"
16
+
17
+ class_option :database,
18
+ type: :string,
19
+ default: nil,
20
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
21
+
22
+ def create_migration_file
23
+ migration_template "add_failed_events_source_queue.rb.erb",
24
+ File.join(pgbus_migrate_path, "add_pgbus_failed_events_source_queue.rb")
25
+ end
26
+
27
+ def display_post_install
28
+ say ""
29
+ say "Pgbus failed events source_queue added!", :green
30
+ say ""
31
+ say "Next steps:"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
+ say " 2. Restart pgbus processes so they record failures per physical queue"
34
+ say ""
35
+ end
36
+
37
+ private
38
+
39
+ def migration_version
40
+ "[#{ActiveRecord::Migration.current_version}]"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ class AddPgbusFailedEventsSourceQueue < ActiveRecord::Migration<%= migration_version %>
2
+ # Under priority routing each _pN sub-queue numbers its messages from its
3
+ # own sequence, so two jobs can fail with the same msg_id under one logical
4
+ # queue. source_queue names the physical table the message was read from,
5
+ # and the failure upsert is keyed on it. Rows written before this keep
6
+ # NULL there: pgbus reads them as belonging to any table of their queue.
7
+ def up
8
+ add_column :pgbus_failed_events, :source_queue, :string, if_not_exists: true
9
+ add_index :pgbus_failed_events, %i[queue_name msg_id source_queue],
10
+ unique: true, name: "idx_pgbus_failed_events_queue_msg_source", if_not_exists: true
11
+ remove_index :pgbus_failed_events, name: "idx_pgbus_failed_events_queue_msg", if_exists: true
12
+ end
13
+
14
+ # (queue_name, msg_id) can repeat once source_queue is in use: the latest
15
+ # row of each pair is kept so the old unique index can be built again.
16
+ def down
17
+ execute <<~SQL
18
+ DELETE FROM pgbus_failed_events older
19
+ USING pgbus_failed_events newer
20
+ WHERE older.queue_name = newer.queue_name AND older.msg_id = newer.msg_id AND older.id < newer.id
21
+ SQL
22
+ add_index :pgbus_failed_events, %i[queue_name msg_id],
23
+ unique: true, name: "idx_pgbus_failed_events_queue_msg", if_not_exists: true
24
+ remove_index :pgbus_failed_events, name: "idx_pgbus_failed_events_queue_msg_source", if_exists: true
25
+ remove_column :pgbus_failed_events, :source_queue, if_exists: true
26
+ end
27
+ end
@@ -1,5 +1,10 @@
1
1
  class AddPgbusFailedEventsUniqueIndex < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
+ # Superseded by pgbus:add_failed_events_source_queue's (queue_name, msg_id,
4
+ # source_queue) index: this one would make two priority sub-queues'
5
+ # failures with the same msg_id share one row again.
6
+ return if index_name_exists?(:pgbus_failed_events, "idx_pgbus_failed_events_queue_msg_source")
7
+
3
8
  add_index :pgbus_failed_events, [:queue_name, :msg_id],
4
9
  unique: true, name: "idx_pgbus_failed_events_queue_msg",
5
10
  if_not_exists: true
@@ -53,9 +53,12 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
53
53
  add_index :pgbus_processes, :last_heartbeat_at,
54
54
  name: "idx_pgbus_processes_heartbeat"
55
55
 
56
- # Failed events log (for dashboard visibility)
56
+ # Failed events log (for dashboard visibility). queue_name is the logical
57
+ # queue, source_queue the physical table the message was read from: under
58
+ # priority routing each _pN sub-queue numbers its messages on its own.
57
59
  create_table :pgbus_failed_events do |t|
58
60
  t.string :queue_name, null: false
61
+ t.string :source_queue
59
62
  t.bigint :msg_id
60
63
  t.jsonb :payload
61
64
  t.jsonb :headers
@@ -68,8 +71,8 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
68
71
 
69
72
  add_index :pgbus_failed_events, :queue_name, name: "idx_pgbus_failed_events_queue"
70
73
  add_index :pgbus_failed_events, :failed_at, name: "idx_pgbus_failed_events_time"
71
- add_index :pgbus_failed_events, [:queue_name, :msg_id],
72
- unique: true, name: "idx_pgbus_failed_events_queue_msg"
74
+ add_index :pgbus_failed_events, [:queue_name, :msg_id, :source_queue],
75
+ unique: true, name: "idx_pgbus_failed_events_queue_msg_source"
73
76
 
74
77
  # Concurrency semaphores (counting locks for job concurrency limits)
75
78
  create_table :pgbus_semaphores do |t|
@@ -28,9 +28,11 @@ module Pgbus
28
28
  if read_count > config.max_retries
29
29
  # Read the last error before clear! deletes the only row that has
30
30
  # it: the DLQ copy carries it in its headers (issue #495).
31
- last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
31
+ last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i,
32
+ source_queue: physical_queue(queue_name, source_queue))
32
33
  handle_dead_letter(message, queue_name, payload, source_queue: source_queue, last_error: last_error)
33
- FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
34
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i,
35
+ source_queue: physical_queue(queue_name, source_queue))
34
36
  signal_concurrency(payload)
35
37
  signal_batch_discarded(payload)
36
38
  release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message)
@@ -129,7 +131,10 @@ module Pgbus
129
131
  # failed attempt, so a first delivery has no row: skip the per-job
130
132
  # DELETE round trip (issue #484). A stale row from a dropped-and-
131
133
  # recreated queue reusing this msg_id is the dispatcher's to sweep.
132
- FailedEventRecorder.clear!(queue_name: queue_name, msg_id: msg_id) if read_count > 1
134
+ if read_count > 1
135
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: msg_id,
136
+ source_queue: physical_queue(queue_name, source_queue))
137
+ end
133
138
  end
134
139
 
135
140
  instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
@@ -145,7 +150,7 @@ module Pgbus
145
150
  # Before this, a fiber interruption between perform_now and archive_from
146
151
  # silently lost control flow — no failed event row, no job_failed
147
152
  # notification, uniqueness lock held until VT expired. See issue #126.
148
- handle_failure(message, queue_name, e, payload: payload)
153
+ handle_failure(message, queue_name, e, payload: payload, source_queue: source_queue)
149
154
  # A failed :while_executing attempt is no longer executing — release
150
155
  # so the retry (same message, after VT) can acquire. :until_executed
151
156
  # keeps its lock until success/DLQ by design (#126, #333).
@@ -188,6 +193,14 @@ module Pgbus
188
193
  "msg_id=#{message.msg_id} queue=#{queue_name} read_ct=#{message.read_ct}"
189
194
  end
190
195
 
196
+ # The table the message was read from: the _pN sub-queue under priority
197
+ # routing, else the logical queue's own. Each table numbers its messages
198
+ # from its own sequence, so a failed-event row is keyed on it (issue
199
+ # #583). Built only on the failure and redelivery paths.
200
+ def physical_queue(queue_name, source_queue)
201
+ source_queue || config.queue_name(queue_name)
202
+ end
203
+
191
204
  def assign_batch_id(job, payload)
192
205
  batch_id = payload[Batch::METADATA_KEY]
193
206
  return unless batch_id && job.respond_to?(:batch_id=)
@@ -298,7 +311,7 @@ module Pgbus
298
311
  [((Time.now.utc - enqueued_at) * 1000).round, 0].max
299
312
  end
300
313
 
301
- def handle_failure(message, queue_name, error, payload: nil)
314
+ def handle_failure(message, queue_name, error, payload: nil, source_queue: nil)
302
315
  ctx = { action: "execute_job", queue: queue_name, job_class: payload&.dig("job_class"),
303
316
  msg_id: message.msg_id.to_i, read_ct: message.read_ct.to_i }
304
317
  ErrorReporter.report(error, ctx)
@@ -311,20 +324,23 @@ module Pgbus
311
324
  FailedEventRecorder.record!(
312
325
  queue_name: queue_name,
313
326
  msg_id: message.msg_id.to_i,
327
+ source_queue: physical_queue(queue_name, source_queue),
314
328
  payload: payload || message.message,
315
329
  headers: message.respond_to?(:headers) ? message.headers : nil,
316
330
  error: error,
317
331
  retry_count: [message.read_ct.to_i - 1, 0].max
318
332
  )
319
333
 
320
- apply_retry_backoff(message, queue_name, payload)
334
+ apply_retry_backoff(message, queue_name, payload, source_queue: source_queue)
321
335
  end
322
336
 
323
337
  # Extend the message's visibility timeout with exponential backoff
324
338
  # so retries aren't all bunched at the default flat VT interval.
325
339
  # Skipped on the first read (read_ct=1) — that's the initial
326
- # attempt, not a retry.
327
- def apply_retry_backoff(message, queue_name, payload)
340
+ # attempt, not a retry. Under priority routing the message sits in the
341
+ # _pN sub-queue the worker read it from; the logical name resolves to a
342
+ # base table that is never created (issue #582).
343
+ def apply_retry_backoff(message, queue_name, payload, source_queue: nil)
328
344
  attempt = message.read_ct.to_i - 1
329
345
  return if attempt < 1
330
346
 
@@ -335,7 +351,11 @@ module Pgbus
335
351
  RetryBackoff.compute_delay(attempt: attempt)
336
352
  end
337
353
 
338
- client.set_visibility_timeout(queue_name, message.msg_id.to_i, vt: delay)
354
+ if source_queue
355
+ client.set_visibility_timeout(source_queue, message.msg_id.to_i, vt: delay, prefixed: false)
356
+ else
357
+ client.set_visibility_timeout(queue_name, message.msg_id.to_i, vt: delay)
358
+ end
339
359
  rescue StandardError => e
340
360
  Pgbus.logger.debug { "[Pgbus] Retry backoff VT update failed: #{e.message}" }
341
361
  end
@@ -54,6 +54,13 @@ module Pgbus
54
54
  # wait, so recycling/deploy never wedges on a permanently-stuck job.
55
55
  attr_accessor :stall_threshold, :read_timeout, :drain_timeout
56
56
 
57
+ # Boot deadline (default 120s): a forked child that has not checked in
58
+ # this long after fork (no pgbus_processes row on this host, no liveness
59
+ # byte) is asked for a thread dump (TTIN), then SIGKILLed and re-forked
60
+ # by the normal restart path. nil disables. Separate from stall_threshold
61
+ # because a cold Rails boot can legitimately outlast a loop-liveness window.
62
+ attr_accessor :boot_timeout
63
+
57
64
  # immediate_shutdown_grace (default 2s): on QUIT, a worker or consumer
58
65
  # stops taking work and gives the jobs already running this long to finish
59
66
  # before it kills their threads. Killed jobs' ActiveRecord connections are
@@ -234,6 +241,14 @@ module Pgbus
234
241
  # plain TCPServer bound to `health_bind` (default localhost).
235
242
  attr_accessor :health_port, :health_bind
236
243
 
244
+ # Seconds a visible, never-read (read_ct = 0) message must have been
245
+ # claimable before the health verdict counts it as "never claimed while
246
+ # draining processes are alive" (issue #589). Below it a worker's next
247
+ # poll is still coming, so a fresh enqueue or a delayed job whose wait just
248
+ # expired no longer reads STALLED. 0 restores the pre-0.22 rule (any
249
+ # visible read_ct = 0 row counts). Default 60.
250
+ attr_reader :health_unclaimed_grace
251
+
237
252
  # Streams (turbo-rails replacement, SSE-based)
238
253
  attr_accessor :streams_enabled, :streams_path, :streams_signed_name_secret,
239
254
  :streams_default_retention, :streams_retention, :streams_heartbeat_interval, :streams_peer_close_interval,
@@ -312,6 +327,7 @@ module Pgbus
312
327
  @max_worker_lifetime = nil
313
328
 
314
329
  @stall_threshold = 90
330
+ @boot_timeout = 120
315
331
  @read_timeout = 30
316
332
  @drain_timeout = 30
317
333
  @immediate_shutdown_grace = 2
@@ -411,6 +427,7 @@ module Pgbus
411
427
  # HTTP health endpoints — nil port disables the standalone server.
412
428
  @health_port = nil
413
429
  @health_bind = "127.0.0.1"
430
+ @health_unclaimed_grace = 60
414
431
 
415
432
  @streams_enabled = true
416
433
  @streams_path = nil
@@ -918,6 +935,9 @@ module Pgbus
918
935
  unless stall_threshold.nil? || (stall_threshold.is_a?(Numeric) && stall_threshold.positive?)
919
936
  raise Pgbus::ConfigurationError, "stall_threshold must be a positive number or nil to disable"
920
937
  end
938
+ unless boot_timeout.nil? || (boot_timeout.is_a?(Numeric) && boot_timeout.positive?)
939
+ raise Pgbus::ConfigurationError, "boot_timeout must be a positive number or nil to disable"
940
+ end
921
941
  unless read_timeout.nil? || (read_timeout.is_a?(Numeric) && read_timeout.positive?)
922
942
  raise Pgbus::ConfigurationError, "read_timeout must be a positive number or nil to disable"
923
943
  end
@@ -940,6 +960,8 @@ module Pgbus
940
960
 
941
961
  raise Pgbus::ConfigurationError, "health_bind must be a non-empty String" unless health_bind.is_a?(String) && !health_bind.empty?
942
962
 
963
+ validate_health_unclaimed_grace!
964
+
943
965
  raise Pgbus::ConfigurationError, "statsd_host must be a non-empty String" unless statsd_host.is_a?(String) && !statsd_host.empty?
944
966
 
945
967
  unless statsd_port.is_a?(Integer) && statsd_port.between?(1, 65_535)
@@ -1528,6 +1550,14 @@ module Pgbus
1528
1550
  @recurring_execution_retention = coerce_duration!(value, :recurring_execution_retention)
1529
1551
  end
1530
1552
 
1553
+ # Not coerce_duration!: 0 is valid here (the pre-0.22 rule), and nil is not
1554
+ # a "disabled" sentinel. A Duration becomes integer seconds; anything else
1555
+ # is kept for validate! to judge.
1556
+ def health_unclaimed_grace=(value)
1557
+ duration = defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
1558
+ @health_unclaimed_grace = duration ? value.to_i : value
1559
+ end
1560
+
1531
1561
  # recurring_tasks_file (singular) is deprecated in favor of the plural
1532
1562
  # recurring_tasks_files. The engine still writes the singular internally to
1533
1563
  # record which default file was loaded, so it stays a plain accessor; the
@@ -1856,6 +1886,14 @@ module Pgbus
1856
1886
  raise Pgbus::ConfigurationError, "immediate_shutdown_grace must be a finite number >= 0"
1857
1887
  end
1858
1888
 
1889
+ # A finite real number >= 0; nil is not "off" (0 is).
1890
+ def validate_health_unclaimed_grace!
1891
+ grace = health_unclaimed_grace
1892
+ return if grace.is_a?(Numeric) && grace.real? && grace.finite? && grace >= 0
1893
+
1894
+ raise Pgbus::ConfigurationError, "health_unclaimed_grace must be a finite number of seconds >= 0, got #{grace.inspect}"
1895
+ end
1896
+
1859
1897
  def validate_positive_duration!(numeric, name)
1860
1898
  raise Pgbus::ConfigurationError, "#{name} must be a positive number, got #{numeric}" unless numeric.positive?
1861
1899
 
@@ -4,45 +4,49 @@ require "time"
4
4
 
5
5
  module Pgbus
6
6
  # Records job failures to pgbus_failed_events for dashboard visibility.
7
- # Uses upsert (INSERT ON CONFLICT UPDATE) keyed on (queue_name, msg_id)
8
- # so each message has at most one failed_event row tracking its latest error.
7
+ # Uses upsert (INSERT ON CONFLICT UPDATE) keyed on the message, so each
8
+ # message has at most one failed_event row tracking its latest error.
9
+ #
10
+ # A message is (queue_name, msg_id, source_queue): queue_name is the logical
11
+ # queue, source_queue the physical table the message was read from. Under
12
+ # priority routing each `_pN` sub-queue numbers its messages from its own
13
+ # sequence, so the logical name and msg_id alone can name two jobs (issue
14
+ # #583). A row written before the source_queue column existed has NULL there
15
+ # and is read as belonging to any table of its queue. Until the column is
16
+ # migrated, every query keeps its (queue_name, msg_id) shape.
9
17
  class FailedEventRecorder
18
+ # How long a "column missing" answer stands before the schema is probed
19
+ # again, so a process started before the migration picks the column up.
20
+ SOURCE_QUEUE_RECHECK_SECONDS = 60
21
+
22
+ @source_queue_column_mutex = Mutex.new
23
+
10
24
  class << self
11
- def record!(queue_name:, msg_id:, payload:, headers:, error:, retry_count:)
12
- connection.exec_query(
13
- <<~SQL.squish,
14
- INSERT INTO pgbus_failed_events
15
- (queue_name, msg_id, payload, headers, error_class, error_message, backtrace, retry_count, failed_at)
16
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP)
17
- ON CONFLICT (queue_name, msg_id) DO UPDATE SET
18
- error_class = EXCLUDED.error_class,
19
- error_message = EXCLUDED.error_message,
20
- backtrace = EXCLUDED.backtrace,
21
- retry_count = EXCLUDED.retry_count,
22
- failed_at = EXCLUDED.failed_at
23
- SQL
24
- "FailedEvent Record",
25
- [
26
- queue_name,
27
- msg_id.to_i,
28
- payload.is_a?(String) ? payload : JSON.generate(payload),
29
- headers.is_a?(String) ? headers : headers&.then { |h| JSON.generate(h) },
30
- error.class.name,
31
- error.message.to_s.truncate(10_000),
32
- error.backtrace&.first(30)&.join("\n"),
33
- retry_count
34
- ]
35
- )
25
+ def record!(queue_name:, msg_id:, source_queue:, payload:, headers:, error:, retry_count:)
26
+ details = [
27
+ payload.is_a?(String) ? payload : JSON.generate(payload),
28
+ headers.is_a?(String) ? headers : headers&.then { |h| JSON.generate(h) },
29
+ error.class.name,
30
+ error.message.to_s.truncate(10_000),
31
+ error.backtrace&.first(30)&.join("\n"),
32
+ retry_count
33
+ ]
34
+ legacy = !source_queue_column?
35
+ if legacy
36
+ connection.exec_query(LEGACY_RECORD_SQL, "FailedEvent Record", [queue_name, msg_id.to_i, *details])
37
+ else
38
+ connection.exec_query(RECORD_SQL, "FailedEvent Record", [queue_name, source_queue, msg_id.to_i, *details])
39
+ end
36
40
  rescue StandardError => e
41
+ # A process started before the migration would keep sending the old
42
+ # ON CONFLICT target, whose index the migration drops: probe again.
43
+ reset_source_queue_column_check! if legacy
37
44
  ErrorReporter.report(e, { action: "record_failed_event", queue: queue_name, msg_id: msg_id })
38
45
  end
39
46
 
40
- def exists?(queue_name:, msg_id:)
41
- result = connection.select_value(
42
- "SELECT 1 FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2 LIMIT 1",
43
- "FailedEvent Exists",
44
- [queue_name, msg_id.to_i]
45
- )
47
+ def exists?(queue_name:, msg_id:, source_queue:)
48
+ sql, binds = message_scope(queue_name, msg_id, source_queue)
49
+ result = connection.select_value("SELECT 1 FROM pgbus_failed_events WHERE #{sql} LIMIT 1", "FailedEvent Exists", binds)
46
50
  !result.nil?
47
51
  rescue StandardError => e
48
52
  Pgbus.logger.debug { "[Pgbus] FailedEvent exists? check failed: #{e.class}: #{e.message}" }
@@ -53,12 +57,15 @@ module Pgbus
53
57
  # dead-letters the message and clears the row (issue #495). failed_at
54
58
  # comes back as an ISO 8601 UTC string whatever the adapter decodes it
55
59
  # to. A failed read returns nil: it must never stop a dead-letter.
56
- def last_error(queue_name:, msg_id:)
60
+ # The message's own row wins over one written before source_queue.
61
+ def last_error(queue_name:, msg_id:, source_queue:)
62
+ sql, binds = message_scope(queue_name, msg_id, source_queue)
63
+ order = binds.size > 2 ? " ORDER BY source_queue IS NULL, failed_at DESC" : ""
57
64
  row = connection.select_one(
58
65
  "SELECT error_class, error_message, backtrace, retry_count, failed_at " \
59
- "FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2 LIMIT 1",
66
+ "FROM pgbus_failed_events WHERE #{sql}#{order} LIMIT 1",
60
67
  "FailedEvent Last Error",
61
- [queue_name, msg_id.to_i]
68
+ binds
62
69
  )
63
70
  return unless row
64
71
 
@@ -91,12 +98,11 @@ module Pgbus
91
98
  end
92
99
  end
93
100
 
94
- def clear!(queue_name:, msg_id:)
95
- connection.exec_delete(
96
- "DELETE FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2",
97
- "FailedEvent Clear",
98
- [queue_name, msg_id.to_i]
99
- )
101
+ # Deletes the message's own row, and a row written before source_queue
102
+ # existed: a sibling sub-queue's row with the same msg_id stays.
103
+ def clear!(queue_name:, msg_id:, source_queue:)
104
+ sql, binds = message_scope(queue_name, msg_id, source_queue)
105
+ connection.exec_delete("DELETE FROM pgbus_failed_events WHERE #{sql}", "FailedEvent Clear", binds)
100
106
  rescue StandardError => e
101
107
  # ERROR-level: a failed clear leaves a stale row in the dashboard
102
108
  # AFTER the job actually succeeded — confusing and load-bearing
@@ -107,8 +113,69 @@ module Pgbus
107
113
  end
108
114
  end
109
115
 
116
+ # Whether pgbus_failed_events has the source_queue column (issue #583).
117
+ # Probed like ProcessedEvent.completion_column?: a gem upgraded before
118
+ # its migration gets false, a one-time warning, and the (queue_name,
119
+ # msg_id) queries. A present column is remembered for good; a missing
120
+ # one for SOURCE_QUEUE_RECHECK_SECONDS. A probe that raises is not
121
+ # memoized.
122
+ def source_queue_column?
123
+ # Double-checked: the unlocked read only skips the lock while the
124
+ # answer stands; it is written under @source_queue_column_mutex. The
125
+ # state is true, nil (never probed) or the monotonic time a missing
126
+ # column's answer runs out.
127
+ known = source_queue_column_answer(@source_queue_column) # rubocop:disable ThreadSafety/ClassInstanceVariable
128
+ return known unless known.nil?
129
+
130
+ @source_queue_column_mutex.synchronize do
131
+ known = source_queue_column_answer(@source_queue_column)
132
+ next known unless known.nil?
133
+
134
+ supported = detect_source_queue_column(warn: @source_queue_column.nil?)
135
+ @source_queue_column = supported || (monotonic_now + SOURCE_QUEUE_RECHECK_SECONDS)
136
+ supported
137
+ end
138
+ end
139
+
140
+ # Clears the memoized probe: specs exercise both schema shapes, and a
141
+ # failed legacy write re-probes after the migration lands.
142
+ def reset_source_queue_column_check!
143
+ @source_queue_column_mutex.synchronize { @source_queue_column = nil }
144
+ end
145
+
110
146
  private
111
147
 
148
+ # WHERE fragment and binds naming one message's rows.
149
+ def message_scope(queue_name, msg_id, source_queue)
150
+ return ["queue_name = $1 AND msg_id = $2", [queue_name, msg_id.to_i]] unless source_queue_column?
151
+
152
+ ["queue_name = $1 AND msg_id = $2 AND (source_queue = $3 OR source_queue IS NULL)",
153
+ [queue_name, msg_id.to_i, source_queue]]
154
+ end
155
+
156
+ # true, false while a missing column's answer stands, nil to probe.
157
+ def source_queue_column_answer(state)
158
+ return true if state == true
159
+
160
+ false if state && monotonic_now < state
161
+ end
162
+
163
+ def monotonic_now
164
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
165
+ end
166
+
167
+ def detect_source_queue_column(warn:)
168
+ supported = connection.column_exists?(:pgbus_failed_events, :source_queue)
169
+ if warn && !supported
170
+ Pgbus.logger.warn do
171
+ "[Pgbus] pgbus_failed_events is missing the source_queue column; under priority routing two " \
172
+ "sub-queues' failures with the same msg_id share one row. " \
173
+ "Run `rails generate pgbus:add_failed_events_source_queue` (or pgbus:update) and migrate."
174
+ end
175
+ end
176
+ supported
177
+ end
178
+
112
179
  def iso_utc(value)
113
180
  time = value.is_a?(String) ? Time.parse(value) : value
114
181
  time&.utc&.iso8601(6)
@@ -124,5 +191,31 @@ module Pgbus
124
191
  end
125
192
  end
126
193
  end
194
+
195
+ UPSERT_ERROR_COLUMNS = <<~SQL.gsub(/\s+/, " ").strip.freeze
196
+ DO UPDATE SET
197
+ error_class = EXCLUDED.error_class,
198
+ error_message = EXCLUDED.error_message,
199
+ backtrace = EXCLUDED.backtrace,
200
+ retry_count = EXCLUDED.retry_count,
201
+ failed_at = EXCLUDED.failed_at
202
+ SQL
203
+ private_constant :UPSERT_ERROR_COLUMNS
204
+
205
+ RECORD_SQL = <<~SQL.gsub(/\s+/, " ").strip.freeze
206
+ INSERT INTO pgbus_failed_events
207
+ (queue_name, source_queue, msg_id, payload, headers, error_class, error_message, backtrace, retry_count, failed_at)
208
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP)
209
+ ON CONFLICT (queue_name, msg_id, source_queue) #{UPSERT_ERROR_COLUMNS}
210
+ SQL
211
+ private_constant :RECORD_SQL
212
+
213
+ LEGACY_RECORD_SQL = <<~SQL.gsub(/\s+/, " ").strip.freeze
214
+ INSERT INTO pgbus_failed_events
215
+ (queue_name, msg_id, payload, headers, error_class, error_message, backtrace, retry_count, failed_at)
216
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP)
217
+ ON CONFLICT (queue_name, msg_id) #{UPSERT_ERROR_COLUMNS}
218
+ SQL
219
+ private_constant :LEGACY_RECORD_SQL
127
220
  end
128
221
  end