pgbus 0.19.4 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +96 -7
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +130 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +142 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +442 -0
- data/config/locales/de.yml +442 -0
- data/config/locales/en.yml +442 -0
- data/config/locales/es.yml +442 -0
- data/config/locales/fi.yml +442 -0
- data/config/locales/fr.yml +442 -0
- data/config/locales/it.yml +442 -0
- data/config/locales/ja.yml +442 -0
- data/config/locales/nb.yml +442 -0
- data/config/locales/nl.yml +442 -0
- data/config/locales/pt.yml +442 -0
- data/config/locales/sv.yml +442 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +21 -3
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +32 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
- data/lib/pgbus/process/dispatcher.rb +26 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
- data/lib/pgbus/web/data_source.rb +3 -0
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +76 -0
- data/lib/pgbus/web/workflow_timeline.rb +113 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +131 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +73 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +22 -0
- metadata +92 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The dispatcher's workflow housekeeping.
|
|
6
|
+
#
|
|
7
|
+
# repair (every config.workflow_sweep_interval):
|
|
8
|
+
# - an in_progress step past its stall_at (started_at + the step's
|
|
9
|
+
# timeout:, or workflow_stall_threshold) gets workflow_stall_action,
|
|
10
|
+
# with StepStalled recorded. A dead worker is usually caught sooner, on
|
|
11
|
+
# redelivery (StepExecution); this catches a live but hung step, and
|
|
12
|
+
# every crash when the visibility heartbeat is off.
|
|
13
|
+
# - a scheduled step of a ready run, due more than workflow_stall_threshold
|
|
14
|
+
# ago and not sent within it, is sent again by bumping enqueued_at (the
|
|
15
|
+
# model callback re-fires). No queue inspection: a duplicate message is
|
|
16
|
+
# a no-op at the claim.
|
|
17
|
+
# - a waiting step of a waiting run past its timeout_at (wait_timeout:)
|
|
18
|
+
# gets its on_timeout (WaitTimeout), so a timeout fires within one
|
|
19
|
+
# workflow_sweep_interval of its deadline.
|
|
20
|
+
# - a fan_out step still waiting on a batch that finished more than
|
|
21
|
+
# workflow_stall_threshold ago lost its BatchFinishedJob (dead-lettered,
|
|
22
|
+
# or never sent): the sweep resolves it as the callback would have.
|
|
23
|
+
# - pgbus.workflow.gauge: runs by class and status, and by class and stuck
|
|
24
|
+
# reason (waiting_overdue: waiting longer than
|
|
25
|
+
# workflow_waiting_visibility_threshold on a step without a timeout).
|
|
26
|
+
#
|
|
27
|
+
# cleanup (hourly): finished and cancelled runs older than
|
|
28
|
+
# workflow_retention are deleted in batches; their steps and events go
|
|
29
|
+
# with them (ON DELETE CASCADE).
|
|
30
|
+
module Sweep
|
|
31
|
+
BATCH_SIZE = 500
|
|
32
|
+
|
|
33
|
+
# The schema probe's memo (a true answer is kept for the life of the
|
|
34
|
+
# process), read and written under the lock; the probe itself runs
|
|
35
|
+
# outside it.
|
|
36
|
+
@schema_lock = Mutex.new
|
|
37
|
+
@tables_ready = false
|
|
38
|
+
@waiting_ready = false
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def repair(config: Pgbus.configuration, now: Time.current)
|
|
42
|
+
return nil unless tables_ready?
|
|
43
|
+
|
|
44
|
+
threshold = config.workflow_stall_threshold
|
|
45
|
+
summary = { stalled: repair_stalled(now: now, config: config), resent: resend_overdue(now: now, threshold: threshold) }
|
|
46
|
+
waiting = waiting_ready?
|
|
47
|
+
summary[:timed_out] = waiting ? WaitTimeout.expire_due(now: now) : 0
|
|
48
|
+
summary[:fan_outs] = waiting ? FanOut.resolve_lost(now: now, grace: threshold) : 0
|
|
49
|
+
emit_gauge(now: now, threshold: threshold, waiting_threshold: (config.workflow_waiting_visibility_threshold if waiting))
|
|
50
|
+
summary
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cleanup(retention: Pgbus.configuration.workflow_retention, now: Time.current, batch_size: BATCH_SIZE)
|
|
54
|
+
return 0 unless retention&.positive? && tables_ready?
|
|
55
|
+
|
|
56
|
+
cutoff = now - retention
|
|
57
|
+
deleted = 0
|
|
58
|
+
loop do
|
|
59
|
+
ids = WorkflowEntry.stale(before: cutoff).order(:id).limit(batch_size).pluck(:id)
|
|
60
|
+
break if ids.empty?
|
|
61
|
+
|
|
62
|
+
deleted += WorkflowEntry.where(id: ids).delete_all
|
|
63
|
+
end
|
|
64
|
+
deleted
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True once the three workflow tables exist (pgbus:add_workflows).
|
|
68
|
+
def tables_ready?
|
|
69
|
+
return true if @schema_lock.synchronize { @tables_ready }
|
|
70
|
+
|
|
71
|
+
ready = begin
|
|
72
|
+
[WorkflowEntry, WorkflowStep, WorkflowEvent].all?(&:table_exists?)
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
Pgbus.logger.debug { "[Pgbus] Workflow table probe failed: #{e.class}: #{e.message}" }
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
@schema_lock.synchronize { @tables_ready = true } if ready
|
|
78
|
+
ready
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# True once the waiting-step schema exists (pgbus:add_workflow_signals,
|
|
82
|
+
# or an add_workflows that carries it): the signals table and the
|
|
83
|
+
# step columns. Asked of the database through the connection, not the
|
|
84
|
+
# schema cache (which keeps a negative answer). A process that loaded
|
|
85
|
+
# the step model before the migration still needs a restart to see
|
|
86
|
+
# the new columns, as for any column added under a running app.
|
|
87
|
+
def waiting_ready?
|
|
88
|
+
return true if @schema_lock.synchronize { @waiting_ready }
|
|
89
|
+
|
|
90
|
+
ready = begin
|
|
91
|
+
connection = WorkflowStep.connection
|
|
92
|
+
connection.table_exists?(WorkflowSignal.table_name) && connection.column_exists?(WorkflowStep.table_name, :timeout_at)
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Pgbus.logger.debug { "[Pgbus] Workflow waiting-step schema probe failed: #{e.class}: #{e.message}" }
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
@schema_lock.synchronize { @waiting_ready = true } if ready
|
|
98
|
+
ready
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Specs only.
|
|
102
|
+
def reset_tables_ready!
|
|
103
|
+
@schema_lock.synchronize do
|
|
104
|
+
@tables_ready = false
|
|
105
|
+
@waiting_ready = false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def repair_stalled(now:, config:)
|
|
112
|
+
WorkflowStep.stalled(now: now).order(:id).limit(BATCH_SIZE).pluck(:id, :workflow_id).count do |id, workflow_id|
|
|
113
|
+
stall(id, workflow_id, now: now, config: config)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Re-checked under the run's lock: the step may have finished since
|
|
118
|
+
# the query.
|
|
119
|
+
def stall(id, workflow_id, now:, config:)
|
|
120
|
+
Runner.locked(workflow_id) do |record, notes|
|
|
121
|
+
step = record && WorkflowStep.find_by(id: id)
|
|
122
|
+
next false unless step&.status == "in_progress" && step.stall_at && step.stall_at < now
|
|
123
|
+
|
|
124
|
+
StepOutcome.stall!(record, step, detection: "sweep", notes: notes, config: config)
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resend_overdue(now:, threshold:)
|
|
130
|
+
WorkflowStep.overdue(threshold: threshold, now: now).order(:id).limit(BATCH_SIZE).to_a.each do |step|
|
|
131
|
+
step.update!(enqueued_at: now)
|
|
132
|
+
end.size
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def emit_gauge(now:, threshold:, waiting_threshold: nil)
|
|
136
|
+
statuses = WorkflowEntry.status_counts
|
|
137
|
+
stuck = WorkflowEntry.stuck_reason_counts(threshold: threshold, now: now, waiting_threshold: waiting_threshold)
|
|
138
|
+
classes = (statuses.keys.map(&:first) + stuck.keys.map(&:first) + Workflow.registered_classes.map(&:name)).uniq.sort
|
|
139
|
+
Instrumentation.instrument(
|
|
140
|
+
"pgbus.workflow.gauge",
|
|
141
|
+
statuses: zero_filled(classes, States::WORKFLOW, statuses, :status),
|
|
142
|
+
stuck: zero_filled(classes, States::STUCK_REASONS, stuck, :reason)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Every class × every value, so a count that drops to zero is
|
|
147
|
+
# reported as zero instead of keeping its last value.
|
|
148
|
+
def zero_filled(classes, values, counts, key)
|
|
149
|
+
classes.product(values).map do |klass, value|
|
|
150
|
+
{ workflow_class: klass, key => value, count: counts.fetch([klass, value], 0) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# Run workflow steps inline in a spec, ignoring their wait:.
|
|
6
|
+
#
|
|
7
|
+
# RSpec.configure { |config| config.include Pgbus::Workflow::TestHelpers }
|
|
8
|
+
#
|
|
9
|
+
# wf = SignupWorkflow.start!(user)
|
|
10
|
+
# perform_next_step(wf) # runs :welcome now
|
|
11
|
+
# run_step_inline(wf, :reminder) # runs :reminder, raising if another step is next
|
|
12
|
+
# speedrun_workflow(wf) # runs every step until the run stops being ready
|
|
13
|
+
# expect(wf.status).to eq("finished")
|
|
14
|
+
#
|
|
15
|
+
# deliver_signal(wf, :shipped, payload: { at: Time.current }) # signal!, then the step it wakes
|
|
16
|
+
# complete_fan_out(wf) # every member of the waiting fan_out's batch completes
|
|
17
|
+
# expire_wait(wf) # the waiting step's on_timeout applies now
|
|
18
|
+
#
|
|
19
|
+
# Each step runs through the real StepJob (claim, guards, policies, the
|
|
20
|
+
# audit trail) against the test database; the StepJob messages the
|
|
21
|
+
# model callback sends meanwhile go to whatever adapter the test uses
|
|
22
|
+
# and are not needed.
|
|
23
|
+
module TestHelpers
|
|
24
|
+
class Error < Workflow::Error; end
|
|
25
|
+
|
|
26
|
+
# Runs the run's scheduled step now. Returns the reloaded workflow.
|
|
27
|
+
def perform_next_step(workflow)
|
|
28
|
+
TestHelpers.ensure_ready!(workflow)
|
|
29
|
+
step = TestHelpers.scheduled_step(workflow) || raise(Error, "#{workflow.inspect} has no scheduled step")
|
|
30
|
+
TestHelpers.perform(step, workflow)
|
|
31
|
+
workflow.reload
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Runs the run's scheduled step when it is +step_name+; raises when the
|
|
35
|
+
# run is on another step.
|
|
36
|
+
def run_step_inline(workflow, step_name)
|
|
37
|
+
TestHelpers.ensure_ready!(workflow)
|
|
38
|
+
step = TestHelpers.scheduled_step(workflow)
|
|
39
|
+
unless step&.step_name == step_name.to_s
|
|
40
|
+
raise Error, "#{workflow.inspect} is not about to run #{step_name} (scheduled: #{step&.step_name || "nothing"})"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
TestHelpers.perform(step, workflow)
|
|
44
|
+
workflow.reload
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Runs scheduled steps one after another until the run is no longer
|
|
48
|
+
# ready: it stops at paused, finished and cancelled, and raises
|
|
49
|
+
# Pgbus::Workflow::StepWaiting on waiting, naming what the step waits
|
|
50
|
+
# for (deliver_signal, complete_fan_out or expire_wait moves it on).
|
|
51
|
+
def speedrun_workflow(workflow, max_steps: 1_000)
|
|
52
|
+
max_steps.times do
|
|
53
|
+
case workflow.reload.status
|
|
54
|
+
when "ready" then perform_next_step(workflow)
|
|
55
|
+
when "waiting" then raise StepWaiting, "#{workflow.inspect} #{TestHelpers.waiting_for(workflow)}"
|
|
56
|
+
when "performing"
|
|
57
|
+
raise Error, "#{workflow.inspect} is performing: speedrun_workflow cannot move it on"
|
|
58
|
+
else return workflow
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
status = workflow.reload.status
|
|
62
|
+
raise StepWaiting, "#{workflow.inspect} #{TestHelpers.waiting_for(workflow)}" if status == "waiting"
|
|
63
|
+
return workflow unless %w[ready performing].include?(status)
|
|
64
|
+
|
|
65
|
+
raise Error, "#{workflow.inspect} was still #{workflow.status} after #{max_steps} steps"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# signal! the run, then run the step it scheduled (the waiting step it
|
|
69
|
+
# woke, or the step that claims a buffered signal). Returns the
|
|
70
|
+
# reloaded workflow.
|
|
71
|
+
# A redelivery (duplicate_delivery?) woke nothing, so it runs nothing.
|
|
72
|
+
def deliver_signal(workflow, name, payload: {}, idempotency_key: nil)
|
|
73
|
+
signal = workflow.signal!(name, payload: payload, idempotency_key: idempotency_key, actor: "test_helpers")
|
|
74
|
+
return workflow.reload if signal.duplicate_delivery?
|
|
75
|
+
|
|
76
|
+
perform_next_step(workflow)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Completes every member of the batch the run's waiting fan_out step
|
|
80
|
+
# waits on (Pgbus::Batch.job_completed, as the executor does), then
|
|
81
|
+
# runs the batch's BatchFinishedJob inline. The members do not run.
|
|
82
|
+
# Returns the reloaded workflow.
|
|
83
|
+
def complete_fan_out(workflow)
|
|
84
|
+
step = TestHelpers.waiting_step(workflow)
|
|
85
|
+
raise Error, "#{workflow.inspect} is not waiting on a fan_out batch" unless step&.batch_id
|
|
86
|
+
|
|
87
|
+
if Pgbus::Batch.executions_migrated?
|
|
88
|
+
Pgbus::BatchExecution.where(batch_id: step.batch_id).order(:id).pluck(:job_id).each do |job_id|
|
|
89
|
+
Pgbus::Batch.job_completed(step.batch_id, job_id: job_id)
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
Pgbus::Batch.find(step.batch_id)&.pending_jobs.to_i.times { Pgbus::Batch.job_completed(step.batch_id) }
|
|
93
|
+
end
|
|
94
|
+
BatchFinishedJob.new(step.id).perform_now
|
|
95
|
+
workflow.reload
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Applies the waiting step's on_timeout now, whatever its timeout_at.
|
|
99
|
+
# Returns the reloaded workflow.
|
|
100
|
+
def expire_wait(workflow)
|
|
101
|
+
step = TestHelpers.waiting_step(workflow)
|
|
102
|
+
raise Error, "#{workflow.inspect} has no waiting step with a wait_timeout:" unless step&.timeout_at
|
|
103
|
+
|
|
104
|
+
Runner.locked(workflow.workflow_id) do |record, notes|
|
|
105
|
+
WaitTimeout.fire!(record, WorkflowStep.find(step.id), notes: notes)
|
|
106
|
+
end
|
|
107
|
+
workflow.reload
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A paused or finished run's scheduled step is not claimable: running
|
|
111
|
+
# it would silently do nothing (and pull its wait forward for resume!).
|
|
112
|
+
def self.ensure_ready!(workflow)
|
|
113
|
+
status = workflow.reload.status
|
|
114
|
+
return if status == "ready"
|
|
115
|
+
raise StepWaiting, "#{workflow.inspect} #{waiting_for(workflow)}" if status == "waiting"
|
|
116
|
+
|
|
117
|
+
hint = case status
|
|
118
|
+
when "paused" then "resume! it first"
|
|
119
|
+
when "finished", "cancelled" then "it is over; rerun! starts a new run"
|
|
120
|
+
when "performing" then "a step is still running"
|
|
121
|
+
else "nothing inline can move it on"
|
|
122
|
+
end
|
|
123
|
+
raise Error, "#{workflow.inspect} is #{status}, not ready: #{hint}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.waiting_step(workflow)
|
|
127
|
+
WorkflowStep.where(workflow_id: workflow.workflow_id, status: "waiting").order(:id).last
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# "is waiting for signal shipped (deliver_signal …)" / "… batch (complete_fan_out …)".
|
|
131
|
+
def self.waiting_for(workflow)
|
|
132
|
+
step = waiting_step(workflow)
|
|
133
|
+
return "is waiting" unless step
|
|
134
|
+
return "is waiting on step #{step.step_name}'s batch #{step.batch_id}: complete_fan_out moves it on" if step.batch_id
|
|
135
|
+
|
|
136
|
+
"is waiting on step #{step.step_name} for signal #{step.wait_for}: deliver_signal moves it on" \
|
|
137
|
+
"#{", expire_wait times it out" if step.timeout_at}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.scheduled_step(workflow)
|
|
141
|
+
WorkflowStep.where(workflow_id: workflow.workflow_id, status: "scheduled").order(:id).last
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A step still waiting is pulled forward through run_now!, so the audit
|
|
145
|
+
# trail records the skipped wait (actor "test_helpers").
|
|
146
|
+
def self.perform(step, workflow)
|
|
147
|
+
workflow.run_now!(actor: "test_helpers", reason: "run inline by a test helper") if step.scheduled_at > Time.current
|
|
148
|
+
StepJob.new(step.id).perform_now
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# wait_timeout: on a wait_for: step (issue #541). The dispatcher's
|
|
6
|
+
# workflow sweep finds waiting steps of waiting runs whose timeout_at
|
|
7
|
+
# passed and applies their on_timeout under the run's row lock, so a
|
|
8
|
+
# timeout fires within config.workflow_sweep_interval of its deadline:
|
|
9
|
+
#
|
|
10
|
+
# :pause! the step fails with WaitTimedOut and the run pauses
|
|
11
|
+
# :cancel! the run is cancelled
|
|
12
|
+
# :skip! the step is skipped and the run moves on
|
|
13
|
+
# :run the step is scheduled again; its body runs with
|
|
14
|
+
# received_signal nil and timed_out? true
|
|
15
|
+
#
|
|
16
|
+
# A signal that arrives after the timeout fired stays pending, like any
|
|
17
|
+
# signal no step waits for. A paused run's wait does not time out until
|
|
18
|
+
# it is resumed.
|
|
19
|
+
module WaitTimeout
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Drains every due wait, Sweep::BATCH_SIZE at a time (at most
|
|
23
|
+
# +max_batches+ of them per sweep, so one sweep cannot run unbounded).
|
|
24
|
+
def expire_due(now: Time.current, limit: Sweep::BATCH_SIZE, max_batches: 20)
|
|
25
|
+
total = 0
|
|
26
|
+
max_batches.times do
|
|
27
|
+
batch = WorkflowStep.wait_expired(now: now).order(:id).limit(limit).pluck(:id, :workflow_id)
|
|
28
|
+
total += expire_batch(batch, now: now)
|
|
29
|
+
break if batch.size < limit
|
|
30
|
+
end
|
|
31
|
+
total
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def expire_batch(batch, now:)
|
|
35
|
+
batch.count do |id, workflow_id|
|
|
36
|
+
Runner.locked(workflow_id) do |record, notes|
|
|
37
|
+
step = record && WorkflowStep.find_by(id: id)
|
|
38
|
+
next false unless record&.status == "waiting" && step&.status == "waiting" && step.timeout_at && step.timeout_at < now
|
|
39
|
+
|
|
40
|
+
fire!(record, step, notes: notes, now: now)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Applies the step's on_timeout now (the sweep, and expire_wait in
|
|
47
|
+
# TestHelpers). Returns the action applied.
|
|
48
|
+
def fire!(record, step, notes:, now: Time.current)
|
|
49
|
+
definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
|
|
50
|
+
action = definition&.on_timeout || :pause!
|
|
51
|
+
Runner.event!(record, "wait_timed_out", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
52
|
+
details: { wait_for: step.wait_for, action: action.to_s,
|
|
53
|
+
timeout_at: step.timeout_at&.utc&.iso8601(3) }.compact)
|
|
54
|
+
notes.instrument("pgbus.workflow.wait_timed_out",
|
|
55
|
+
Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, action: action.to_s))
|
|
56
|
+
apply(record, step, action, notes: notes, now: now)
|
|
57
|
+
action
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def apply(record, step, action, notes:, now:)
|
|
61
|
+
return Waiting.wake!(record, step, now: now, timeout_at: [step.timeout_at, now].compact.min) if action == :run
|
|
62
|
+
|
|
63
|
+
error = action == :pause! ? timed_out_error(step, now) : nil
|
|
64
|
+
Waiting.resolve!(record, step, FlowControl::Outcome.for(action), notes: notes, error: error)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def timed_out_error(step, now)
|
|
68
|
+
WaitTimedOut.new("#{step.step_name} waited for #{step.wait_for} since #{step.waiting_since&.utc&.iso8601} " \
|
|
69
|
+
"and timed out at #{(step.timeout_at || now).utc.iso8601}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The waiting state wait_for: steps and fan_out steps share (issue #541),
|
|
6
|
+
# every move under the run's row lock:
|
|
7
|
+
#
|
|
8
|
+
# park! in_progress → waiting the gate found no signal, or the
|
|
9
|
+
# fan_out batch opened (run: park)
|
|
10
|
+
# wake! waiting → scheduled a signal it matches arrived, or the
|
|
11
|
+
# wait timed out with on_timeout: :run
|
|
12
|
+
# (run: wake); the model callback sends
|
|
13
|
+
# the StepJob once the write commits
|
|
14
|
+
# resolve! waiting → (an outcome) the batch finished, the wait timed
|
|
15
|
+
# out, or an operator skipped it: the
|
|
16
|
+
# outcome is applied by StepOutcome as
|
|
17
|
+
# for any finished attempt (run: resolve,
|
|
18
|
+
# then advance, finish, step_pause, ...)
|
|
19
|
+
#
|
|
20
|
+
# A run an outside pause! reached first stays paused (hold): the step row
|
|
21
|
+
# moves, nothing runs, and resume! picks it up.
|
|
22
|
+
module Waiting
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# False when the attempt had already lost its in_progress row (stalled
|
|
26
|
+
# or cancelled while it ran).
|
|
27
|
+
# A fan_out's +output+ (the body's output(...)) waits on the row.
|
|
28
|
+
def park!(record, step, now:, batch_id: nil, wait_for: nil, timeout_at: nil, output: nil) # rubocop:disable Naming/PredicateMethod
|
|
29
|
+
attrs = { waiting_since: now, stall_at: nil, batch_id: batch_id, wait_for: wait_for, timeout_at: timeout_at }
|
|
30
|
+
attrs[:output] = output if output
|
|
31
|
+
return false if WorkflowStep.park!(step.id, **attrs).zero?
|
|
32
|
+
|
|
33
|
+
step.assign_attributes(status: "waiting", **attrs)
|
|
34
|
+
Runner.transition!(record, paused?(record) ? :hold : :park)
|
|
35
|
+
Runner.event!(record, "step_waiting", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
36
|
+
details: { wait_for: wait_for, batch_id: batch_id,
|
|
37
|
+
timeout_at: timeout_at&.utc&.iso8601(3) }.compact)
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A paused run's step is written without the model callback, so no
|
|
42
|
+
# StepJob goes out that its claim would only drop; resume! sends it.
|
|
43
|
+
def wake!(record, step, now:, **attrs)
|
|
44
|
+
if paused?(record)
|
|
45
|
+
step.update_columns(status: "scheduled", scheduled_at: now, **attrs)
|
|
46
|
+
Runner.transition!(record, :hold)
|
|
47
|
+
else
|
|
48
|
+
step.update!(status: "scheduled", scheduled_at: now, **attrs)
|
|
49
|
+
Runner.transition!(record, :wake)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns false when the row was no longer waiting.
|
|
54
|
+
def resolve!(record, step, outcome, notes:, error: nil)
|
|
55
|
+
return false if WorkflowStep.unpark!(step.id).zero?
|
|
56
|
+
|
|
57
|
+
step.assign_attributes(status: "in_progress")
|
|
58
|
+
Runner.transition!(record, :resolve) unless paused?(record)
|
|
59
|
+
output = step.respond_to?(:output) ? step.output : nil
|
|
60
|
+
StepOutcome.apply(record, step, outcome, notes: notes, error: error, output: output)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# start! (and rerun!) of a class with waiting steps needs their schema.
|
|
64
|
+
def check_schema!(klass)
|
|
65
|
+
definitions = klass.workflow_steps.definitions
|
|
66
|
+
fan_out = definitions.any? { |definition| definition.kind == "fan_out" }
|
|
67
|
+
return unless fan_out || definitions.any?(&:waits_for_signal?)
|
|
68
|
+
|
|
69
|
+
unless Sweep.waiting_ready?
|
|
70
|
+
raise WaitingStepsUnmigrated, "#{klass.name} has waiting steps (fan_out or wait_for:): run " \
|
|
71
|
+
"`rails generate pgbus:add_workflow_signals` and migrate first"
|
|
72
|
+
end
|
|
73
|
+
return unless fan_out && !Batch.callback_jobs_migrated?
|
|
74
|
+
|
|
75
|
+
raise BatchCallbacksUnmigrated, "#{klass.name} has a fan_out step, whose batch resumes the run through a " \
|
|
76
|
+
"callback job: run `rails generate pgbus:add_batch_callback_jobs` and migrate first"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def paused?(record) = record.status == "paused"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/class/attribute"
|
|
5
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
# A durable multi-step workflow (issue #540). Subclass it, declare steps,
|
|
9
|
+
# start a run:
|
|
10
|
+
#
|
|
11
|
+
# class SignupWorkflow < Pgbus::Workflow
|
|
12
|
+
# cancel_if { subject.nil? || subject.closed? }
|
|
13
|
+
# on_exception Net::OpenTimeout, action: :reattempt!, wait: 15.seconds, max_reattempts: 5
|
|
14
|
+
#
|
|
15
|
+
# step :welcome do
|
|
16
|
+
# WelcomeMailer.with(user: subject).welcome.deliver_later
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# step :reminder, wait: 2.days, skip_if: -> { subject.onboarded? } do
|
|
20
|
+
# ReminderMailer.with(user: subject).remind.deliver_later
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# wf = SignupWorkflow.start!(user, plan: "pro")
|
|
25
|
+
# wf.pause!(actor: "alice", reason: "SMTP down") / wf.resume! / wf.cancel!
|
|
26
|
+
#
|
|
27
|
+
# Each step attempt is a pgbus_workflow_steps row and one
|
|
28
|
+
# Pgbus::Workflow::StepJob message, sent after the row commits. The step
|
|
29
|
+
# body runs on a fresh instance of the class, with +subject+ and
|
|
30
|
+
# +arguments+. Pgbus::WorkflowEntry is the run's row; this class is the
|
|
31
|
+
# DSL and the handle.
|
|
32
|
+
class Workflow
|
|
33
|
+
class Error < Pgbus::Error; end
|
|
34
|
+
|
|
35
|
+
# A second start! for a subject that already has an ongoing run of this
|
|
36
|
+
# class. #workflow is the run that holds the slot.
|
|
37
|
+
class AlreadyRunning < Error
|
|
38
|
+
attr_reader :workflow
|
|
39
|
+
|
|
40
|
+
def initialize(message = nil, workflow: nil)
|
|
41
|
+
super(message)
|
|
42
|
+
@workflow = workflow
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A transition the States table does not allow from the workflow's state.
|
|
47
|
+
class InvalidTransition < Error; end
|
|
48
|
+
|
|
49
|
+
# resume!/resume_at! on a workflow that is not paused.
|
|
50
|
+
class NotPaused < InvalidTransition; end
|
|
51
|
+
|
|
52
|
+
# A row names a step the class does not define (and no removed_step
|
|
53
|
+
# gravestone covers it), or resume_at! names an unknown step.
|
|
54
|
+
class StepNotDefined < Error; end
|
|
55
|
+
|
|
56
|
+
# Recorded on a step whose worker died or that outlived its stall_at.
|
|
57
|
+
class StepStalled < Error; end
|
|
58
|
+
|
|
59
|
+
# Arguments or a step output over config.workflow_max_payload_size.
|
|
60
|
+
class PayloadTooLarge < Error; end
|
|
61
|
+
|
|
62
|
+
# A step-only verb or reader (reattempt!, finished!, output,
|
|
63
|
+
# step_idempotency_key, received_signal) called outside a step body.
|
|
64
|
+
class NotInStep < Error; end
|
|
65
|
+
|
|
66
|
+
# signal! on a finished or cancelled run, or by subject when the subject
|
|
67
|
+
# has no ongoing run of the class (missing: :raise).
|
|
68
|
+
class NotOngoing < InvalidTransition; end
|
|
69
|
+
|
|
70
|
+
# Recorded on a fan_out step whose batch had dead-lettered members.
|
|
71
|
+
class FanOutFailed < Error; end
|
|
72
|
+
|
|
73
|
+
# Recorded on a waiting step whose wait_timeout: passed with on_timeout: :pause!.
|
|
74
|
+
class WaitTimedOut < Error; end
|
|
75
|
+
|
|
76
|
+
# start! of a class with a fan_out step before the batch callback-job
|
|
77
|
+
# columns exist (rails generate pgbus:add_batch_callback_jobs).
|
|
78
|
+
class BatchCallbacksUnmigrated < Error; end
|
|
79
|
+
|
|
80
|
+
# start! of a class with a fan_out or wait_for: step before the
|
|
81
|
+
# waiting-step schema exists (rails generate pgbus:add_workflow_signals).
|
|
82
|
+
class WaitingStepsUnmigrated < Error; end
|
|
83
|
+
|
|
84
|
+
# speedrun_workflow reached a step that waits for a signal or a batch.
|
|
85
|
+
class StepWaiting < Error; end
|
|
86
|
+
|
|
87
|
+
extend DSL
|
|
88
|
+
include FlowControl
|
|
89
|
+
include Instance
|
|
90
|
+
|
|
91
|
+
# Class-level declarations: frozen values replaced whole by the DSL, set
|
|
92
|
+
# while the class body loads and only read afterwards.
|
|
93
|
+
# rubocop:disable ThreadSafety/ClassAndModuleAttributes -- written only by the class body at load, each a frozen value replaced whole
|
|
94
|
+
class_attribute :_workflow_steps, instance_accessor: false, instance_predicate: false, default: Steps::EMPTY
|
|
95
|
+
class_attribute :_cancel_conditions, instance_accessor: false, instance_predicate: false, default: [].freeze
|
|
96
|
+
class_attribute :_exception_policies, instance_accessor: false, instance_predicate: false, default: [].freeze
|
|
97
|
+
class_attribute :_step_job_options, instance_accessor: false, instance_predicate: false, default: {}.freeze
|
|
98
|
+
# rubocop:enable ThreadSafety/ClassAndModuleAttributes
|
|
99
|
+
|
|
100
|
+
class << self
|
|
101
|
+
def inherited(subclass)
|
|
102
|
+
super
|
|
103
|
+
Registry.register(subclass)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Every subclass that has a name, once (what the insights page lists).
|
|
107
|
+
def registered_classes = Registry.classes
|
|
108
|
+
|
|
109
|
+
# Start a run: the workflow row and its first step row, in one
|
|
110
|
+
# transaction; the first StepJob is sent once that commits.
|
|
111
|
+
#
|
|
112
|
+
# SignupWorkflow.start!(user, plan: "pro") # arguments["plan"]
|
|
113
|
+
# CheckoutWorkflow.start!(order, idempotency_key: "checkout-#{order.id}")
|
|
114
|
+
#
|
|
115
|
+
# +idempotency_key+ is reserved: with one, a second start! with the same
|
|
116
|
+
# key returns the first run (any status) with duplicate_start? true.
|
|
117
|
+
# Raises AlreadyRunning when the subject already has an ongoing run.
|
|
118
|
+
def start!(subject = nil, idempotency_key: nil, **arguments)
|
|
119
|
+
Runner.start(self, subject: subject, arguments: arguments, idempotency_key: idempotency_key)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The run with this workflow_id, as an instance of the class that
|
|
123
|
+
# started it; nil when there is none, or when that class is not this
|
|
124
|
+
# one or a subclass of it. Pgbus::Workflow.find finds any run (as a
|
|
125
|
+
# bare Pgbus::Workflow when its class no longer exists, so it can still
|
|
126
|
+
# be cancelled).
|
|
127
|
+
def find(workflow_id)
|
|
128
|
+
record = WorkflowEntry.find_by(workflow_id: workflow_id)
|
|
129
|
+
return nil unless record
|
|
130
|
+
|
|
131
|
+
klass = record.workflow_klass || Workflow
|
|
132
|
+
klass <= self ? klass.new(record) : nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Signal the ongoing run of this class for +subject+ (a webhook that
|
|
136
|
+
# knows the order, not the run):
|
|
137
|
+
#
|
|
138
|
+
# OrderWorkflow.signal!(order, :payment_confirmed, payload: { amount: 42 }, idempotency_key: event.id,
|
|
139
|
+
# missing: :ignore)
|
|
140
|
+
#
|
|
141
|
+
# The one-ongoing-per-subject index means at most one run matches. With
|
|
142
|
+
# none (never started, or already over), missing: :raise raises
|
|
143
|
+
# NotOngoing and missing: :ignore returns nil. A subject-less run is
|
|
144
|
+
# signalled through its handle: find(workflow_id).signal!.
|
|
145
|
+
def signal!(subject, name, payload: {}, idempotency_key: nil, missing: :raise, actor: nil, reason: nil)
|
|
146
|
+
raise ArgumentError, "missing: must be :raise or :ignore, got #{missing.inspect}" unless %i[raise ignore].include?(missing)
|
|
147
|
+
if subject.nil?
|
|
148
|
+
raise ArgumentError, "#{self.name}.signal! needs a subject; signal a subject-less run through its handle " \
|
|
149
|
+
"(#{self.name}.find(workflow_id).signal!)"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
attrs = Payload.subject_attributes(subject)
|
|
153
|
+
record = WorkflowEntry.ongoing.find_by(workflow_class: self.name, subject_type: attrs[:subject_type],
|
|
154
|
+
subject_id: attrs[:subject_id])
|
|
155
|
+
raise NotOngoing, "#{self.name} has no ongoing run for #{attrs[:subject_type]} #{attrs[:subject_id]}" unless record
|
|
156
|
+
|
|
157
|
+
new(record).signal!(name, payload: payload, idempotency_key: idempotency_key, actor: actor, reason: reason)
|
|
158
|
+
rescue NotOngoing
|
|
159
|
+
raise if missing == :raise
|
|
160
|
+
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# How many ongoing runs still sit on each removed step:
|
|
165
|
+
# { "reminder" => 3 }. A gravestone can go once its count is zero.
|
|
166
|
+
def removed_steps_in_flight
|
|
167
|
+
names = _workflow_steps.removed_names
|
|
168
|
+
return {} if names.empty?
|
|
169
|
+
|
|
170
|
+
counts = WorkflowEntry.ongoing.where(workflow_class: name, current_step: names).group(:current_step).count
|
|
171
|
+
names.to_h { |step_name| [step_name, counts.fetch(step_name, 0)] }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
attr_reader :record
|
|
176
|
+
|
|
177
|
+
def initialize(record, duplicate_start: false)
|
|
178
|
+
@record = record
|
|
179
|
+
@duplicate_start = duplicate_start
|
|
180
|
+
@step_context = nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|