pgbus 0.20.0 → 0.21.0

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -6
  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/workflow_insights_helper.rb +8 -0
  8. data/app/helpers/pgbus/workflows_helper.rb +2 -2
  9. data/app/models/pgbus/blocked_execution.rb +12 -0
  10. data/app/models/pgbus/uniqueness_key.rb +88 -3
  11. data/app/models/pgbus/workflow_entry.rb +3 -2
  12. data/app/models/pgbus/workflow_step.rb +7 -0
  13. data/app/views/pgbus/workflows/_timeline.html.erb +11 -1
  14. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +3 -0
  15. data/config/locales/da.yml +7 -1
  16. data/config/locales/de.yml +7 -1
  17. data/config/locales/en.yml +7 -1
  18. data/config/locales/es.yml +7 -1
  19. data/config/locales/fi.yml +7 -1
  20. data/config/locales/fr.yml +7 -1
  21. data/config/locales/it.yml +7 -1
  22. data/config/locales/ja.yml +7 -1
  23. data/config/locales/nb.yml +7 -1
  24. data/config/locales/nl.yml +7 -1
  25. data/config/locales/pt.yml +7 -1
  26. data/config/locales/sv.yml +7 -1
  27. data/lib/generators/pgbus/templates/add_workflows.rb.erb +3 -1
  28. data/lib/generators/pgbus/templates/migration.rb.erb +3 -1
  29. data/lib/pgbus/active_job/adapter.rb +81 -3
  30. data/lib/pgbus/active_job/delivery.rb +20 -6
  31. data/lib/pgbus/active_job/executor.rb +18 -8
  32. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  33. data/lib/pgbus/configuration/workflow_settings.rb +1 -1
  34. data/lib/pgbus/configuration.rb +43 -1
  35. data/lib/pgbus/dead_letter_header.rb +9 -1
  36. data/lib/pgbus/instrumentation.rb +3 -2
  37. data/lib/pgbus/integrations/appsignal/dashboard.json +30 -1
  38. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +1 -1
  39. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +1 -1
  40. data/lib/pgbus/metrics/workflow_metrics.rb +11 -1
  41. data/lib/pgbus/process/consumer.rb +2 -1
  42. data/lib/pgbus/process/dispatcher.rb +13 -3
  43. data/lib/pgbus/process/worker.rb +10 -1
  44. data/lib/pgbus/recurring/schedule.rb +1 -1
  45. data/lib/pgbus/streams/presence.rb +65 -36
  46. data/lib/pgbus/uniqueness.rb +23 -6
  47. data/lib/pgbus/version.rb +1 -1
  48. data/lib/pgbus/web/data_source/workflow_list.rb +52 -7
  49. data/lib/pgbus/web/data_source.rb +123 -15
  50. data/lib/pgbus/web/workflow_insights_report.rb +11 -3
  51. data/lib/pgbus/web/workflow_state.rb +19 -3
  52. data/lib/pgbus/web/workflow_timeline.rb +23 -6
  53. data/lib/pgbus/workflow/instance.rb +36 -3
  54. data/lib/pgbus/workflow/runner.rb +2 -2
  55. data/lib/pgbus/workflow/signal_peek.rb +47 -0
  56. data/lib/pgbus/workflow/signals.rb +31 -10
  57. data/lib/pgbus/workflow/step_definition.rb +49 -5
  58. data/lib/pgbus/workflow/step_execution.rb +27 -10
  59. data/lib/pgbus/workflow/wait_timeout.rb +56 -4
  60. data/lib/pgbus/workflow.rb +11 -5
  61. data/sig/pgbus/configuration.rbs +1 -0
  62. data/sig/pgbus.rbs +43 -0
  63. metadata +2 -1
