pgbus 0.21.0 → 0.21.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: 1c3c6921f17cfc002f7177240a3d1b2c202a78c7127e8f0da75a75ebcaf365d1
4
- data.tar.gz: b73396771876a230b1b62b6b30dceb87329be7259810c5346fd4a3fc78f59991
3
+ metadata.gz: c2dece606e269b4f406a3ba3d786facbdd53aedd675fda239803c5136e241678
4
+ data.tar.gz: 8498b9396aa4577f18b5e2e7793e82e9a67ce896b7495f061d51bd28e56112a1
5
5
  SHA512:
6
- metadata.gz: 5667ec2a6ebd1ef9e09f5f89818e69784267b3aa0c13a0b5cef2782b346af2361c106454fbb58d67a688b48f451483327fa4f3edc0a1d9a82664aa599af52454
7
- data.tar.gz: 83d7a44001fe09db593caacc788307198361279c023b7ed2ed8397fdec17fb353fa44087d9a973857951835d6cb1206314ade872a99d85586988561be2477a85
6
+ metadata.gz: 7032434023b3253d81e3c6668a4db3481a500a1421cce80fe94a45f235a244be23da1b0e25ec3657acbaaee4ba11c17e56891d4c401010f22340ae5635f0f55a
7
+ data.tar.gz: 76e5a0d5dd1343274848660e7ba2b6389b14c955551117b1b9a0821a994960ece32a46e6bedfccd4f2e9ddae3326aa37e3f11cfd3bea70edf78a76c3c6e3961f
data/CHANGELOG.md CHANGED
@@ -1,5 +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
+ - **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.
9
+
10
+ ### Changed
11
+
12
+ - **`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.
13
+
14
+ ### Fixed
15
+
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+
20
+ - **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.
21
+
3
22
  ## [0.21.0] - 2026-10-19
4
23
 
5
24
  ### Added
@@ -11,11 +30,15 @@
11
30
  ### Changed
12
31
 
13
32
  - **`actor:` and `reason:` are reserved keywords of `Pgbus::Workflow.start!` (issue #571).** Like `idempotency_key:`, they are taken for the audit trail and no longer reach the run's `arguments`. A workflow that receives an argument literally named `actor` or `reason` through `start!` renames it, e.g. `start!(subject, requested_by: "alice")` read as `arguments[:requested_by]`. Runs already started keep their stored arguments, and `rerun!` copies them as stored. Refs #571.
33
+ - **`Pgbus::Client#message_exists?`, `#message_in_queue?`, `#message_archived?`, `#message_with_job_id?` and `#dead_letter_physical_name` take a logical queue name unless you pass `prefixed: false` (issue #569).** They used to guess that a name starting with `"#{queue_prefix}_"` was already physical. **If you call one of them with a physical table name, add `prefixed: false`;** without it the name is prefixed again. Under priority routing two of them also answer differently for a logical name: `message_archived?` probes every `_pN` archive (it probed a base table that priority routing never creates, and answered nil), and `dead_letter_physical_name` keeps a logical name's own `_pN` suffix (`orders_p2` → `…_orders_p2_dlq`, the queue `move_to_dead_letter` writes), stripping it only from a physical sub-queue name passed with `prefixed: false`.
34
+ - **The batch completion callback is `on_complete`.** `Pgbus::Batch.new(on_complete:)`, the builder's `b.on_complete` and the `#on_complete` reader replace `on_finish`, which keeps working as a deprecated alias until 1.0 and logs one warning per process. The database columns stay `on_finish_class` / `on_finish_job` until the 1.0 schema cleanup, so batches created before upgrading still fire, and the `pgbus.batch_finished` instrumentation event keeps its name. The dashboard's batch page labels the callback "On Complete". Refs #538.
35
+
14
36
 
15
37
  ### Fixed
16
38
 
