pgbus 0.21.1 → 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 +4 -4
- data/CHANGELOG.md +15 -0
- data/lib/generators/pgbus/add_failed_events_source_queue_generator.rb +44 -0
- data/lib/generators/pgbus/templates/add_failed_events_source_queue.rb.erb +27 -0
- data/lib/generators/pgbus/templates/add_failed_events_unique_index.rb.erb +5 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +6 -3
- data/lib/pgbus/active_job/executor.rb +29 -9
- data/lib/pgbus/configuration.rb +11 -0
- data/lib/pgbus/failed_event_recorder.rb +135 -42
- data/lib/pgbus/generators/migration_detector.rb +16 -9
- data/lib/pgbus/process/consumer.rb +12 -5
- data/lib/pgbus/process/heartbeat.rb +12 -1
- data/lib/pgbus/process/readiness_snapshot.rb +14 -6
- data/lib/pgbus/process/supervisor.rb +178 -48
- data/lib/pgbus/process/worker.rb +4 -3
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/job_list.rb +16 -3
- data/lib/pgbus/web/data_source.rb +36 -22
- data/lib/pgbus/web/health_app.rb +1 -1
- data/sig/pgbus/configuration.rbs +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2dece606e269b4f406a3ba3d786facbdd53aedd675fda239803c5136e241678
|
|
4
|
+
data.tar.gz: 8498b9396aa4577f18b5e2e7793e82e9a67ce896b7495f061d51bd28e56112a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7032434023b3253d81e3c6668a4db3481a500a1421cce80fe94a45f235a244be23da1b0e25ec3657acbaaee4ba11c17e56891d4c401010f22340ae5635f0f55a
|
|
7
|
+
data.tar.gz: 76e5a0d5dd1343274848660e7ba2b6389b14c955551117b1b9a0821a994960ece32a46e6bedfccd4f2e9ddae3326aa37e3f11cfd3bea70edf78a76c3c6e3961f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
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
|
+
|
|
3
14
|
### Fixed
|
|
4
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
|
+
|
|
5
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.
|
|
6
21
|
|
|
7
22
|
## [0.21.0] - 2026-10-19
|
|
@@ -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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
|
8
|
-
#
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
66
|
+
"FROM pgbus_failed_events WHERE #{sql}#{order} LIMIT 1",
|
|
60
67
|
"FailedEvent Last Error",
|
|
61
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
@@ -97,6 +97,7 @@ module Pgbus
|
|
|
97
97
|
add_workflows: "pgbus:add_workflows",
|
|
98
98
|
add_workflow_signals: "pgbus:add_workflow_signals",
|
|
99
99
|
add_failed_events_index: "pgbus:add_failed_events_index",
|
|
100
|
+
add_failed_events_source_queue: "pgbus:add_failed_events_source_queue",
|
|
100
101
|
add_processed_event_completion: "pgbus:add_processed_event_completion",
|
|
101
102
|
add_batch_executions: "pgbus:add_batch_executions",
|
|
102
103
|
add_batch_callback_jobs: "pgbus:add_batch_callback_jobs",
|
|
@@ -122,6 +123,7 @@ module Pgbus
|
|
|
122
123
|
add_workflows: "workflow tables (durable multi-step workflows: runs, step attempts, audit trail)",
|
|
123
124
|
add_workflow_signals: "waiting workflow steps (fan_out, wait_for: signals, wait timeouts)",
|
|
124
125
|
add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
|
|
126
|
+
add_failed_events_source_queue: "source_queue on pgbus_failed_events (one row per priority sub-queue's failure)",
|
|
125
127
|
add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
|
|
126
128
|
add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
|
|
127
129
|
add_batch_callback_jobs: "batch callback-job columns (configured ActiveJob instances as batch callbacks)",
|
|
@@ -151,7 +153,7 @@ module Pgbus
|
|
|
151
153
|
*recurring_migrations,
|
|
152
154
|
*deploy_tasks_migrations,
|
|
153
155
|
*workflows_migrations,
|
|
154
|
-
*
|
|
156
|
+
*failed_events_migrations,
|
|
155
157
|
*processed_event_completion_migrations,
|
|
156
158
|
*batch_executions_migrations,
|
|
157
159
|
*batch_callback_jobs_migrations,
|
|
@@ -266,16 +268,21 @@ module Pgbus
|
|
|
266
268
|
migrations
|
|
267
269
|
end
|
|
268
270
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
271
|
+
# FailedEventRecorder's upsert needs the unique index that matches its
|
|
272
|
+
# ON CONFLICT target, or it fails and the failure goes unrecorded. The
|
|
273
|
+
# source_queue migration (issue #583) adds the column, creates the
|
|
274
|
+
# (queue_name, msg_id, source_queue) index and drops the old
|
|
275
|
+
# (queue_name, msg_id) one, so it is the only migration queued here:
|
|
276
|
+
# add_failed_events_index would put back the index that collapses two
|
|
277
|
+
# priority sub-queues' failures into one row. That index still present
|
|
278
|
+
# (or put back by running the old generator by name) queues it again.
|
|
279
|
+
def failed_events_migrations
|
|
275
280
|
return [] unless table_exists?("pgbus_failed_events")
|
|
276
|
-
return [] if
|
|
281
|
+
return [] if column_names("pgbus_failed_events").include?("source_queue") &&
|
|
282
|
+
index_exists?("pgbus_failed_events", "idx_pgbus_failed_events_queue_msg_source") &&
|
|
283
|
+
!index_exists?("pgbus_failed_events", "idx_pgbus_failed_events_queue_msg")
|
|
277
284
|
|
|
278
|
-
[:
|
|
285
|
+
[:add_failed_events_source_queue]
|
|
279
286
|
end
|
|
280
287
|
|
|
281
288
|
# completed_at backs the two-phase idempotency claim (issue #385).
|
|
@@ -267,7 +267,10 @@ module Pgbus
|
|
|
267
267
|
Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
|
|
268
268
|
# A redelivery that succeeds leaves no stale failure behind. First
|
|
269
269
|
# deliveries never had one: no query on the hot path (#484's gate).
|
|
270
|
-
|
|
270
|
+
if message.read_ct.to_i > 1
|
|
271
|
+
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i,
|
|
272
|
+
source_queue: config.queue_name(queue_name))
|
|
273
|
+
end
|
|
271
274
|
@circuit_breaker.record_success(queue_name)
|
|
272
275
|
record_stat(message, queue_name, "success", execution_start)
|
|
273
276
|
rescue StandardError => e
|
|
@@ -293,7 +296,8 @@ module Pgbus
|
|
|
293
296
|
# failed-event row, then announce it. A move that raises keeps the row,
|
|
294
297
|
# so the next attempt still carries the reason, and announces nothing.
|
|
295
298
|
def dead_letter(message, queue_name)
|
|
296
|
-
last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i
|
|
299
|
+
last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i,
|
|
300
|
+
source_queue: config.queue_name(queue_name))
|
|
297
301
|
headers = DeadLetterHeader.build(
|
|
298
302
|
existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "consumer",
|
|
299
303
|
source_queue: config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
|
|
@@ -301,7 +305,8 @@ module Pgbus
|
|
|
301
305
|
max_retries: config.max_retries, error: last_error
|
|
302
306
|
)
|
|
303
307
|
Pgbus.client.move_to_dead_letter(queue_name, message, headers: headers)
|
|
304
|
-
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i
|
|
308
|
+
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i,
|
|
309
|
+
source_queue: config.queue_name(queue_name))
|
|
305
310
|
instrument_dead_lettered(message, queue_name, last_error)
|
|
306
311
|
end
|
|
307
312
|
|
|
@@ -361,10 +366,12 @@ module Pgbus
|
|
|
361
366
|
# failure, under the LOGICAL queue name, so the dashboard reads the event
|
|
362
367
|
# as retrying with its error. No ErrorReporter call: Handler#process!
|
|
363
368
|
# already instruments pgbus.event_failed, which feeds the reporters.
|
|
369
|
+
# A handler queue has no priority sub-queues: its one table is the source.
|
|
364
370
|
def record_failed_event(message, queue_name, error)
|
|
365
371
|
FailedEventRecorder.record!(
|
|
366
|
-
queue_name: queue_name, msg_id: message.msg_id.to_i,
|
|
367
|
-
headers: message.headers, error: error,
|
|
372
|
+
queue_name: queue_name, msg_id: message.msg_id.to_i, source_queue: config.queue_name(queue_name),
|
|
373
|
+
payload: message.message, headers: message.headers, error: error,
|
|
374
|
+
retry_count: [message.read_ct.to_i - 1, 0].max
|
|
368
375
|
)
|
|
369
376
|
end
|
|
370
377
|
|
|
@@ -32,6 +32,7 @@ module Pgbus
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def start
|
|
35
|
+
@started = true
|
|
35
36
|
register_process
|
|
36
37
|
@timer = Concurrent::TimerTask.new(execution_interval: INTERVAL) { beat }
|
|
37
38
|
@timer.execute
|
|
@@ -44,7 +45,8 @@ module Pgbus
|
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def beat
|
|
47
|
-
return
|
|
48
|
+
return if @stopped
|
|
49
|
+
return retry_registration unless @process_id
|
|
48
50
|
|
|
49
51
|
@on_beat&.call
|
|
50
52
|
updates = { last_heartbeat_at: Time.current }
|
|
@@ -57,6 +59,15 @@ module Pgbus
|
|
|
57
59
|
|
|
58
60
|
private
|
|
59
61
|
|
|
62
|
+
# The registration at start failed (a DB blip at boot). Retry it here so
|
|
63
|
+
# the process does not stay invisible: a pipeless child checks in with
|
|
64
|
+
# the supervisor's boot deadline only through this row (issue #587).
|
|
65
|
+
def retry_registration
|
|
66
|
+
return unless @started
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize { register_process unless @stopped }
|
|
69
|
+
end
|
|
70
|
+
|
|
60
71
|
# Build the metadata hash to persist on this beat. Returns nil when no
|
|
61
72
|
# supplier is configured so heartbeats without dynamic metadata
|
|
62
73
|
# (supervisor/dispatcher/scheduler) leave the column untouched, exactly
|