@@ -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.21.0"
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,39 @@ 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 steps waiting
162
+ # on a signal, 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. A per-run
165
+ # wait_timeout: may have cleared the deadline at that notice (issue
166
+ # #574), so a step without one is looked up too.
167
+ def late_since(waiting)
168
+ steps = waiting.select { |step| step_attribute(step, :wait_for) }
169
+ return {} if steps.empty?
170
+
171
+ WorkflowEvent.where(workflow_id: steps.map(&:workflow_id), step_id: steps.map(&:id),
172
+ event: WorkflowTimeline::WAIT_TIMED_OUT)
173
+ .select("DISTINCT ON (step_id) step_id, details, created_at").order(Arel.sql("step_id, id"))
174
+ .to_h { |event| [event.step_id, missed_deadline(event)] }
175
+ end
176
+
177
+ # The deadline the event reports; when it went out, for an event
178
+ # written without one.
179
+ def missed_deadline(event)
180
+ deadline = event.details.to_h["timeout_at"]
181
+ deadline ? Time.iso8601(deadline) : event.created_at
182
+ end
183
+
184
+ # The waiting step's on_timeout as its class declares it: nil without
185
+ # a wait_timeout:, or when the class is not loaded in this process.
186
+ def wait_on_timeout(record, step)
187
+ return nil unless step_attribute(step, :timeout_at)
188
+
189
+ definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
190
+ definition.on_timeout.to_s if definition.is_a?(Workflow::StepDefinition)
191
+ end
192
+
193
+ def workflow_row(record, step, progress, late = {})
159
194
  waiting = step&.status == "waiting"
160
195
  { workflow_id: record.workflow_id, workflow_class: record.workflow_class, subject_type: record.subject_type,
161
196
  subject_id: record.subject_id, status: record.status, current_step: record.current_step,
@@ -163,6 +198,7 @@ module Pgbus
163
198
  waiting_for: (step_attribute(step, :wait_for) if waiting),
164
199
  waiting_since: (step_attribute(step, :waiting_since) if waiting),
165
200
  timeout_at: (step_attribute(step, :timeout_at) if waiting),
201
+ on_timeout: (wait_on_timeout(record, step) if waiting), late_since: (late[step.id] if waiting),
166
202
  batch_id: step_attribute(step, :batch_id),
167
203
  batch_progress: (progress[step_attribute(step, :batch_id)] if waiting),
168
204
  scheduled_at: (step.scheduled_at if step&.status == "scheduled"),
@@ -192,7 +228,7 @@ module Pgbus
192
228
  definition_outdated: record.definition_outdated?, class_loaded: !klass.nil?,
193
229
  step_names: klass ? klass.workflow_steps.names : [], resume_action: resume_action, resume_step: resume_step,
194
230
  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) }
231
+ timeline: WorkflowTimeline.chart(step_hashes, timeline_events(record.workflow_id), status: record.status, now: now) }
196
232
  end
197
233
 
198
234
  def workflow_step_hash(step)
@@ -207,9 +243,18 @@ module Pgbus
207
243
  signal_id: step_attribute(step, :signal_id) }
208
244
  end
209
245
 
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 } }
246
+ # The pauses (oldest first) and the newest wait_timed_out events the
247
+ # timeline marks, read apart so a wait late for weeks cannot crowd out
248
+ # the pauses.
249
+ def timeline_events(workflow_id)
250
+ pauses = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::PAUSE_EVENTS).order(:id)
251
+ .limit(PAUSE_HISTORY).map { |event| { event: event.event, created_at: event.created_at } }
252
+ timeouts = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::WAIT_TIMED_OUT)
253
+ .order(id: :desc).limit(WAIT_TIMEOUT_HISTORY).map do |event|
254
+ { event: event.event, created_at: event.created_at, step_name: event.step_name, attempt: event.attempt,
255
+ details: event.details }
256
+ end
257
+ pauses + timeouts
213
258
  end
214
259
 
215
260
  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,
@@ -47,14 +47,14 @@ module Pgbus
47
47
  )
48
48
  end
49
49
 
50
- # [{ name:, kind:, removed:, declared: }] in pipeline order.
50
+ # [{ name:, kind:, removed:, declared:, wait_timeout: }] in pipeline order.
51
51
  def step_list(klass, stats, ongoing, reached, stopped)
52
52
  seen = (stats.keys + ongoing.map { |row| row[:step] } + reached.keys + stopped.keys.map(&:first)).compact.uniq
53
53
  declared = klass ? klass.workflow_steps.entries.map { |entry| declared_step(entry) } : []
54
54
  extra = (seen - declared.map { |entry| entry[:name] }).sort_by do |name|
