pgbus 0.20.0 → 0.20.1

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -0
  3. data/README.md +3 -2
  4. data/app/frontend/pgbus/modules/charts.js +18 -0
  5. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  6. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  7. data/app/helpers/pgbus/workflows_helper.rb +2 -2
  8. data/app/models/pgbus/blocked_execution.rb +12 -0
  9. data/app/models/pgbus/uniqueness_key.rb +88 -3
  10. data/app/models/pgbus/workflow_step.rb +7 -0
  11. data/app/views/pgbus/workflows/_timeline.html.erb +11 -1
  12. data/config/locales/da.yml +4 -1
  13. data/config/locales/de.yml +4 -1
  14. data/config/locales/en.yml +4 -1
  15. data/config/locales/es.yml +4 -1
  16. data/config/locales/fi.yml +4 -1
  17. data/config/locales/fr.yml +4 -1
  18. data/config/locales/it.yml +4 -1
  19. data/config/locales/ja.yml +4 -1
  20. data/config/locales/nb.yml +4 -1
  21. data/config/locales/nl.yml +4 -1
  22. data/config/locales/pt.yml +4 -1
  23. data/config/locales/sv.yml +4 -1
  24. data/lib/pgbus/active_job/adapter.rb +81 -3
  25. data/lib/pgbus/active_job/delivery.rb +20 -6
  26. data/lib/pgbus/active_job/executor.rb +18 -8
  27. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  28. data/lib/pgbus/configuration.rb +43 -1
  29. data/lib/pgbus/dead_letter_header.rb +9 -1
  30. data/lib/pgbus/instrumentation.rb +3 -2
  31. data/lib/pgbus/integrations/appsignal/dashboard.json +30 -1
  32. data/lib/pgbus/metrics/workflow_metrics.rb +11 -1
  33. data/lib/pgbus/process/consumer.rb +2 -1
  34. data/lib/pgbus/process/dispatcher.rb +13 -3
  35. data/lib/pgbus/process/worker.rb +10 -1
  36. data/lib/pgbus/recurring/schedule.rb +1 -1
  37. data/lib/pgbus/streams/presence.rb +65 -36
  38. data/lib/pgbus/uniqueness.rb +23 -6
  39. data/lib/pgbus/version.rb +1 -1
  40. data/lib/pgbus/web/data_source/workflow_list.rb +50 -7
  41. data/lib/pgbus/web/data_source.rb +123 -15
  42. data/lib/pgbus/web/workflow_state.rb +16 -3
  43. data/lib/pgbus/web/workflow_timeline.rb +23 -6
  44. data/lib/pgbus/workflow/step_definition.rb +4 -2
  45. data/lib/pgbus/workflow/wait_timeout.rb +25 -4
  46. data/sig/pgbus/configuration.rbs +1 -0
  47. metadata +1 -1
@@ -505,12 +505,22 @@ module Pgbus
505
505
  # Unbound rows (synthetic "pending" queue and/or msg_id=0) must not probe
506
506
  # pgmq.q_<prefix>_pending — that table does not exist, message_exists?
507
507
  # returns nil, and the reaper would keep the lock forever (issue #418).
508
- # Scan every live queue for the payload key instead.
508
+ # Scan every live queue for the payload key instead. A job parked behind
509
+ # its concurrency slot is in no queue: its key counts as live while it
510
+ # is parked (issue #564). The parked jobs are asked first: a promotion
511
+ # sends the job's message before its parked row's delete commits (or
512
+ # both in one transaction), so a job promoted between the two reads is
513
+ # still parked for the first or already queued for the second. Read the
514
+ # other way round it could be in neither.
509
515
  def gone_unbound_locks(unbound)
510
516
  return [] if unbound.empty?
511
517
 
512
- present = Pgbus.client.uniqueness_keys_present(unbound.map(&:lock_key))
513
- unbound.reject { |key| present.include?(key.lock_key) }
518
+ parked = BlockedExecution.parked_uniqueness_keys(unbound.map(&:lock_key))
519
+ unparked = unbound.reject { |key| parked.include?(key.lock_key) }
520
+ return [] if unparked.empty?
521
+
522
+ present = Pgbus.client.uniqueness_keys_present(unparked.map(&:lock_key))
523
+ unparked.reject { |key| present.include?(key.lock_key) }
514
524
  end
515
525
 
516
526
  # Returns true if the message referenced by this bound lock is definitely
@@ -407,12 +407,21 @@ module Pgbus
407
407
  # so we also pass `limit: qty` to cap the total across all queues —
408
408
  # otherwise we get `queue_count * qty` messages and overflow the
409
409
  # execution pool, crashing the worker fork (issue #123).
410
+ #
411
+ # The logical name is the configured one the table was made from, not
412
+ # the table name with its prefix cut: a name normalized for its table
413
+ # ("orders-handler" → "orders_handler") would otherwise reach the
414
+ # executor spelled unlike the name its jobs' uniqueness locks are bound
415
+ # under, and unlike every other fetch path (issue #564).
410
416
  def fetch_multi(active_queues, qty)
