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.
@@ -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
@@ -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
- *failed_events_index_migrations,
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
- def failed_events_index_migrations
270
- # pgbus_failed_events is created by pgbus:install, but the
271
- # unique (queue_name, msg_id) index was added later via its own
272
- # generator. If the table exists without the unique index,
273
- # FailedEventRecorder's upsert silently swallows ON CONFLICT
274
- # errors — so this is a real bug waiting to bite.
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 index_exists?("pgbus_failed_events", "idx_pgbus_failed_events_queue_msg")
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
- [:add_failed_events_index]
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
- FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i) if message.read_ct.to_i > 1
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, payload: message.message,
367
- headers: message.headers, error: error, retry_count: [message.read_ct.to_i - 1, 0].max
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 unless @process_id && !@stopped
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
@@ -7,12 +7,20 @@ module Pgbus
7
7
  # server's accept thread — the immutability is what makes the cross-thread
8
8
  # handoff safe without a lock (issue #386).
9
9
  #
10
- # `expected` is the child count forked by boot_processes; `live` is the
11
- # current fork-table size. A child sitting in crash-restart backoff keeps
12
- # `live < expected`, which is exactly the signal a rolling deploy's health
13
- # gate needs to fail on: the replacement container never goes ready, and
14
- # the orchestrator keeps the old container running.
15
- ReadinessSnapshot = Data.define(:booted, :shutting_down, :expected, :live) do
10
+ # `expected` is the child count forked by boot_processes; `forked` is the
11
+ # current fork-table size; `live` counts the forks that have checked in
12
+ # (a process row on this host, or a liveness byte). A child sitting in
13
+ # crash-restart backoff, or one that forked and hangs before checking in
14
+ # (issue #587), keeps `live < expected`, which is exactly the signal a
15
+ # rolling deploy's health gate needs to fail on: the replacement
16
+ # container never goes ready, and the orchestrator keeps the old
17
+ # container running. `forked` defaults to `live` for callers that do not
18
+ # track check-ins.
19
+ ReadinessSnapshot = Data.define(:booted, :shutting_down, :expected, :live, :forked) do
20
+ def initialize(booted:, shutting_down:, expected:, live:, forked: live)
21
+ super
22
+ end
23
+
16
24
  def ready?
17
25
  booted && !shutting_down && live >= expected
18
26
  end