55
55
  [reached.dig(name, :position) || Float::INFINITY, stats.dig(name, :first_seen_at)&.to_f || Float::INFINITY, name]
56
56
  end
57
- declared + extra.map { |name| { name: name, kind: nil, removed: false, declared: false } }
57
+ declared + extra.map { |name| { name: name, kind: nil, removed: false, declared: false, wait_timeout: nil } }
58
58
  end
59
59
 
60
60
  def declared_step(entry)
@@ -63,7 +63,15 @@ module Pgbus
63
63
  elsif entry.waits_for_signal? then "signal"
64
64
  else "step"
65
65
  end
66
- { name: entry.name, kind: kind, removed: entry.removed?, declared: true }
66
+ { name: entry.name, kind: kind, removed: entry.removed?, declared: true, wait_timeout: wait_timeout(entry) }
67
+ end
68
+
69
+ # A signal step's wait_timeout: in seconds, "dynamic" for a per-run one
70
+ # (issue #574), nil without one.
71
+ def wait_timeout(entry)
72
+ return nil if entry.removed?
73
+
74
+ entry.dynamic_wait_timeout? ? "dynamic" : entry.wait_timeout
67
75
  end
68
76
 
69
77
  def step(entry, stats, ongoing)
@@ -6,8 +6,8 @@ module Pgbus
6
6
  # (its status, or "scheduled" for a ready run whose step waits for a
7
7
  # time still ahead) and what it is doing and what happens next, as a
8
8
  # pgbus.workflows.next.<key>_html key and its arguments. Arguments named
9
- # :step, :signal and :batch are names, :ago a past moment and :time a
10
- # future one; the helper renders each. Pure.
9
+ # :step, :signal and :batch are names, :ago and :late past moments and
10
+ # :time a future one; the helper renders each. Pure.
11
11
  module WorkflowState
12
12
  BADGE_TONES = { "ready" => :gray, "scheduled" => :gray, "performing" => :blue, "waiting" => :yellow,
13
13
  "paused" => :yellow, "finished" => :green, "cancelled" => :red }.freeze
@@ -51,7 +51,23 @@ module Pgbus
51
51
  return ["waiting", "waiting_step", { step: step }] unless row[:waiting_for]
52
52
 
53
53
  args = { signal: row[:waiting_for], ago: row[:waiting_since] }
54
- row[:timeout_at] ? ["waiting", "waiting_signal_timeout", args.merge(time: row[:timeout_at])] : ["waiting", "waiting_signal", args]
54
+ return signal_deadline(row, args.merge(time: row[:timeout_at])) if row[:timeout_at]
55
+ # A per-run wait_timeout: reported once under on_timeout: :notify,
56
+ # then left without a deadline (issue #574).
57
+ return ["waiting", "waiting_signal_late_no_deadline", args.merge(late: row[:late_since])] if row[:late_since]
58
+
59
+ ["waiting", "waiting_signal", args]
60
+ end
61
+
62
+ # An on_timeout: :notify deadline sends a late notice and the wait goes
63
+ # on (issue #565); late_since is the first deadline the wait missed.
64
+ def signal_deadline(row, args)
65
+ # A deploy that changed on_timeout since the last notice: the next
66
+ # deadline does something else (nil: the class is not loaded here).
67
+ late = row[:late_since] && [nil, "notify"].include?(row[:on_timeout]&.to_s)
68
+ return ["waiting", "waiting_signal_late", args.merge(late: row[:late_since])] if late
69
+
70
+ ["waiting", row[:on_timeout].to_s == "notify" ? "waiting_signal_notify" : "waiting_signal_timeout", args]
55
71
  end
56
72
 
57
73
  def paused(row, step)
@@ -11,13 +11,15 @@ module Pgbus
11
11
  # waiting waiting_since → … parked on a signal or its fan-out batch
12
12
  #
13
13
  # and a paused run's pauses (paused → resumed, from the events) are their
14
- # own segments, so a long gap explains itself. The show page's step table
15
- # prints durations; the range-bar chart (charts.js renderTimeline) draws
16
- # chart. Pure: rows in, hashes out.
14
+ # own segments, so a long gap explains itself; each wait_timed_out event
15
+ # is a marker. The show page's step table prints durations; the
16
+ # range-bar chart (charts.js renderTimeline) draws chart. Pure: rows in,
17
+ # hashes out.
17
18
  module WorkflowTimeline