411
417
  messages = Pgbus.client.read_multi(active_queues, qty: qty, limit: qty) || []
418
+ return messages if messages.empty?
419
+
412
420
  prefix = "#{config.queue_prefix}_"
421
+ configured = active_queues.to_h { |q| [config.queue_name(q), q] }
413
422
 
414
423
  messages.map do |m|
415
- logical = m.queue_name&.delete_prefix(prefix) || active_queues.first
424
+ logical = configured[m.queue_name] || m.queue_name&.delete_prefix(prefix) || active_queues.first
416
425
  [logical, m]
417
426
  end
418
427
  end
@@ -143,7 +143,7 @@ module Pgbus
143
143
  key = resolve_uniqueness_key(job_class, config, task)
144
144
  return nil unless key
145
145
 
146
- acquired = UniquenessKey.acquire!(key, queue_name: resolve_queue(task), msg_id: 0)
146
+ acquired = UniquenessKey.acquire!(key, queue_name: Uniqueness.lock_queue_name(resolve_queue(task)), msg_id: 0)
147
147
 
148
148
  if acquired
149
149
  key
@@ -72,6 +72,7 @@ module Pgbus
72
72
  @schema_mutex = Mutex.new
73
73
  @seats_supported = nil
74
74
  @probed_at = nil
75
+ @schema_generation = 0
75
76
 
76
77
  class << self
77
78
  # True when pgbus_presence_members has the seat columns (the
@@ -81,17 +82,29 @@ module Pgbus
81
82
  # The answer is read and written under @schema_mutex only (issue
82
83
  # #527). A successful probe is kept as above; a probe that raises is
83
84
  # not, so the next call probes again.
85
+ #
86
+ # The probe itself runs outside @schema_mutex (issue #566): it waits
87
+ # for the adapter's lock, and a request inside a transaction holds
88
+ # that lock while it asks seats_supported?. Two threads may probe at
89
+ # once; a true answer one of them stored is never taken back, and a
90
+ # probe that a reset_schema_cache! overtook stores nothing.
84
91
  def seats_supported?
92
+ cached, generation = @schema_mutex.synchronize do
93
+ [(@seats_supported if schema_answer_fresh?(@seats_supported, @probed_at)), @schema_generation]
94
+ end
95
+ return cached unless cached.nil?
96
+
97
+ rows = exec_params(<<~SQL, [])
98
+ SELECT 1 AS present FROM information_schema.columns
99
+ WHERE table_name = 'pgbus_presence_members' AND column_name = 'connection_id'
100
+ AND table_schema = ANY (current_schemas(false))
101
+ SQL
102
+ supported = !rows.to_a.empty?
85
103
  @schema_mutex.synchronize do
86
- next @seats_supported if schema_answer_fresh?(@seats_supported, @probed_at)
104
+ next supported unless generation == @schema_generation
87
105
 
88
- rows = connection.exec_params(<<~SQL, [])
89
- SELECT 1 AS present FROM information_schema.columns
90
- WHERE table_name = 'pgbus_presence_members' AND column_name = 'connection_id'
91
- AND table_schema = ANY (current_schemas(false))
92
- SQL
93
106
  @probed_at = monotonic_now
94
- @seats_supported = !rows.to_a.empty?
107
+ @seats_supported ||= supported
95
108
  end
96
109
  end
97
110
 
@@ -108,6 +121,7 @@ module Pgbus
108
121
 
109
122
  def reset_schema_cache!
110
123
  @schema_mutex.synchronize do
124
+ @schema_generation += 1
111
125
  @probed_at = nil
112
126
  @seats_supported = nil
113
127
  end
@@ -124,7 +138,7 @@ module Pgbus
124
138
  def expire_seats!(ttl_for:, min_ttl:)
125
139
  return [] unless seats_supported?
126
140
 
127
- candidates = connection.exec_params(<<~SQL, [min_ttl])
141
+ candidates = exec_params(<<~SQL, [min_ttl])
128
142
  SELECT stream_name, connection_id FROM pgbus_presence_members
129
143
  WHERE connection_id IS NOT NULL AND last_seen_at < NOW() - make_interval(secs => $1)
130
144
  SQL
@@ -133,23 +147,23 @@ module Pgbus
133
147
  end
134
148
  end
135
149
 
150
+ # The PG::Connection under the adapter. Run SQL through exec_params,
151
+ # not on this: the adapter's lock is released once it is returned.
136
152
  def connection
137
- # Respect multi-database setups (connects_to). When
138
- # Pgbus.configuration.connects_to is set, the pgbus tables
139
- # live in a separate database accessed via Pgbus::BusRecord;
140
- # the default path uses ActiveRecord::Base. Matches the
141
- # canonical pattern in Pgbus::Process::QueueLock#connection
142
- # and Pgbus::Configuration's connection probe.
143
- unless defined?(::ActiveRecord::Base)
144
- raise Pgbus::ConfigurationError,
145
- "Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
146
- end
153
+ adapter.raw_connection
154
+ end
147
155
 