17
39
  - **The workflows one-ongoing index predicate dumps the same `schema.rb` line from a migrated and from a schema-loaded database (issue #573).** `pgbus:add_workflows` and the fresh-install migration declared `idx_pgbus_workflows_one_ongoing` with `where: "status IN ('ready','performing','waiting','paused')"`. `status` is a `varchar`, so Postgres stored the list as `varchar` literals cast to `text[]`; `db:schema:load` parses that dumped string into a different expression, and each later dump flipped the line depending on which kind of database it came from. Both templates now write `status::text IN (…)`, whose stored form replays to itself. The index means the same either way, so existing installs need nothing. An app that wants a stable dump can recreate the index in a migration of its own: `remove_index :pgbus_workflows, name: "idx_pgbus_workflows_one_ongoing"`, then `add_index :pgbus_workflows, [:workflow_class, :subject_type, :subject_id], unique: true, where: "status::text IN ('ready','performing','waiting','paused')", name: "idx_pgbus_workflows_one_ongoing"` (both run in the migration's transaction, so the constraint never lapses; `remove_index` takes an ACCESS EXCLUSIVE lock, so `pgbus_workflows` is locked against reads and writes (workers, workflow starts and the dashboard wait) until the migration commits), and dump the schema again. Refs #573.
18
40
  - **A guard that ends a `wait_for:` step after a reattempt consumes the signal the step holds (issue #572).** A woken attempt that called `reattempt!` (or failed into a pause and was resumed) kept its signal `claimed` for the next attempt, whose row carries no `signal_id` until its gate runs. When `cancel_if` or `skip_if` ended that next attempt (or a guard raised and its `on_exception` policy skipped or cancelled, or a deploy had declared the step a `removed_step` meanwhile), the signal stayed `claimed` forever. The attempt now records the held signal before its outcome, so it is consumed when the step ends and kept when it reattempts or pauses, as for any attempt. Refs #572.
41
+ - **A queue whose logical name starts with the prefix is probed in its own table (issue #569).** `Pgbus::Client#message_exists?`, `#message_in_queue?`, `#message_archived?`, `#message_with_job_id?` and `#dead_letter_physical_name` decided whether a name was already physical by `name.start_with?("#{queue_prefix}_")`, so a logical `pgbus_mailers` (table `pgbus_pgbus_mailers`) was probed as `pgmq.q_pgbus_mailers`. The answer was "unknown", or another queue's answer when that table existed. The dispatcher kept an orphaned uniqueness lock under such a queue forever. The dashboard's failed-event Retry enqueued a copy next to the original message. The batch sweep kept an orphan execution row forever, or un-counted a dead-lettered job. The name's spelling no longer decides anything: these methods take a logical name, expanded through `config.queue_name` and the queue strategy, unless the caller passes `prefixed: false` for a physical table name. This is the `prefixed:` convention `delete_message`, `archive_message`, `set_visibility_timeout`, `purge_queue` and `drop_queue` already follow. `message_archived?` now also expands a logical name to every `_pN` archive under priority routing, as `message_exists?` already did; before, it probed a base table that does not exist there. The batch sweep now passes `prefixed: false` for a backfilled execution row (it holds the table its message went to) and the logical name for an orphan row. The caller-facing change is under Changed.
19
42
 
20
43
  ## [0.20.1] - 2026-10-18
21
44
 
@@ -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
@@ -38,14 +38,17 @@ module Pgbus
38
38
  swept
39
39
  end
40
40
 
41
+ # A row with a msg_id was backfilled with the physical table its message
42
+ # was sent to (the `_pN` sub-queue under priority routing), so every
43
+ # probe takes that name as is.
41
44
  def classify_stale(row, client)
42
- in_queue = client.message_in_queue?(row.queue_name, msg_id: row.msg_id)
45
+ in_queue = client.message_in_queue?(row.queue_name, msg_id: row.msg_id, prefixed: false)
43
46
  return :still_present if in_queue != false
44
47
 
45
- dlq = client.dead_letter_physical_name(row.queue_name)
46
- return :failed if client.message_with_job_id?(dlq, job_id: row.job_id) == true
48
+ dlq = client.dead_letter_physical_name(row.queue_name, prefixed: false)
49
+ return :failed if client.message_with_job_id?(dlq, job_id: row.job_id, prefixed: false) == true
47
50
 
48
- return :completed if client.message_archived?(row.queue_name, msg_id: row.msg_id) == true
51
+ return :completed if client.message_archived?(row.queue_name, msg_id: row.msg_id, prefixed: false) == true
49
52
 
50
53
  Pgbus.logger.warn do
51
54
  "[Pgbus] Batch execution #{row.job_id} (batch #{row.batch_id}) has no queue, " \
@@ -84,6 +87,8 @@ module Pgbus
84
87
  swept
85
88
  end
86
89
 
90
+ # A row without a msg_id still holds the logical queue name it was
91
+ # inserted with (the job's queue_name); the backfill was never applied.
87
92
  def classify_orphan(row, client)
88
93
  return :live if row.queue_name.nil?
89
94
 
@@ -91,7 +96,7 @@ module Pgbus
91
96
  return :live if in_queue != false
92
97
 
93
98
  dlq = client.dead_letter_physical_name(row.queue_name)
94
- return :failed if client.message_with_job_id?(dlq, job_id: row.job_id) == true
99
+ return :failed if client.message_with_job_id?(dlq, job_id: row.job_id, prefixed: false) == true
95
100
 
96
101
  :orphan
97
102
  end
data/lib/pgbus/client.rb CHANGED
@@ -720,16 +720,24 @@ module Pgbus
720
720
  # reaper to determine if a uniqueness lock with msg_id=0 (placeholder)
721
721
  # still has a corresponding queue message.
722
722
  #
723
- # +queue_name+ may be either a logical name (e.g. "default") or an already
724
- # prefixed physical name (e.g. "pgbus_default"). The client normalizes both.
723
+ # +queue_name+ is a logical name (e.g. "default"), expanded through the
724
+ # queue strategy, unless the caller passes +prefixed: false+ for a physical
725
+ # table name (e.g. "pgbus_default_p1"), used as is. The name's spelling
726
+ # decides nothing: a logical "pgbus_mailers" lives in pgbus_pgbus_mailers
727
+ # (issue #569).
725
728
  #
726
729
  # Returns:
727
730
  # true — the message definitely exists in the queue
728
731
  # false — the message definitely does not exist
729
732
  # nil — could not determine (e.g. queue table missing or unknown error).
730
733
  # Callers MUST treat nil as "exists" for safety.
731
- def message_in_queue?(queue_name, msg_id:)
732
- message_exists?(queue_name, msg_id: msg_id)
734
+ #
735
+ # Under priority routing a logical name probes every `_pN` table, and each
736
+ # table has its own msg_id sequence: a bare +msg_id+ may match a different
737
+ # message in a sibling sub-queue. A caller that knows the sub-queue passes
738
+ # its physical name with +prefixed: false+.
739
+ def message_in_queue?(queue_name, msg_id:, prefixed: true)
740
+ message_exists?(queue_name, msg_id: msg_id, prefixed: prefixed)
733
741
  end
734
742
 
735
743
  # Look up a queue row by ActiveJob job_id in the JSON payload. Used by the
@@ -738,10 +746,10 @@ module Pgbus
738
746
  #
739
747
  # Same tri-state as message_exists?: true / false / nil (unknown). A
740
748
  # logical name expands to every physical table the strategy owns (the
741
- # `_pN` sub-queues under priority routing); an already-prefixed physical
742
- # name probes itself only.
743
- def message_with_job_id?(queue_name, job_id:)
744
- tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
749
+ # `_pN` sub-queues under priority routing); a physical name passed with
750
+ # +prefixed: false+ probes itself only.
751
+ def message_with_job_id?(queue_name, job_id:, prefixed: true)
752
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed).map { |name| QueueNameValidator.sanitize!(name) }
745
753
  determined = false
746
754
 
747
755
  synchronized do
@@ -758,43 +766,85 @@ module Pgbus
758
766
  determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
759
767
  end
760
768
 
761
- # DLQ companion of a logical or already-physical queue name. Does not
762
- # re-prefix a name that resolve_full_queue_name already expanded.
763
- def dead_letter_physical_name(queue_name)
764
- full = resolve_full_queue_name(queue_name)
769
+ # The physical table that holds message +msg_id+, or nil when none does.
770
+ # See locate_messages.
771
+ def locate_message(queue_name, msg_id:, job_id: nil, prefixed: true)
772
+ locate_messages(queue_name, [[msg_id, job_id]], prefixed: prefixed)[msg_id.to_i]
773
+ end
774
+
775
+ # { msg_id => physical table } for each [msg_id, job_id] pair whose
776
+ # message is queued, one query per table on one connection. A logical
777
+ # name expands to every table the strategy owns, and under priority
778
+ # routing each `_pN` sub-queue numbers its messages from its own
779
+ # sequence: only a message carrying the pair's job_id matches, or any
780
+ # message with that msg_id when job_id is nil, the first table winning
781
+ # (issue #577). A physical name with +prefixed: false+ is probed alone.
782
+ # A table that does not exist holds nothing; other errors raise.
783
+ def locate_messages(queue_name, messages, prefixed: true)
784
+ wanted = messages.to_h { |msg_id, job_id| [msg_id.to_i, job_id&.to_s] }
785
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed)
786
+ located = {}
787
+
788
+ synchronized do
789
+ with_raw_connection do |conn|
790
+ tables.each do |name|
791
+ pending = wanted.keys - located.keys
792
+ break if pending.empty?
793
+
794
+ queued_job_ids(conn, QueueNameValidator.sanitize!(name), pending).each do |row|
795
+ msg_id = row["msg_id"].to_i
796
+ job_id = wanted[msg_id]
797
+ located[msg_id] ||= name if job_id.nil? || row["job_id"] == job_id
798
+ end
799
+ end
800
+ end
801
+ end
802
+
803
+ located
804
+ end
805
+
806
+ # DLQ companion of a logical queue name, or of a physical table name passed
807
+ # with +prefixed: false+.
808
+ def dead_letter_physical_name(queue_name, prefixed: true)
809
+ return config.dead_letter_queue_name(queue_name) if prefixed
810
+
765
811
  # Priority sub-queues share the logical queue's DLQ (`pgbus_default_dlq`),
766
- # not a per-priority `…_pN_dlq`. Only strip when the strategy actually
767
- # creates `_pN` tables — a logical queue named `orders_p0` must keep it.
768
- base = @queue_strategy.priority? ? full.sub(/_p\d+\z/, "") : full
812
+ # not a per-priority `…_pN_dlq`. Only a physical name can be a sub-queue,
813
+ # and only when the strategy actually creates `_pN` tables: a logical
814
+ # queue named `orders_p0` keeps its suffix.
815
+ name = queue_name.to_s
816
+ base = @queue_strategy.priority? ? name.sub(/_p\d+\z/, "") : name
769
817
  "#{base}#{Pgbus::DEAD_LETTER_SUFFIX}"
770
818
  end
771
819
 
772
820
  # Same tri-state as message_exists?: true / false / nil (unknown).
773
- def message_archived?(queue_name, msg_id:)
774
- full_name = resolve_full_queue_name(queue_name)
775
- sanitized = QueueNameValidator.sanitize!(full_name)
821
+ # A logical name probes the archive of every physical table the strategy
822
+ # owns, like message_exists?; a physical name (prefixed: false) its own.
823
+ # A bare msg_id is unique per table only (see message_in_queue?).
824
+ def message_archived?(queue_name, msg_id:, prefixed: true)
825
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed).map { |name| QueueNameValidator.sanitize!(name) }
826
+ determined = false
776
827
 
