pgbus 0.19.4 → 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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +97 -7
  4. data/Rakefile +6 -1
  5. data/app/controllers/pgbus/dashboard_controller.rb +2 -0
  6. data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
  7. data/app/controllers/pgbus/workflows_controller.rb +197 -0
  8. data/app/frontend/pgbus/modules/charts.js +148 -4
  9. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  10. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  11. data/app/frontend/pgbus/style.css +1 -1
  12. data/app/helpers/pgbus/button_helper.rb +4 -2
  13. data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
  14. data/app/helpers/pgbus/workflows_helper.rb +165 -0
  15. data/app/models/pgbus/blocked_execution.rb +12 -0
  16. data/app/models/pgbus/uniqueness_key.rb +88 -3
  17. data/app/models/pgbus/workflow_entry.rb +172 -0
  18. data/app/models/pgbus/workflow_event.rb +43 -0
  19. data/app/models/pgbus/workflow_signal.rb +77 -0
  20. data/app/models/pgbus/workflow_step.rb +149 -0
  21. data/app/views/layouts/pgbus/application.html.erb +2 -0
  22. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  23. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  24. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  25. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  26. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  27. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  28. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  29. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  30. data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
  31. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  32. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  33. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  34. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  35. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  36. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  37. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  38. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  39. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  40. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  41. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  42. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  43. data/app/views/pgbus/workflows/index.html.erb +15 -0
  44. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  45. data/app/views/pgbus/workflows/show.html.erb +93 -0
  46. data/config/i18n-tasks.yml +6 -0
  47. data/config/locales/da.yml +445 -0
  48. data/config/locales/de.yml +445 -0
  49. data/config/locales/en.yml +445 -0
  50. data/config/locales/es.yml +445 -0
  51. data/config/locales/fi.yml +445 -0
  52. data/config/locales/fr.yml +445 -0
  53. data/config/locales/it.yml +445 -0
  54. data/config/locales/ja.yml +445 -0
  55. data/config/locales/nb.yml +445 -0
  56. data/config/locales/nl.yml +445 -0
  57. data/config/locales/pt.yml +445 -0
  58. data/config/locales/sv.yml +445 -0
  59. data/config/routes.rb +26 -0
  60. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  61. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  62. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  63. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  64. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  65. data/lib/pgbus/active_job/adapter.rb +81 -3
  66. data/lib/pgbus/active_job/delivery.rb +63 -0
  67. data/lib/pgbus/active_job/executor.rb +20 -8
  68. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +64 -4
  71. data/lib/pgbus/dead_letter_header.rb +9 -1
  72. data/lib/pgbus/generators/migration_detector.rb +40 -0
  73. data/lib/pgbus/instrumentation.rb +33 -0
  74. data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
  75. data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
  76. data/lib/pgbus/mcp/rack_app.rb +7 -2
  77. data/lib/pgbus/mcp/runner.rb +2 -1
  78. data/lib/pgbus/mcp/serialization.rb +25 -0
  79. data/lib/pgbus/mcp/server.rb +53 -10
  80. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  81. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  82. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  83. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  84. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  85. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  86. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  87. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  88. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  90. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  91. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  92. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  93. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  94. data/lib/pgbus/mcp/write_tool.rb +134 -0
  95. data/lib/pgbus/mcp.rb +15 -0
  96. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  97. data/lib/pgbus/metrics/subscriber.rb +14 -1
  98. data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
  99. data/lib/pgbus/process/consumer.rb +2 -1
  100. data/lib/pgbus/process/dispatcher.rb +39 -3
  101. data/lib/pgbus/process/worker.rb +10 -1
  102. data/lib/pgbus/recurring/schedule.rb +1 -1
  103. data/lib/pgbus/streams/presence.rb +65 -36
  104. data/lib/pgbus/uniqueness.rb +23 -6
  105. data/lib/pgbus/version.rb +1 -1
  106. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  107. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  108. data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
  109. data/lib/pgbus/web/data_source.rb +126 -15
  110. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  111. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  112. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  113. data/lib/pgbus/web/workflow_payload.rb +36 -0
  114. data/lib/pgbus/web/workflow_query.rb +82 -0
  115. data/lib/pgbus/web/workflow_state.rb +89 -0
  116. data/lib/pgbus/web/workflow_timeline.rb +130 -0
  117. data/lib/pgbus/web/workflow_window.rb +51 -0
  118. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  119. data/lib/pgbus/workflow/dsl.rb +105 -0
  120. data/lib/pgbus/workflow/error_capture.rb +63 -0
  121. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  122. data/lib/pgbus/workflow/fan_out.rb +157 -0
  123. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  124. data/lib/pgbus/workflow/flow_control.rb +94 -0
  125. data/lib/pgbus/workflow/instance.rb +183 -0
  126. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  127. data/lib/pgbus/workflow/payload.rb +53 -0
  128. data/lib/pgbus/workflow/registry.rb +53 -0
  129. data/lib/pgbus/workflow/removed_step.rb +14 -0
  130. data/lib/pgbus/workflow/runner.rb +218 -0
  131. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  132. data/lib/pgbus/workflow/signals.rb +132 -0
  133. data/lib/pgbus/workflow/states.rb +119 -0
  134. data/lib/pgbus/workflow/step_context.rb +58 -0
  135. data/lib/pgbus/workflow/step_definition.rb +133 -0
  136. data/lib/pgbus/workflow/step_execution.rb +242 -0
  137. data/lib/pgbus/workflow/step_job.rb +28 -0
  138. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  139. data/lib/pgbus/workflow/steps.rb +103 -0
  140. data/lib/pgbus/workflow/sweep.rb +156 -0
  141. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  142. data/lib/pgbus/workflow/wait_timeout.rb +94 -0
  143. data/lib/pgbus/workflow/waiting.rb +82 -0
  144. data/lib/pgbus/workflow.rb +183 -0
  145. data/sig/pgbus/configuration.rbs +23 -0
  146. metadata +92 -1