148
- if Pgbus.configuration.connects_to
149
- Pgbus::BusRecord.connection.raw_connection
150
- else
151
- ::ActiveRecord::Base.connection.raw_connection
152
- end
156
+ # Runs one statement on the adapter's PG::Connection while holding
157
+ # the adapter's lock (issue #566). raw_connection takes that lock
158
+ # only to hand the connection out; on a pinned pool (transactional
159
+ # tests) every thread shares the one adapter, and its lock is all
160
+ # that keeps a second thread's statement off the libpq connection
161
+ # while this one is in flight. Unpinned, the lock is a NullLock. The
162
+ # lock is reentrant, so a thread already holding it (a request
163
+ # inside a transaction) re-enters it.
164
+ def exec_params(sql, binds)
165
+ adapter = self.adapter
166
+ adapter.lock.synchronize { adapter.raw_connection.exec_params(sql, binds) }
153
167
  end
154
168
 
155
169
  # Reports a seat join/leave to streams_presence_on_change (issue #515).
@@ -171,6 +185,21 @@ module Pgbus
171
185
 
172
186
  private
173
187
 
188
+ # Respect multi-database setups (connects_to). When
189
+ # Pgbus.configuration.connects_to is set, the pgbus tables
190
+ # live in a separate database accessed via Pgbus::BusRecord;
191
+ # the default path uses ActiveRecord::Base. Matches the
192
+ # canonical pattern in Pgbus::Process::QueueLock#connection
193
+ # and Pgbus::Configuration's connection probe.
194
+ def adapter
195
+ unless defined?(::ActiveRecord::Base)
196
+ raise Pgbus::ConfigurationError,
197
+ "Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
198
+ end
199
+
200
+ Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ::ActiveRecord::Base.connection
201
+ end
202
+
174
203
  def schema_answer_fresh?(supported, probed_at)
175
204
  return false if supported.nil? || probed_at.nil?
176
205
 
@@ -178,7 +207,7 @@ module Pgbus
178
207
  end
179
208
 
180
209
  def expire_stream_seats(stream_name, connection_ids, ttl)
181
- rows = connection.exec_params(<<~SQL, [stream_name, text_array(connection_ids), ttl])
210
+ rows = exec_params(<<~SQL, [stream_name, text_array(connection_ids), ttl])
182
211
  DELETE FROM pgbus_presence_members
183
212
  WHERE stream_name = $1 AND connection_id = ANY($2::text[])
184
213
  AND last_seen_at < NOW() - make_interval(secs => $3)
@@ -306,7 +335,7 @@ module Pgbus
306
335
  return touch_seats(Array(connection_id)) if connection_id
307
336
 
308
337
  member_id = member_id.to_s
309
- connection.exec_params(<<~SQL, [@stream.name, member_id])
338
+ exec_params(<<~SQL, [@stream.name, member_id])
310
339
  UPDATE pgbus_presence_members
311
340
  SET last_seen_at = NOW()
312
341
  WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
@@ -320,7 +349,7 @@ module Pgbus
320
349
  def set_state(connection_id:, state:)
321
350
  raise ArgumentError, "set_state takes a Hash (got #{state.class})" unless state.is_a?(Hash)
322
351
 
323
- rows = connection.exec_params(<<~SQL, [@stream.name, connection_id.to_s, JSON.generate(state)])
352
+ rows = exec_params(<<~SQL, [@stream.name, connection_id.to_s, JSON.generate(state)])
324
353
  UPDATE pgbus_presence_members
325
354
  SET state = state || $3::jsonb, last_seen_at = NOW()
326
355
  WHERE stream_name = $1 AND connection_id = $2
@@ -335,7 +364,7 @@ module Pgbus
335
364
  # table each row is one seat and also carries connection_id, seat
336
365
  # and state; group by "id" for one entry per member.
337
366
  def members
338
- rows = connection.exec_params(<<~SQL, [@stream.name])
367
+ rows = exec_params(<<~SQL, [@stream.name])
339
368
  SELECT #{seats? ? SEAT_RECORD_COLUMNS : RECORD_COLUMNS}
340
369
  FROM pgbus_presence_members
341
370
  WHERE stream_name = $1
@@ -347,7 +376,7 @@ module Pgbus
347
376
  # Returns the count of current members. Faster than members.size
348
377
  # because it doesn't deserialize metadata.
349
378
  def count
350
- rows = connection.exec_params(<<~SQL, [@stream.name])
379
+ rows = exec_params(<<~SQL, [@stream.name])
351
380
  SELECT COUNT(*) AS n FROM pgbus_presence_members WHERE stream_name = $1
352
381
  SQL
353
382
  rows.first["n"].to_i
@@ -362,7 +391,7 @@ module Pgbus
362
391
  # workers running the sweep concurrently won't double-emit leave
363
392
  # events. Yields each expired record when a block is given.