777
828
  synchronized do
778
829
  with_raw_connection do |conn|
779
- msg_id_archived?(conn, sanitized, msg_id.to_i)
830
+ tables.each do |sanitized|
831
+ archived = probe_archive_presence(conn, sanitized, msg_id.to_i)
832
+ return true if archived == true
833
+
834
+ determined = true unless archived == :missing
835
+ end
780
836
  end
781
837
  end
782
- rescue ActiveRecord::StatementInvalid => e
783
- raise unless undefined_table_error?(e)
784
-
785
- nil
786
- rescue StandardError => e
787
- raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
788
838
 
789
- nil
839
+ determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
790
840
  end
791
841
 
792
- def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil)
842
+ def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil, prefixed: true)
793
843
  has_msg_id = !msg_id.nil?
794
844
  has_uniqueness_key = !uniqueness_key.nil?
795
845
  raise ArgumentError, "pass msg_id, uniqueness_key, or both" unless has_msg_id || has_uniqueness_key
796
846
 
797
- tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
847
+ tables = lookup_physical_queue_names(queue_name, prefixed: prefixed).map { |name| QueueNameValidator.sanitize!(name) }
798
848
  determined = false
799
849
 
800
850
  synchronized do
@@ -1092,21 +1142,10 @@ module Pgbus
1092
1142
  end