18
19
  OPEN = %w[scheduled waiting in_progress].freeze
19
20
  PAUSE_ENDS = %w[resumed cancelled finished].freeze
20
21
  PAUSE_EVENTS = (%w[paused] + PAUSE_ENDS).freeze
22
+ WAIT_TIMED_OUT = "wait_timed_out"
21
23
  RAN_SERIES = { "completed" => "ran_ok", "skipped" => "ran_ok", "failed" => "ran_failed",
22
24
  "in_progress" => "ran_open", "waiting" => "ran_open" }.freeze
23
25
 
@@ -69,8 +71,8 @@ module Pgbus
69
71
  periods
70
72
  end
71
73
 
72
- # The chart payload: one row per attempt (oldest first) and the paused
73
- # periods. Times are epoch milliseconds; each segment carries its
74
+ # The chart payload: one row per attempt (oldest first), the paused
75
+ # periods and the wait_timed_out marks (wait_timeouts). Times are epoch milliseconds; each segment carries its
74
76
  # series (its colour: ran is coloured by how the attempt ended) and its
75
77
  # duration in words for the tooltip.
76
78
  def chart(steps, events, status:, now:)
@@ -82,7 +84,20 @@ module Pgbus
82
84
  paused = paused_periods(events, status: status, now: now).map do |period|
83
85
  chart_segment(period.merge(kind: :paused), "paused")
84
86
  end
85
- { rows: rows, paused: paused }
87
+ { rows: rows, paused: paused, wait_timeouts: wait_timeouts(events) }
88
+ end
89
+
90
+ # [{ at:, step:, action: }] per wait_timed_out event, oldest first, at
91
+ # the deadline it reports (when it went out, without one): the chart
92
+ # draws each as a line across the rows, the page lists them. An
93
+ # on_timeout: :notify wait has one per late notice (issue #565).
94
+ def wait_timeouts(events)
95
+ timed_out = events.select { |event| event[:event] == WAIT_TIMED_OUT }.sort_by { |event| event[:created_at] }
96
+ timed_out.map do |event|
97
+ details = event[:details].to_h
98
+ { at: millis(deadline(details["timeout_at"]) || event[:created_at]),
99
+ step: "#{event[:step_name]} ##{event[:attempt]}", action: details["action"] }
100
+ end
86
101
  end
87
102
 
88
103
  def series_for(kind, step)
@@ -98,6 +113,8 @@ module Pgbus
98
113
 
99
114
  def millis(time) = (time.to_f * 1000).round
100
115
 
116
+ def deadline(iso) = iso && Time.iso8601(iso)
117
+
101
118
  # Drops a piece without both ends or of no length, and ends every piece
102
119
  # at the attempt's finished_at (a step cancelled before its scheduled_at).
103
120
  def clip(pieces, finished)
@@ -94,6 +94,33 @@ module Pgbus
94
94
  @timed_out == true
95
95
  end
96
96
 
97
+ # Inside a step (its guards and its body): true when this attempt holds
98
+ # a signal before its gate runs: signal! woke the step, or an earlier
99
+ # attempt of it took one and reattempted or failed into a pause. False
100
+ # on an attempt that has not waited yet (even one whose gate then takes
101
+ # a buffered signal), after an on_timeout: :run wake, and in a step
102
+ # without wait_for:. The same answer for the whole attempt.
103
+ def woken?
104
+ step_context!(:woken?)
105
+ @woken = !@signal_peek&.held.nil? if @woken.nil?
106
+ @woken
107
+ end
108
+
109
+ # Inside a step (its guards and its body): true when the gate runs this
110
+ # attempt with a signal: the one it holds (woken?), or a pending one its
111
+ # wait_for: matcher takes. The guard for "skip a wait the record already
112
+ # passed", which neither skips a signalled step nor leaves its signal
113
+ # pending. False after an on_timeout: :run wake (the gate takes no
114
+ # signal then) and in a step without wait_for:. Reads only: the gate
115
+ # claims the signal after the guards.
116
+ #
117
+ # step :await_shipped, skip_if: -> { subject.shipped? && !signal_ready? }, wait_for: :shipped do … end
118
+ def signal_ready?
119
+ step_context!(:signal_ready?)
120
+ @signal_ready = !@signal_peek&.ready.nil? if @signal_ready.nil?
121
+ @signal_ready
122
+ end
123
+
97
124
  # Deliver a signal to this run. Stored under the run's lock; a step
