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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +97 -7
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +148 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/blocked_execution.rb +12 -0
- data/app/models/pgbus/uniqueness_key.rb +88 -3
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +149 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +445 -0
- data/config/locales/de.yml +445 -0
- data/config/locales/en.yml +445 -0
- data/config/locales/es.yml +445 -0
- data/config/locales/fi.yml +445 -0
- data/config/locales/fr.yml +445 -0
- data/config/locales/it.yml +445 -0
- data/config/locales/ja.yml +445 -0
- data/config/locales/nb.yml +445 -0
- data/config/locales/nl.yml +445 -0
- data/config/locales/pt.yml +445 -0
- data/config/locales/sv.yml +445 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/adapter.rb +81 -3
- data/lib/pgbus/active_job/delivery.rb +63 -0
- data/lib/pgbus/active_job/executor.rb +20 -8
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +64 -4
- data/lib/pgbus/dead_letter_header.rb +9 -1
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +33 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/dispatcher.rb +39 -3
- data/lib/pgbus/process/worker.rb +10 -1
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/streams/presence.rb +65 -36
- data/lib/pgbus/uniqueness.rb +23 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
- data/lib/pgbus/web/data_source.rb +126 -15
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +89 -0
- data/lib/pgbus/web/workflow_timeline.rb +130 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +133 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +94 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +23 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1570
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1576
|
-
#
|
|
1608
|
+
# Release the locks a collection of formatted messages hold, in a single
|
|
1609
|
+
# query.
|
|
1577
1610
|
def release_locks_for_messages(messages)
|
|
1578
|
-
|
|
1579
|
-
|
|
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 #{
|
|
1588
|
-
|
|
1631
|
+
"SELECT queue_name, msg_id, payload FROM pgbus_failed_events WHERE #{condition}",
|
|
1632
|
+
"Pgbus Collect Failed Keys", binds
|
|
1589
1633
|
)
|
|
1590
1634
|
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|