364
393
  def sweep!(older_than:)
365
- rows = connection.exec_params(<<~SQL, [@stream.name, older_than])
394
+ rows = exec_params(<<~SQL, [@stream.name, older_than])
366
395
  DELETE FROM pgbus_presence_members
367
396
  WHERE stream_name = $1 AND last_seen_at < $2#{" AND connection_id IS NULL" if seats_now?}
368
397
  RETURNING #{RECORD_COLUMNS}
@@ -388,7 +417,7 @@ module Pgbus
388
417
 
389
418
  def upsert_member_once(member_id, metadata)
390
419
  conflict = seats? ? "(stream_name, member_id) WHERE connection_id IS NULL" : "(stream_name, member_id)"
391
- rows = connection.exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata)])
420
+ rows = exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata)])
392
421
  INSERT INTO pgbus_presence_members (stream_name, member_id, metadata, joined_at, last_seen_at)
393
422
  VALUES ($1, $2, $3::jsonb, NOW(), NOW())
394
423
  ON CONFLICT #{conflict}
@@ -399,7 +428,7 @@ module Pgbus
399
428
  end
400
429
 
401
430
  def upsert_seat(member_id, metadata, connection_id, seat)
402
- rows = connection.exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata), connection_id, seat])
431
+ rows = exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata), connection_id, seat])
403
432
  INSERT INTO pgbus_presence_members
404
433
  (stream_name, member_id, metadata, connection_id, seat, joined_at, last_seen_at)
405
434
  VALUES ($1, $2, $3::jsonb, $4, $5, NOW(), NOW())
@@ -412,7 +441,7 @@ module Pgbus
412
441
  end
413
442
 
414
443
  def delete_member(member_id)
415
- rows = connection.exec_params(<<~SQL, [@stream.name, member_id])
444
+ rows = exec_params(<<~SQL, [@stream.name, member_id])
416
445
  DELETE FROM pgbus_presence_members
417
446
  WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
418
447
  RETURNING #{RECORD_COLUMNS}
@@ -422,7 +451,7 @@ module Pgbus
422
451
  end
423
452
 
424
453
  def delete_seat(connection_id)
425
- rows = connection.exec_params(<<~SQL, [@stream.name, connection_id])
454
+ rows = exec_params(<<~SQL, [@stream.name, connection_id])
426
455
  DELETE FROM pgbus_presence_members
427
456
  WHERE stream_name = $1 AND connection_id = $2
428
457
  RETURNING #{SEAT_RECORD_COLUMNS}
@@ -434,7 +463,7 @@ module Pgbus
434
463
  def touch_seats(connection_ids)
435
464
  return [] if connection_ids.empty?
436
465
 
437
- rows = connection.exec_params(<<~SQL, [@stream.name, self.class.text_array(connection_ids)])
466
+ rows = exec_params(<<~SQL, [@stream.name, self.class.text_array(connection_ids)])
438
467
  UPDATE pgbus_presence_members
439
468
  SET last_seen_at = NOW()
440
469
  WHERE stream_name = $1 AND connection_id = ANY($2::text[])
@@ -443,8 +472,8 @@ module Pgbus
443
472
  rows.to_a.map { |row| row["connection_id"] }
444
473
  end
445
474
 
446
- def connection
447
- self.class.connection
475
+ def exec_params(sql, binds)
476
+ self.class.exec_params(sql, binds)
448
477
  end
449
478
  end
450
479
  end
@@ -16,8 +16,14 @@ module Pgbus
16
16
  # or executing.
17
17
  # 2. Execution: PGMQ's visibility timeout is the execution lock —
18
18
  # no separate claim_for_execution step needed.
19
- # 3. Completion/DLQ: DELETE FROM pgbus_uniqueness_keys WHERE lock_key = ?.
20
- # 4. Crash recovery: if a worker dies, VT expires, the message becomes
19
+ # 3. Retry: a retry_job / retry_on re-enqueue passes through the key the
20
+ # running attempt holds, and the row is handed to the retry: unbound
21
+ # and re-stamped before the retry is sent or parked (unbind!), bound
22
+ # to it once sent (bind!).
23
+ # 4. Completion/DLQ: the finishing message deletes the row only while it
24
+ # holds it (release_held_by!), so a row handed to a retry, or a
25
+ # successor's, stays. The dashboard's discards do the same.
26
+ # 5. Crash recovery: if a worker dies, VT expires, the message becomes
21
27
  # readable again. The uniqueness key row stays (correctly — the job
22
28
  # hasn't finished). The next worker picks it up and executes.
23
29
  #
@@ -179,14 +185,25 @@ module Pgbus
179
185
  return :no_lock unless config[:strategy] == :until_executed
180
186
 
181
187
  acquired = if msg_id && queue_name
182
- UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id)
188
+ UniquenessKey.acquire!(key, queue_name: lock_queue_name(queue_name), msg_id: msg_id)
183
189
  else
184
190
  # Pre-produce check: use advisory lock + ON CONFLICT