98
125
  # waiting for it (wait_for:) is woken at once, otherwise it waits,
99
126
  # pending, for the step that claims it. With an idempotency_key a
@@ -164,19 +191,25 @@ module Pgbus
164
191
  @guarding = false
165
192
  end
166
193
 
167
- # Used by the StepJob only.
168
- def enter_step(context)
194
+ # Used by the StepJob only. +signals+ is the attempt's SignalPeek, which
195
+ # woken? and signal_ready? read until the gate answers them.
196
+ def enter_step(context, signals: nil)
169
197
  @step_context = context
198
+ @signal_peek = signals
170
199
  @output_values = nil
171
200
  @pending_output = nil
172
201
  @received_signal = nil
173
202
  @timed_out = false
203
+ @woken = nil
204
+ @signal_ready = nil
174
205
  end
175
206
 
176
207
  # Used by the StepJob's signal gate only.
177
- def enter_wait(signal:, timed_out:)
208
+ def enter_wait(signal:, timed_out:, woken: false)
178
209
  @received_signal = signal
179
210
  @timed_out = timed_out
211
+ @woken = woken
212
+ @signal_ready = !signal.nil?
180
213
  end
181
214
  end
182
215
  end
@@ -68,11 +68,11 @@ module Pgbus
68
68
 
69
69
  # --- start! and rerun! --------------------------------------------
70
70
 
71
- def start(klass, subject:, arguments:, idempotency_key: nil)
71
+ def start(klass, subject:, arguments:, idempotency_key: nil, actor: DEFAULT_ACTOR, reason: nil)
72
72
  attrs = run_attributes(klass, idempotency_key: idempotency_key)
73
73
  .merge(Payload.subject_attributes(subject))
74
74
  .merge(arguments: Payload.dump(arguments, what: "arguments of #{klass.name}"))
75
- insert_run(klass, attrs, actor: DEFAULT_ACTOR)
75
+ insert_run(klass, attrs, actor: actor, reason: reason)
76
76
  end
77
77
 
78
78
  # A finished or cancelled run started again: same subject and
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # What a wait_for: step's attempt holds and what its gate would run it
6
+ # with (issue #572), read without claiming anything. The StepJob builds
7
+ # one per attempt: the step readers woken? and signal_ready? read it in
8
+ # the guards, and the gate decides with the same one, so the gate takes
9
+ # exactly the signal signal_ready? promised. All of it runs under the
10
+ # run's row lock, so each answer is looked up (and a matcher run) once.
11
+ class SignalPeek
12
+ def initialize(record, step, definition, now: Time.current)
13
+ @record = record
14
+ @step = step
15
+ @definition = definition
16
+ @now = now
17
+ end
18
+
19
+ # The claimed signal the step holds (signal! woke it, or an earlier
20
+ # attempt took one and reattempted or paused), or nil. Always nil for a
21
+ # step without wait_for:.
22
+ def held
23
+ return @held if defined?(@held)
24
+
25
+ @held = waits? ? WorkflowSignal.held_by_step(@record.workflow_id, @step.step_name) : nil
26
+ end
27
+
28
+ # The sweep woke this attempt with on_timeout: :run: its body runs
29
+ # without a signal (Signals.timed_out?).
30
+ def timed_out? = Signals.timed_out?(@step, @now)
31
+
32
+ # The signal the gate runs this attempt with: the one the step holds,
33
+ # else the oldest pending one its matcher takes. nil when the wait timed
34
+ # out (the body runs without one), when nothing matches (the step
35
+ # parks), and for a step without wait_for:.
36
+ def ready
37
+ return @ready if defined?(@ready)
38
+
39
+ @ready = waits? && !timed_out? ? held || Signals.match(@record, @definition.wait_for) : nil
40
+ end
41
+
42
+ private
43
+
44
+ def waits? = @definition.waits_for_signal?
45
+ end
46
+ end
47
+ end