1093
1143
  end
1094
1144
 
1095
- # Accept either a logical name ("default") or an already-prefixed
1096
- # physical name ("pgbus_default") and return the physical name.
1097
- # Coerces symbols to strings so callers can pass either form.
1098
- def resolve_full_queue_name(queue_name)
1099
- name = queue_name.to_s
1100
- prefix = "#{config.queue_prefix}_"
1101
- name.start_with?(prefix) ? name : config.queue_name(name)
1102
- end
1103
-
1104
1145
  # Logical names expand through QueueFactory so a priority queue's _pN
1105
- # tables are included; already-prefixed physical names stay as-is.
1106
- def lookup_physical_queue_names(queue_name)
1107
- name = queue_name.to_s
1108
- prefix = "#{config.queue_prefix}_"
1109
- name.start_with?(prefix) ? [name] : @queue_strategy.physical_queue_names(name)
1146
+ # tables are included; a physical name (prefixed: false) stays as is.
1147
+ def lookup_physical_queue_names(queue_name, prefixed:)
1148
+ prefixed ? @queue_strategy.physical_queue_names(queue_name.to_s) : [queue_name.to_s]
1110
1149
  end
1111
1150
 
1112
1151
  def probe_queue_presence(conn, sanitized, msg_id:, uniqueness_key:)