185
- UniquenessKey.acquire!(key, queue_name: queue_name || PLACEHOLDER_QUEUE, msg_id: msg_id || 0)
191
+ UniquenessKey.acquire!(key, queue_name: lock_queue_name(queue_name || PLACEHOLDER_QUEUE),
192
+ msg_id: msg_id || 0)
186
193
  end
187
194
  acquired ? :acquired : :locked
188
195
  end
189
196
 
197
+ # The queue a job's uniqueness lock records: the logical name as its
198
+ # table spells it ("orders-handler" → "orders_handler"), the inverse of
199
+ # stripping the prefix off the table name. Every writer and releaser of
200
+ # a job's lock goes through it, so a release that knows only the table
201
+ # (the dashboard) or a worker that spells the queue the table's way
202
+ # still finds the row it was bound under (issue #564).
203
+ def lock_queue_name(queue_name, config: Pgbus.configuration)
204
+ config.queue_name(queue_name).delete_prefix("#{config.queue_prefix}_")
205
+ end
206
+
190
207
  # Acquire the uniqueness lock at execution time (:while_executing only).
191
208
  # Returns true if acquired, false if another instance is running.
192
209
  #
@@ -199,7 +216,7 @@ module Pgbus
199
216
  return true unless strategy == :while_executing
200
217
 
201
218
  queue_name ||= payload["queue_name"] || "unknown"
202
- UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id.to_i,
219
+ UniquenessKey.acquire!(key, queue_name: lock_queue_name(queue_name), msg_id: msg_id.to_i,
203
220
  reacquire_same_message: msg_id.to_i.positive?)
204
221
  end
205
222
 
@@ -217,7 +234,7 @@ module Pgbus
217
234
  return unless key
218
235
  return unless msg_id.to_i.positive?
219
236
 
220
- UniquenessKey.bind!(key, queue_name: queue_name, msg_id: msg_id)
237
+ UniquenessKey.bind!(key, queue_name: lock_queue_name(queue_name), msg_id: msg_id)
221
238
  end
222
239
 
223
240
  # True when the uniqueness row is not yet bound to a real PGMQ message.
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.20.0"
4
+ VERSION = "0.20.1"
5
5
  end
@@ -17,6 +17,7 @@ module Pgbus
17
17
  # The pause events the timeline pairs, read on their own so a long
18
18
  # audit trail never drops a run's earliest pauses from the chart.
19
19
  PAUSE_HISTORY = 1_000
20
+ WAIT_TIMEOUT_HISTORY = 200
20
21
  SIGNAL_HISTORY = 100
21
22
  RERUN_LINKS = 10
22
23
  ATTENTION_GROUPS = 200
@@ -46,7 +47,8 @@ module Pgbus
46
47
 
47
48
  steps = WorkflowStep.where(workflow_id: workflow_id).order(id: :desc).limit(STEP_HISTORY).to_a
48
49
  progress = batch_progress([step_attribute(steps.first, :batch_id)].compact)
49
- workflow_row(record, steps.first, progress).merge(workflow_extras(record, steps))
50
+ late = late_since(steps.first&.status == "waiting" ? [steps.first] : [])
51
+ workflow_row(record, steps.first, progress, late).merge(workflow_extras(record, steps))
50
52
  rescue StandardError => e
51
53
  Pgbus.logger.error { "[Pgbus::Web] Error fetching workflow #{workflow_id}: #{e.class}: #{e.message}" }
52
54
  nil
@@ -96,7 +98,8 @@ module Pgbus
96
98
  latest = latest_steps(records.map(&:workflow_id))
97
99
  waiting = latest.values.select { |step| step.status == "waiting" }
98
100
  progress = batch_progress(waiting.filter_map { |step| step_attribute(step, :batch_id) })
99
- records.map { |record| workflow_row(record, latest[record.workflow_id], progress) }
101
+ late = late_since(waiting)
102
+ records.map { |record| workflow_row(record, latest[record.workflow_id], progress, late) }
100
103
  rescue StandardError => e
101
104
  Pgbus.logger.error { "[Pgbus::Web] Error fetching workflows: #{e.class}: #{e.message}" }
102
105
  []
@@ -155,7 +158,37 @@ module Pgbus
155
158
  end
156
159
  end
157
160
 
