pgbus 0.19.4 → 0.20.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +96 -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 +130 -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/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 +142 -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 +39 -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 +442 -0
- data/config/locales/de.yml +442 -0
- data/config/locales/en.yml +442 -0
- data/config/locales/es.yml +442 -0
- data/config/locales/fi.yml +442 -0
- data/config/locales/fr.yml +442 -0
- data/config/locales/it.yml +442 -0
- data/config/locales/ja.yml +442 -0
- data/config/locales/nb.yml +442 -0
- data/config/locales/nl.yml +442 -0
- data/config/locales/pt.yml +442 -0
- data/config/locales/sv.yml +442 -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/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +21 -3
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +32 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +128 -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 +65 -0
- data/lib/pgbus/process/dispatcher.rb +26 -0
- 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 +248 -0
- data/lib/pgbus/web/data_source.rb +3 -0
- 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 +76 -0
- data/lib/pgbus/web/workflow_timeline.rb +113 -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 +131 -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 +73 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +22 -0
- metadata +92 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
# The Workflows pages' words (issue #542): status badges, the "what happens
|
|
8
|
+
# next" sentence, the subject link, the Resume label, the audit-trail
|
|
9
|
+
# sentence and the attention reasons. Moments go through pgbus_time; the
|
|
10
|
+
# _html keys let the <time>, <code> and links through, translate escapes
|
|
11
|
+
# every other argument.
|
|
12
|
+
module WorkflowsHelper
|
|
13
|
+
BADGE_CSS = {
|
|
14
|
+
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
|
15
|
+
gray: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
|
|
16
|
+
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
|
|
17
|
+
green: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
|
18
|
+
red: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# The outside verbs the show page has a button for (Send signal is its
|
|
22
|
+
# own form; pause and reschedule stay in the console for now).
|
|
23
|
+
DASHBOARD_ACTIONS = %i[resume resume_at run_now skip rerun cancel].freeze
|
|
24
|
+
|
|
25
|
+
# A link inside a sentence: inline (the button helper's inline-flex would
|
|
26
|
+
# break the sentence) and underlined; WCAG 2.5.8 exempts inline targets.
|
|
27
|
+
INLINE_LINK_CSS = "font-medium underline text-indigo-700 hover:text-indigo-900 dark:text-indigo-300 dark:hover:text-indigo-200"
|
|
28
|
+
|
|
29
|
+
# Text inputs, selects and textareas on the Workflows pages.
|
|
30
|
+
INPUT_CSS = "mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 " \
|
|
31
|
+
"px-2 py-1.5 text-sm text-gray-900 dark:text-white focus:border-indigo-500 focus:ring-indigo-500"
|
|
32
|
+
|
|
33
|
+
STEP_TONES = { "scheduled" => :gray, "waiting" => :yellow, "in_progress" => :blue, "completed" => :green,
|
|
34
|
+
"failed" => :red, "cancelled" => :gray, "skipped" => :gray }.freeze
|
|
35
|
+
|
|
36
|
+
SIGNAL_TONES = { "pending" => :yellow, "claimed" => :blue, "consumed" => :green }.freeze
|
|
37
|
+
|
|
38
|
+
# The verbs that need the run's class (its step list): resume!, resume_at!
|
|
39
|
+
# and rerun! raise StepNotDefined without it, and skip! would find no next
|
|
40
|
+
# step and finish the run.
|
|
41
|
+
CLASS_ACTIONS = %i[resume resume_at rerun skip].freeze
|
|
42
|
+
|
|
43
|
+
# The show page's buttons: available_actions it has a button for, without
|
|
44
|
+
# CLASS_ACTIONS when the run's class is not loaded here.
|
|
45
|
+
def pgbus_workflow_dashboard_actions(workflow)
|
|
46
|
+
actions = Array(workflow[:available_actions]) & DASHBOARD_ACTIONS
|
|
47
|
+
workflow[:class_loaded] == false ? actions - CLASS_ACTIONS : actions
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def pgbus_workflow_state(row)
|
|
51
|
+
Web::WorkflowState.present(row, threshold: Pgbus.configuration.workflow_stall_threshold)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pgbus_workflow_badge(result)
|
|
55
|
+
pgbus_workflow_tone_badge(t("pgbus.workflows.statuses.#{result.badge}"), result.badge_tone)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def pgbus_workflow_step_badge(status)
|
|
59
|
+
pgbus_workflow_tone_badge(t("pgbus.workflows.step_statuses.#{status}"), STEP_TONES.fetch(status.to_s, :gray))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pgbus_workflow_signal_badge(status)
|
|
63
|
+
pgbus_workflow_tone_badge(t("pgbus.workflows.signal_statuses.#{status}"), SIGNAL_TONES.fetch(status.to_s, :gray))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def pgbus_workflow_tone_badge(label, tone)
|
|
67
|
+
tag.span(label, class: "#{ApplicationHelper::BATCH_BADGE_BASE} #{BADGE_CSS.fetch(tone)}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# "Waiting for batch OrderWorkflow#notify_vendors (7 of 12 jobs done)."
|
|
71
|
+
def pgbus_workflow_next(row, result = pgbus_workflow_state(row))
|
|
72
|
+
args = result.next_args.to_h do |key, value|
|
|
73
|
+
case key
|
|
74
|
+
when :step, :signal then [key, tag.code(value, class: "font-mono")]
|
|
75
|
+
when :batch then [key, pgbus_workflow_batch_link(value, row[:batch_id])]
|
|
76
|
+
when :ago then [key, pgbus_time(value)]
|
|
77
|
+
when :time then [key, pgbus_time(value, clock: true)]
|
|
78
|
+
else [key, value]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
translate("pgbus.workflows.next.#{result.next_key}_html", **args)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pgbus_workflow_batch_link(name, batch_id)
|
|
85
|
+
label = tag.code(name, class: "font-mono")
|
|
86
|
+
batch_id ? link_to(label, pgbus.batch_path(batch_id), class: INLINE_LINK_CSS, data: { turbo_frame: "_top" }) : label
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# "Order 1042", linked through config.workflow_subject_url when it gives
|
|
90
|
+
# an http(s) URL or a same-site path; plain text otherwise, and "—"
|
|
91
|
+
# for a subject-less run.
|
|
92
|
+
def pgbus_workflow_subject(row)
|
|
93
|
+
return Web::TimeFormat::NONE unless row[:subject_type]
|
|
94
|
+
|
|
95
|
+
label = "#{row[:subject_type]} #{row[:subject_id]}"
|
|
96
|
+
url = pgbus_workflow_subject_url(row[:subject_type], row[:subject_id])
|
|
97
|
+
url ? pgbus_link_to(label, url, data: { turbo_frame: "_top" }) : label
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pgbus_workflow_subject_url(type, id)
|
|
101
|
+
builder = Pgbus.configuration.workflow_subject_url
|
|
102
|
+
return nil unless builder
|
|
103
|
+
|
|
104
|
+
pgbus_workflow_safe_url(builder.call(type, id))
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
Pgbus.logger.error { "[Pgbus::Web] workflow_subject_url raised #{e.class}: #{e.message}" }
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# An absolute http(s) URL with a host, or a path on this site ("/…",
|
|
111
|
+
# never "//host" or "/\host"). Anything else (javascript:, data:, a
|
|
112
|
+
# relative path) is not linked.
|
|
113
|
+
def pgbus_workflow_safe_url(value)
|
|
114
|
+
url = value.to_s.strip
|
|
115
|
+
return nil if url.empty?
|
|
116
|
+
return url if url.start_with?("/") && !url.start_with?("//", "/\\")
|
|
117
|
+
|
|
118
|
+
uri = URI.parse(url)
|
|
119
|
+
%w[http https].include?(uri.scheme&.downcase) && uri.host.present? ? url : nil
|
|
120
|
+
rescue URI::InvalidURIError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The Resume button's label: what resume! will do (WorkflowActionPlan).
|
|
125
|
+
def pgbus_workflow_resume_label(workflow)
|
|
126
|
+
action = workflow[:resume_action]
|
|
127
|
+
return t("pgbus.workflows.actions.resume") unless action
|
|
128
|
+
|
|
129
|
+
t("pgbus.workflows.resume.#{action}", step: workflow[:resume_step])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# "Resumed by dashboard:alice: SMTP fixed", "Step failed by system (capture #2)".
|
|
133
|
+
def pgbus_workflow_event_sentence(event)
|
|
134
|
+
what = t("pgbus.workflows.events.#{event[:event]}", default: event[:event].to_s.humanize)
|
|
135
|
+
sentence = t("pgbus.workflows.audit.by", event: what, actor: event[:actor])
|
|
136
|
+
if event[:step_name]
|
|
137
|
+
sentence = t("pgbus.workflows.audit.with_step", sentence: sentence, step: event[:step_name], attempt: event[:attempt])
|
|
138
|
+
end
|
|
139
|
+
event[:reason].present? ? t("pgbus.workflows.audit.with_reason", sentence: sentence, reason: event[:reason]) : sentence
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def pgbus_workflow_value(value)
|
|
143
|
+
value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The bulk confirm: "Resume all 3 runs paused at capture on Timeout::Error?"
|
|
147
|
+
def pgbus_workflow_bulk_confirm(action, group)
|
|
148
|
+
key = group[:error_class] ? "#{action}_error" : action.to_s
|
|
149
|
+
t("pgbus.workflows.attention.confirm.#{key}", count: group[:count], step: group[:step_name], error: group[:error_class])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The index URL with the current query, merged with +extra+ (nil drops a key).
|
|
153
|
+
def pgbus_workflows_path_with(query, **extra)
|
|
154
|
+
pgbus.workflows_path(query.to_param(**extra))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Series names and words the timeline chart prints (charts.js renderTimeline).
|
|
158
|
+
def pgbus_workflow_chart_i18n
|
|
159
|
+
series = %w[waited queued waiting paused ran_ok ran_failed ran_open ran_cancelled]
|
|
160
|
+
{ series: series.index_with { |name| t("pgbus.workflows.timeline.series.#{name}") },
|
|
161
|
+
paused_row: t("pgbus.workflows.timeline.paused_row"),
|
|
162
|
+
statuses: Workflow::States::STEP.index_with { |status| t("pgbus.workflows.step_statuses.#{status}") } }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# One workflow run (pgbus_workflows). Pgbus::Workflow is the DSL and the
|
|
7
|
+
# handle; this is its row. Every status change goes through
|
|
8
|
+
# Pgbus::Workflow::Runner, as a guarded UPDATE under the row lock.
|
|
9
|
+
class WorkflowEntry < BusRecord
|
|
10
|
+
self.table_name = "pgbus_workflows"
|
|
11
|
+
|
|
12
|
+
scope :ongoing, -> { where(status: Workflow::States::ONGOING) }
|
|
13
|
+
scope :terminal, -> { where(status: Workflow::States::TERMINAL) }
|
|
14
|
+
scope :stale, ->(before:) { terminal.where(updated_at: ...before) }
|
|
15
|
+
|
|
16
|
+
# The run's row, locked FOR UPDATE for the rest of the transaction.
|
|
17
|
+
def self.lock_for(workflow_id)
|
|
18
|
+
lock.find_by(workflow_id: workflow_id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Single-winner status change: 1 when this call moved the row, 0 when
|
|
22
|
+
# its status was not in +from+.
|
|
23
|
+
def self.transition!(workflow_id, from:, to:, **attrs) # rubocop:disable Naming/MethodParameterName
|
|
24
|
+
where(workflow_id: workflow_id, status: from).update_all(attrs.merge(status: to, updated_at: Time.current))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Inserts a run. With an idempotency_key a conflict on
|
|
28
|
+
# (workflow_class, idempotency_key) inserts nothing and returns false; a
|
|
29
|
+
# conflict on the one-ongoing-per-subject index raises
|
|
30
|
+
# ActiveRecord::RecordNotUnique. Raw SQL rather than
|
|
31
|
+
# insert_all(unique_by:), which resolves the index through the schema
|
|
32
|
+
# cache (issue #401).
|
|
33
|
+
def self.insert_run!(attrs) # rubocop:disable Naming/PredicateMethod
|
|
34
|
+
columns = attrs.keys
|
|
35
|
+
values = attrs.values.map { |value| value.is_a?(Hash) ? JSON.generate(value) : value }
|
|
36
|
+
placeholders = (1..columns.size).map { |i| "$#{i}" }.join(", ")
|
|
37
|
+
conflict = (" ON CONFLICT (workflow_class, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING" if attrs[:idempotency_key])
|
|
38
|
+
result = connection.exec_query(
|
|
39
|
+
"INSERT INTO #{table_name} (#{columns.join(", ")}) VALUES (#{placeholders})#{conflict} RETURNING id",
|
|
40
|
+
"WorkflowEntry Insert", values
|
|
41
|
+
)
|
|
42
|
+
result.rows.any?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# { [workflow_class, status] => count } over every run.
|
|
46
|
+
def self.status_counts
|
|
47
|
+
group(:workflow_class, :status).count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Why ongoing runs need a person, as { [workflow_class, reason] => count }
|
|
51
|
+
# (Workflow::States::STUCK_REASONS). One query: each paused or ready run
|
|
52
|
+
# (and, with +waiting_threshold+, each waiting run) with its latest step row.
|
|
53
|
+
# stalled paused on a step that failed with StepStalled
|
|
54
|
+
# paused_error paused on a step that failed with any other error
|
|
55
|
+
# paused_manual paused otherwise (the step called pause!, or an outside pause!)
|
|
56
|
+
# overdue ready, its scheduled step due more than +threshold+ ago
|
|
57
|
+
# waiting_overdue waiting longer than +waiting_threshold+ on a step
|
|
58
|
+
# without a wait_timeout: (only with +waiting_threshold+,
|
|
59
|
+
# which needs the waiting-step columns)
|
|
60
|
+
# With +workflow_class+, that class's runs only (the workflow insights,
|
|
61
|
+
# issue #549).
|
|
62
|
+
def self.stuck_reason_counts(threshold:, now: Time.current, waiting_threshold: nil, workflow_class: nil)
|
|
63
|
+
binds = stuck_binds(threshold, now, waiting_threshold)
|
|
64
|
+
class_bind = "$#{binds.size + 1}" if workflow_class
|
|
65
|
+
sql = <<~SQL
|
|
66
|
+
SELECT stuck.workflow_class, stuck.reason, COUNT(*) AS runs
|
|
67
|
+
FROM (#{stuck_runs_sql(waiting: !waiting_threshold.nil?, class_bind: class_bind)}) stuck
|
|
68
|
+
WHERE stuck.reason IS NOT NULL
|
|
69
|
+
GROUP BY stuck.workflow_class, stuck.reason
|
|
70
|
+
SQL
|
|
71
|
+
connection.exec_query(sql, "WorkflowEntry Stuck", binds + [workflow_class].compact).rows.to_h do |klass, reason, count|
|
|
72
|
+
[[klass, reason], count.to_i]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The dashboard's needs-attention view (issue #542): the stuck runs of
|
|
77
|
+
# stuck_reason_counts grouped by (class, step, reason, error class), the
|
|
78
|
+
# biggest group first, at most +limit+ groups. first_seen_at/last_seen_at
|
|
79
|
+
# bound when the group's runs got stuck (paused_at, the overdue step's
|
|
80
|
+
# scheduled_at, or the waiting step's waiting_since); error_message is
|
|
81
|
+
# the newest one's. With +workflow_class+, that class's groups only.
|
|
82
|
+
def self.attention_groups(threshold:, now: Time.current, waiting_threshold: nil, limit: 200, workflow_class: nil)
|
|
83
|
+
binds = stuck_binds(threshold, now, waiting_threshold)
|
|
84
|
+
class_bind = "$#{binds.size + 1}" if workflow_class
|
|
85
|
+
sql = <<~SQL
|
|
86
|
+
SELECT stuck.workflow_class, stuck.step_name, stuck.reason, stuck.error_class, COUNT(*) AS runs,
|
|
87
|
+
MIN(stuck.since) AS first_seen_at, MAX(stuck.since) AS last_seen_at,
|
|
88
|
+
(ARRAY_AGG(stuck.error_message ORDER BY stuck.since DESC NULLS LAST, stuck.id DESC))[1] AS error_message
|
|
89
|
+
FROM (#{stuck_runs_sql(waiting: !waiting_threshold.nil?, class_bind: class_bind)}) stuck
|
|
90
|
+
WHERE stuck.reason IS NOT NULL
|
|
91
|
+
GROUP BY stuck.workflow_class, stuck.step_name, stuck.reason, stuck.error_class
|
|
92
|
+
ORDER BY runs DESC, last_seen_at DESC NULLS LAST, stuck.workflow_class
|
|
93
|
+
LIMIT #{Integer(limit)}
|
|
94
|
+
SQL
|
|
95
|
+
result = connection.exec_query(sql, "WorkflowEntry Attention", binds + [workflow_class].compact)
|
|
96
|
+
result.cast_values.map do |klass, step_name, reason, error_class, count, first_seen, last_seen, message|
|
|
97
|
+
{ workflow_class: klass, step_name: step_name, reason: reason, error_class: error_class, count: count.to_i,
|
|
98
|
+
first_seen_at: first_seen, last_seen_at: last_seen, error_message: message }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The workflow_ids of one attention group (+group+: workflow_class,
|
|
103
|
+
# step_name, reason, error_class; a nil error_class matches runs without
|
|
104
|
+
# one), oldest stuck first, at most +limit+: what a bulk action walks.
|
|
105
|
+
def self.attention_ids(group, threshold:, now: Time.current, waiting_threshold: nil, limit: 500)
|
|
106
|
+
binds = stuck_binds(threshold, now, waiting_threshold)
|
|
107
|
+
first = binds.size + 1
|
|
108
|
+
sql = <<~SQL
|
|
109
|
+
SELECT stuck.workflow_id
|
|
110
|
+
FROM (#{stuck_runs_sql(waiting: !waiting_threshold.nil?)}) stuck
|
|
111
|
+
WHERE stuck.workflow_class = $#{first} AND stuck.step_name = $#{first + 1} AND stuck.reason = $#{first + 2}
|
|
112
|
+
AND stuck.error_class IS NOT DISTINCT FROM $#{first + 3}
|
|
113
|
+
ORDER BY stuck.since NULLS LAST, stuck.id
|
|
114
|
+
LIMIT #{Integer(limit)}
|
|
115
|
+
SQL
|
|
116
|
+
values = binds + group.values_at(:workflow_class, :step_name, :reason, :error_class)
|
|
117
|
+
connection.exec_query(sql, "WorkflowEntry Attention Ids", values).rows.map(&:first)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Each paused or ready run (and, when +waiting+, each waiting run) with
|
|
121
|
+
# its latest step row and why it is stuck (NULL when it is not). Binds
|
|
122
|
+
# $1 (the overdue cutoff) and, when +waiting+, $2 (the waiting cutoff);
|
|
123
|
+
# +class_bind+ names the placeholder that narrows it to one class.
|
|
124
|
+
def self.stuck_runs_sql(waiting:, class_bind: nil)
|
|
125
|
+
<<~SQL
|
|
126
|
+
SELECT w.id, w.workflow_id, w.workflow_class, COALESCE(s.step_name, w.current_step) AS step_name,
|
|
127
|
+
s.error_class, s.error_message, stuck_reason.reason,
|
|
128
|
+
CASE
|
|
129
|
+
WHEN stuck_reason.reason IN ('stalled', 'paused_error', 'paused_manual') THEN w.paused_at
|
|
130
|
+
WHEN stuck_reason.reason = 'overdue' THEN s.scheduled_at
|
|
131
|
+
#{"WHEN stuck_reason.reason = 'waiting_overdue' THEN s.waiting_since" if waiting}
|
|
132
|
+
END AS since
|
|
133
|
+
FROM #{table_name} w
|
|
134
|
+
LEFT JOIN LATERAL (
|
|
135
|
+
SELECT step_name, status, error_class, error_message, scheduled_at#{", timeout_at, waiting_since" if waiting}
|
|
136
|
+
FROM #{WorkflowStep.table_name}
|
|
137
|
+
WHERE workflow_id = w.workflow_id ORDER BY id DESC LIMIT 1
|
|
138
|
+
) s ON TRUE
|
|
139
|
+
CROSS JOIN LATERAL (
|
|
140
|
+
SELECT CASE
|
|
141
|
+
WHEN w.status = 'paused' AND s.status = 'failed' AND s.error_class = 'Pgbus::Workflow::StepStalled' THEN 'stalled'
|
|
142
|
+
WHEN w.status = 'paused' AND s.status = 'failed' THEN 'paused_error'
|
|
143
|
+
WHEN w.status = 'paused' THEN 'paused_manual'
|
|
144
|
+
WHEN w.status = 'ready' AND s.status = 'scheduled' AND s.scheduled_at < $1 THEN 'overdue'
|
|
145
|
+
#{"WHEN w.status = 'waiting' AND s.status = 'waiting' AND s.timeout_at IS NULL AND s.waiting_since < $2 THEN 'waiting_overdue'" if waiting}
|
|
146
|
+
END AS reason
|
|
147
|
+
) stuck_reason
|
|
148
|
+
WHERE w.status IN ('paused', 'ready'#{", 'waiting'" if waiting})#{" AND w.workflow_class = #{class_bind}" if class_bind}
|
|
149
|
+
SQL
|
|
150
|
+
end
|
|
151
|
+
private_class_method :stuck_runs_sql
|
|
152
|
+
|
|
153
|
+
def self.stuck_binds(threshold, now, waiting_threshold)
|
|
154
|
+
waiting_threshold.nil? ? [now - threshold] : [now - threshold, now - waiting_threshold]
|
|
155
|
+
end
|
|
156
|
+
private_class_method :stuck_binds
|
|
157
|
+
|
|
158
|
+
# The Pgbus::Workflow subclass that started this run, or nil when it no
|
|
159
|
+
# longer exists.
|
|
160
|
+
def workflow_klass
|
|
161
|
+
klass = workflow_class.safe_constantize
|
|
162
|
+
klass if klass.is_a?(Class) && klass < Workflow
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# True when the run started on a different step list than its class
|
|
166
|
+
# declares now (a step added, removed or reordered since).
|
|
167
|
+
def definition_outdated?
|
|
168
|
+
klass = workflow_klass
|
|
169
|
+
klass ? klass.definition_digest != definition_digest : false
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# The audit trail (pgbus_workflow_events): one row per transition and per
|
|
5
|
+
# operator action, with who did it (actor) and why (reason). Append-only:
|
|
6
|
+
# written by Pgbus::Workflow::Runner inside the transaction of the
|
|
7
|
+
# transition it records, read-only once inserted, removed with its run.
|
|
8
|
+
#
|
|
9
|
+
# Actors: "app" (an outside verb called without actor:), "system" (the
|
|
10
|
+
# StepJob, the sweep, crash detection), or what the caller passes
|
|
11
|
+
# ("dashboard:<name>", "mcp", "alice", ...).
|
|
12
|
+
class WorkflowEvent < BusRecord
|
|
13
|
+
self.table_name = "pgbus_workflow_events"
|
|
14
|
+
|
|
15
|
+
REASON_LIMIT = 1_000
|
|
16
|
+
DETAIL_LIMIT = 1_000
|
|
17
|
+
|
|
18
|
+
EVENTS = %w[
|
|
19
|
+
started step_scheduled step_started step_completed step_failed step_skipped step_reattempted
|
|
20
|
+
paused resumed cancelled finished run_now rescheduled rerun stall_detected late_completion_ignored
|
|
21
|
+
signal_received signal_claimed step_waiting wait_timed_out fan_out_opened fan_out_finished
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def self.record!(workflow_id:, event:, actor:, reason: nil, step: nil, details: {})
|
|
25
|
+
create!(
|
|
26
|
+
workflow_id: workflow_id, event: event.to_s, actor: actor.to_s,
|
|
27
|
+
reason: reason && Workflow::ErrorCapture.truncate(reason.to_s, REASON_LIMIT),
|
|
28
|
+
step_id: step&.id, step_name: step&.step_name, attempt: step&.attempt,
|
|
29
|
+
details: cap(details), created_at: Time.current
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# String values in details are capped, so one large error message cannot
|
|
34
|
+
# bloat the trail.
|
|
35
|
+
def self.cap(details)
|
|
36
|
+
details.to_h do |key, value|
|
|
37
|
+
[key.to_s, value.is_a?(String) ? Workflow::ErrorCapture.truncate(value, DETAIL_LIMIT) : value]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def readonly? = persisted? || super
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# One signal delivered to a workflow run (pgbus_workflow_signals, issue
|
|
7
|
+
# #541): pending (buffered, no step has taken it), claimed (a wait_for:
|
|
8
|
+
# step took it and runs with it as received_signal) and consumed (that
|
|
9
|
+
# step ended). Written only by Pgbus::Workflow::Signals under the run's row
|
|
10
|
+
# lock.
|
|
11
|
+
#
|
|
12
|
+
# +payload+ reads back the signal! payload with indifferent access
|
|
13
|
+
# (through ActiveJob::Arguments, as workflow arguments do).
|
|
14
|
+
class WorkflowSignal < BusRecord
|
|
15
|
+
self.table_name = "pgbus_workflow_signals"
|
|
16
|
+
|
|
17
|
+
STATUSES = %w[pending claimed consumed].freeze
|
|
18
|
+
|
|
19
|
+
# Inserts a signal. A second signal with the same (workflow_id,
|
|
20
|
+
# idempotency_key) inserts nothing and returns nil. Raw SQL rather than
|
|
21
|
+
# insert_all(unique_by:), which resolves the index through the schema
|
|
22
|
+
# cache (issue #401), as WorkflowEntry.insert_run! does.
|
|
23
|
+
def self.insert_signal!(workflow_id:, name:, payload:, idempotency_key:, now: Time.current)
|
|
24
|
+
sql = <<~SQL
|
|
25
|
+
INSERT INTO #{table_name} (workflow_id, name, payload, idempotency_key, status, created_at)
|
|
26
|
+
VALUES ($1, $2, $3, $4, 'pending', $5)
|
|
27
|
+
ON CONFLICT (workflow_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
|
|
28
|
+
RETURNING id
|
|
29
|
+
SQL
|
|
30
|
+
result = connection.exec_query(sql, "WorkflowSignal Insert",
|
|
31
|
+
[workflow_id, name, JSON.generate(payload), idempotency_key, now])
|
|
32
|
+
id = result.rows.first&.first
|
|
33
|
+
id && find(id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The pending signals of a run (only +name+'s when given); iterate them
|
|
37
|
+
# oldest first with find_each, which batches by id.
|
|
38
|
+
def self.pending_for(workflow_id, name: nil)
|
|
39
|
+
scope = where(workflow_id: workflow_id, status: "pending")
|
|
40
|
+
name ? scope.where(name: name.to_s) : scope
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The signal an earlier attempt of +step_name+ claimed and did not
|
|
44
|
+
# consume (a reattempt, or a resume! after a failure, runs with it).
|
|
45
|
+
def self.held_by_step(workflow_id, step_name)
|
|
46
|
+
attempts = WorkflowStep.where(workflow_id: workflow_id, step_name: step_name.to_s).select(:id)
|
|
47
|
+
where(workflow_id: workflow_id, status: "claimed", claimed_by_step_id: attempts).order(:id).first
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# pending → claimed, by +step_id+. Returns the number of rows moved.
|
|
51
|
+
def self.claim!(id, step_id:, now: Time.current)
|
|
52
|
+
where(id: id, status: "pending").update_all(status: "claimed", claimed_by_step_id: step_id, claimed_at: now)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# claimed → consumed. Returns the number of rows moved.
|
|
56
|
+
def self.consume!(id, now: Time.current)
|
|
57
|
+
where(id: id, status: "claimed").update_all(status: "consumed", consumed_at: now)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_writer :duplicate_delivery
|
|
61
|
+
|
|
62
|
+
# True on what signal! returned for a redelivery: a signal with the same
|
|
63
|
+
# idempotency key was already stored, and nothing else happened.
|
|
64
|
+
def duplicate_delivery? = @duplicate_delivery == true
|
|
65
|
+
|
|
66
|
+
def payload
|
|
67
|
+
return @payload if defined?(@payload)
|
|
68
|
+
|
|
69
|
+
@payload = Workflow::Payload.load(super) || {}.with_indifferent_access
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reload(...)
|
|
73
|
+
remove_instance_variable(:@payload) if defined?(@payload)
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# One step attempt (pgbus_workflow_steps). The unique (workflow_id,
|
|
7
|
+
# step_name, attempt) index makes a duplicate attempt impossible, and the
|
|
8
|
+
# guarded claim (scheduled → in_progress) makes a duplicate StepJob a no-op.
|
|
9
|
+
#
|
|
10
|
+
# The after-commit callback below is the ONLY place a
|
|
11
|
+
# Pgbus::Workflow::StepJob is enqueued: start!, an advance, a reattempt,
|
|
12
|
+
# resume!, run_now!, and the sweep all write a row (a new scheduled row, or
|
|
13
|
+
# a bumped enqueued_at) and let it fire once the write is visible, even
|
|
14
|
+
# inside an application transaction.
|
|
15
|
+
class WorkflowStep < BusRecord
|
|
16
|
+
self.table_name = "pgbus_workflow_steps"
|
|
17
|
+
|
|
18
|
+
after_save_commit :enqueue_step_job, if: :needs_enqueue?
|
|
19
|
+
|
|
20
|
+
# Inserts the next attempt of +step_name+: one more than the highest
|
|
21
|
+
# attempt so far (1 for a step that never ran).
|
|
22
|
+
def self.insert_next!(workflow_id:, workflow_class:, step_name:, scheduled_at:)
|
|
23
|
+
attempt = where(workflow_id: workflow_id, step_name: step_name).maximum(:attempt).to_i + 1
|
|
24
|
+
create!(workflow_id: workflow_id, workflow_class: workflow_class, step_name: step_name, attempt: attempt,
|
|
25
|
+
status: "scheduled", scheduled_at: scheduled_at, created_at: Time.current)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# scheduled → in_progress, only once the row is due (a second of slack
|
|
29
|
+
# for clock skew between the sender and this worker). A message that
|
|
30
|
+
# arrives early (left behind by reschedule! to a later time) claims
|
|
31
|
+
# nothing; the later send does. Returns the number of rows claimed.
|
|
32
|
+
def self.claim!(id, now:, stall_at:, msg_id:, worker:, app_revision:)
|
|
33
|
+
where(id: id, status: "scheduled").where(scheduled_at: ..(now + 1))
|
|
34
|
+
.update_all(status: "in_progress", started_at: now, stall_at: stall_at,
|
|
35
|
+
msg_id: msg_id, worker: worker, app_revision: app_revision)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Records an attempt's outcome, only while the row is still in_progress.
|
|
39
|
+
# 0 means the attempt was declared stalled (or the run cancelled) while
|
|
40
|
+
# it was still running: its outcome is not applied.
|
|
41
|
+
def self.finish!(id, **attrs)
|
|
42
|
+
where(id: id, status: "in_progress").update_all(attrs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# in_progress → waiting: the step parks on a signal or its fan-out batch
|
|
46
|
+
# (Workflow::Waiting). Returns the number of rows moved.
|
|
47
|
+
def self.park!(id, **attrs)
|
|
48
|
+
where(id: id, status: "in_progress").update_all(attrs.merge(status: "waiting"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# waiting → in_progress, so the outcome of a finished wait (the batch
|
|
52
|
+
# finished, the wait timed out, an operator skipped it) is applied like
|
|
53
|
+
# any attempt's. Returns the number of rows moved.
|
|
54
|
+
def self.unpark!(id)
|
|
55
|
+
where(id: id, status: "waiting").update_all(status: "in_progress")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# waiting rows of waiting runs past their timeout_at (the sweep applies
|
|
59
|
+
# their on_timeout).
|
|
60
|
+
def self.wait_expired(now: Time.current)
|
|
61
|
+
where(status: "waiting").where(timeout_at: ...now)
|
|
62
|
+
.where(workflow_id: WorkflowEntry.where(status: "waiting").select(:workflow_id))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The scheduled, waiting and in_progress rows of a run, cancelled (an
|
|
66
|
+
# outside cancel!, a resume_at!).
|
|
67
|
+
def self.cancel_open!(workflow_id, now: Time.current)
|
|
68
|
+
where(workflow_id: workflow_id, status: Workflow::States::OPEN_STEP)
|
|
69
|
+
.update_all(status: "cancelled", outcome: "cancelled", finished_at: now)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The newest row of a run: the step it is on.
|
|
73
|
+
def self.latest_for(workflow_id)
|
|
74
|
+
where(workflow_id: workflow_id).order(id: :desc).first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# How many of +step_name+'s latest finished attempts in a row were
|
|
78
|
+
# reattempted (what max_reattempts counts). Any other outcome, such as a
|
|
79
|
+
# pause an operator resumed, ends the run of reattempts.
|
|
80
|
+
def self.consecutive_reattempts(workflow_id, step_name)
|
|
81
|
+
outcomes = where(workflow_id: workflow_id, step_name: step_name).where.not(outcome: nil)
|
|
82
|
+
.order(attempt: :desc).pluck(:outcome)
|
|
83
|
+
outcomes.take_while { |outcome| outcome == "reattempted" }.size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.completed_output(workflow_id, step_name)
|
|
87
|
+
where(workflow_id: workflow_id, step_name: step_name.to_s, status: "completed").order(attempt: :desc).pick(:output)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# { step_name => output } of each step's latest completed attempt.
|
|
91
|
+
def self.completed_outputs(workflow_id)
|
|
92
|
+
where(workflow_id: workflow_id, status: "completed").order(:attempt).pluck(:step_name, :output).to_h
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# in_progress rows past their stall_at.
|
|
96
|
+
def self.stalled(now: Time.current)
|
|
97
|
+
where(status: "in_progress").where(stall_at: ...now)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# scheduled rows of ready runs, due more than +threshold+ ago and not
|
|
101
|
+
# sent within it: the sweep sends them again.
|
|
102
|
+
def self.overdue(threshold:, now: Time.current)
|
|
103
|
+
cutoff = now - threshold
|
|
104
|
+
where(status: "scheduled").where(scheduled_at: ...cutoff)
|
|
105
|
+
.where("enqueued_at IS NULL OR enqueued_at < ?", cutoff)
|
|
106
|
+
.where(workflow_id: WorkflowEntry.where(status: "ready").select(:workflow_id))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Fires on a new scheduled row, on a change to scheduled, and on a
|
|
110
|
+
# bumped enqueued_at; never on any other save.
|
|
111
|
+
def needs_enqueue?
|
|
112
|
+
status == "scheduled" && (previously_new_record? || saved_change_to_status? || saved_change_to_enqueued_at?)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Runs after commit, so a failure cannot roll back the row: it is
|
|
118
|
+
# reported, and the sweep sends the step again once it is
|
|
119
|
+
# workflow_stall_threshold overdue.
|
|
120
|
+
def enqueue_step_job
|
|
121
|
+
job = Workflow::StepJob.set(**step_job_set_options).perform_later(id)
|
|
122
|
+
update_columns(enqueued_at: Time.current, job_id: job.job_id) if job
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
Pgbus.logger.error do
|
|
125
|
+
"[Pgbus] Workflow StepJob for step #{id} (#{step_name}) could not be enqueued: #{e.class}: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
ErrorReporter.report(e, { action: "workflow_step_enqueue", workflow_class: workflow_class,
|
|
128
|
+
workflow_id: workflow_id, step_name: step_name, attempt: attempt })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# queue/priority from the class and the step (step wins), sent at
|
|
132
|
+
# scheduled_at (which already holds any job_options wait/wait_until, see
|
|
133
|
+
# Workflow::Runner.schedule!).
|
|
134
|
+
def step_job_set_options
|
|
135
|
+
klass = workflow_class.safe_constantize
|
|
136
|
+
options = klass.is_a?(Class) && klass < Workflow ? klass.step_job_options_for(step_name) : {}
|
|
137
|
+
set = { queue: options[:queue] || Pgbus.configuration.workflow_queue, priority: options[:priority] }
|
|
138
|
+
set[:wait_until] = scheduled_at if scheduled_at > Time.current
|
|
139
|
+
set.compact
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
<%= pgbus_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
|
|
131
131
|
<%= pgbus_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
|
|
132
132
|
<%= pgbus_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
|
|
133
|
+
<%= pgbus_nav_link t("pgbus.layout.nav.workflows"), pgbus.workflows_path %>
|
|
133
134
|
<%= pgbus_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
|
|
134
135
|
<%= pgbus_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
|
|
135
136
|
<%= pgbus_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
|
|
@@ -191,6 +192,7 @@
|
|
|
191
192
|
<%= pgbus_mobile_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
|
|
192
193
|
<%= pgbus_mobile_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
|
|
193
194
|
<%= pgbus_mobile_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
|
|
195
|
+
<%= pgbus_mobile_nav_link t("pgbus.layout.nav.workflows"), pgbus.workflows_path %>
|
|
194
196
|
<%= pgbus_mobile_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
|
|
195
197
|
<%= pgbus_mobile_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
|
|
196
198
|
<%= pgbus_mobile_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
|
|
@@ -77,6 +77,23 @@
|
|
|
77
77
|
<p class="text-xs text-gray-600 dark:text-gray-300"><%= throughput[:available] ? t("pgbus.dashboard.stats_cards.throughput_detail", minutes: throughput[:window_minutes]) : t("pgbus.dashboard.stats_cards.throughput_unavailable") %></p>
|
|
78
78
|
<% end %>
|
|
79
79
|
|
|
80
|
+
<%# Two links in one card (the list and the attention view), so the card
|
|
81
|
+
is a div; its title and number link to the list. %>
|
|
82
|
+
<% workflows = @workflow_counts %>
|
|
83
|
+
<div data-testid="workflows-card" class="<%= card %>">
|
|
84
|
+
<%= link_to pgbus.workflows_path, data: { turbo_frame: "_top" }, class: "block" do %>
|
|
85
|
+
<p class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.dashboard.stats_cards.workflows") %></p>
|
|
86
|
+
<p class="<%= number %>"><%= number_with_delimiter(workflows[:ongoing]) %></p>
|
|
87
|
+
<% end %>
|
|
88
|
+
<p class="text-xs text-gray-600 dark:text-gray-300">
|
|
89
|
+
<%= t("pgbus.dashboard.stats_cards.workflows_detail", paused: workflows[:paused]) %>
|
|
90
|
+
<% if workflows[:attention].positive? %>
|
|
91
|
+
· <%= pgbus_link_to t("pgbus.dashboard.stats_cards.workflows_attention", count: workflows[:attention]),
|
|
92
|
+
pgbus.attention_workflows_path, size: :sm, tone: :yellow, data: { turbo_frame: "_top" } %>
|
|
93
|
+
<% end %>
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
80
97
|
<%= link_to pgbus.locks_path, data: { turbo_frame: "_top" }, class: card do %>
|
|
81
98
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.dashboard.stats_cards.parked_jobs") %></p>
|
|
82
99
|
<p class="mt-1 text-3xl font-semibold <%= @stats[:parked_total].to_i > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-900 dark:text-white' %>">
|