@@ -1125,6 +1164,18 @@ module Pgbus
1125
1164
  :missing
1126
1165
  end
1127
1166
 
1167
+ def probe_archive_presence(conn, sanitized, msg_id)
1168
+ msg_id_archived?(conn, sanitized, msg_id)
1169
+ rescue ActiveRecord::StatementInvalid => e
1170
+ raise unless undefined_table_error?(e)
1171
+
1172
+ :missing
1173
+ rescue StandardError => e
1174
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
1175
+
1176
+ :missing
1177
+ end
1178
+
1128
1179
  def probe_job_id_presence(conn, sanitized, job_id)
1129
1180
  job_id_present?(conn, sanitized, job_id)
1130
1181
  rescue ActiveRecord::StatementInvalid => e
@@ -1137,6 +1188,22 @@ module Pgbus
1137
1188
  :missing
1138
1189
  end
1139
1190
 
1191
+ # msg_ids are integers, so the array literal is safe.
1192
+ def queued_job_ids(conn, sanitized, msg_ids)
1193
+ conn.exec_params(
1194
+ "SELECT msg_id, message::jsonb ->> 'job_id' AS job_id FROM pgmq.q_#{sanitized} WHERE msg_id = ANY($1::bigint[])",
1195
+ ["{#{msg_ids.join(",")}}"]
1196
+ )
1197
+ rescue ActiveRecord::StatementInvalid => e
1198
+ raise unless undefined_table_error?(e)
1199
+
1200
+ []
1201
+ rescue StandardError => e
1202
+ raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
1203
+
1204
+ []
1205
+ end
1206
+
1140
1207
  def scan_uniqueness_keys(conn, sanitized, keys, found)
1141
1208
  placeholders = keys.each_index.map { |i| "$#{i + 1}" }.join(", ")
1142
1209
  result = conn.exec_params(
@@ -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
@@ -312,6 +319,7 @@ module Pgbus
312
319
  @max_worker_lifetime = nil
313
320
 
314
321
  @stall_threshold = 90
322
+ @boot_timeout = 120
315
323
  @read_timeout = 30
316
324
  @drain_timeout = 30
317
325
  @immediate_shutdown_grace = 2
@@ -918,6 +926,9 @@ module Pgbus
918
926
  unless stall_threshold.nil? || (stall_threshold.is_a?(Numeric) && stall_threshold.positive?)
919
927
  raise Pgbus::ConfigurationError, "stall_threshold must be a positive number or nil to disable"
920
928
  end
929
+ unless boot_timeout.nil? || (boot_timeout.is_a?(Numeric) && boot_timeout.positive?)
930
+ raise Pgbus::ConfigurationError, "boot_timeout must be a positive number or nil to disable"
931
+ end
921
932
  unless read_timeout.nil? || (read_timeout.is_a?(Numeric) && read_timeout.positive?)
922
933
  raise Pgbus::ConfigurationError, "read_timeout must be a positive number or nil to disable"
923
934
  end