158
- def workflow_row(record, step, progress)
161
+ # { step_id => the first deadline its wait missed } for waiting steps
162
+ # with a deadline, one query: the first wait_timed_out event of each.
163
+ # Only on_timeout: :notify leaves a step waiting after that event
164
+ # (issue #565): every other on_timeout ends the attempt.
165
+ def late_since(waiting)
166
+ steps = waiting.select { |step| step_attribute(step, :timeout_at) }
167
+ return {} if steps.empty?
168
+
169
+ WorkflowEvent.where(workflow_id: steps.map(&:workflow_id), step_id: steps.map(&:id),
170
+ event: WorkflowTimeline::WAIT_TIMED_OUT)
171
+ .select("DISTINCT ON (step_id) step_id, details, created_at").order(Arel.sql("step_id, id"))
172
+ .to_h { |event| [event.step_id, missed_deadline(event)] }
173
+ end
174
+
175
+ # The deadline the event reports; when it went out, for an event
176
+ # written without one.
177
+ def missed_deadline(event)
178
+ deadline = event.details.to_h["timeout_at"]
179
+ deadline ? Time.iso8601(deadline) : event.created_at
180
+ end
181
+
182
+ # The waiting step's on_timeout as its class declares it: nil without
183
+ # a wait_timeout:, or when the class is not loaded in this process.
184
+ def wait_on_timeout(record, step)
185
+ return nil unless step_attribute(step, :timeout_at)
186
+
187
+ definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
188
+ definition.on_timeout.to_s if definition.is_a?(Workflow::StepDefinition)
189
+ end
190
+
191
+ def workflow_row(record, step, progress, late = {})
159
192
  waiting = step&.status == "waiting"
160
193
  { workflow_id: record.workflow_id, workflow_class: record.workflow_class, subject_type: record.subject_type,
161
194
  subject_id: record.subject_id, status: record.status, current_step: record.current_step,
@@ -163,6 +196,7 @@ module Pgbus
163
196
  waiting_for: (step_attribute(step, :wait_for) if waiting),
164
197
  waiting_since: (step_attribute(step, :waiting_since) if waiting),
165
198
  timeout_at: (step_attribute(step, :timeout_at) if waiting),
199
+ on_timeout: (wait_on_timeout(record, step) if waiting), late_since: (late[step.id] if waiting),
166
200
  batch_id: step_attribute(step, :batch_id),
167
201
  batch_progress: (progress[step_attribute(step, :batch_id)] if waiting),
168
202
  scheduled_at: (step.scheduled_at if step&.status == "scheduled"),
@@ -192,7 +226,7 @@ module Pgbus
192
226
  definition_outdated: record.definition_outdated?, class_loaded: !klass.nil?,
193
227
  step_names: klass ? klass.workflow_steps.names : [], resume_action: resume_action, resume_step: resume_step,
194
228
  signal_names: WorkflowActionPlan.signal_names(steps.first, klass), reruns: workflow_reruns(record.workflow_id),
195
- timeline: WorkflowTimeline.chart(step_hashes, pause_events(record.workflow_id), status: record.status, now: now) }
229
+ timeline: WorkflowTimeline.chart(step_hashes, timeline_events(record.workflow_id), status: record.status, now: now) }
196
230
  end
197
231
 
198
232
  def workflow_step_hash(step)
@@ -207,9 +241,18 @@ module Pgbus
207
241
  signal_id: step_attribute(step, :signal_id) }
208
242
  end
209
243
 
210
- def pause_events(workflow_id)
211
- WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::PAUSE_EVENTS).order(:id).limit(PAUSE_HISTORY)
212
- .map { |event| { event: event.event, created_at: event.created_at } }
244
+ # The pauses (oldest first) and the newest wait_timed_out events the
245
+ # timeline marks, read apart so a wait late for weeks cannot crowd out
246
+ # the pauses.
247
+ def timeline_events(workflow_id)
248
+ pauses = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::PAUSE_EVENTS).order(:id)
249
+ .limit(PAUSE_HISTORY).map { |event| { event: event.event, created_at: event.created_at } }
250
+ timeouts = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::WAIT_TIMED_OUT)
251
+ .order(id: :desc).limit(WAIT_TIMEOUT_HISTORY).map do |event|
252
+ { event: event.event, created_at: event.created_at, step_name: event.step_name, attempt: event.attempt,
253
+ details: event.details }
254
+ end
255
+ pauses + timeouts
213
256
  end
214
257
 
215
258
  def workflow_signals(workflow_id)
@@ -321,7 +321,7 @@ module Pgbus
321
321
  def discard_failed_event(id)
322
322
  event = failed_event(id)
323
323
  if event
324
- release_lock_for_payload(event["payload"])
324
+ release_locks_held_by_failed_events("id = $1", [id.to_i])
325
325
  archive_failed_message(event)
326
326
  end
327
327
 
@@ -920,7 +920,8 @@ module Pgbus
920
920
  # memory — otherwise the message is archived but the lock row stays
921
921
  # behind, blocking later publishes with the same key. Mirrors
922
922
  # discard_event.
923
- release_lock_for_payload(detail[:message])
923
+ release_lock_held_by(detail[:message], queue_name: lock_queue_for_table(queue_name), msg_id: detail[:msg_id],
924
+ enqueued_at: detail[:enqueued_at])
924
925
  @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
925
926
  clear_event_failure(queue_name, msg_id)
926
927
  true
@@ -1554,6 +1555,13 @@ module Pgbus
1554
1555
  end
1555
1556
 
1556
1557
  # --- Lock cleanup helpers ---
