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,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The instance side of a workflow: the run's readers, its outputs and
|
|
6
|
+
# idempotency keys, and the outside verbs. An instance wraps one
|
|
7
|
+
# Pgbus::WorkflowEntry row; inside a step it also knows which step
|
|
8
|
+
# attempt it is running (the step context).
|
|
9
|
+
module Instance
|
|
10
|
+
def workflow_id = record.workflow_id
|
|
11
|
+
|
|
12
|
+
def workflow_class = record.workflow_class
|
|
13
|
+
|
|
14
|
+
def status = record.status
|
|
15
|
+
|
|
16
|
+
def current_step = record.current_step
|
|
17
|
+
|
|
18
|
+
# True when start!(…, idempotency_key:) found an existing run with that
|
|
19
|
+
# key and returned it instead of starting another.
|
|
20
|
+
def duplicate_start? = @duplicate_start
|
|
21
|
+
|
|
22
|
+
# The record the run was started for, loaded through its GlobalID;
|
|
23
|
+
# nil for a subject-less run or when the record is gone.
|
|
24
|
+
def subject
|
|
25
|
+
return @subject if defined?(@subject)
|
|
26
|
+
|
|
27
|
+
@subject = Payload.locate_subject(record.subject_gid)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# start!'s keyword arguments, with indifferent access.
|
|
31
|
+
def arguments
|
|
32
|
+
return @arguments if defined?(@arguments)
|
|
33
|
+
|
|
34
|
+
@arguments = Payload.load(record.arguments) || {}.with_indifferent_access
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reload
|
|
38
|
+
@record = WorkflowEntry.find_by!(workflow_id: workflow_id)
|
|
39
|
+
remove_instance_variable(:@subject) if defined?(@subject)
|
|
40
|
+
remove_instance_variable(:@arguments) if defined?(@arguments)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The audit trail, oldest first.
|
|
45
|
+
def events = WorkflowEvent.where(workflow_id: workflow_id).order(:id).to_a
|
|
46
|
+
|
|
47
|
+
# The outside verbs legal in the current status (States).
|
|
48
|
+
def available_actions = States.available_actions(status)
|
|
49
|
+
|
|
50
|
+
# Inside a step: record durable output values. They are written in the
|
|
51
|
+
# same UPDATE that completes the step, so they exist exactly when the
|
|
52
|
+
# step does; later steps read them with output_of. Merges across calls.
|
|
53
|
+
def output(**values)
|
|
54
|
+
context = step_context!(:output)
|
|
55
|
+
raise NotInStep, "output can only be called from the step body, not from cancel_if or skip_if" if @guarding
|
|
56
|
+
|
|
57
|
+
merged = (@output_values || {}).merge(values)
|
|
58
|
+
@pending_output = Payload.dump(merged, what: "output of step #{context.step_name}")
|
|
59
|
+
@output_values = merged
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# What this attempt's output(...) calls recorded, serialized (or nil).
|
|
64
|
+
def pending_output = @pending_output
|
|
65
|
+
|
|
66
|
+
# The output of +step_name+'s latest completed attempt, or nil. A
|
|
67
|
+
# failed or reattempted attempt's output is never returned.
|
|
68
|
+
def output_of(step_name)
|
|
69
|
+
Payload.load(WorkflowStep.completed_output(workflow_id, step_name))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# { "step_name" => output } for every step with a completed attempt.
|
|
73
|
+
def outputs
|
|
74
|
+
WorkflowStep.completed_outputs(workflow_id).transform_values { |output| Payload.load(output) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The same on every attempt of this step (and on a resume_at! re-run):
|
|
78
|
+
# hand it to an external API so a repeated side effect is deduplicated.
|
|
79
|
+
def step_idempotency_key
|
|
80
|
+
"pgbus-wf:#{workflow_id}:#{step_context!(:step_idempotency_key).step_name}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Inside a wait_for: step: the Pgbus::WorkflowSignal the step took (its
|
|
84
|
+
# +payload+ has indifferent access), or nil when its wait timed out.
|
|
85
|
+
def received_signal
|
|
86
|
+
step_context!(:received_signal)
|
|
87
|
+
@received_signal
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Inside a wait_for: step: true when the body runs because the wait
|
|
91
|
+
# timed out (on_timeout: :run) rather than for a signal.
|
|
92
|
+
def timed_out?
|
|
93
|
+
step_context!(:timed_out?)
|
|
94
|
+
@timed_out == true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Deliver a signal to this run. Stored under the run's lock; a step
|
|
98
|
+
# waiting for it (wait_for:) is woken at once, otherwise it waits,
|
|
99
|
+
# pending, for the step that claims it. With an idempotency_key a
|
|
100
|
+
# redelivery stores nothing and returns the first signal
|
|
101
|
+
# (duplicate_delivery? true). Raises NotOngoing on a finished or
|
|
102
|
+
# cancelled run. +reason+ goes on the signal_received event.
|
|
103
|
+
#
|
|
104
|
+
# wf.signal!(:payment_confirmed, payload: { amount: 42 }, idempotency_key: event.id)
|
|
105
|
+
def signal!(name, payload: {}, idempotency_key: nil, actor: nil, reason: nil)
|
|
106
|
+
@record, signal = Signals.deliver(workflow_id, name, payload: payload, idempotency_key: idempotency_key,
|
|
107
|
+
actor: actor || Runner::DEFAULT_ACTOR, reason: reason)
|
|
108
|
+
signal
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Differs per attempt, for the rare call that must not be deduplicated
|
|
112
|
+
# across attempts.
|
|
113
|
+
def attempt_idempotency_key
|
|
114
|
+
"#{step_idempotency_key}:#{step_context!(:attempt_idempotency_key).attempt}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# paused → ready. A failed step runs again as a new attempt, a
|
|
118
|
+
# completed one is followed by the next step.
|
|
119
|
+
def resume!(actor: nil, reason: nil) = apply_outside_verb(:resume, actor: actor, reason: reason)
|
|
120
|
+
|
|
121
|
+
# paused → ready, running +step_name+ next.
|
|
122
|
+
def resume_at!(step_name, actor: nil, reason: nil)
|
|
123
|
+
apply_outside_verb(:resume_at, actor: actor, reason: reason, step_name: step_name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The scheduled step runs now instead of after its wait.
|
|
127
|
+
def run_now!(actor: nil, reason: nil) = apply_outside_verb(:run_now, actor: actor, reason: reason)
|
|
128
|
+
|
|
129
|
+
# The scheduled step runs at +at+ instead.
|
|
130
|
+
def reschedule!(at:, actor: nil, reason: nil) # rubocop:disable Naming/MethodParameterName
|
|
131
|
+
apply_outside_verb(:reschedule, actor: actor, reason: reason, at: at)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A finished or cancelled run, started again as a new run with the same
|
|
135
|
+
# subject and arguments. Returns the new run.
|
|
136
|
+
def rerun!(actor: nil, reason: nil)
|
|
137
|
+
Runner.rerun(self, actor: actor || Runner::DEFAULT_ACTOR, reason: reason)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def inspect
|
|
141
|
+
"#<#{self.class.name} workflow_id=#{workflow_id.inspect} status=#{status.inspect} current_step=#{current_step.inspect}>"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
attr_reader :step_context
|
|
147
|
+
|
|
148
|
+
def apply_outside_verb(verb, actor:, reason:, **)
|
|
149
|
+
@record = Runner.apply(self, verb, actor: actor || Runner::DEFAULT_ACTOR, reason: reason, **)
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def step_context!(reader)
|
|
154
|
+
step_context || raise(NotInStep, "#{reader} can only be called inside a step body")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Used by the StepJob only: cancel_if and skip_if run inside this, where
|
|
158
|
+
# the flow-control verbs raise NotInStep (only the body and an
|
|
159
|
+
# on_exception block can end a step).
|
|
160
|
+
def guarding
|
|
161
|
+
@guarding = true
|
|
162
|
+
yield
|
|
163
|
+
ensure
|
|
164
|
+
@guarding = false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Used by the StepJob only.
|
|
168
|
+
def enter_step(context)
|
|
169
|
+
@step_context = context
|
|
170
|
+
@output_values = nil
|
|
171
|
+
@pending_output = nil
|
|
172
|
+
@received_signal = nil
|
|
173
|
+
@timed_out = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Used by the StepJob's signal gate only.
|
|
177
|
+
def enter_wait(signal:, timed_out:)
|
|
178
|
+
@received_signal = signal
|
|
179
|
+
@timed_out = timed_out
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The outside verbs, each called by Runner.apply with the run's row
|
|
6
|
+
# locked and the transition already checked against the States table.
|
|
7
|
+
module OutsideVerbs
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def pause(record, notes, actor:, reason:)
|
|
11
|
+
from = record.status
|
|
12
|
+
Runner.transition!(record, :pause, paused_at: Time.current)
|
|
13
|
+
Runner.event!(record, "paused", actor: actor, reason: reason, details: { from: from })
|
|
14
|
+
notes.instrument("pgbus.workflow_paused", Runner.payload_for(record, step_name: record.current_step, error_class: nil))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cancel(record, notes, actor:, reason:)
|
|
18
|
+
now = Time.current
|
|
19
|
+
from = record.status
|
|
20
|
+
Runner.transition!(record, :cancel, cancelled_at: now)
|
|
21
|
+
WorkflowStep.cancel_open!(record.workflow_id, now: now)
|
|
22
|
+
Runner.event!(record, "cancelled", actor: actor, reason: reason, details: { from: from })
|
|
23
|
+
notes.instrument("pgbus.workflow_cancelled", Runner.payload_for(record, step_name: record.current_step))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What runs next depends on the row the run paused on: a failed one
|
|
27
|
+
# (an exception or a stall) runs again as a new attempt; a completed or
|
|
28
|
+
# skipped one (the step called pause!) is followed by the next step, or
|
|
29
|
+
# finishes the run when it was the last; a scheduled one (an outside
|
|
30
|
+
# pause! before it ran) is sent again; one still running goes on; one
|
|
31
|
+
# still waiting (for a signal or its batch) waits again.
|
|
32
|
+
def resume(record, notes, actor:, reason:)
|
|
33
|
+
latest = WorkflowStep.latest_for(record.workflow_id)
|
|
34
|
+
resumed = { paused_at: nil, **ErrorCapture.cleared }
|
|
35
|
+
case latest&.status
|
|
36
|
+
when "in_progress"
|
|
37
|
+
Runner.transition!(record, :unpause, **resumed)
|
|
38
|
+
resumed_event(record, actor, reason, latest)
|
|
39
|
+
when "waiting"
|
|
40
|
+
extend_wait(record, latest)
|
|
41
|
+
Runner.transition!(record, :rewait, **resumed)
|
|
42
|
+
resumed_event(record, actor, reason, latest)
|
|
43
|
+
when "scheduled"
|
|
44
|
+
Runner.transition!(record, :resume, **resumed)
|
|
45
|
+
resumed_event(record, actor, reason, latest)
|
|
46
|
+
latest.update!(enqueued_at: Time.current)
|
|
47
|
+
when "failed"
|
|
48
|
+
Runner.transition!(record, :resume, **resumed)
|
|
49
|
+
resumed_event(record, actor, reason, latest)
|
|
50
|
+
Runner.schedule!(record, latest.step_name, at: Time.current, actor: actor)
|
|
51
|
+
else
|
|
52
|
+
resume_after(record, notes, latest, resumed, actor: actor, reason: reason)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The step a waiting run waits on is skipped (a signal it holds is
|
|
57
|
+
# consumed), as if it had called skip!; the next step is scheduled, or
|
|
58
|
+
# the run finishes after the last. Its fan-out batch, if any, runs on and
|
|
59
|
+
# its callback finds nothing to resume.
|
|
60
|
+
def skip(record, notes, actor:, reason:)
|
|
61
|
+
step = WorkflowStep.latest_for(record.workflow_id)
|
|
62
|
+
unless step&.status == "waiting"
|
|
63
|
+
raise InvalidTransition, "workflow #{record.workflow_id} has no waiting step to skip (latest: #{step&.status || "none"})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Waiting.resolve!(record, step, FlowControl::Outcome.skip(actor: actor, reason: reason), notes: notes)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resume_at(record, _notes, actor:, reason:, step_name:)
|
|
70
|
+
definition = definition!(record, step_name)
|
|
71
|
+
now = Time.current
|
|
72
|
+
WorkflowStep.cancel_open!(record.workflow_id, now: now)
|
|
73
|
+
Runner.transition!(record, :resume_at, current_step: definition.name, paused_at: nil, **ErrorCapture.cleared)
|
|
74
|
+
Runner.event!(record, "resumed", actor: actor, reason: reason, details: { resume_at: definition.name })
|
|
75
|
+
Runner.schedule!(record, definition.name, at: now, actor: actor)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def run_now(record, _notes, actor:, reason:)
|
|
79
|
+
reschedule(record, nil, actor: actor, reason: reason, at: Time.current, event: "run_now")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reschedule(record, _notes, actor:, reason:, at:, event: "rescheduled") # rubocop:disable Naming/MethodParameterName
|
|
83
|
+
time = at.respond_to?(:to_time) && !at.is_a?(String) ? at.to_time : nil
|
|
84
|
+
raise ArgumentError, "reschedule!(at:) takes a Time, got #{at.inspect}" unless time
|
|
85
|
+
|
|
86
|
+
step = WorkflowStep.where(workflow_id: record.workflow_id, status: "scheduled").order(id: :desc).first
|
|
87
|
+
raise InvalidTransition, "workflow #{record.workflow_id} has no scheduled step to #{event.tr("_", " ")}" unless step
|
|
88
|
+
|
|
89
|
+
verb = event == "run_now" ? :run_now : :reschedule
|
|
90
|
+
from = step.scheduled_at
|
|
91
|
+
Runner.transition!(record, verb)
|
|
92
|
+
step.update!(scheduled_at: time, enqueued_at: Time.current)
|
|
93
|
+
Runner.event!(record, event, actor: actor, reason: reason, step: step,
|
|
94
|
+
details: { from: from.utc.iso8601(3), to: step.scheduled_at.utc.iso8601(3) })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resume_after(record, notes, latest, resumed, actor:, reason:)
|
|
98
|
+
klass = record.workflow_klass || raise(StepNotDefined, "#{record.workflow_class} is not a Pgbus::Workflow class any more")
|
|
99
|
+
steps = klass.workflow_steps
|
|
100
|
+
if latest && steps.entries.none? { |entry| entry.name == latest.step_name }
|
|
101
|
+
raise StepNotDefined, "#{klass.name} has no step #{latest.step_name} (declare `removed_step :#{latest.step_name}`)"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
following = latest ? steps.next_after(latest.step_name) : steps.first
|
|
105
|
+
if following.nil?
|
|
106
|
+
Runner.transition!(record, :finish, finished_at: Time.current, **resumed)
|
|
107
|
+
resumed_event(record, actor, reason, latest)
|
|
108
|
+
Runner.event!(record, "finished", actor: actor)
|
|
109
|
+
notes.instrument("pgbus.workflow_finished", Runner.payload_for(record))
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
Runner.transition!(record, :resume, current_step: following.name, **resumed)
|
|
114
|
+
resumed_event(record, actor, reason, latest)
|
|
115
|
+
Runner.schedule!(record, following.name, at: Time.current + following.wait, actor: actor)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The time a run spent paused does not count against its waiting
|
|
119
|
+
# step's wait_timeout: the deadline moves by the length of the pause
|
|
120
|
+
# (from when the step started waiting, if that came later).
|
|
121
|
+
def extend_wait(record, step, now: Time.current)
|
|
122
|
+
return unless step.timeout_at && record.paused_at
|
|
123
|
+
|
|
124
|
+
paused_for = now - [record.paused_at, step.waiting_since].compact.max
|
|
125
|
+
step.update_columns(timeout_at: step.timeout_at + paused_for) if paused_for.positive?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resumed_event(record, actor, reason, step)
|
|
129
|
+
Runner.event!(record, "resumed", actor: actor, reason: reason, step: step)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def definition!(record, step_name)
|
|
133
|
+
klass = record.workflow_klass || raise(StepNotDefined, "#{record.workflow_class} is not a Pgbus::Workflow class any more")
|
|
134
|
+
klass.workflow_steps.fetch(step_name) || raise(StepNotDefined, "#{klass.name} has no step #{step_name}")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class Workflow
|
|
7
|
+
# Arguments, step outputs and the subject, to and from the database.
|
|
8
|
+
# Values go through ActiveJob::Arguments (JSON, GlobalID-aware, never
|
|
9
|
+
# Marshal), are capped at config.workflow_max_payload_size, and come back
|
|
10
|
+
# with indifferent access. GlobalIDs are gated by
|
|
11
|
+
# config.allowed_global_id_models, as job arguments are.
|
|
12
|
+
module Payload
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def dump(values, what:, limit: Pgbus.configuration.workflow_max_payload_size)
|
|
16
|
+
serialized = ::ActiveJob::Arguments.serialize([values.to_h]).first
|
|
17
|
+
size = JSON.generate(serialized).bytesize
|
|
18
|
+
raise PayloadTooLarge, "#{what} is #{size} bytes, over workflow_max_payload_size (#{limit})" if size > limit
|
|
19
|
+
|
|
20
|
+
serialized
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def load(serialized, configuration: Pgbus.configuration)
|
|
24
|
+
return nil if serialized.nil?
|
|
25
|
+
|
|
26
|
+
Serializer.assert_job_global_ids_allowed!(serialized, configuration: configuration)
|
|
27
|
+
::ActiveJob::Arguments.deserialize([serialized]).first.with_indifferent_access
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The subject columns of a run: the polymorphic type and id the
|
|
31
|
+
# one-ongoing index keys on, and the GlobalID +subject+ loads through.
|
|
32
|
+
def subject_attributes(subject)
|
|
33
|
+
return { subject_type: nil, subject_id: nil, subject_gid: nil } if subject.nil?
|
|
34
|
+
unless subject.respond_to?(:to_global_id) && subject.respond_to?(:id)
|
|
35
|
+
raise ArgumentError, "a workflow subject must be a GlobalID record (an ActiveRecord model), got #{subject.class}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
klass = subject.class
|
|
39
|
+
type = klass.respond_to?(:base_class) ? klass.base_class.name : klass.name
|
|
40
|
+
{ subject_type: type, subject_id: subject.id.to_s, subject_gid: subject.to_global_id.to_s }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# nil when the run has no subject or the record is gone.
|
|
44
|
+
def locate_subject(gid, configuration: Pgbus.configuration)
|
|
45
|
+
return nil if gid.nil?
|
|
46
|
+
|
|
47
|
+
Serializer.locate_global_id(gid, configuration: configuration)
|
|
48
|
+
rescue ActiveRecord::RecordNotFound
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
class Workflow
|
|
8
|
+
# Every Pgbus::Workflow subclass, registered from `inherited` (what the
|
|
9
|
+
# insights page lists). The list is a frozen Array replaced whole under a
|
|
10
|
+
# lock built at load (docs/concurrency.md): a reader never sees it change
|
|
11
|
+
# under it, and two classes defined at once are both kept.
|
|
12
|
+
#
|
|
13
|
+
# Classes are kept as objects, not names: `Class.new(Pgbus::Workflow)`
|
|
14
|
+
# has no name yet when `inherited` runs. Names are read when listing, and
|
|
15
|
+
# a name defined again (code reloading) lists its latest class only.
|
|
16
|
+
module Registry
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
@classes = [].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def register(klass)
|
|
22
|
+
@lock.synchronize { @classes = merged(@classes, klass) }
|
|
23
|
+
klass
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def classes
|
|
27
|
+
named = @lock.synchronize { @classes }.select(&:name)
|
|
28
|
+
named.reverse.uniq(&:name).reverse
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Specs only.
|
|
32
|
+
def reset!
|
|
33
|
+
@lock.synchronize { @classes = [].freeze }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# SHA256 of the ordered [name, kind] pairs of a Steps list.
|
|
37
|
+
def digest(steps)
|
|
38
|
+
Digest::SHA256.hexdigest(JSON.generate(steps.digest_pairs))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# A class registered again by name (code reloading) replaces the old
|
|
44
|
+
# one, so reloads do not accumulate. Anonymous classes are kept until
|
|
45
|
+
# they get a name; classes lists them then.
|
|
46
|
+
def merged(list, klass)
|
|
47
|
+
name = klass.name
|
|
48
|
+
(list.reject { |existing| existing.equal?(klass) || (name && existing.name == name) } << klass).freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# A removed_step gravestone: a step that existed once. A scheduled row
|
|
6
|
+
# still naming it is skipped and the workflow moves on to the next step
|
|
7
|
+
# declared after it.
|
|
8
|
+
RemovedStep = Data.define(:name) do
|
|
9
|
+
def kind = "removed"
|
|
10
|
+
|
|
11
|
+
def removed? = true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
class Workflow
|
|
8
|
+
# The transitions every caller shares: start!, the outside verbs, the
|
|
9
|
+
# StepJob and the sweep. Each change happens under the workflow row lock
|
|
10
|
+
# (WorkflowEntry.lock_for) as a guarded UPDATE whose WHERE status IN (...)
|
|
11
|
+
# comes from the States table, and writes its WorkflowEvent in the same
|
|
12
|
+
# transaction. Instrumentation and error reports wait until it commits.
|
|
13
|
+
module Runner
|
|
14
|
+
DEFAULT_ACTOR = "app"
|
|
15
|
+
SYSTEM_ACTOR = "system"
|
|
16
|
+
|
|
17
|
+
# Instrumentation events and error reports collected inside a
|
|
18
|
+
# transaction, sent once it is over.
|
|
19
|
+
class Notes
|
|
20
|
+
def initialize
|
|
21
|
+
@entries = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def instrument(event, payload) = @entries << [:instrument, event, payload]
|
|
25
|
+
|
|
26
|
+
def report(error, context) = @entries << [:report, error, context]
|
|
27
|
+
|
|
28
|
+
def flush
|
|
29
|
+
entries = @entries
|
|
30
|
+
@entries = []
|
|
31
|
+
entries.each do |kind, subject, detail|
|
|
32
|
+
kind == :instrument ? Instrumentation.instrument(subject, detail) : ErrorReporter.report(subject, detail)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sends now, or, when the transition ran inside an app's transaction
|
|
37
|
+
# on the same connection, once that transaction commits (and never
|
|
38
|
+
# if it rolls back): what is reported must have happened.
|
|
39
|
+
def flush_after_commit(connection)
|
|
40
|
+
return flush unless connection.transaction_open?
|
|
41
|
+
|
|
42
|
+
connection.add_transaction_record(AfterCommit.new(self))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The transaction-record interface ActiveRecord calls on commit and
|
|
46
|
+
# rollback (the one after_commit callbacks use), on 7.1 and later;
|
|
47
|
+
# ActiveRecord.after_all_transactions_commit needs 7.2.
|
|
48
|
+
class AfterCommit
|
|
49
|
+
def initialize(notes)
|
|
50
|
+
@notes = notes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def trigger_transactional_callbacks? = true
|
|
54
|
+
|
|
55
|
+
def has_transactional_callbacks? = true # rubocop:disable Naming/PredicatePrefix -- ActiveRecord's name
|
|
56
|
+
|
|
57
|
+
def before_committed!; end
|
|
58
|
+
|
|
59
|
+
def committed!(*, **) = @notes.flush
|
|
60
|
+
|
|
61
|
+
def rolledback!(*, **); end
|
|
62
|
+
|
|
63
|
+
def add_to_transaction(*); end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# --- start! and rerun! --------------------------------------------
|
|
70
|
+
|
|
71
|
+
def start(klass, subject:, arguments:, idempotency_key: nil)
|
|
72
|
+
attrs = run_attributes(klass, idempotency_key: idempotency_key)
|
|
73
|
+
.merge(Payload.subject_attributes(subject))
|
|
74
|
+
.merge(arguments: Payload.dump(arguments, what: "arguments of #{klass.name}"))
|
|
75
|
+
insert_run(klass, attrs, actor: DEFAULT_ACTOR)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A finished or cancelled run started again: same subject and
|
|
79
|
+
# arguments (copied as stored, not reloaded), no idempotency key.
|
|
80
|
+
def rerun(workflow, actor:, reason: nil)
|
|
81
|
+
WorkflowEntry.transaction do
|
|
82
|
+
old = WorkflowEntry.lock_for(workflow.workflow_id) || raise(ActiveRecord::RecordNotFound,
|
|
83
|
+
"workflow #{workflow.workflow_id} is gone")
|
|
84
|
+
States.check!(:rerun, old.status)
|
|
85
|
+
klass = old.workflow_klass || raise(StepNotDefined, "#{old.workflow_class} is not a Pgbus::Workflow class any more")
|
|
86
|
+
attrs = run_attributes(klass, rerun_of: old.workflow_id)
|
|
87
|
+
.merge(old.attributes.symbolize_keys.slice(:subject_type, :subject_id, :subject_gid, :arguments))
|
|
88
|
+
rerun = insert_run(klass, attrs, actor: actor, reason: reason)
|
|
89
|
+
event!(old, "rerun", actor: actor, reason: reason, details: { rerun_workflow_id: rerun.workflow_id })
|
|
90
|
+
rerun
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_attributes(klass, idempotency_key: nil, rerun_of: nil)
|
|
95
|
+
raise ArgumentError, "an anonymous workflow class cannot start: give it a constant name" unless klass.name
|
|
96
|
+
|
|
97
|
+
first = klass.workflow_steps.first
|
|
98
|
+
raise ArgumentError, "#{klass.name} declares no steps" unless first
|
|
99
|
+
|
|
100
|
+
Waiting.check_schema!(klass)
|
|
101
|
+
|
|
102
|
+
now = Time.current
|
|
103
|
+
{ workflow_id: SecureRandom.uuid, workflow_class: klass.name, status: "ready", current_step: first.name,
|
|
104
|
+
step_job_options: klass.step_job_options.transform_keys(&:to_s), idempotency_key: idempotency_key&.to_s,
|
|
105
|
+
definition_digest: klass.definition_digest, rerun_of_workflow_id: rerun_of, created_at: now, updated_at: now }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The run row and its first step row in one transaction (a savepoint
|
|
109
|
+
# inside an app transaction, so a unique violation leaves the app's
|
|
110
|
+
# transaction usable). The first StepJob goes out when it commits.
|
|
111
|
+
def insert_run(klass, attrs, actor:, reason: nil)
|
|
112
|
+
record, duplicate = WorkflowEntry.transaction(requires_new: true) do
|
|
113
|
+
next [existing_run(klass, attrs), true] unless WorkflowEntry.insert_run!(attrs)
|
|
114
|
+
|
|
115
|
+
record = WorkflowEntry.find_by!(workflow_id: attrs[:workflow_id])
|
|
116
|
+
event!(record, "started", actor: actor, reason: reason, details: { rerun_of: attrs[:rerun_of_workflow_id] }.compact)
|
|
117
|
+
first = klass.workflow_steps.first
|
|
118
|
+
schedule!(record, first.name, at: Time.current + first.wait, actor: actor)
|
|
119
|
+
[record, false]
|
|
120
|
+
end
|
|
121
|
+
klass.new(record, duplicate_start: duplicate)
|
|
122
|
+
rescue ActiveRecord::RecordNotUnique
|
|
123
|
+
# ON CONFLICT only absorbs the idempotency index. Two concurrent
|
|
124
|
+
# starts with the same key and subject can instead trip the
|
|
125
|
+
# one-ongoing index first; by then the winner has committed, so its
|
|
126
|
+
# key is visible: that is still a duplicate start, not a second run.
|
|
127
|
+
existing = attrs[:idempotency_key] && existing_run(klass, attrs, required: false)
|
|
128
|
+
return klass.new(existing, duplicate_start: true) if existing
|
|
129
|
+
|
|
130
|
+
holder = WorkflowEntry.ongoing.find_by(workflow_class: klass.name, subject_type: attrs[:subject_type],
|
|
131
|
+
subject_id: attrs[:subject_id])
|
|
132
|
+
raise AlreadyRunning.new("#{klass.name} already has an ongoing run for #{attrs[:subject_type]} " \
|
|
133
|
+
"#{attrs[:subject_id]} (#{holder&.workflow_id})", workflow: holder && klass.new(holder))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def existing_run(klass, attrs, required: true)
|
|
137
|
+
scope = WorkflowEntry.where(workflow_class: klass.name, idempotency_key: attrs[:idempotency_key])
|
|
138
|
+
required ? scope.take! : scope.take
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# --- outside verbs ------------------------------------------------
|
|
142
|
+
|
|
143
|
+
# pause!, cancel!, resume!, resume_at!, run_now!, reschedule! on a run.
|
|
144
|
+
# Returns the updated row; raises the States table's error when the
|
|
145
|
+
# verb is not legal in the run's state.
|
|
146
|
+
def apply(workflow, verb, actor:, reason: nil, **options)
|
|
147
|
+
locked(workflow.workflow_id) do |record, notes|
|
|
148
|
+
raise ActiveRecord::RecordNotFound, "workflow #{workflow.workflow_id} is gone" unless record
|
|
149
|
+
|
|
150
|
+
States.check!(verb, record.status)
|
|
151
|
+
OutsideVerbs.public_send(verb, record, notes, actor: actor, reason: reason, **options)
|
|
152
|
+
record
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# --- shared helpers -----------------------------------------------
|
|
157
|
+
|
|
158
|
+
# Runs the block in a transaction with the run's row locked (nil when
|
|
159
|
+
# the run is gone), and sends what the block noted once it commits.
|
|
160
|
+
#
|
|
161
|
+
# The notes are enrolled inside the transition's own transaction, the
|
|
162
|
+
# way a saved model is: they go out when that transaction's work really
|
|
163
|
+
# commits, whether it is a top-level transaction, part of an app's
|
|
164
|
+
# joinable one, or a savepoint inside a non-joinable one (Rails'
|
|
165
|
+
# transactional tests), and never on a rollback.
|
|
166
|
+
def locked(workflow_id)
|
|
167
|
+
notes = Notes.new
|
|
168
|
+
WorkflowEntry.transaction do
|
|
169
|
+
result = yield WorkflowEntry.lock_for(workflow_id), notes
|
|
170
|
+
notes.flush_after_commit(WorkflowEntry.connection)
|
|
171
|
+
result
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The guarded UPDATE for +verb+: WHERE status IN (States.from(verb)).
|
|
176
|
+
# Under the row lock it cannot lose once the caller checked the state;
|
|
177
|
+
# 0 rows raises rather than letting a transition pass silently.
|
|
178
|
+
def transition!(record, verb, **attrs)
|
|
179
|
+
to = States.to(verb)
|
|
180
|
+
updated = WorkflowEntry.transition!(record.workflow_id, from: States.from(verb), to: to, **attrs)
|
|
181
|
+
raise InvalidTransition, "#{verb} found workflow #{record.workflow_id} outside #{States.from(verb).join("/")}" if updated.zero?
|
|
182
|
+
|
|
183
|
+
record.assign_attributes(attrs.merge(status: to))
|
|
184
|
+
to
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def event!(record, event, actor:, reason: nil, step: nil, details: {})
|
|
188
|
+
WorkflowEvent.record!(workflow_id: record.workflow_id, event: event, actor: actor, reason: reason, step: step,
|
|
189
|
+
details: details)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A new attempt row of +step_name+ (its after-commit callback sends the
|
|
193
|
+
# StepJob), with its step_scheduled event. A job_options wait:/wait_until:
|
|
194
|
+
# (class or step) is folded into scheduled_at here, once, so the sweep
|
|
195
|
+
# does not take a deliberately delayed step for a lost one and
|
|
196
|
+
# run_now!/reschedule! move the one time that counts.
|
|
197
|
+
def schedule!(record, step_name, at:, actor:) # rubocop:disable Naming/MethodParameterName
|
|
198
|
+
at = job_delay_floor(record, step_name, at)
|
|
199
|
+
step = WorkflowStep.insert_next!(workflow_id: record.workflow_id, workflow_class: record.workflow_class,
|
|
200
|
+
step_name: step_name, scheduled_at: at)
|
|
201
|
+
event!(record, "step_scheduled", actor: actor, step: step, details: { scheduled_at: at.utc.iso8601(3) })
|
|
202
|
+
step
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def job_delay_floor(record, step_name, at) # rubocop:disable Naming/MethodParameterName
|
|
206
|
+
options = record.workflow_klass&.step_job_options_for(step_name) || {}
|
|
207
|
+
[at, options[:wait] && (Time.current + options[:wait]), options[:wait_until]].compact.max
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# "hostname:pid", stamped on every attempt a worker claims.
|
|
211
|
+
def worker_tag = "#{Socket.gethostname}:#{::Process.pid}"
|
|
212
|
+
|
|
213
|
+
def payload_for(record, **extra)
|
|
214
|
+
{ workflow_class: record.workflow_class, workflow_id: record.workflow_id }.merge(extra)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|