@@ -13,6 +13,9 @@ module Pgbus
13
13
  include ProcessList
14
14
  include DashboardSignals
15
15
  include DeployTaskList
16
+ include WorkflowList
17
+ include WorkflowActions
18
+ include WorkflowInsights
16
19
 
17
20
  # pgbus_failed_events rows outside the EventBus handler queues; binds
18
21
  # handler_queue_names_literal as $1.
@@ -318,7 +321,7 @@ module Pgbus
318
321
  def discard_failed_event(id)
319
322
  event = failed_event(id)
320
323
  if event
321
- release_lock_for_payload(event["payload"])
324
+ release_locks_held_by_failed_events("id = $1", [id.to_i])
322
325
  archive_failed_message(event)
323
326
  end
324
327
 
@@ -917,7 +920,8 @@ module Pgbus
917
920
  # memory — otherwise the message is archived but the lock row stays
918
921
  # behind, blocking later publishes with the same key. Mirrors
919
922
  # discard_event.
920
- 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])
921
925
  @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
922
926
  clear_event_failure(queue_name, msg_id)
923
927
  true
@@ -1551,6 +1555,13 @@ module Pgbus
1551
1555
  end
1552
1556
 
1553
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.
1554
1565
 
1555
1566
  # Extract uniqueness key from a queue message and release its lock.
1556
1567
  def release_lock_for_message(queue_name, msg_id)
@@ -1561,39 +1572,139 @@ module Pgbus
1561
1572
  )
1562
1573
  return unless row
1563
1574
 
1564
- 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)
1565
1577
  rescue StandardError => e
1566
1578
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing lock for message #{msg_id}: #{e.message}" }
1567
1579
  end
1568
1580
 
1569
- # Extract uniqueness key from a JSON payload string and release its lock.
1570
- 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:)
1571
1602
  key = extract_uniqueness_key_from_payload_str(payload_str)
1572
- 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)
1573
1606
  end