1558
+ #
1559
+ # Discarding a message releases only the uniqueness lock that message
1560
+ # holds (UniquenessKey.release_held_by!): its job may have handed the
1561
+ # lock to a retry that is still waiting (issue #564), or a successor may
1562
+ # have taken the key, and neither is the discarded message's to free.
1563
+ # The Locks page's own deletes stay key-only: there the key is the
1564
+ # operator's target.
1557
1565
 
1558
1566
  # Extract uniqueness key from a queue message and release its lock.
1559
1567
  def release_lock_for_message(queue_name, msg_id)
@@ -1564,39 +1572,139 @@ module Pgbus
1564
1572
  )
1565
1573
  return unless row
1566
1574
 
1567
- release_lock_for_payload(row["message"])
1575
+ holder_queue, holder_msg_id, sent_at = lock_holder(queue_name, row["msg_id"], row["headers"], row["enqueued_at"])
1576
+ release_lock_held_by(row["message"], queue_name: holder_queue, msg_id: holder_msg_id, enqueued_at: sent_at)
1568
1577
  rescue StandardError => e
1569
1578
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing lock for message #{msg_id}: #{e.message}" }
1570
1579
  end
1571
1580
 
1572
- # Extract uniqueness key from a JSON payload string and release its lock.
1573
- def release_lock_for_payload(payload_str)
1581
+ # [queue, msg_id, sent_at] of the message whose lock a discard may
1582
+ # release. A live message is its own holder. A dead-lettered copy's is
1583
+ # the message it was moved from, which its dead-letter header names:
1584
+ # the copy's own enqueued_at is the move to the DLQ, later than a lock
1585
+ # its job may have handed to a retry. A copy whose header does not name
1586
+ # the original (dead-lettered by an earlier release, which freed its
1587
+ # lock then) has no send time to go by, so only a bound lock could
1588
+ # match, and none is bound to a DLQ copy.
1589
+ def lock_holder(queue_name, msg_id, headers, enqueued_at)
1590
+ own = [lock_queue_for_table(queue_name), msg_id.to_i]
1591
+ return [*own, enqueued_at] unless queue_name.to_s.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
1592
+
1593
+ block = DeadLetterHeader.parse(headers)
1594
+ source_msg_id = block && Integer(block["source_msg_id"], exception: false)
1595
+ return [*own, nil] unless source_msg_id && block["source_queue"]
1596
+
1597
+ [lock_queue_for_table(block["source_queue"]), source_msg_id, block["source_enqueued_at"]]
1598
+ end
1599
+
1600
+ # Release the lock held by the message whose JSON payload this is.
1601
+ def release_lock_held_by(payload_str, queue_name:, msg_id:, enqueued_at:)
1574
1602
  key = extract_uniqueness_key_from_payload_str(payload_str)
1575
- UniquenessKey.release!(key) if key
1603
+ return unless key
1604
+
1605
+ UniquenessKey.release_held_by!(key, queue_name: queue_name, msg_id: msg_id.to_i, enqueued_at: enqueued_at)
1576
1606
  end
1577
1607
 
1578
- # Extract uniqueness keys from a collection of formatted messages and
1579
- # release all associated locks in a single query.
1608
+ # Release the locks a collection of formatted messages hold, in a single
1609
+ # query.
1580
1610
  def release_locks_for_messages(messages)
1581
- keys = messages.filter_map { |m| extract_uniqueness_key_from_payload_str(m[:message]) }
1582
- UniquenessKey.where(lock_key: keys).delete_all if keys.any?
1611
+ holders = messages.filter_map do |m|
1612
+ key = extract_uniqueness_key_from_payload_str(m[:message])
1613
+ [key, *lock_holder(m[:queue_name], m[:msg_id], m[:headers], m[:enqueued_at])] if key
1614
+ end
1615
+ UniquenessKey.release_all_held_by!(holders) if holders.any?
1583
1616
  rescue StandardError => e
1584
1617
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for messages: #{e.message}" }
1585
1618
  end
1586
1619
 
1587
- # Collect uniqueness keys from all failed events and release their locks.
1588
1620
  def release_locks_for_failed_events
1621
+ release_locks_held_by_failed_events(NOT_HANDLER_QUEUE, [handler_queue_names_literal])
1622
+ end
1623
+
1624
+ # Release the locks the messages of the failed jobs matching +condition+
1625
+ # hold. A failed job's message is still in its queue, waiting for its
1626
+ # next read, and its enqueued_at is the send time: failed_at is not, a
1627
+ # lock handed to a retry before the failure would count as the job's.
1628
+ # A message that is gone has no send time, so only a bound lock goes.
1629
+ def release_locks_held_by_failed_events(condition, binds)
1589
1630
  rows = connection.select_all(
1590
- "SELECT payload FROM pgbus_failed_events WHERE #{NOT_HANDLER_QUEUE}", "Pgbus Collect Failed Keys",
1591
- [handler_queue_names_literal]
1631
+ "SELECT queue_name, msg_id, payload FROM pgbus_failed_events WHERE #{condition}",
1632
+ "Pgbus Collect Failed Keys", binds
1592
1633
  )
