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
|
@@ -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, :late 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"), wait_timed_out: t("pgbus.workflows.events.wait_timed_out"),
|
|
162
|
+
statuses: Workflow::States::STEP.index_with { |status| t("pgbus.workflows.step_statuses.#{status}") } }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -62,5 +62,17 @@ module Pgbus
|
|
|
62
62
|
.limit(limit)
|
|
63
63
|
.pluck(:concurrency_key)
|
|
64
64
|
end
|
|
65
|
+
|
|
66
|
+
# The uniqueness keys, out of +lock_keys+, that a parked job carries. Its
|
|
67
|
+
# :until_executed row stays unbound until it runs and it sits in no queue,
|
|
68
|
+
# so the uniqueness reaper asks here before calling the row an orphan. A
|
|
69
|
+
# row parked before the double-encoding fix is read through its string,
|
|
70
|
+
# since the reaper can run before the promote sweep repairs it.
|
|
71
|
+
def self.parked_uniqueness_keys(lock_keys)
|
|
72
|
+
key = Uniqueness::METADATA_KEY
|
|
73
|
+
key_path = "CASE WHEN jsonb_typeof(payload) = 'string' " \
|
|
74
|
+
"THEN (payload #>> '{}')::jsonb ->> '#{key}' ELSE payload ->> '#{key}' END"
|
|
75
|
+
where("#{key_path} IN (?)", lock_keys).distinct.pluck(Arel.sql(key_path)).to_set
|
|
76
|
+
end
|
|
65
77
|
end
|
|
66
78
|
end
|
|
@@ -40,10 +40,11 @@ module Pgbus
|
|
|
40
40
|
# Does not touch created_at — the reaper's age floor is from acquire time.
|
|
41
41
|
# Restricted to this enqueue's unbound row (msg_id=0, matching created_at
|
|
42
42
|
# when acquire! stamped one) so a completed job's bind cannot retarget a
|
|
43
|
-
# successor that re-acquired the key.
|
|
44
|
-
|
|
43
|
+
# successor that re-acquired the key. A retry passes the stamp unbind!
|
|
44
|
+
# returned as +acquired_at+.
|
|
45
|
+
def self.bind!(lock_key, queue_name:, msg_id:, acquired_at: nil)
|
|
45
46
|
stamps = Thread.current[:pgbus_uniqueness_created_at]
|
|
46
|
-
created_at = stamps&.delete(lock_key)
|
|
47
|
+
created_at = acquired_at || stamps&.delete(lock_key)
|
|
47
48
|
sql = "UPDATE #{table_name} SET queue_name = $2, msg_id = $3 " \
|
|
48
49
|
"WHERE lock_key = $1 AND msg_id = 0"
|
|
49
50
|
binds = [lock_key, queue_name, msg_id]
|
|
@@ -86,6 +87,90 @@ module Pgbus
|
|
|
86
87
|
)
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
# The row a message holds: bound to that message (queue + msg_id, see
|
|
91
|
+
# release_if_bound!), or still unbound — a parked job's row, or one whose
|
|
92
|
+
# bind failed — and acquired no later than the message was sent ($4). A
|
|
93
|
+
# lock is always taken before its message exists, so an unbound row
|
|
94
|
+
# acquired after the send is a successor's. created_at is a timestamp
|
|
95
|
+
# without time zone written in the session's zone, and the cast compares
|
|
96
|
+
# it in that same zone; PGMQ's enqueued_at string carries its offset.
|
|
97
|
+
# Unbound means msg_id <= 0 only: "pending" (the placeholder queue) is a
|
|
98
|
+
# name a real queue can have too, and a lock bound to one of its messages
|
|
99
|
+
# is bound.
|
|
100
|
+
HELD_BY_CONDITION = "lock_key = $1 AND ((queue_name = $2 AND msg_id = $3 AND msg_id > 0) OR " \
|
|
101
|
+
"(msg_id <= 0 AND created_at <= $4::timestamptz))"
|
|
102
|
+
private_constant :HELD_BY_CONDITION
|
|
103
|
+
|
|
104
|
+
# Release the lock a finishing message holds (success or DLQ). Unlike the
|
|
105
|
+
# key-only release!, it leaves a row that has moved on: handed to the
|
|
106
|
+
# message's retry (unbind!), or taken by a successor after this
|
|
107
|
+
# message's own row went. +enqueued_at+ is when that message was sent;
|
|
108
|
+
# without it only the row bound to the message goes (an unbound row could
|
|
109
|
+
# be a retry's it handed over), and the reaper takes an orphan.
|
|
110
|
+
def self.release_held_by!(lock_key, queue_name:, msg_id:, enqueued_at:)
|
|
111
|
+
Thread.current[:pgbus_uniqueness_created_at]&.delete(lock_key)
|
|
112
|
+
connection.exec_delete(
|
|
113
|
+
"DELETE FROM #{table_name} WHERE #{HELD_BY_CONDITION}",
|
|
114
|
+
"UniquenessKey Release Held By", held_by_binds(lock_key, queue_name, msg_id, sent_at_literal(enqueued_at))
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Hand the running attempt's lock to its retry, which goes out through the
|
|
119
|
+
# key the attempt still holds (issue #333) and never acquires one. Called
|
|
120
|
+
# before the retry exists, as a first enqueue acquires before its send:
|
|
121
|
+
# the row is left unbound on the retry's queue and stamped now, after the
|
|
122
|
+
# attempt was sent, so the attempt's release no longer counts it as its
|
|
123
|
+
# own while the retry's (sent later) does, even if the retry finishes
|
|
124
|
+
# before bind! points the row at it. Returns the new stamp for that
|
|
125
|
+
# bind!, or nil when the attempt no longer holds the key. Without the
|
|
126
|
+
# attempt's enqueued_at only a row bound to it is taken.
|
|
127
|
+
def self.unbind!(lock_key, from_queue_name:, from_msg_id:, from_enqueued_at:, queue_name:)
|
|
128
|
+
binds = held_by_binds(lock_key, from_queue_name, from_msg_id, sent_at_literal(from_enqueued_at))
|
|
129
|
+
result = connection.exec_query(
|
|
130
|
+
"UPDATE #{table_name} SET queue_name = $5, msg_id = 0, created_at = CURRENT_TIMESTAMP " \
|
|
131
|
+
"WHERE #{HELD_BY_CONDITION} RETURNING created_at",
|
|
132
|
+
"UniquenessKey Unbind", binds + [queue_name.to_s]
|
|
133
|
+
)
|
|
134
|
+
result.rows.first&.first
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# release_held_by! for many messages at once — the dashboard's bulk
|
|
138
|
+
# discards and queue purges — as [lock_key, queue_name, msg_id,
|
|
139
|
+
# enqueued_at] tuples, one statement per thousand.
|
|
140
|
+
def self.release_all_held_by!(holders)
|
|
141
|
+
holders.each_slice(1000).sum do |slice|
|
|
142
|
+
values = slice.map do |lock_key, queue_name, msg_id, enqueued_at|
|
|
143
|
+
"(#{connection.quote(lock_key.to_s)}, #{connection.quote(queue_name.to_s)}, #{msg_id.to_i}, " \
|
|
144
|
+
"#{connection.quote(sent_at_literal(enqueued_at))}::timestamptz)"
|
|
145
|
+
end
|
|
146
|
+
connection.exec_delete(
|
|
147
|
+
"DELETE FROM #{table_name} AS u USING (VALUES #{values.join(", ")}) AS m(lock_key, queue_name, msg_id, sent_at) " \
|
|
148
|
+
"WHERE u.lock_key = m.lock_key AND ((u.queue_name = m.queue_name AND u.msg_id = m.msg_id AND u.msg_id > 0) OR " \
|
|
149
|
+
"(u.msg_id <= 0 AND u.created_at <= m.sent_at))",
|
|
150
|
+
"UniquenessKey Release All Held By"
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.held_by_binds(lock_key, queue_name, msg_id, sent_at)
|
|
156
|
+
[lock_key, queue_name.to_s, msg_id.to_i, sent_at]
|
|
157
|
+
end
|
|
158
|
+
private_class_method :held_by_binds
|
|
159
|
+
|
|
160
|
+
# A message's enqueued_at as a timestamptz literal that carries its own
|
|
161
|
+
# offset. PGMQ hands over a String that already does; a Time (the
|
|
162
|
+
# dashboard reads it through ActiveRecord) or epoch seconds would
|
|
163
|
+
# otherwise be bound as zone-less digits and read in the session's zone.
|
|
164
|
+
# An unknown send time is -infinity: no unbound row counts as the
|
|
165
|
+
# message's own.
|
|
166
|
+
def self.sent_at_literal(enqueued_at)
|
|
167
|
+
sent_at = enqueued_at.is_a?(Numeric) ? Time.at(enqueued_at) : enqueued_at
|
|
168
|
+
return "-infinity" if sent_at.nil?
|
|
169
|
+
|
|
170
|
+
sent_at.respond_to?(:iso8601) ? sent_at.iso8601(6) : sent_at.to_s
|
|
171
|
+
end
|
|
172
|
+
private_class_method :sent_at_literal
|
|
173
|
+
|
|
89
174
|
# Release a lock only while it is still UNBOUND (msg_id = 0) AND was
|
|
90
175
|
# acquired no later than +acquired_before+. Used when discarding a parked
|
|
91
176
|
# job: a parked job never got a msg_id, so its lock was never bound, and
|
|
@@ -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
|