pgbus 0.19.3 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +104 -9
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +130 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +142 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +442 -0
- data/config/locales/de.yml +442 -0
- data/config/locales/en.yml +442 -0
- data/config/locales/es.yml +442 -0
- data/config/locales/fi.yml +442 -0
- data/config/locales/fr.yml +442 -0
- data/config/locales/it.yml +442 -0
- data/config/locales/ja.yml +442 -0
- data/config/locales/nb.yml +442 -0
- data/config/locales/nl.yml +442 -0
- data/config/locales/pt.yml +442 -0
- data/config/locales/sv.yml +442 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/adapter.rb +67 -26
- data/lib/pgbus/active_job/batch_id.rb +23 -1
- data/lib/pgbus/active_job/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- data/lib/pgbus/batch/builder.rb +1 -0
- data/lib/pgbus/batch.rb +35 -3
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +21 -3
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +32 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
- data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
- data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
- data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
- data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
- data/lib/pgbus/process/dispatcher.rb +26 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
- data/lib/pgbus/web/data_source.rb +3 -0
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +76 -0
- data/lib/pgbus/web/workflow_timeline.rb +113 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +131 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +73 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +22 -0
- metadata +95 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# One declared step: its name, its body (a block or the name of an
|
|
6
|
+
# instance method) and its options. Built and validated when the class
|
|
7
|
+
# body runs, so a bad option fails at load, not on the first run.
|
|
8
|
+
class StepDefinition
|
|
9
|
+
OPTIONS = %i[wait skip_if job_options timeout on_exception wait_for wait_timeout on_timeout].freeze
|
|
10
|
+
# What a wait_for: step does when its wait_timeout: passes first.
|
|
11
|
+
ON_TIMEOUT = %i[pause! cancel! skip! run].freeze
|
|
12
|
+
# The StepJob options a class (set_step_job_options) or a step
|
|
13
|
+
# (job_options:) may set. Anything else raises at class load.
|
|
14
|
+
JOB_OPTIONS = %i[queue priority wait wait_until].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :name, :body, :wait, :skip_if, :job_options, :timeout, :exception_policies,
|
|
17
|
+
:wait_for, :wait_for_name, :wait_timeout, :on_timeout
|
|
18
|
+
|
|
19
|
+
def self.validate_job_options!(options)
|
|
20
|
+
raise ArgumentError, "job_options: must be a Hash, got #{options.inspect}" unless options.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
options.to_h do |key, value|
|
|
23
|
+
key = key.to_sym
|
|
24
|
+
unless JOB_OPTIONS.include?(key)
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"unknown job_options: #{key.inspect} — accepted: #{JOB_OPTIONS.map(&:inspect).join(", ")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
[key, job_option_value(key, value)]
|
|
30
|
+
end.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.job_option_value(key, value)
|
|
34
|
+
case key
|
|
35
|
+
when :queue
|
|
36
|
+
raise ArgumentError, "job_options: queue must be a queue name, got #{value.inspect}" if value.to_s.strip.empty?
|
|
37
|
+
|
|
38
|
+
-value.to_s
|
|
39
|
+
when :priority
|
|
40
|
+
return value if value.is_a?(Integer)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "job_options: priority must be an Integer, got #{value.inspect}"
|
|
43
|
+
when :wait then seconds(value, "job_options: wait", positive: false)
|
|
44
|
+
when :wait_until
|
|
45
|
+
# Time, DateTime and TimeWithZone; not a String, which ActiveSupport
|
|
46
|
+
# also gives #to_time (nil for an unparseable one).
|
|
47
|
+
return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String) && value.to_time
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "job_options: wait_until must be a Time, got #{value.inspect}"
|
|
50
|
+
else value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Seconds as an Integer when whole, else a Float. Durations are
|
|
55
|
+
# accepted; nil stays nil.
|
|
56
|
+
def self.seconds(value, option, positive:)
|
|
57
|
+
return nil if value.nil?
|
|
58
|
+
|
|
59
|
+
seconds = duration?(value) ? value.to_f : value
|
|
60
|
+
unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && (positive ? seconds.positive? : !seconds.negative?)
|
|
61
|
+
requirement = positive ? "a positive" : "a non-negative"
|
|
62
|
+
raise ArgumentError, "#{option}: must be #{requirement} number of seconds or a Duration, got #{value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
seconds == seconds.to_i ? seconds.to_i : seconds.to_f
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.duration?(value)
|
|
69
|
+
defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(name:, body:, wait: nil, skip_if: nil, job_options: {}, timeout: nil, on_exception: nil,
|
|
73
|
+
wait_for: nil, wait_timeout: nil, on_timeout: nil)
|
|
74
|
+
@name = -name.to_s
|
|
75
|
+
@body = body
|
|
76
|
+
@wait = self.class.seconds(wait, "wait", positive: false) || 0
|
|
77
|
+
@skip_if = validate_skip_if(skip_if)
|
|
78
|
+
@job_options = self.class.validate_job_options!(job_options.nil? ? {} : job_options)
|
|
79
|
+
@timeout = self.class.seconds(timeout, "timeout", positive: true)
|
|
80
|
+
@exception_policies = ExceptionPolicy.coerce_all(on_exception)
|
|
81
|
+
assign_wait(wait_for, wait_timeout, on_timeout)
|
|
82
|
+
freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def kind = "step"
|
|
86
|
+
|
|
87
|
+
# True for a wait_for: step: it parks until a matching signal arrives.
|
|
88
|
+
def waits_for_signal? = !wait_for.nil?
|
|
89
|
+
|
|
90
|
+
def removed? = false
|
|
91
|
+
|
|
92
|
+
# Runs the body on +workflow+; the return value is ignored.
|
|
93
|
+
def invoke(workflow)
|
|
94
|
+
body.is_a?(Symbol) ? workflow.send(body) : workflow.instance_exec(&body)
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def skip?(workflow)
|
|
99
|
+
return false if skip_if.nil?
|
|
100
|
+
|
|
101
|
+
result = skip_if.is_a?(Symbol) ? workflow.send(skip_if) : workflow.instance_exec(&skip_if)
|
|
102
|
+
result ? true : false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def assign_wait(wait_for, wait_timeout, on_timeout)
|
|
108
|
+
@wait_for = wait_for.nil? ? nil : SignalMatcher.coerce(wait_for)
|
|
109
|
+
@wait_for_name = @wait_for && SignalMatcher.label(@wait_for)
|
|
110
|
+
raise ArgumentError, "on_timeout: needs wait_for: and wait_timeout:" if !on_timeout.nil? && (@wait_for.nil? || wait_timeout.nil?)
|
|
111
|
+
raise ArgumentError, "wait_timeout: needs wait_for: (it bounds the wait for a signal)" if wait_timeout && @wait_for.nil?
|
|
112
|
+
|
|
113
|
+
@wait_timeout = self.class.seconds(wait_timeout, "wait_timeout", positive: true)
|
|
114
|
+
@on_timeout = @wait_timeout && normalize_on_timeout(on_timeout.nil? ? :pause! : on_timeout)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def normalize_on_timeout(value)
|
|
118
|
+
verb = value.to_s == "run" ? :run : :"#{value.to_s.delete_suffix("!")}!"
|
|
119
|
+
return verb if ON_TIMEOUT.include?(verb)
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "on_timeout: must be one of #{ON_TIMEOUT.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_skip_if(value)
|
|
125
|
+
return value if value.nil? || value.is_a?(Symbol) || value.is_a?(Proc)
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, "skip_if: must be a lambda, a Proc or a method name, got #{value.inspect}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# One StepJob delivery: claim the step row, check the guards, run the
|
|
6
|
+
# body in a fresh workflow instance, apply the outcome.
|
|
7
|
+
#
|
|
8
|
+
# 1. Claim, under the run's row lock: scheduled → in_progress (only once
|
|
9
|
+
# due) and the run ready → performing. A row that is not claimable
|
|
10
|
+
# (already running elsewhere, finished, early, or its run is paused or
|
|
11
|
+
# gone) makes this delivery a no-op, except a redelivery of the very
|
|
12
|
+
# message that claimed an in_progress row while the visibility
|
|
13
|
+
# heartbeat is on: its worker died, and the stall action applies now.
|
|
14
|
+
# 2. Guards, still under the lock: the class or the step no longer
|
|
15
|
+
# defined (pause with StepNotDefined, or skip a removed_step),
|
|
16
|
+
# cancel_if, skip_if.
|
|
17
|
+
# 3. The body, outside any transaction, inside `catch` for the
|
|
18
|
+
# flow-control verbs and `rescue StandardError` for the policies.
|
|
19
|
+
# 4. The outcome, under the lock again (StepOutcome).
|
|
20
|
+
#
|
|
21
|
+
# Whatever the step's outcome, the job itself completes normally: the
|
|
22
|
+
# step row and the pgbus.workflow.* events carry the truth.
|
|
23
|
+
class StepExecution
|
|
24
|
+
Claim = Data.define(:record, :step, :klass, :definition, :workflow, :guard_error)
|
|
25
|
+
|
|
26
|
+
def initialize(step_id, delivery: Pgbus::ActiveJob::Delivery.current, config: Pgbus.configuration)
|
|
27
|
+
@step_id = step_id
|
|
28
|
+
@delivery = delivery
|
|
29
|
+
@config = config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call
|
|
33
|
+
step = WorkflowStep.find_by(id: @step_id)
|
|
34
|
+
return debug("step #{@step_id} no longer exists (its run was deleted)") unless step
|
|
35
|
+
|
|
36
|
+
StepContext.with(workflow_id: step.workflow_id, workflow_class: step.workflow_class, step_name: step.step_name,
|
|
37
|
+
attempt: step.attempt) do
|
|
38
|
+
claim = claim(step)
|
|
39
|
+
run(claim) if claim
|
|
40
|
+
end
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :config
|
|
47
|
+
|
|
48
|
+
# The worker tag and the app revision (which may call the app's
|
|
49
|
+
# app_revision callable or read a file) are resolved before the run's
|
|
50
|
+
# row lock is taken.
|
|
51
|
+
def claim(step)
|
|
52
|
+
@stamp = { worker: Runner.worker_tag, app_revision: Deploy::Revision.current(config) }
|
|
53
|
+
Runner.locked(step.workflow_id) do |record, notes|
|
|
54
|
+
step = WorkflowStep.find_by(id: step.id) if record
|
|
55
|
+
if record.nil? || step.nil?
|
|
56
|
+
debug("step #{@step_id}'s run no longer exists")
|
|
57
|
+
elsif step.status == "in_progress"
|
|
58
|
+
check_redelivery(record, step, notes)
|
|
59
|
+
elsif claimable?(record, step)
|
|
60
|
+
claim_step(record, step, notes)
|
|
61
|
+
else
|
|
62
|
+
debug("step #{step.id} is not claimable (step #{step.status}, run #{record.status}, due #{step.scheduled_at})")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def claimable?(record, step)
|
|
68
|
+
step.status == "scheduled" && States.legal?(:claim, record.status) && step.scheduled_at <= Time.current + 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A live worker keeps its message invisible (VisibilityHeartbeat), so
|
|
72
|
+
# the same message coming back for a row it claimed means that worker
|
|
73
|
+
# is gone. Without the heartbeat a long live step can outlive its
|
|
74
|
+
# visibility timeout, so the sweep's stall_at decides instead.
|
|
75
|
+
def check_redelivery(record, step, notes)
|
|
76
|
+
msg_id = @delivery && @delivery[:msg_id]
|
|
77
|
+
redelivered = !msg_id.nil? && step.msg_id == msg_id.to_i && @delivery[:read_ct].to_i > 1 && config.visibility_heartbeat
|
|
78
|
+
return debug("step #{step.id} is already in progress elsewhere") unless redelivered
|
|
79
|
+
|
|
80
|
+
StepOutcome.stall!(record, step, detection: "redelivery", notes: notes, config: config)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def claim_step(record, step, notes)
|
|
85
|
+
now = Time.current
|
|
86
|
+
klass = record.workflow_klass
|
|
87
|
+
definition = klass&.workflow_steps&.fetch(step.step_name)
|
|
88
|
+
attrs = { stall_at: now + (definition&.timeout || config.workflow_stall_threshold), msg_id: @delivery&.dig(:msg_id),
|
|
89
|
+
**@stamp }
|
|
90
|
+
return nil if WorkflowStep.claim!(step.id, now: now, **attrs).zero?
|
|
91
|
+
|
|
92
|
+
step.assign_attributes(status: "in_progress", started_at: now, **attrs)
|
|
93
|
+
Runner.transition!(record, :claim, current_step: step.step_name)
|
|
94
|
+
Runner.event!(record, "step_started", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
95
|
+
details: attrs.slice(:worker, :app_revision, :msg_id).compact)
|
|
96
|
+
guard(record, step, klass, definition, notes)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the Claim to run, or nil when a guard already ended the
|
|
100
|
+
# attempt. A guard that raises is handed to the exception policies,
|
|
101
|
+
# like a body that raises.
|
|
102
|
+
def guard(record, step, klass, definition, notes)
|
|
103
|
+
return undefined!(record, step, notes, "#{record.workflow_class} is not a Pgbus::Workflow class any more") unless klass
|
|
104
|
+
|
|
105
|
+
unless definition
|
|
106
|
+
return skip!(record, step, notes, klass) if klass.workflow_steps.removed?(step.step_name)
|
|
107
|
+
|
|
108
|
+
return undefined!(record, step, notes, "#{klass.name} has no step #{step.step_name} " \
|
|
109
|
+
"(declare `removed_step :#{step.step_name}` to skip it)")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
workflow = klass.new(record)
|
|
113
|
+
workflow.send(:enter_step, StepContext::Attempt.new(step_id: step.id, step_name: step.step_name, attempt: step.attempt))
|
|
114
|
+
claim = Claim.new(record: record, step: step, klass: klass, definition: definition, workflow: workflow, guard_error: nil)
|
|
115
|
+
begin
|
|
116
|
+
verdict = workflow.send(:guarding) do
|
|
117
|
+
next FlowControl::Outcome.cancel if cancel?(klass, workflow)
|
|
118
|
+
|
|
119
|
+
FlowControl::Outcome.skip if definition.skip?(workflow)
|
|
120
|
+
end
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
return claim.with(guard_error: e)
|
|
123
|
+
end
|
|
124
|
+
return outcome!(record, step, notes, klass, verdict) if verdict
|
|
125
|
+
|
|
126
|
+
gate(record, step, definition, workflow, claim)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A wait_for: step runs only with a signal (or once its wait timed
|
|
130
|
+
# out); without one it parks and this delivery ends. A matcher that
|
|
131
|
+
# raises is handed to the exception policies, like a guard.
|
|
132
|
+
def gate(record, step, definition, workflow, claim)
|
|
133
|
+
return claim unless definition.waits_for_signal?
|
|
134
|
+
|
|
135
|
+
Signals.gate(record, step, definition, workflow) ? claim : nil
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
claim.with(guard_error: e)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def cancel?(klass, workflow)
|
|
141
|
+
klass.cancel_conditions.any? do |condition|
|
|
142
|
+
condition.is_a?(Symbol) ? workflow.send(condition) : workflow.instance_exec(&condition)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def undefined!(record, step, notes, message)
|
|
147
|
+
StepOutcome.apply(record, step, FlowControl::Outcome.pause, notes: notes, error: StepNotDefined.new(message), klass: nil)
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def skip!(record, step, notes, klass) = outcome!(record, step, notes, klass, FlowControl::Outcome.skip)
|
|
152
|
+
|
|
153
|
+
def outcome!(record, step, notes, klass, outcome)
|
|
154
|
+
StepOutcome.apply(record, step, outcome, notes: notes, klass: klass, duration: 0.0)
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def run(claim)
|
|
159
|
+
started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
160
|
+
outcome, error = claim.guard_error ? [nil, claim.guard_error] : execute(claim)
|
|
161
|
+
duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started
|
|
162
|
+
finish(claim, outcome, error, duration)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def execute(claim)
|
|
166
|
+
outcome = catch(FlowControl::THROW_TAG) do
|
|
167
|
+
claim.definition.invoke(claim.workflow)
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
[outcome || FlowControl::Outcome.complete, nil]
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
[nil, e]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The exception policy is decided before the run's lock is taken
|
|
176
|
+
# again: an imperative on_exception block is app code, and must not
|
|
177
|
+
# hold the row lock (and an open transaction) while it runs.
|
|
178
|
+
def finish(claim, outcome, error, duration)
|
|
179
|
+
return finish_late(claim, error) if error && !attempt_active?(claim)
|
|
180
|
+
|
|
181
|
+
decision = error && decide(claim, error)
|
|
182
|
+
outcome = decision.outcome if decision
|
|
183
|
+
Runner.locked(claim.record.workflow_id) do |record, notes|
|
|
184
|
+
next debug("step #{claim.step.id}'s run was deleted while the step ran") unless record
|
|
185
|
+
next park_fan_out(record, claim, outcome, notes) if outcome&.action == :fan_out
|
|
186
|
+
|
|
187
|
+
notes.report(decision.handler_error, handler_context(claim)) if decision&.handler_error
|
|
188
|
+
StepOutcome.apply(record, claim.step, outcome, notes: notes, error: error, output: claim.workflow.pending_output,
|
|
189
|
+
report: decision ? decision.report : true, duration: duration,
|
|
190
|
+
klass: claim.klass)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def park_fan_out(record, claim, outcome, notes)
|
|
195
|
+
FanOut.park(record, claim.step, outcome, notes: notes, output: claim.workflow.pending_output)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# While its body runs, a row leaves in_progress only for a terminal
|
|
199
|
+
# status (a parked row went waiting before any body ran), so an attempt
|
|
200
|
+
# that finds it gone has been declared stalled (or its run cancelled): late.
|
|
201
|
+
def attempt_active?(claim) = WorkflowStep.where(id: claim.step.id, status: "in_progress").exists?
|
|
202
|
+
|
|
203
|
+
# A late attempt that raised: its outcome is ignored either way, so an
|
|
204
|
+
# imperative on_exception block must not run (and cause side effects)
|
|
205
|
+
# for it. A declarative policy still names the outcome it would have
|
|
206
|
+
# applied and decides the report; an imperative one records none.
|
|
207
|
+
def finish_late(claim, error)
|
|
208
|
+
policy = policy_for(claim, error)
|
|
209
|
+
decision = policy.handler ? nil : decide(claim, error, policy: policy)
|
|
210
|
+
column = decision && StepOutcome::STEP_OUTCOME.fetch(decision.outcome.action)
|
|
211
|
+
report = decision ? decision.report : policy.report != :never
|
|
212
|
+
Runner.locked(claim.record.workflow_id) do |record, notes|
|
|
213
|
+
next debug("step #{claim.step.id}'s run was deleted while the step ran") unless record
|
|
214
|
+
|
|
215
|
+
StepOutcome.ignore_late_completion(record, claim.step, "failed", column, error)
|
|
216
|
+
notes.report(error, StepOutcome.error_context(record, claim.step, "late_completion_ignored")) if report
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def policy_for(claim, error)
|
|
221
|
+
ExceptionPolicy.resolve(error, step_policies: claim.definition.exception_policies,
|
|
222
|
+
class_policies: claim.klass.exception_policies)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def decide(claim, error, policy: policy_for(claim, error))
|
|
226
|
+
step = claim.step
|
|
227
|
+
policy.decide(error, workflow: claim.workflow,
|
|
228
|
+
consecutive_reattempts: -> { WorkflowStep.consecutive_reattempts(step.workflow_id, step.step_name) })
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def handler_context(claim)
|
|
232
|
+
{ action: "workflow_on_exception", workflow_class: claim.klass.name, workflow_id: claim.step.workflow_id,
|
|
233
|
+
step_name: claim.step.step_name, attempt: claim.step.attempt }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def debug(message)
|
|
237
|
+
Pgbus.logger.debug { "[Pgbus::Workflow] #{message} #{StepContext.log_tag}".rstrip }
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class Workflow
|
|
7
|
+
# The job behind every step attempt: perform(step_id) runs one
|
|
8
|
+
# StepExecution. Enqueued only by Pgbus::WorkflowStep's after-commit
|
|
9
|
+
# callback, on config.workflow_queue unless the class
|
|
10
|
+
# (set_step_job_options) or the step (job_options:) says otherwise.
|
|
11
|
+
#
|
|
12
|
+
# No retry_on and no discard_on: a step's failure is the exception
|
|
13
|
+
# policies' business, and the job returns normally whatever the step's
|
|
14
|
+
# outcome. Redelivery is reserved for a worker that died mid-step, which
|
|
15
|
+
# the claim recognises. The visibility heartbeat must stay on for this
|
|
16
|
+
# class (never `pgbus_visibility_heartbeat false`): fast crash detection
|
|
17
|
+
# depends on a live worker keeping its message invisible.
|
|
18
|
+
class StepJob < ::ActiveJob::Base
|
|
19
|
+
# Always on, whatever ActiveJob::Base or the app set: the executor reads
|
|
20
|
+
# this, and a redelivery means a dead worker only while it is true.
|
|
21
|
+
def self.pgbus_visibility_heartbeat_enabled = true # rubocop:disable Naming/PredicateMethod -- JobMixin's reader
|
|
22
|
+
|
|
23
|
+
def perform(step_id)
|
|
24
|
+
StepExecution.new(step_id).call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# Applies how a step attempt ended to its row and to the run, under the
|
|
6
|
+
# run's row lock: the StepJob after a body, a guard (cancel_if, skip_if,
|
|
7
|
+
# a removed or unknown step), crash detection and the sweep.
|
|
8
|
+
#
|
|
9
|
+
# The step row is written first, guarded on status = 'in_progress'. A
|
|
10
|
+
# row that is no longer in_progress was declared stalled (or the run was
|
|
11
|
+
# cancelled) while this attempt was still running: the outcome and its
|
|
12
|
+
# output are discarded and only a late_completion_ignored event is
|
|
13
|
+
# recorded. Otherwise the run moves by the States table: advance (the
|
|
14
|
+
# next step is scheduled), finish, reattempt, step_pause, step_cancel, or
|
|
15
|
+
# hold when an outside pause! got there first.
|
|
16
|
+
module StepOutcome
|
|
17
|
+
STEP_STATUS = { complete: "completed", finish: "completed", pause: "completed",
|
|
18
|
+
skip: "skipped", cancel: "cancelled", reattempt: "failed" }.freeze
|
|
19
|
+
STEP_OUTCOME = { complete: "completed", finish: "finished_early", pause: "paused",
|
|
20
|
+
skip: "skipped", cancel: "cancelled", reattempt: "reattempted" }.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Returns false when the attempt had lost its in_progress guard.
|
|
25
|
+
def apply(record, step, outcome, notes:, error: nil, output: nil, report: true, duration: nil, klass: record.workflow_klass) # rubocop:disable Naming/PredicateMethod
|
|
26
|
+
now = Time.current
|
|
27
|
+
status = error ? "failed" : STEP_STATUS.fetch(outcome.action)
|
|
28
|
+
column = STEP_OUTCOME.fetch(outcome.action)
|
|
29
|
+
captured = error && ErrorCapture.capture(error)
|
|
30
|
+
attrs = { status: status, outcome: column, finished_at: now, output: output, **captured.to_h }
|
|
31
|
+
if WorkflowStep.finish!(step.id, **attrs).zero?
|
|
32
|
+
ignore_late_completion(record, step, status, column, error)
|
|
33
|
+
notes.report(error, error_context(record, step, "late_completion_ignored")) if error && report
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Signals.settle(step, status, column, now: now)
|
|
38
|
+
step_events(record, step, outcome, column, error)
|
|
39
|
+
move_run(record, step, outcome, notes: notes, captured: captured, klass: klass, now: now)
|
|
40
|
+
notes.instrument("pgbus.workflow.step", step_payload(record, step, column, status, error, duration))
|
|
41
|
+
notes.report(error, error_context(record, step, column)) if error && report
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A step whose worker died (detection: "redelivery") or that outlived
|
|
46
|
+
# its stall_at (detection: "sweep"): config.workflow_stall_action
|
|
47
|
+
# applies, with Pgbus::Workflow::StepStalled recorded on the row.
|
|
48
|
+
def stall!(record, step, detection:, notes:, config: Pgbus.configuration)
|
|
49
|
+
why = if detection == "redelivery"
|
|
50
|
+
"its message came back while it was in_progress: the worker #{step.worker} died"
|
|
51
|
+
else
|
|
52
|
+
"it was still in_progress at its stall_at #{step.stall_at&.utc&.iso8601} (worker #{step.worker})"
|
|
53
|
+
end
|
|
54
|
+
error = StepStalled.new("#{step.step_name} attempt #{step.attempt} stalled: #{why}")
|
|
55
|
+
Runner.event!(record, "stall_detected", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
56
|
+
details: { detection: detection, worker: step.worker,
|
|
57
|
+
stall_at: step.stall_at&.utc&.iso8601(3) }.compact)
|
|
58
|
+
notes.instrument("pgbus.workflow.stall_detected",
|
|
59
|
+
Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, detection: detection))
|
|
60
|
+
apply(record, step, stall_outcome(config.workflow_stall_action), notes: notes, error: error)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stall_outcome(action)
|
|
64
|
+
case action
|
|
65
|
+
when :reattempt then FlowControl::Outcome.reattempt
|
|
66
|
+
when :cancel then FlowControl::Outcome.cancel
|
|
67
|
+
else FlowControl::Outcome.pause
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def move_run(record, step, outcome, notes:, captured:, klass:, now:)
|
|
72
|
+
paused = record.status == "paused"
|
|
73
|
+
case outcome.action
|
|
74
|
+
when :complete, :skip
|
|
75
|
+
following = klass&.workflow_steps&.next_after(step.step_name)
|
|
76
|
+
following ? advance(record, following, paused: paused, now: now) : finish(record, notes)
|
|
77
|
+
when :finish then finish(record, notes)
|
|
78
|
+
when :reattempt
|
|
79
|
+
Runner.schedule!(record, step.step_name, at: now + (outcome.wait || 0), actor: Runner::SYSTEM_ACTOR)
|
|
80
|
+
Runner.transition!(record, paused ? :hold : :reattempt, current_step: step.step_name)
|
|
81
|
+
when :pause then pause(record, step, outcome, notes, captured: captured, paused: paused, now: now)
|
|
82
|
+
when :cancel then cancel(record, step, outcome, notes, now: now)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def advance(record, following, paused:, now:)
|
|
87
|
+
Runner.schedule!(record, following.name, at: now + following.wait, actor: Runner::SYSTEM_ACTOR)
|
|
88
|
+
Runner.transition!(record, paused ? :hold : :advance, current_step: following.name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def finish(record, notes)
|
|
92
|
+
Runner.transition!(record, :finish, finished_at: Time.current)
|
|
93
|
+
Runner.event!(record, "finished", actor: Runner::SYSTEM_ACTOR)
|
|
94
|
+
notes.instrument("pgbus.workflow_finished", Runner.payload_for(record))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The run row keeps the error that paused it (cleared by resume!).
|
|
98
|
+
def pause(record, step, outcome, notes, captured:, paused:, now:)
|
|
99
|
+
run_error = captured ? captured.except(:error_causes) : {}
|
|
100
|
+
return Runner.transition!(record, :hold, **run_error) if paused
|
|
101
|
+
|
|
102
|
+
error_class = captured&.fetch(:error_class)
|
|
103
|
+
Runner.transition!(record, :step_pause, paused_at: now, **run_error)
|
|
104
|
+
Runner.event!(record, "paused", actor: outcome.actor || Runner::SYSTEM_ACTOR, reason: outcome.reason, step: step,
|
|
105
|
+
details: { error_class: error_class }.compact)
|
|
106
|
+
notes.instrument("pgbus.workflow_paused", Runner.payload_for(record, step_name: step.step_name, error_class: error_class))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cancel(record, step, outcome, notes, now:)
|
|
110
|
+
Runner.transition!(record, :step_cancel, cancelled_at: now)
|
|
111
|
+
WorkflowStep.cancel_open!(record.workflow_id, now: now)
|
|
112
|
+
Runner.event!(record, "cancelled", actor: outcome.actor || Runner::SYSTEM_ACTOR, reason: outcome.reason, step: step)
|
|
113
|
+
notes.instrument("pgbus.workflow_cancelled", Runner.payload_for(record, step_name: step.step_name))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def step_events(record, step, outcome, column, error)
|
|
117
|
+
actor = Runner::SYSTEM_ACTOR
|
|
118
|
+
if error
|
|
119
|
+
Runner.event!(record, "step_failed", actor: actor, step: step,
|
|
120
|
+
details: { error_class: error.class.name, error_message: error.message.to_s })
|
|
121
|
+
end
|
|
122
|
+
case outcome.action
|
|
123
|
+
when :complete, :finish, :pause
|
|
124
|
+
Runner.event!(record, "step_completed", actor: actor, step: step, details: { outcome: column }) unless error
|
|
125
|
+
when :skip then Runner.event!(record, "step_skipped", actor: outcome.actor || actor, reason: outcome.reason, step: step)
|
|
126
|
+
when :reattempt then Runner.event!(record, "step_reattempted", actor: actor, step: step, details: { wait: outcome.wait }.compact)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ignore_late_completion(record, step, status, column, error)
|
|
131
|
+
details = { status: status, outcome: column, error_class: error&.class&.name }.compact
|
|
132
|
+
Runner.event!(record, "late_completion_ignored", actor: Runner::SYSTEM_ACTOR, step: step, details: details)
|
|
133
|
+
Pgbus.logger.warn do
|
|
134
|
+
"[Pgbus] Workflow #{record.workflow_class} #{record.workflow_id}: step #{step.step_name} attempt #{step.attempt} " \
|
|
135
|
+
"finished after it stopped being in_progress (stalled or cancelled); its outcome (#{column}) and output were discarded"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def step_payload(record, step, column, status, error, duration)
|
|
140
|
+
pickup = step.started_at && step.scheduled_at ? [step.started_at - step.scheduled_at, 0].max.to_f : nil
|
|
141
|
+
Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, outcome: column, status: status,
|
|
142
|
+
error_class: error&.class&.name, duration: duration, pickup_latency: pickup)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def error_context(record, step, column)
|
|
146
|
+
{ workflow_class: record.workflow_class, workflow_id: record.workflow_id, step_name: step.step_name,
|
|
147
|
+
attempt: step.attempt, outcome: column }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The ordered step list of one workflow class: StepDefinitions and
|
|
6
|
+
# RemovedStep gravestones, in declaration order. Immutable: every
|
|
7
|
+
# change (`step`, `removed_step`) builds a new frozen Steps that the
|
|
8
|
+
# class stores whole, so a subclass extends its parent's list without
|
|
9
|
+
# touching it and a reader never sees a list change under it
|
|
10
|
+
# (docs/concurrency.md).
|
|
11
|
+
class Steps
|
|
12
|
+
attr_reader :entries
|
|
13
|
+
|
|
14
|
+
def initialize(entries = [])
|
|
15
|
+
@entries = entries.dup.freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
EMPTY = new
|
|
20
|
+
|
|
21
|
+
def add(definition, before: nil, after: nil)
|
|
22
|
+
raise ArgumentError, "use before_step: or after_step:, not both" if before && after
|
|
23
|
+
raise ArgumentError, "#{definition.name} is a removed step (removed_step); pick another name" if removed?(definition.name)
|
|
24
|
+
raise ArgumentError, "duplicate step #{definition.name}" if fetch(definition.name)
|
|
25
|
+
|
|
26
|
+
list = entries.dup
|
|
27
|
+
anchor = before || after
|
|
28
|
+
if anchor
|
|
29
|
+
index = position(anchor)
|
|
30
|
+
unless index
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"#{before ? "before_step" : "after_step"}: #{anchor.to_s.to_sym.inspect} — no step named #{anchor}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
list.insert(before ? index : index + 1, definition)
|
|
36
|
+
else
|
|
37
|
+
list << definition
|
|
38
|
+
end
|
|
39
|
+
self.class.new(list)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Replaces a live step with a gravestone at its position, or appends a
|
|
43
|
+
# gravestone for a step that is already gone from the class.
|
|
44
|
+
def remove(name)
|
|
45
|
+
name = -name.to_s
|
|
46
|
+
raise ArgumentError, "#{name} is already a removed step" if removed?(name)
|
|
47
|
+
|
|
48
|
+
list = entries.dup
|
|
49
|
+
index = position(name)
|
|
50
|
+
gravestone = RemovedStep.new(name: name)
|
|
51
|
+
index ? list[index] = gravestone : list << gravestone
|
|
52
|
+
self.class.new(list)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def definitions = entries.reject(&:removed?)
|
|
56
|
+
|
|
57
|
+
def names = definitions.map(&:name)
|
|
58
|
+
|
|
59
|
+
def removed_names = entries.select(&:removed?).map(&:name)
|
|
60
|
+
|
|
61
|
+
def fetch(name)
|
|
62
|
+
name = name.to_s
|
|
63
|
+
definitions.find { |entry| entry.name == name }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def removed?(name)
|
|
67
|
+
name = name.to_s
|
|
68
|
+
entries.any? { |entry| entry.removed? && entry.name == name }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def first = definitions.first
|
|
72
|
+
|
|
73
|
+
# The next live step declared after +name+ (a step or a gravestone),
|
|
74
|
+
# or nil when +name+ is the last one or unknown.
|
|
75
|
+
def next_after(name)
|
|
76
|
+
index = position(name)
|
|
77
|
+
return nil unless index
|
|
78
|
+
|
|
79
|
+
entries.drop(index + 1).find { |entry| !entry.removed? }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def empty? = definitions.empty?
|
|
83
|
+
|
|
84
|
+
# Name for an anonymous `step { ... }`: step_<n>, the first n from
|
|
85
|
+
# entries.size + 1 not taken by a step or a gravestone.
|
|
86
|
+
def anonymous_name
|
|
87
|
+
number = entries.size + 1
|
|
88
|
+
number += 1 while entries.any? { |entry| entry.name == "step_#{number}" }
|
|
89
|
+
"step_#{number}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# What definition_digest hashes: the ordered [name, kind] pairs.
|
|
93
|
+
def digest_pairs = entries.map { |entry| [entry.name, entry.kind] }
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def position(name)
|
|
98
|
+
name = name.to_s
|
|
99
|
+
entries.index { |entry| entry.name == name }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|