1593
1634
 
1594
- keys = rows.to_a.filter_map { |row| extract_uniqueness_key_from_payload_str(row["payload"]) }
1595
- UniquenessKey.where(lock_key: keys).delete_all if keys.any?
1635
+ # queue_name is the logical name the executor saw: the message is looked
1636
+ # up under it, and the lock is released under its table spelling.
1637
+ # A key proc may return a number; the queue lookup reads it as text.
1638
+ holders = rows.to_a.filter_map do |row|
1639
+ key = extract_uniqueness_key_from_payload_str(row["payload"])&.to_s
1640
+ [key, row["queue_name"], row["msg_id"].to_i] if key && row["msg_id"]
1641
+ end
1642
+ holders = holders.group_by { |_key, queue, _msg_id| queue }.flat_map do |queue, entries|
1643
+ failed_queue_lock_holders(queue, entries)
1644
+ end
1645
+ UniquenessKey.release_all_held_by!(holders) if holders.any?
1596
1646
  rescue StandardError => e
1597
1647
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for failed events: #{e.message}" }
1598
1648
  end
1599
1649
 
1650
+ # The release tuples for one queue's failed jobs. A queue that cannot
1651
+ # be resolved (a stored name no longer valid as a table name) is skipped
1652
+ # with a warning, so it does not stop the other queues' release.
1653
+ def failed_queue_lock_holders(queue, entries)
1654
+ sent = queued_send_times(queue, entries)
1655
+ lock_queue = Uniqueness.lock_queue_name(queue)
1656
+ entries.map { |key, _queue, msg_id| [key, lock_queue, msg_id, sent[[msg_id, key]]] }
1657
+ rescue ActiveRecord::StatementInvalid
1658
+ raise
1659
+ rescue StandardError => e
1660
+ Pgbus.logger.warn { "[Pgbus::Web] Skipped releasing locks for failed jobs on #{queue.inspect}: #{e.message}" }
1661
+ []
1662
+ end
1663
+
1664
+ # { [msg_id, uniqueness key] => enqueued_at } for the messages of
1665
+ # +entries+ ([key, queue, msg_id]) still in the logical queue. Every
1666
+ # priority sub-queue is searched: a failed event records the logical
1667
+ # queue only, and msg_ids repeat across sub-queues, so the key must
1668
+ # match too, and the earliest send wins.
1669
+ def queued_send_times(queue, entries)
1670
+ msg_ids = "{#{entries.map { |_key, _queue, msg_id| msg_id }.uniq.join(",")}}"
1671
+ @client.physical_queue_names(queue).each_with_object({}) do |physical, found|
1672
+ queued_rows(physical, msg_ids).each do |row|
1673
+ pair = [row["msg_id"].to_i, row["lock_key"]]
1674
+ found[pair] = [found[pair], row["enqueued_at"]].compact.min
1675
+ end
1676
+ end
1677
+ end
1678
+
1679
+ # Each read runs in its own savepoint: a sub-queue the strategy names
1680
+ # but that does not exist raises, and inside a caller's transaction
1681
+ # that would abort the discard's own statements after it.
1682
+ def queued_rows(physical, msg_ids)
1683
+ connection.transaction(requires_new: true) do
1684
+ connection.select_all(
1685
+ "SELECT msg_id, enqueued_at, message->>'#{Uniqueness::METADATA_KEY}' AS lock_key " \
1686
+ "FROM pgmq.q_#{sanitize_name(physical)} WHERE msg_id = ANY($1::bigint[])",
1687
+ "Pgbus Failed Message Sent At", [msg_ids]
1688
+ ).to_a
1689
+ end
1690
+ rescue ActiveRecord::StatementInvalid => e
1691
+ raise unless defined?(PG::UndefinedTable) && e.cause.is_a?(PG::UndefinedTable)
1692
+
1693
+ []
1694
+ end
1695
+
1696
+ # The queue a uniqueness lock records for a message in this physical
1697
+ # queue: the table name without its prefix and a priority sub-queue's
1698
+ # _pN, which is how Uniqueness.lock_queue_name spells the queue the lock
1699
+ # was bound for. A dead-letter queue keeps its suffix, so its copy's
1700
+ # msg_id (another sequence) never matches a lock bound to a live message.
1701
+ def lock_queue_for_table(physical_name)
1702
+ name = physical_name.to_s.delete_prefix("#{Pgbus.configuration.queue_prefix}_")
1703
+ return name if name.end_with?(Pgbus::DEAD_LETTER_SUFFIX) || Pgbus.configuration.priority_levels.to_i <= 1
1704
+
1705
+ name.sub(/_p\d+\z/, "")
1706
+ end
1707
+
1600
1708
  # Single unwrap point for PGMQ message / failed_event payload strings.
1601
1709
  # Accepts a raw JSON string or an already-parsed Hash and returns the
1602
1710
  # uniqueness metadata key, or nil when the payload is blank, unparseable,