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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The class-body DSL. Every declaration builds a new frozen value and
|
|
6
|
+
# stores it whole in a class_attribute, so a subclass extends what its
|
|
7
|
+
# parent declared without changing the parent, and nothing is ever
|
|
8
|
+
# mutated after the class body ran (docs/concurrency.md).
|
|
9
|
+
module DSL
|
|
10
|
+
# step :name, wait:, skip_if:, timeout:, job_options:, on_exception:,
|
|
11
|
+
# before_step:, after_step: do ... end
|
|
12
|
+
# step { ... } # anonymous: step_1, step_2, ...
|
|
13
|
+
# step :name # runs the instance method `name`
|
|
14
|
+
# step def name = ... # the same, declared in one line
|
|
15
|
+
def step(name = nil, before_step: nil, after_step: nil, **options, &block)
|
|
16
|
+
raise ArgumentError, "a step needs a name or a block" if name.nil? && block.nil?
|
|
17
|
+
|
|
18
|
+
reject_unknown_options!(options)
|
|
19
|
+
name ||= _workflow_steps.anonymous_name
|
|
20
|
+
definition = StepDefinition.new(name: name, body: block || name.to_sym, **options)
|
|
21
|
+
self._workflow_steps = _workflow_steps.add(definition, before: before_step, after: after_step)
|
|
22
|
+
definition.name.to_sym
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# fan_out :name, on_batch_failure: :pause!, **step_options do |b| ... end
|
|
26
|
+
#
|
|
27
|
+
# A step that adds a group of jobs with b.add and waits for them as one
|
|
28
|
+
# batch: the step parks as waiting, and the batch's on_complete resumes
|
|
29
|
+
# the run once every member finished. Any dead-lettered member applies
|
|
30
|
+
# on_batch_failure (:pause!, :cancel! or :skip!) with
|
|
31
|
+
# Pgbus::Workflow::FanOutFailed recorded. Takes every step option but
|
|
32
|
+
# wait_for:, wait_timeout: and on_timeout:. Without a block it runs the
|
|
33
|
+
# instance method of the same name, which gets the builder.
|
|
34
|
+
def fan_out(name = nil, on_batch_failure: :pause!, before_step: nil, after_step: nil, **options, &block)
|
|
35
|
+
raise ArgumentError, "a fan_out needs a name or a block" if name.nil? && block.nil?
|
|
36
|
+
|
|
37
|
+
name ||= _workflow_steps.anonymous_name
|
|
38
|
+
waits = options.keys & %i[wait_for wait_timeout on_timeout]
|
|
39
|
+
if waits.any?
|
|
40
|
+
raise ArgumentError, "fan_out #{name.to_s.to_sym.inspect} cannot take #{waits.first}: it waits for its batch, " \
|
|
41
|
+
"not for a signal"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
reject_unknown_options!(options, kind: "fan_out", accepted: FanOutDefinition::OPTIONS)
|
|
45
|
+
definition = FanOutDefinition.new(name: name, body: block || name.to_sym, on_batch_failure: on_batch_failure, **options)
|
|
46
|
+
self._workflow_steps = _workflow_steps.add(definition, before: before_step, after: after_step)
|
|
47
|
+
definition.name.to_sym
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A step that no longer exists. Declare it where the step used to be: a
|
|
51
|
+
# run still scheduled on it skips it and moves on to the step after.
|
|
52
|
+
def removed_step(*names)
|
|
53
|
+
names.each { |name| self._workflow_steps = _workflow_steps.remove(name) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs before every step under the workflow lock; truthy cancels the
|
|
57
|
+
# workflow. A block or a method name; several are OR-ed.
|
|
58
|
+
def cancel_if(method_name = nil, &block)
|
|
59
|
+
condition = block || method_name
|
|
60
|
+
raise ArgumentError, "cancel_if needs a block or a method name" unless condition.is_a?(Proc) || condition.is_a?(Symbol)
|
|
61
|
+
|
|
62
|
+
self._cancel_conditions = [*_cancel_conditions, condition].freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A class-level exception policy (see ExceptionPolicy).
|
|
66
|
+
def on_exception(*matching, action: nil, **, &)
|
|
67
|
+
policy = ExceptionPolicy.new(action, matching: matching.flatten, **, &)
|
|
68
|
+
self._exception_policies = [*_exception_policies, policy].freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# StepJob options for every step of this class: queue, priority, wait,
|
|
72
|
+
# wait_until. A step's own job_options: win over these.
|
|
73
|
+
def set_step_job_options(**options)
|
|
74
|
+
self._step_job_options = _step_job_options.merge(StepDefinition.validate_job_options!(options)).freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def workflow_steps = _workflow_steps
|
|
78
|
+
|
|
79
|
+
def cancel_conditions = _cancel_conditions
|
|
80
|
+
|
|
81
|
+
def exception_policies = _exception_policies
|
|
82
|
+
|
|
83
|
+
def step_job_options = _step_job_options
|
|
84
|
+
|
|
85
|
+
# SHA256 of the ordered [name, kind] step list. Stored on every run at
|
|
86
|
+
# start, so a run that began on an older definition can be told apart.
|
|
87
|
+
def definition_digest = Registry.digest(_workflow_steps)
|
|
88
|
+
|
|
89
|
+
# The StepJob options for one step: the class's, then the step's own.
|
|
90
|
+
def step_job_options_for(step_name)
|
|
91
|
+
own = _workflow_steps.fetch(step_name)&.job_options || {}
|
|
92
|
+
_step_job_options.merge(own)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def reject_unknown_options!(options, kind: "step", accepted: StepDefinition::OPTIONS)
|
|
98
|
+
unknown = options.keys - accepted
|
|
99
|
+
return if unknown.empty?
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "unknown #{kind} option #{unknown.first.inspect} (accepted: #{accepted.join(", ")})"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# Turns an exception into the step row's error columns: what an operator
|
|
6
|
+
# needs to act on a failed step without opening the error service.
|
|
7
|
+
#
|
|
8
|
+
# error_class the class name
|
|
9
|
+
# error_message capped at MESSAGE_LIMIT characters
|
|
10
|
+
# error_backtrace cleaned through Rails.backtrace_cleaner when Rails is
|
|
11
|
+
# loaded, cut to config.workflow_error_backtrace_lines
|
|
12
|
+
# error_causes [{ "class", "message" }] along Exception#cause, at most
|
|
13
|
+
# MAX_CAUSES deep (a cyclic chain stops where it repeats)
|
|
14
|
+
module ErrorCapture
|
|
15
|
+
MESSAGE_LIMIT = 10_000
|
|
16
|
+
CAUSE_MESSAGE_LIMIT = 1_000
|
|
17
|
+
MAX_CAUSES = 5
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def capture(error, backtrace_lines: Pgbus.configuration.workflow_error_backtrace_lines)
|
|
22
|
+
{
|
|
23
|
+
error_class: error.class.name,
|
|
24
|
+
error_message: truncate(error.message.to_s, MESSAGE_LIMIT),
|
|
25
|
+
error_backtrace: backtrace(error, backtrace_lines).join("\n"),
|
|
26
|
+
error_causes: causes(error)
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The columns cleared when a later attempt succeeds or a run resumes.
|
|
31
|
+
def cleared = { error_class: nil, error_message: nil, error_backtrace: nil }
|
|
32
|
+
|
|
33
|
+
def backtrace(error, lines)
|
|
34
|
+
raw = error.backtrace || []
|
|
35
|
+
cleaned = clean(raw)
|
|
36
|
+
(cleaned.empty? ? raw : cleaned).first(lines)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def causes(error)
|
|
40
|
+
seen = {}.compare_by_identity
|
|
41
|
+
seen[error] = true
|
|
42
|
+
chain = []
|
|
43
|
+
cause = error.cause
|
|
44
|
+
while cause && chain.size < MAX_CAUSES && !seen[cause]
|
|
45
|
+
seen[cause] = true
|
|
46
|
+
chain << { "class" => cause.class.name, "message" => truncate(cause.message.to_s, CAUSE_MESSAGE_LIMIT) }
|
|
47
|
+
cause = cause.cause
|
|
48
|
+
end
|
|
49
|
+
chain
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def truncate(string, limit)
|
|
53
|
+
string.length > limit ? "#{string[0, limit - 1]}…" : string
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clean(lines)
|
|
57
|
+
return lines unless defined?(::Rails) && ::Rails.respond_to?(:backtrace_cleaner) && ::Rails.backtrace_cleaner
|
|
58
|
+
|
|
59
|
+
::Rails.backtrace_cleaner.clean(lines)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# What happens when a step body raises (issue #540). A policy matches an
|
|
6
|
+
# exception (by class, class name, or a list of either; none = every
|
|
7
|
+
# StandardError) and names an action:
|
|
8
|
+
#
|
|
9
|
+
# on_exception Net::OpenTimeout, action: :reattempt!, wait: 15.seconds, max_reattempts: 5
|
|
10
|
+
# on_exception PaymentDeclined, action: :cancel!, report: :never
|
|
11
|
+
# on_exception action: :pause! # blanket
|
|
12
|
+
# on_exception Timeout::Error do |error| # imperative: must call a verb
|
|
13
|
+
# error.message.include?("read") ? reattempt!(wait: 5) : skip!
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Resolution: the step's own policies, then the class's specific ones,
|
|
17
|
+
# then the class's blanket ones, then the default pause!. The error is
|
|
18
|
+
# recorded on the step row either way; +report+ decides whether it also
|
|
19
|
+
# goes to Pgbus::ErrorReporter (:always, :never, or :terminal_only: only
|
|
20
|
+
# when the action ends the retrying). max_reattempts counts consecutive
|
|
21
|
+
# reattempted attempts of the same step; once spent, terminal_action
|
|
22
|
+
# (default pause!) applies.
|
|
23
|
+
class ExceptionPolicy
|
|
24
|
+
ACTIONS = %i[pause! reattempt! cancel! skip!].freeze
|
|
25
|
+
TERMINAL_ACTIONS = %i[pause! cancel! skip!].freeze
|
|
26
|
+
REPORTS = %i[always never terminal_only].freeze
|
|
27
|
+
|
|
28
|
+
# +outcome+ is a FlowControl::Outcome; +report+ whether ErrorReporter
|
|
29
|
+
# hears of it; +handler_error+ is what an imperative block raised.
|
|
30
|
+
Decision = Data.define(:outcome, :report, :terminal, :handler_error)
|
|
31
|
+
|
|
32
|
+
attr_reader :action, :matching, :wait, :max_reattempts, :terminal_action, :report, :handler
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# The step-level `on_exception:` option: a policy, an action
|
|
36
|
+
# (`:reattempt!`), a Hash of policy options, or an Array of those.
|
|
37
|
+
def coerce_all(value)
|
|
38
|
+
Array(value.is_a?(Hash) ? [value] : value).map { |entry| coerce(entry) }.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def coerce(value)
|
|
42
|
+
case value
|
|
43
|
+
when ExceptionPolicy then value
|
|
44
|
+
when Symbol, String then new(value.to_sym)
|
|
45
|
+
when Hash
|
|
46
|
+
options = value.transform_keys(&:to_sym)
|
|
47
|
+
new(options.delete(:action), **options)
|
|
48
|
+
else
|
|
49
|
+
raise ArgumentError, "on_exception: takes a Pgbus::Workflow::ExceptionPolicy, an action, a Hash or an Array, " \
|
|
50
|
+
"got #{value.inspect}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve(error, step_policies: [], class_policies: [])
|
|
55
|
+
step_policies.find { |policy| policy.matches?(error) } ||
|
|
56
|
+
class_policies.find { |policy| policy.specific? && policy.matches?(error) } ||
|
|
57
|
+
class_policies.find { |policy| !policy.specific? && policy.matches?(error) } ||
|
|
58
|
+
DEFAULT
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(action = nil, matching: nil, wait: nil, max_reattempts: nil, terminal_action: nil, report: :always, &handler)
|
|
63
|
+
raise ArgumentError, "on_exception takes action: or a block, not both" if action && handler
|
|
64
|
+
raise ArgumentError, "on_exception needs action: (#{ACTIONS.join(", ")}) or a block" unless action || handler
|
|
65
|
+
|
|
66
|
+
@action = action && normalize_action(action, ACTIONS, "action")
|
|
67
|
+
reattempt_only!(wait: wait, terminal_action: terminal_action)
|
|
68
|
+
@matching = normalize_matching(matching)
|
|
69
|
+
@wait = StepDefinition.seconds(wait, "wait", positive: false)
|
|
70
|
+
@max_reattempts = validate_max_reattempts(max_reattempts)
|
|
71
|
+
@terminal_action = normalize_action(terminal_action.nil? ? :pause! : terminal_action, TERMINAL_ACTIONS, "terminal_action")
|
|
72
|
+
@report = validate_report(report)
|
|
73
|
+
@handler = handler
|
|
74
|
+
freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def specific? = !matching.nil?
|
|
78
|
+
|
|
79
|
+
def matches?(error)
|
|
80
|
+
return error.is_a?(StandardError) if matching.nil?
|
|
81
|
+
|
|
82
|
+
matching.any? do |matcher|
|
|
83
|
+
matcher.is_a?(String) ? error.class.ancestors.any? { |ancestor| ancestor.name == matcher } : error.is_a?(matcher)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# +consecutive_reattempts+ is a callable (it reads the step rows), only
|
|
88
|
+
# called when max_reattempts needs it.
|
|
89
|
+
def decide(error, workflow:, consecutive_reattempts: -> { 0 })
|
|
90
|
+
return decide_with_handler(error, workflow) if handler
|
|
91
|
+
return decide_reattempt(consecutive_reattempts) if action == :reattempt!
|
|
92
|
+
|
|
93
|
+
Decision.new(outcome: FlowControl::Outcome.for(action), report: report != :never, terminal: true, handler_error: nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def decide_reattempt(consecutive_reattempts)
|
|
99
|
+
if max_reattempts && consecutive_reattempts.call >= max_reattempts
|
|
100
|
+
return Decision.new(outcome: FlowControl::Outcome.for(terminal_action), report: report != :never,
|
|
101
|
+
terminal: true, handler_error: nil)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Decision.new(outcome: FlowControl::Outcome.reattempt(wait: wait), report: report == :always,
|
|
105
|
+
terminal: false, handler_error: nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The block runs on the workflow instance and must call a verb; one
|
|
109
|
+
# that returns without calling any pauses the workflow. A block that
|
|
110
|
+
# raises pauses it too, and its own error is handed back for reporting.
|
|
111
|
+
def decide_with_handler(error, workflow)
|
|
112
|
+
outcome = catch(FlowControl::THROW_TAG) do
|
|
113
|
+
workflow.instance_exec(error, &handler)
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
terminal = !outcome&.reattempt?
|
|
117
|
+
Decision.new(outcome: outcome || FlowControl::Outcome.pause,
|
|
118
|
+
report: report == :always || (report == :terminal_only && terminal),
|
|
119
|
+
terminal: terminal, handler_error: nil)
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
Decision.new(outcome: FlowControl::Outcome.pause, report: true, terminal: true, handler_error: e)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_action(value, allowed, option)
|
|
125
|
+
verb = :"#{value.to_s.delete_suffix("!")}!"
|
|
126
|
+
return verb if allowed.include?(verb)
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, "#{option}: must be one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_matching(matching)
|
|
132
|
+
return nil if matching.nil? || (matching.respond_to?(:empty?) && matching.empty?)
|
|
133
|
+
|
|
134
|
+
Array(matching).map do |matcher|
|
|
135
|
+
next -matcher.to_s if matcher.is_a?(String) || matcher.is_a?(Symbol)
|
|
136
|
+
next matcher if matcher.is_a?(Module)
|
|
137
|
+
|
|
138
|
+
raise ArgumentError, "matching: takes exception classes or class names, got #{matcher.inspect}"
|
|
139
|
+
end.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# wait: and terminal_action: shape a reattempt!; anywhere else (another
|
|
143
|
+
# action, an imperative block, which passes its own wait) they would be
|
|
144
|
+
# silently ignored.
|
|
145
|
+
def reattempt_only!(**options)
|
|
146
|
+
given = options.compact.keys
|
|
147
|
+
return if given.empty? || action == :reattempt!
|
|
148
|
+
|
|
149
|
+
raise ArgumentError, "#{given.map { |key| "#{key}:" }.join(" and ")} only applies to action: :reattempt!"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_max_reattempts(value)
|
|
153
|
+
return nil if value.nil?
|
|
154
|
+
raise ArgumentError, "max_reattempts: only applies to action: :reattempt!" unless action == :reattempt!
|
|
155
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
156
|
+
|
|
157
|
+
raise ArgumentError, "max_reattempts: must be a positive Integer, got #{value.inspect}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_report(value)
|
|
161
|
+
return value if REPORTS.include?(value)
|
|
162
|
+
|
|
163
|
+
raise ArgumentError, "report: must be one of #{REPORTS.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# What applies when no policy matches: pause!, reported.
|
|
168
|
+
ExceptionPolicy::DEFAULT = ExceptionPolicy.new(:pause!)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# fan_out steps (issue #541): a group of jobs the workflow waits for as
|
|
6
|
+
# one batch.
|
|
7
|
+
#
|
|
8
|
+
# fan_out :notify_vendors do |b|
|
|
9
|
+
# b.add(*subject.vendors.map { |vendor| NotifyVendorJob.new(vendor.id) })
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# 1. run (inside the StepJob, outside any transaction): the body gets a
|
|
13
|
+
# Builder that records its b.add calls. A flow-control verb or an
|
|
14
|
+
# exception in the body ends the step before any batch exists. When
|
|
15
|
+
# the body returns, Pgbus.batch opens with on_complete
|
|
16
|
+
# BatchFinishedJob.new(step_id) and the recorded adds.
|
|
17
|
+
# 2. park (under the run's lock): the step waits on the batch. A batch
|
|
18
|
+
# that already finished (an empty one finishes at once, before
|
|
19
|
+
# Pgbus.batch returns) is resolved here and then, since its callback
|
|
20
|
+
# may have run before the step parked and found nothing to resume.
|
|
21
|
+
# 3. finished (BatchFinishedJob, under the lock): no dead-lettered member
|
|
22
|
+
# completes the step and advances; any applies on_batch_failure with
|
|
23
|
+
# FanOutFailed recorded. A step no longer waiting (skipped, cancelled,
|
|
24
|
+
# already resolved by the park) is left alone.
|
|
25
|
+
#
|
|
26
|
+
# A reattempt or a resume! of a failed fan_out runs the whole body again
|
|
27
|
+
# and opens a fresh batch: member jobs must be idempotent.
|
|
28
|
+
module FanOut
|
|
29
|
+
# The `b` a fan_out body gets. Records its adds; the batch opens once
|
|
30
|
+
# the body returns. The workflow owns the batch's callbacks.
|
|
31
|
+
class Builder
|
|
32
|
+
CALLBACK_MESSAGE = "a fan_out step owns its batch's callbacks (on_complete resumes the workflow); " \
|
|
33
|
+
"do the follow-up work in the next step"
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@adds = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Adds job instances (MyJob.new(args), optionally .set(...)). A block
|
|
40
|
+
# runs inside the batch, so a perform_later in it joins too.
|
|
41
|
+
def add(*jobs, &block)
|
|
42
|
+
jobs = Batch::Builder.job_instances(jobs)
|
|
43
|
+
@adds << [jobs, block]
|
|
44
|
+
jobs
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def size = @adds.sum { |jobs, _block| jobs.size }
|
|
48
|
+
|
|
49
|
+
%i[on_complete on_success on_failure on_finish].each do |slot|
|
|
50
|
+
define_method(slot) { |*, **| raise ArgumentError, "#{slot}: #{CALLBACK_MESSAGE}" }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Replays the recorded adds on a real batch builder.
|
|
54
|
+
def replay(batch_builder)
|
|
55
|
+
@adds.each { |jobs, block| batch_builder.add(*jobs, &block) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
def run(definition, workflow)
|
|
62
|
+
builder = Builder.new
|
|
63
|
+
definition.body.is_a?(Symbol) ? workflow.send(definition.body, builder) : workflow.instance_exec(builder, &definition.body)
|
|
64
|
+
batch = open_batch(workflow, workflow.send(:step_context), builder)
|
|
65
|
+
throw FlowControl::THROW_TAG, FlowControl::Outcome.fan_out(batch_id: batch.batch_id, size: batch.reload.total_jobs)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def open_batch(workflow, attempt, builder)
|
|
69
|
+
queue = workflow.class.step_job_options_for(attempt.step_name)[:queue] || Pgbus.configuration.workflow_queue
|
|
70
|
+
callback = BatchFinishedJob.new(attempt.step_id).set(queue: queue)
|
|
71
|
+
Pgbus.batch(description: "#{workflow.workflow_class}##{attempt.step_name} #{workflow.workflow_id}",
|
|
72
|
+
properties: { workflow_id: workflow.workflow_id, step_id: attempt.step_id }) do |b|
|
|
73
|
+
b.on_complete(callback)
|
|
74
|
+
builder.replay(b)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The StepJob's outcome for a fan_out whose batch is open. What the body
|
|
79
|
+
# recorded with output(...) is kept on the row until the step resolves.
|
|
80
|
+
def park(record, step, outcome, notes:, output: nil)
|
|
81
|
+
batch_id = outcome.details.fetch(:batch_id)
|
|
82
|
+
Runner.event!(record, "fan_out_opened", actor: Runner::SYSTEM_ACTOR, step: step, details: outcome.details)
|
|
83
|
+
unless Waiting.park!(record, step, now: Time.current, batch_id: batch_id, output: output)
|
|
84
|
+
StepOutcome.ignore_late_completion(record, step, "waiting", "fan_out", nil)
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
entry = BatchEntry.find_by(batch_id: batch_id)
|
|
89
|
+
resolve(record, step, entry, notes) if entry&.status == "finished"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# BatchFinishedJob#perform (and the sweep, for a lost one). Truthy when
|
|
93
|
+
# it resolved the step.
|
|
94
|
+
def finished(step_id)
|
|
95
|
+
step = WorkflowStep.find_by(id: step_id)
|
|
96
|
+
return log("step #{step_id} no longer exists (its run was deleted)") unless step
|
|
97
|
+
|
|
98
|
+
Runner.locked(step.workflow_id) do |record, notes|
|
|
99
|
+
step = record && WorkflowStep.find_by(id: step_id)
|
|
100
|
+
unless step&.status == "waiting" && step.batch_id
|
|
101
|
+
next log("step #{step_id} is not waiting on its batch (#{step&.status || "gone"}): nothing to resume")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
entry = BatchEntry.find_by(batch_id: step.batch_id)
|
|
105
|
+
next resolve_vanished(record, step, notes) unless entry
|
|
106
|
+
next log("batch #{step.batch_id} of step #{step_id} has not finished") unless entry.status == "finished"
|
|
107
|
+
|
|
108
|
+
resolve(record, step, entry, notes)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The sweep's repair for a lost callback: fan_out steps still waiting on
|
|
113
|
+
# a batch that finished more than +grace+ ago, or whose batch row is
|
|
114
|
+
# gone (batch retention ran before a long-delayed callback). Returns how
|
|
115
|
+
# many it resolved.
|
|
116
|
+
def resolve_lost(now:, grace:, limit: Sweep::BATCH_SIZE)
|
|
117
|
+
finished_batches = BatchEntry.finished.where(finished_at: ...(now - grace)).select(:batch_id)
|
|
118
|
+
waiting = WorkflowStep.where(status: "waiting").where.not(batch_id: nil)
|
|
119
|
+
lost = waiting.where(batch_id: finished_batches)
|
|
120
|
+
.or(waiting.where(waiting_since: ...(now - grace)).where.not(batch_id: BatchEntry.select(:batch_id)))
|
|
121
|
+
lost.order(:id).limit(limit).pluck(:id).count { |step_id| finished(step_id) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The batch row is gone, so its outcome is unknown: the step pauses
|
|
125
|
+
# (whatever on_batch_failure says) with FanOutFailed, for a person to
|
|
126
|
+
# resume (a fresh batch) or skip.
|
|
127
|
+
def resolve_vanished(record, step, notes)
|
|
128
|
+
Runner.event!(record, "fan_out_finished", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
129
|
+
details: { batch_id: step.batch_id, vanished: true })
|
|
130
|
+
error = FanOutFailed.new("batch #{step.batch_id} no longer exists (deleted before its callback ran); its outcome is unknown")
|
|
131
|
+
Waiting.resolve!(record, step, FlowControl::Outcome.pause, notes: notes, error: error)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def resolve(record, step, entry, notes)
|
|
135
|
+
batch = Batch.find(entry.batch_id)
|
|
136
|
+
failed = batch.failed_jobs
|
|
137
|
+
total = batch.total_jobs
|
|
138
|
+
Runner.event!(record, "fan_out_finished", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
139
|
+
details: { batch_id: entry.batch_id, failed_jobs: failed, total_jobs: total })
|
|
140
|
+
return Waiting.resolve!(record, step, FlowControl::Outcome.complete, notes: notes) if failed.zero?
|
|
141
|
+
|
|
142
|
+
error = FanOutFailed.new("#{failed} of #{total} jobs dead-lettered (batch #{entry.batch_id})")
|
|
143
|
+
Waiting.resolve!(record, step, FlowControl::Outcome.for(failure_action(record, step)), notes: notes, error: error)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def failure_action(record, step)
|
|
147
|
+
definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
|
|
148
|
+
definition.respond_to?(:on_batch_failure) ? definition.on_batch_failure : :pause!
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def log(message)
|
|
152
|
+
Pgbus.logger.info { "[Pgbus::Workflow] fan_out: #{message}" }
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# A `fan_out` step (issue #541): a StepDefinition whose body adds jobs
|
|
6
|
+
# to a builder instead of doing the work itself. FanOut runs the body,
|
|
7
|
+
# opens the batch and parks the step on it.
|
|
8
|
+
class FanOutDefinition < StepDefinition
|
|
9
|
+
# What a batch with dead-lettered members does to the run.
|
|
10
|
+
FAILURE_ACTIONS = %i[pause! cancel! skip!].freeze
|
|
11
|
+
# The step options a fan_out takes (on_batch_failure: besides): every
|
|
12
|
+
# one but the signal waits.
|
|
13
|
+
OPTIONS = (StepDefinition::OPTIONS - %i[wait_for wait_timeout on_timeout]).freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :on_batch_failure
|
|
16
|
+
|
|
17
|
+
def initialize(on_batch_failure: :pause!, **)
|
|
18
|
+
@on_batch_failure = normalize_failure_action(on_batch_failure)
|
|
19
|
+
super(**)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def kind = "fan_out"
|
|
23
|
+
|
|
24
|
+
# Runs the body with a recording builder, opens the batch, and ends the
|
|
25
|
+
# attempt with the :fan_out outcome (FanOut.run throws it).
|
|
26
|
+
def invoke(workflow)
|
|
27
|
+
FanOut.run(self, workflow)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize_failure_action(value)
|
|
33
|
+
verb = value.nil? ? nil : :"#{value.to_s.delete_suffix("!")}!"
|
|
34
|
+
return verb if FAILURE_ACTIONS.include?(verb)
|
|
35
|
+
|
|
36
|
+
raise ArgumentError,
|
|
37
|
+
"on_batch_failure: must be one of #{FAILURE_ACTIONS.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The flow-control verbs. Inside a step body each one ends the body at
|
|
6
|
+
# once (`throw :pgbus_workflow_flow, Outcome`) and the StepJob applies the
|
|
7
|
+
# outcome under the workflow lock. Outside a step, `pause!` and `cancel!`
|
|
8
|
+
# are operator verbs (they change the run through the Runner, with
|
|
9
|
+
# actor:/reason: on the audit trail); the step-only verbs raise NotInStep.
|
|
10
|
+
module FlowControl
|
|
11
|
+
THROW_TAG = :pgbus_workflow_flow
|
|
12
|
+
ACTIONS = %i[complete reattempt pause cancel skip finish fan_out].freeze
|
|
13
|
+
|
|
14
|
+
# What a step attempt ended with. +wait+ is the reattempt delay in
|
|
15
|
+
# seconds (nil = now); +actor+ and +reason+ are what a step's own
|
|
16
|
+
# pause!/cancel! (or an operator's skip!) passed, for the audit trail;
|
|
17
|
+
# +details+ carries a fan_out's batch_id and size.
|
|
18
|
+
Outcome = Data.define(:action, :wait, :actor, :reason, :details) do
|
|
19
|
+
def initialize(action:, wait: nil, actor: nil, reason: nil, details: nil) = super
|
|
20
|
+
|
|
21
|
+
def self.complete = new(action: :complete, wait: nil)
|
|
22
|
+
def self.pause(actor: nil, reason: nil) = new(action: :pause, actor: actor, reason: reason)
|
|
23
|
+
def self.cancel(actor: nil, reason: nil) = new(action: :cancel, actor: actor, reason: reason)
|
|
24
|
+
def self.skip(actor: nil, reason: nil) = new(action: :skip, actor: actor, reason: reason)
|
|
25
|
+
def self.finish = new(action: :finish, wait: nil)
|
|
26
|
+
def self.reattempt(wait: nil) = new(action: :reattempt, wait: StepDefinition.seconds(wait, "wait", positive: false))
|
|
27
|
+
# A fan_out body returned and its batch is open: the step parks on it.
|
|
28
|
+
def self.fan_out(batch_id:, size:) = new(action: :fan_out, details: { batch_id: batch_id, size: size }.freeze)
|
|
29
|
+
|
|
30
|
+
# An ExceptionPolicy action (:pause!, :reattempt!, :cancel!, :skip!).
|
|
31
|
+
def self.for(verb, wait: nil)
|
|
32
|
+
case verb.to_s.delete_suffix("!").to_sym
|
|
33
|
+
when :pause then pause
|
|
34
|
+
when :cancel then cancel
|
|
35
|
+
when :skip then skip
|
|
36
|
+
when :reattempt then reattempt(wait: wait)
|
|
37
|
+
when :finished, :finish then finish
|
|
38
|
+
else raise ArgumentError, "unknown flow-control verb #{verb.inspect}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reattempt? = action == :reattempt
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Inside a step: end it here, completed, with the workflow paused.
|
|
46
|
+
# Outside: pause the run (ready, performing or waiting → paused).
|
|
47
|
+
def pause!(actor: nil, reason: nil)
|
|
48
|
+
return throw_outcome(Outcome.pause(actor: actor, reason: reason)) if in_step?
|
|
49
|
+
|
|
50
|
+
apply_outside_verb(:pause, actor: actor, reason: reason)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Inside a step: end it here and cancel the workflow.
|
|
54
|
+
# Outside: cancel the run from any ongoing state.
|
|
55
|
+
def cancel!(actor: nil, reason: nil)
|
|
56
|
+
return throw_outcome(Outcome.cancel(actor: actor, reason: reason)) if in_step?
|
|
57
|
+
|
|
58
|
+
apply_outside_verb(:cancel, actor: actor, reason: reason)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Ends this attempt as failed and schedules the next one, +wait+
|
|
62
|
+
# seconds from now. Step bodies only.
|
|
63
|
+
def reattempt!(wait: nil)
|
|
64
|
+
throw_outcome(Outcome.reattempt(wait: wait), verb: :reattempt!)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Inside a step: end it here, skipped; the workflow moves on.
|
|
68
|
+
# Outside: skip the step a waiting run waits on (a signal it holds is
|
|
69
|
+
# consumed) and schedule the next one.
|
|
70
|
+
def skip!(actor: nil, reason: nil)
|
|
71
|
+
return throw_outcome(Outcome.skip) if in_step?
|
|
72
|
+
|
|
73
|
+
apply_outside_verb(:skip, actor: actor, reason: reason)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Ends the step as completed and finishes the workflow, skipping the
|
|
77
|
+
# steps after it. Step bodies only.
|
|
78
|
+
def finished!
|
|
79
|
+
throw_outcome(Outcome.finish, verb: :finished!)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def in_step? = !step_context.nil?
|
|
85
|
+
|
|
86
|
+
def throw_outcome(outcome, verb: nil)
|
|
87
|
+
raise NotInStep, "#{verb} can only be called inside a step body" if verb && !in_step?
|
|
88
|
+
raise NotInStep, "flow-control verbs end a step from its body or an on_exception block, not from cancel_if or skip_if" if @guarding
|
|
89
|
+
|
|
90
|
+
throw THROW_TAG, outcome
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|