1574
1607
 
1575
- # Extract uniqueness keys from a collection of formatted messages and
1576
- # release all associated locks in a single query.
1608
+ # Release the locks a collection of formatted messages hold, in a single
1609
+ # query.
1577
1610
  def release_locks_for_messages(messages)
1578
- keys = messages.filter_map { |m| extract_uniqueness_key_from_payload_str(m[:message]) }
1579
- 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?
1580
1616
  rescue StandardError => e
1581
1617
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for messages: #{e.message}" }
1582
1618
  end
1583
1619
 
1584
- # Collect uniqueness keys from all failed events and release their locks.
1585
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)
1586
1630
  rows = connection.select_all(
1587
- "SELECT payload FROM pgbus_failed_events WHERE #{NOT_HANDLER_QUEUE}", "Pgbus Collect Failed Keys",
1588
- [handler_queue_names_literal]
1631
+ "SELECT queue_name, msg_id, payload FROM pgbus_failed_events WHERE #{condition}",
1632
+ "Pgbus Collect Failed Keys", binds
1589
1633
  )
1590
1634
 
1591
- keys = rows.to_a.filter_map { |row| extract_uniqueness_key_from_payload_str(row["payload"]) }
1592
- 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?
1593
1646
  rescue StandardError => e
1594
1647
  Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for failed events: #{e.message}" }
1595
1648
  end
1596
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
+
1597
1708
  # Single unwrap point for PGMQ message / failed_event payload strings.
1598
1709
  # Accepts a raw JSON string or an already-parsed Hash and returns the
1599
1710
  # uniqueness metadata key, or nil when the payload is blank, unparseable,
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # What a Workflows dashboard action will do (issue #542), so its button
6
+ # can say so before anyone presses it. Which actions exist at all is
7
+ # Workflow::States.available_actions; this only names their effect.
8
+ module WorkflowActionPlan
9
+ # The status the runs of a needs-attention group are in.
10
+ GROUP_STATUS = { "stalled" => "paused", "paused_error" => "paused", "paused_manual" => "paused",
11
+ "overdue" => "ready", "waiting_overdue" => "waiting" }.freeze
12
+
13
+ module_function
14
+
15
+ def group_status(reason) = GROUP_STATUS.fetch(reason.to_s)
16
+
17
+ # [action, step_name] for resume! of a paused run, as OutsideVerbs.resume
18
+ # decides it from the run's latest step row:
19
+ # :retry the step failed: it runs again as a new attempt
20
+ # :continue the step completed or was skipped: the next step runs
21
+ # :finish …and it was the last: the run finishes
22
+ # :resend the step was scheduled when the run paused: it is sent again
23
+ # :rewait the step was waiting: it waits again
24
+ # :carry_on the step was still running: the run goes on performing it
25
+ # [nil, nil] when the run is not paused, or its class is gone.
26
+ def resume_plan(record, latest, klass)
27
+ return [nil, nil] unless record.status == "paused" && latest
28
+
29
+ case latest.status
30
+ when "failed" then [:retry, latest.step_name]
31
+ when "scheduled" then [:resend, latest.step_name]
32
+ when "waiting" then [:rewait, latest.step_name]
33
+ when "in_progress" then [:carry_on, latest.step_name]
34
+ when "completed", "skipped" then following_plan(latest, klass)
35
+ else [nil, nil]
36
+ end
37
+ end
38
+
39
+ def following_plan(latest, klass)
40
+ return [nil, nil] unless klass
41
+ # resume! raises StepNotDefined for a step the class no longer
42
+ # declares (and no removed_step covers): no plan to promise.
43
+ return [nil, nil] unless klass.workflow_steps.entries.any? { |entry| entry.name == latest.step_name }
44
+
45
+ following = klass.workflow_steps.next_after(latest.step_name)
46
+ following ? [:continue, following.name] : [:finish, nil]
47
+ end
48
+
49
+ # The signal names a waiting step takes, for the Send signal select:
50
+ # [name] for a wait_for: name or SignalMatcher, [] for a custom matcher
51
+ # (the form then takes free text) or a step that is not waiting on one.
52
+ def signal_names(latest, klass)
53
+ return [] unless latest&.status == "waiting" && klass
54
+ return [] if latest.has_attribute?(:batch_id) && latest.batch_id
55
+
56
+ matcher = klass.workflow_steps.fetch(latest.step_name)&.wait_for
57
+ name = matcher.respond_to?(:signal_name) ? matcher.signal_name : nil
58
+ name ? [name.to_s] : []
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # The SQL behind the workflow insights (issue #549): one query per block
6
+ # of the page, each bounded by the window and served by an index #540
7
+ # created. The step stats read idx_pgbus_workflow_steps_insights
8
+ # (workflow_class, step_name, finished_at); the run counts read
9
+ # idx_pgbus_workflows_class_status (workflow_class, status, created_at),
10
+ # with every status listed so the created_at range applies under each;
11
+ # the ended runs read idx_pgbus_workflows_status (status, updated_at): a
12
+ # run ends in the UPDATE that sets its finished_at/cancelled_at, so
13
+ # updated_at >= the ended-at bound keeps every row the window wants.
14
+ #
15
+ # Status lists are the States constants, inlined; every value that comes
16
+ # from outside (a class name, a time, a bucket length) is a bind.
17
+ module WorkflowInsightsQueries
18
+ ALL = Workflow::States::WORKFLOW
19
+ ONGOING = Workflow::States::ONGOING
20
+ # Upper bounds (ms) of the end-to-end duration histogram: < 1 s, 10 s,
21
+ # 1 min, 10 min, 1 h, 6 h, 1 d, 7 d, and the open last bin.
22
+ DURATION_BOUNDS_MS = [1_000, 10_000, 60_000, 600_000, 3_600_000, 21_600_000, 86_400_000, 604_800_000].freeze
23
+ REVISIONS = 5
24
+
25
+ module_function
26
+
27
+ # Every workflow_class with a row, by a loose index scan (one probe per
28
+ # class) instead of a DISTINCT over every run.
29
+ def class_names
30
+ sql = <<~SQL
31
+ WITH RECURSIVE classes AS (
32
+ (SELECT workflow_class FROM #{runs} ORDER BY workflow_class LIMIT 1)
33
+ UNION ALL
34
+ SELECT (SELECT w.workflow_class FROM #{runs} w WHERE w.workflow_class > c.workflow_class
35
+ ORDER BY w.workflow_class LIMIT 1)
36
+ FROM classes c WHERE c.workflow_class IS NOT NULL
37
+ )
38
+ SELECT workflow_class FROM classes WHERE workflow_class IS NOT NULL
39
+ SQL
40
+ select(sql, "Classes").rows.map(&:first)
41
+ end
42
+
43
+ def class_known?(workflow_class)
44
+ select("SELECT 1 FROM #{runs} WHERE workflow_class = $1 LIMIT 1", "Class Known", [workflow_class]).rows.any?
45
+ end
46
+
47
+ # The ongoing runs by (class, status, current step).
48
+ def ongoing(workflow_class: nil)
49
+ sql = <<~SQL
50
+ SELECT workflow_class, status, current_step, COUNT(*)
51
+ FROM #{runs}
52
+ WHERE status IN (#{list(ONGOING)})#{" AND workflow_class = $1" if workflow_class}
53
+ GROUP BY workflow_class, status, current_step
54
+ SQL
55
+ select(sql, "Ongoing", [workflow_class].compact).cast_values.map do |klass, status, step, runs|
56
+ { workflow_class: klass, status: status, step: step, runs: runs.to_i }
57
+ end
58
+ end
59
+
60
+ # Per class: the runs that finished or were cancelled between +since+
61
+ # and +until_time+, and the end-to-end percentiles (created → finished)
62
+ # of the finished.
63
+ def ended(since:, until_time:, workflow_class: nil)
64
+ sql = <<~SQL
65
+ SELECT workflow_class, COUNT(*) FILTER (WHERE status = 'finished'), COUNT(*) FILTER (WHERE status = 'cancelled'),
66
+ COUNT(e2e_ms), #{percentiles("e2e_ms", 0.5, 0.95, 0.99)}
67
+ FROM (#{ended_runs(until_bind: "$2", class_bind: ("$3" if workflow_class))}) ended
68
+ GROUP BY workflow_class
69
+ SQL
70
+ select(sql, "Ended", [since, until_time, workflow_class].compact).cast_values.map do |klass, finished, cancelled, samples, *pcts|
71
+ { workflow_class: klass, finished: finished.to_i, cancelled: cancelled.to_i,
72
+ e2e_ms: percentile_hash(%i[p50 p95 p99], pcts, samples) }
73
+ end
74
+ end
75
+
76
+ # { bin => runs } of one class's finished runs over DURATION_BOUNDS_MS
77
+ # (bin 0 is under the first bound, the last bin is past the last one).
78
+ def duration_histogram(workflow_class, since:, until_time:)
79
+ sql = <<~SQL
80
+ SELECT width_bucket(e2e_ms::float8, ARRAY[#{DURATION_BOUNDS_MS.join(", ")}]::float8[]), COUNT(*)
81
+ FROM (#{ended_runs(until_bind: "$2", class_bind: "$3")}) ended
82
+ WHERE status = 'finished'
83
+ GROUP BY 1
84
+ SQL
85
+ select(sql, "Durations", [since, until_time, workflow_class]).rows.to_h { |bin, runs| [bin.to_i, runs.to_i] }
86
+ end
87
+
88
+ # { bucket start => [started, finished, cancelled] }. Buckets are
89
+ # multiples of +bucket+ seconds on the wall clock of +zone+ (an IANA
90
+ # name), so a day bucket starts at that zone's midnight on both sides
91
+ # of a DST change; a bucket start is that wall clock's epoch seconds
92
+ # (WorkflowInsightsReport.wall_epoch).
93
+ def throughput(workflow_class, since:, until_time:, bucket:, zone: "UTC")
94
+ sql = <<~SQL
95
+ SELECT bucket, SUM(started), SUM(finished), SUM(cancelled) FROM (
96
+ SELECT #{wall_bucket("created_at")} AS bucket, 1 AS started, 0 AS finished, 0 AS cancelled
97
+ FROM #{runs}
98
+ WHERE workflow_class = $2 AND status IN (#{list(ALL)}) AND created_at BETWEEN $1 AND $5
99
+ UNION ALL
100
+ SELECT #{wall_bucket("ended_at")}, 0, (status = 'finished')::int, (status = 'cancelled')::int
101
+ FROM (#{ended_runs(until_bind: "$5", class_bind: "$2")}) ended
102
+ ) buckets
103
+ GROUP BY bucket
104
+ SQL
105
+ select(sql, "Throughput", [since, workflow_class, bucket, zone, until_time]).rows.to_h do |start, started, finished, cancelled|
106
+ [start.to_i * bucket, [started.to_i, finished.to_i, cancelled.to_i]]
107
+ end
108
+ end
109
+
110
+ # The bucket number of a UTC timestamp column on the wall clock of the
111
+ # zone bound as $4, in buckets of $3 seconds.
112
+ def wall_bucket(column)
113
+ "FLOOR(EXTRACT(EPOCH FROM (#{column} AT TIME ZONE 'UTC' AT TIME ZONE $4)) / $3)::bigint"
114
+ end
115
+
116
+ # Per step: the attempts that finished between +since+ and +until_time+.
117
+ def step_stats(workflow_class, since:, until_time:)
118
+ sql = <<~SQL
119
+ SELECT step_name, #{stat_columns}, MIN(created_at)
120
+ FROM (#{attempt_rows}) attempts
121
+ GROUP BY step_name
122
+ SQL
123
+ select(sql, "Steps", [workflow_class, since, until_time]).cast_values.to_h do |name, *stats, first_seen_at|
124
+ [name, attempt_stats(stats).merge(first_seen_at: first_seen_at)]
125
+ end
126
+ end
127
+
128
+ # Per step, over the runs started between +since+ and +until_time+: how many have a row
129
+ # for it (reached) and its mean position in their runs (the step order
130
+ # of a class that no longer loads). Partitioned by the run's bigint id:
131
+ # sorting 50k workflow_id strings under a locale collation costs ~4x.
132
+ def funnel_reached(workflow_class, since:, until_time:)
133
+ sql = <<~SQL
134
+ SELECT step_name, COUNT(*), AVG(position) FROM (
135
+ SELECT s.step_name, RANK() OVER (PARTITION BY w.id ORDER BY MIN(s.id)) AS position
136
+ FROM #{runs} w JOIN #{steps} s ON s.workflow_id = w.workflow_id
137
+ WHERE w.workflow_class = $1 AND w.status IN (#{list(ALL)}) AND w.created_at BETWEEN $2 AND $3
138
+ GROUP BY w.id, s.step_name
139
+ ) reached
140
+ GROUP BY step_name
141
+ SQL
142
+ select(sql, "Funnel", [workflow_class, since, until_time]).rows.to_h do |name, reached, position|
143
+ [name, { reached: reached.to_i, position: position.to_f }]
144
+ end
145
+ end
146
+
147
+ # Where each run started between +since+ and +until_time+ is now:
148
+ # { [step, status] => runs }.
149
+ def funnel_stopped(workflow_class, since:, until_time:)
150
+ sql = <<~SQL
151
+ SELECT current_step, status, COUNT(*)
152
+ FROM #{runs}
153
+ WHERE workflow_class = $1 AND status IN (#{list(ALL)}) AND created_at BETWEEN $2 AND $3
154
+ GROUP BY current_step, status
155
+ SQL
156
+ select(sql, "Stopped", [workflow_class, since, until_time]).rows.to_h { |step, status, runs| [[step, status], runs.to_i] }
157
+ end
158
+
159
+ # The step stats of the REVISIONS app revisions that finished an
160
+ # attempt last, per (revision, step) and per revision (step nil).
161
+ def by_revision(workflow_class, since:, until_time:)
162
+ sql = <<~SQL
163
+ WITH revisions AS (
164
+ SELECT app_revision, MIN(finished_at) AS first_seen_at, MAX(finished_at) AS last_seen_at
165
+ FROM #{steps}
166
+ WHERE workflow_class = $1 AND finished_at BETWEEN $2 AND $3 AND app_revision IS NOT NULL
167
+ GROUP BY app_revision
168
+ ORDER BY last_seen_at DESC
169
+ LIMIT #{REVISIONS}
170
+ )
171
+ SELECT attempts.app_revision, revisions.first_seen_at, revisions.last_seen_at, attempts.step_name, #{stat_columns}
172
+ FROM (#{attempt_rows}) attempts
173
+ JOIN revisions ON revisions.app_revision = attempts.app_revision
174
+ GROUP BY GROUPING SETS ((attempts.app_revision, revisions.first_seen_at, revisions.last_seen_at, attempts.step_name),
175
+ (attempts.app_revision, revisions.first_seen_at, revisions.last_seen_at))
176
+ ORDER BY revisions.last_seen_at DESC, attempts.step_name NULLS FIRST
177
+ SQL
178
+ select(sql, "Revisions", [workflow_class, since, until_time]).cast_values.map do |revision, first_seen, last_seen, name, *stats|
179
+ { revision: revision, first_seen_at: first_seen, last_seen_at: last_seen, step: name, stats: attempt_stats(stats) }
180
+ end
181
+ end
182
+
183
+ # The finished and cancelled runs that ended between $1 and +until_bind+
184
+ # (of the class at +class_bind+): never one dated after now (a skewed
185
+ # clock), which the charts would not show either.
186
+ def ended_runs(until_bind:, class_bind: nil)
187
+ <<~SQL
188
+ SELECT workflow_class, status, COALESCE(finished_at, cancelled_at) AS ended_at,
189
+ CASE WHEN status = 'finished' THEN GREATEST(EXTRACT(EPOCH FROM (finished_at - created_at)) * 1000, 0) END AS e2e_ms
190
+ FROM #{runs}
191
+ WHERE status IN ('finished', 'cancelled') AND updated_at >= $1
192
+ AND COALESCE(finished_at, cancelled_at) BETWEEN $1 AND #{until_bind}#{" AND workflow_class = #{class_bind}" if class_bind}
193
+ SQL
194
+ end
195
+
196
+ # The attempts of class $1 that finished between $2 and $3 (never one
197
+ # dated after now). run_ms: completed or
198
+ # failed attempts that started; pickup_ms (scheduled → started, never
199
+ # negative): every attempt that started.
200
+ def attempt_rows
201
+ <<~SQL
202
+ SELECT step_name, status, outcome, error_class, app_revision, created_at,
203
+ CASE WHEN status IN ('completed', 'failed') AND started_at IS NOT NULL
204
+ THEN GREATEST(EXTRACT(EPOCH FROM (finished_at - started_at)) * 1000, 0) END AS run_ms,
205
+ CASE WHEN started_at IS NOT NULL
206
+ THEN GREATEST(EXTRACT(EPOCH FROM (started_at - scheduled_at)) * 1000, 0) END AS pickup_ms
207
+ FROM #{steps}
208
+ WHERE workflow_class = $1 AND finished_at BETWEEN $2 AND $3
209
+ SQL
210
+ end
211
+
212
+ STAT_KEYS = %i[attempts completed failed reattempted skipped cancelled].freeze
213
+
214
+ def stat_columns
215
+ <<~SQL.squish
216
+ COUNT(*), COUNT(*) FILTER (WHERE status = 'completed'), COUNT(*) FILTER (WHERE error_class IS NOT NULL),
217
+ COUNT(*) FILTER (WHERE outcome = 'reattempted'), COUNT(*) FILTER (WHERE status = 'skipped'),
218
+ COUNT(*) FILTER (WHERE status = 'cancelled'),
219
+ COUNT(run_ms), #{percentiles("run_ms", 0.5, 0.95, 0.99)},
220
+ COUNT(pickup_ms), #{percentiles("pickup_ms", 0.5, 0.95)}
221
+ SQL
222
+ end
223
+
224
+ def attempt_stats(values)
225
+ counts = STAT_KEYS.zip(values.first(STAT_KEYS.size).map(&:to_i)).to_h
226
+ run_samples, run50, run95, run99, pickup_samples, pickup50, pickup95 = values.drop(STAT_KEYS.size)
227
+ counts.merge(run_ms: percentile_hash(%i[p50 p95 p99], [run50, run95, run99], run_samples),
228
+ pickup_ms: percentile_hash(%i[p50 p95], [pickup50, pickup95], pickup_samples))
229
+ end
230
+
231
+ def percentiles(column, *fractions)
232
+ fractions.map { |f| "percentile_cont(#{f}) WITHIN GROUP (ORDER BY #{column})" }.join(", ")
233
+ end
234
+
235
+ def percentile_hash(keys, values, samples)
236
+ keys.zip(values.map { |v| v&.to_f&.round(1) }).to_h.merge(samples: samples.to_i)
237
+ end
238
+
239
+ def list(statuses) = statuses.map { |status| connection.quote(status) }.join(", ")
240
+
241
+ def runs = WorkflowEntry.quoted_table_name
242
+
243
+ def steps = WorkflowStep.quoted_table_name
244
+
245
+ def connection = WorkflowEntry.connection
246
+
247
+ def select(sql, name, binds = []) = connection.exec_query(sql, "WorkflowInsights #{name}", binds)
248
+ end
249
+ end
250
+ end