geneva_drive 0.4.0 → 0.6.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 +38 -0
- data/MANUAL.md +1037 -355
- data/README.md +3 -1
- data/Rakefile +13 -2
- data/bin/md2html +96 -19
- data/lib/generators/geneva_drive/install/install_generator.rb +25 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_step_executions.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/allow_null_hero_on_workflows.rb +66 -0
- data/lib/generators/geneva_drive/install/templates/create_step_executions_migration.rb +25 -5
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +14 -3
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +22 -2
- data/lib/geneva_drive/combined_exception_policy.rb +106 -0
- data/lib/geneva_drive/exception_policy.rb +354 -0
- data/lib/geneva_drive/executor.rb +430 -148
- data/lib/geneva_drive/flow_control.rb +82 -11
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +174 -52
- data/lib/geneva_drive/jobs/perform_step_job.rb +91 -3
- data/lib/geneva_drive/migration_helpers.rb +55 -20
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/step_definition.rb +160 -40
- data/lib/geneva_drive/step_execution/metadata_accessor.rb +121 -0
- data/lib/geneva_drive/step_execution.rb +124 -2
- data/lib/geneva_drive/test_helpers.rb +123 -4
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +485 -73
- data/lib/geneva_drive.rb +100 -2
- data/test/dsl/class_level_on_exception_test.rb +200 -0
- data/test/dsl/exception_policy_test.rb +331 -0
- data/test/dsl/step_definition_test.rb +126 -2
- data/test/dsl/step_level_exception_policy_test.rb +143 -0
- data/test/dummy/config/storage.yml +7 -0
- data/test/dummy_install/config/storage.yml +7 -0
- data/test/jobs/housekeeping_job_test.rb +451 -5
- data/test/jobs/perform_step_job_double_deferral_test.rb +268 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +137 -0
- data/test/step_execution/metadata_accessor_test.rb +147 -0
- data/test/test_helper.rb +37 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/class_level_on_exception_integration_test.rb +313 -0
- data/test/workflow/composable_exception_policy_test.rb +231 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/executor_test.rb +100 -4
- data/test/workflow/external_cancel_test.rb +245 -0
- data/test/workflow/flow_control_test.rb +4 -3
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/max_reattempts_test.rb +51 -0
- data/test/workflow/pause_resume_test.rb +574 -0
- data/test/workflow/reattempt_on_exception_integration_test.rb +68 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -0
- data/test/workflow/resume_and_skip_test.rb +57 -41
- data/test/workflow/with_inline_enqueue_test.rb +137 -0
- data/test/workflow/workflow_test.rb +117 -2
- metadata +47 -61
- data/test/dummy/db/migrate/20241217000001_create_geneva_drive_workflows.rb +0 -68
- data/test/dummy/db/migrate/20241217000002_create_geneva_drive_step_executions.rb +0 -90
- data/test/dummy/db/migrate/20241217000003_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20241217000004_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/schema.rb +0 -72
- data/test/dummy/log/development.log +0 -112
- data/test/dummy/log/test.log +0 -4
- data/test/dummy/tmp/local_secret.txt +0 -1
- data/test/dummy_install/Rakefile +0 -6
- data/test/dummy_install/app/assets/stylesheets/application.css +0 -15
- data/test/dummy_install/app/controllers/application_controller.rb +0 -4
- data/test/dummy_install/app/helpers/application_helper.rb +0 -2
- data/test/dummy_install/app/jobs/application_job.rb +0 -7
- data/test/dummy_install/app/models/application_record.rb +0 -3
- data/test/dummy_install/app/models/user.rb +0 -9
- data/test/dummy_install/app/views/layouts/application.html.erb +0 -27
- data/test/dummy_install/app/views/layouts/mailer.html.erb +0 -13
- data/test/dummy_install/app/views/layouts/mailer.text.erb +0 -1
- data/test/dummy_install/app/views/pwa/manifest.json.erb +0 -22
- data/test/dummy_install/app/views/pwa/service-worker.js +0 -26
- data/test/dummy_install/bin/dev +0 -2
- data/test/dummy_install/bin/rails +0 -4
- data/test/dummy_install/bin/rake +0 -4
- data/test/dummy_install/bin/setup +0 -34
- data/test/dummy_install/config/application.rb +0 -29
- data/test/dummy_install/config/boot.rb +0 -5
- data/test/dummy_install/config/cable.yml +0 -10
- data/test/dummy_install/config/database.yml +0 -13
- data/test/dummy_install/config/environment.rb +0 -5
- data/test/dummy_install/config/environments/development.rb +0 -51
- data/test/dummy_install/config/environments/production.rb +0 -70
- data/test/dummy_install/config/environments/test.rb +0 -39
- data/test/dummy_install/config/initializers/assets.rb +0 -7
- data/test/dummy_install/config/initializers/content_security_policy.rb +0 -25
- data/test/dummy_install/config/initializers/filter_parameter_logging.rb +0 -8
- data/test/dummy_install/config/initializers/geneva_drive.rb +0 -76
- data/test/dummy_install/config/initializers/inflections.rb +0 -16
- data/test/dummy_install/config/locales/en.yml +0 -31
- data/test/dummy_install/config/puma.rb +0 -38
- data/test/dummy_install/config/routes.rb +0 -3
- data/test/dummy_install/config.ru +0 -6
- data/test/dummy_install/db/migrate/20241217000000_create_users.rb +0 -12
- data/test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb +0 -97
- data/test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy_install/db/schema.rb +0 -76
- data/test/dummy_install/log/test.log +0 -4031
- data/test/dummy_install/public/400.html +0 -114
- data/test/dummy_install/public/404.html +0 -114
- data/test/dummy_install/public/406-unsupported-browser.html +0 -114
- data/test/dummy_install/public/422.html +0 -114
- data/test/dummy_install/public/500.html +0 -114
- data/test/dummy_install/public/icon.png +0 -0
- data/test/dummy_install/public/icon.svg +0 -3
- data/test/dummy_install/tmp/local_secret.txt +0 -1
- data/test/generators/install_generator_test.rb +0 -94
|
@@ -25,27 +25,94 @@ class GenevaDrive::Executor
|
|
|
25
25
|
"performing" => %w[ready performing canceled paused finished]
|
|
26
26
|
}.freeze
|
|
27
27
|
|
|
28
|
+
# Result of a resumable step interrupting itself mid-iteration: the current
|
|
29
|
+
# execution completes with outcome "continued" and a successor execution is
|
|
30
|
+
# created to carry on from the persisted cursor. `wait` optionally delays
|
|
31
|
+
# the successor.
|
|
32
|
+
Interruption = Struct.new(:wait)
|
|
33
|
+
|
|
28
34
|
# Executes a step execution with full flow control and exception handling.
|
|
35
|
+
# Handles both regular steps and resumable (cursor-iterating) steps.
|
|
29
36
|
#
|
|
30
37
|
# @param step_execution [GenevaDrive::StepExecution] the step to execute
|
|
38
|
+
# @param logger [Logger, nil] optional base logger to inject into the workflow.
|
|
39
|
+
# If provided, this logger will be used as the base for all workflow logging,
|
|
40
|
+
# with workflow and step-specific tags added on top. This allows callers
|
|
41
|
+
# (background jobs, controllers, etc.) to pass in a logger that already
|
|
42
|
+
# has appropriate context tags (e.g., job_id, request_id).
|
|
43
|
+
# @param interruptible [Boolean] whether resumable steps respect interruption
|
|
44
|
+
# conditions (max_iterations, max_runtime, shutdown, external pause/cancel).
|
|
45
|
+
# Test helpers pass false to run a resumable step to completion in one call.
|
|
46
|
+
# @param max_iterations [Integer, nil] overrides the step definition's
|
|
47
|
+
# max_iterations for this execution (used by the run_iterations test helper)
|
|
31
48
|
# @return [void]
|
|
32
|
-
|
|
33
|
-
|
|
49
|
+
#
|
|
50
|
+
# @example Execute with a pre-tagged logger from a background job
|
|
51
|
+
# logger = Rails.logger.tagged("job_id=#{job_id}")
|
|
52
|
+
# GenevaDrive::Executor.execute!(step_execution, logger: logger)
|
|
53
|
+
def self.execute!(step_execution, logger: nil, interruptible: true, max_iterations: nil)
|
|
54
|
+
new.call(step_execution, logger: logger, interruptible: interruptible, max_iterations: max_iterations)
|
|
34
55
|
end
|
|
35
56
|
|
|
36
57
|
# Performs the step execution.
|
|
37
58
|
#
|
|
38
59
|
# @param step_execution [GenevaDrive::StepExecution] the step to execute
|
|
60
|
+
# @param logger [Logger, nil] optional base logger to inject into the workflow
|
|
61
|
+
# @param interruptible [Boolean] whether resumable steps respect interruption conditions
|
|
62
|
+
# @param max_iterations [Integer, nil] per-execution override of the step's max_iterations
|
|
39
63
|
# @return [void]
|
|
40
|
-
def call(step_execution)
|
|
64
|
+
def call(step_execution, logger: nil, interruptible: true, max_iterations: nil)
|
|
41
65
|
@step_execution = step_execution
|
|
42
66
|
@workflow = step_execution.workflow
|
|
67
|
+
@interruptible = interruptible
|
|
68
|
+
@max_iterations_override = max_iterations
|
|
69
|
+
|
|
70
|
+
# Build the full logger chain (base -> workflow -> step tags) and inject
|
|
71
|
+
# it so step code calling `logger` gets the fully-tagged step execution
|
|
72
|
+
# logger. Falls back to Rails.logger if no logger is provided.
|
|
73
|
+
step_logger = build_step_logger(logger || Rails.logger, step_execution)
|
|
74
|
+
Rails.error.set_context(geneva_drive: error_context)
|
|
75
|
+
|
|
76
|
+
@workflow.with_logger(step_logger) do
|
|
77
|
+
step_execution.with_logger(step_logger) do
|
|
78
|
+
execute_with_logger(step_execution)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Checks if a resumable step should be interrupted at a checkpoint.
|
|
84
|
+
# Called by IterableStep during checkpoint!.
|
|
85
|
+
#
|
|
86
|
+
# @param iterations [Integer, nil] iterations completed so far in this execution
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def should_interrupt?(iterations: nil)
|
|
89
|
+
# If interruption is disabled (e.g., in tests), never interrupt
|
|
90
|
+
return false unless @interruptible
|
|
91
|
+
|
|
92
|
+
max_iterations = @max_iterations_override || (@step_definition.respond_to?(:max_iterations) ? @step_definition.max_iterations : nil)
|
|
93
|
+
return true if max_iterations && iterations && iterations >= max_iterations
|
|
94
|
+
return true if max_runtime_exceeded?
|
|
95
|
+
return true if job_should_exit?
|
|
96
|
+
return true if workflow_interrupted?
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Executes the step with the current logger configuration.
|
|
103
|
+
#
|
|
104
|
+
# @param step_execution [GenevaDrive::StepExecution] the step to execute
|
|
105
|
+
# @return [void]
|
|
106
|
+
def execute_with_logger(step_execution)
|
|
43
107
|
@logger = step_execution.logger
|
|
44
108
|
# Phase 1: Acquire locks, validate, and prepare for execution
|
|
45
109
|
@logger.debug("Preparing execution context")
|
|
46
110
|
step_def = prepare_execution
|
|
47
111
|
return unless step_def
|
|
48
112
|
|
|
113
|
+
@step_definition = step_def
|
|
114
|
+
@start_time = Time.current
|
|
115
|
+
|
|
49
116
|
# Phase 2: Execute step block (locks released)
|
|
50
117
|
@logger.debug("Running before_step_execution hook")
|
|
51
118
|
@workflow.before_step_execution(@step_execution)
|
|
@@ -63,22 +130,59 @@ class GenevaDrive::Executor
|
|
|
63
130
|
finalize_execution(flow_result)
|
|
64
131
|
end
|
|
65
132
|
|
|
66
|
-
private
|
|
67
|
-
|
|
68
133
|
attr_reader :step_execution, :workflow, :logger
|
|
69
134
|
|
|
135
|
+
# Builds execution context to attach to Error Monitoring reports.
|
|
136
|
+
#
|
|
137
|
+
# @return [Hash]
|
|
138
|
+
def error_context
|
|
139
|
+
{
|
|
140
|
+
workflow_id: workflow.id,
|
|
141
|
+
workflow_class: workflow.class.name,
|
|
142
|
+
step_execution_id: step_execution.id,
|
|
143
|
+
step_name: step_execution.step_name,
|
|
144
|
+
hero_type: workflow.hero_type,
|
|
145
|
+
hero_id: workflow.hero_id
|
|
146
|
+
}.compact
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Builds a fully-tagged logger for the step execution.
|
|
150
|
+
# Adds workflow tags and step execution tags to the base logger.
|
|
151
|
+
#
|
|
152
|
+
# @param base_logger [Logger] the base logger (typically from the job)
|
|
153
|
+
# @param step_execution [GenevaDrive::StepExecution] the step execution
|
|
154
|
+
# @return [Logger] the fully-tagged logger
|
|
155
|
+
def build_step_logger(base_logger, step_execution)
|
|
156
|
+
workflow = step_execution.workflow
|
|
157
|
+
|
|
158
|
+
# Wrap with TaggedLogging and add workflow tags
|
|
159
|
+
tagged_logger = ActiveSupport::TaggedLogging.new(base_logger)
|
|
160
|
+
tag_parts = [workflow.class.name, " id=", workflow.to_param]
|
|
161
|
+
if workflow.hero_id.present?
|
|
162
|
+
tag_parts.concat([" hero_type=", workflow.hero_type, " hero_id=", workflow.hero_id])
|
|
163
|
+
end
|
|
164
|
+
workflow_tag = tag_parts.join
|
|
165
|
+
workflow_logger = tagged_logger.tagged(workflow_tag)
|
|
166
|
+
|
|
167
|
+
# Add step execution tags
|
|
168
|
+
workflow_logger.tagged("execution_id=#{step_execution.id} step_name=#{step_execution.step_name}")
|
|
169
|
+
end
|
|
170
|
+
|
|
70
171
|
# Executes the step block with instrumentation.
|
|
71
172
|
#
|
|
173
|
+
# For resumable steps, the block receives an IterableStep and runs inside a
|
|
174
|
+
# catch(:interrupt) so that checkpoints (and skip_to!) can interrupt the
|
|
175
|
+
# iteration. An interrupt is normalized into an {Interruption} result.
|
|
176
|
+
#
|
|
72
177
|
# @param step_def [StepDefinition]
|
|
73
|
-
# @return [Symbol, FlowControlSignal, Hash] the execution result
|
|
178
|
+
# @return [Symbol, FlowControlSignal, Hash, Interruption] the execution result
|
|
74
179
|
def execute_step(step_def)
|
|
75
180
|
payload = instrumentation_payload
|
|
76
181
|
payload[:step_name] = step_def.name
|
|
77
182
|
|
|
78
183
|
ActiveSupport::Notifications.instrument("step.geneva_drive", payload) do |p|
|
|
79
184
|
result = catch(:flow_control) do
|
|
80
|
-
step_def
|
|
81
|
-
:completed
|
|
185
|
+
run_step_code(step_def)
|
|
82
186
|
rescue => e
|
|
83
187
|
logger.error("Encountered #{e.class}, cleaning up and re-raising")
|
|
84
188
|
# Don't transition here - just capture the error info
|
|
@@ -87,15 +191,47 @@ class GenevaDrive::Executor
|
|
|
87
191
|
|
|
88
192
|
p[:outcome] = case result
|
|
89
193
|
when :completed then :completed
|
|
194
|
+
when GenevaDrive::Executor::Interruption then :interrupted
|
|
90
195
|
when GenevaDrive::FlowControlSignal then result.action
|
|
91
196
|
when Hash then :exception
|
|
92
197
|
end
|
|
93
198
|
p[:exception] = result[:error] if result.is_a?(Hash)
|
|
199
|
+
p[:wait] = result.wait if result.is_a?(GenevaDrive::Executor::Interruption)
|
|
94
200
|
|
|
95
201
|
result
|
|
96
202
|
end
|
|
97
203
|
end
|
|
98
204
|
|
|
205
|
+
# Runs the user's step code, handling the resumable interrupt channel.
|
|
206
|
+
#
|
|
207
|
+
# @param step_def [StepDefinition]
|
|
208
|
+
# @return [Symbol, Interruption] :completed, or an Interruption for resumable steps
|
|
209
|
+
def run_step_code(step_def)
|
|
210
|
+
unless step_def.resumable?
|
|
211
|
+
step_def.execute_in_context(workflow)
|
|
212
|
+
return :completed
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
iter = GenevaDrive::IterableStep.new(
|
|
216
|
+
step_def.name,
|
|
217
|
+
step_execution.cursor_value,
|
|
218
|
+
execution: step_execution,
|
|
219
|
+
resumed: step_execution.resuming?,
|
|
220
|
+
interrupter: self
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# catch(:interrupt) returns:
|
|
224
|
+
# - :completed if the block finishes normally
|
|
225
|
+
# - nil if throw :interrupt (immediate successor)
|
|
226
|
+
# - Duration/Numeric if throw :interrupt, wait (delayed successor)
|
|
227
|
+
result = catch(:interrupt) do
|
|
228
|
+
step_def.execute_in_context(workflow, iter)
|
|
229
|
+
:completed
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
(result == :completed) ? :completed : Interruption.new(result)
|
|
233
|
+
end
|
|
234
|
+
|
|
99
235
|
# Finalizes execution with instrumentation.
|
|
100
236
|
#
|
|
101
237
|
# @param flow_result [Symbol, FlowControlSignal, Hash]
|
|
@@ -117,11 +253,13 @@ class GenevaDrive::Executor
|
|
|
117
253
|
#
|
|
118
254
|
# @return [Hash]
|
|
119
255
|
def instrumentation_payload
|
|
120
|
-
{
|
|
256
|
+
payload = {
|
|
121
257
|
execution_id: step_execution.id,
|
|
122
258
|
workflow_id: workflow.id,
|
|
123
259
|
workflow_class: workflow.class.name
|
|
124
260
|
}
|
|
261
|
+
payload[:resumable] = true if resumable_step?
|
|
262
|
+
payload
|
|
125
263
|
end
|
|
126
264
|
|
|
127
265
|
# Phase 1: Validates states and transitions to executing.
|
|
@@ -137,14 +275,14 @@ class GenevaDrive::Executor
|
|
|
137
275
|
result = with_execution_lock do
|
|
138
276
|
# Validate step execution can start
|
|
139
277
|
unless step_execution.state == "scheduled"
|
|
140
|
-
logger.info("Step execution is
|
|
278
|
+
logger.info("Step execution #{step_execution.id} is already #{step_execution.state.inspect}, skipping duplicate job")
|
|
141
279
|
|
|
142
280
|
next nil
|
|
143
281
|
end
|
|
144
282
|
|
|
145
283
|
# Validate workflow is in a state that allows execution
|
|
146
284
|
unless %w[ready performing].include?(workflow.state)
|
|
147
|
-
logger.info("Workflow is
|
|
285
|
+
logger.info("Workflow #{workflow.id} is #{workflow.state.inspect}, cannot execute step — canceling step execution")
|
|
148
286
|
|
|
149
287
|
step_execution.update!(
|
|
150
288
|
state: "canceled",
|
|
@@ -157,7 +295,7 @@ class GenevaDrive::Executor
|
|
|
157
295
|
|
|
158
296
|
# Check hero exists (unless workflow opts out)
|
|
159
297
|
if workflow.hero.blank? && !workflow.class._may_proceed_without_hero
|
|
160
|
-
logger.info("
|
|
298
|
+
logger.info("Workflow #{workflow.id} has no hero and requires one — canceling workflow")
|
|
161
299
|
transition_step!("canceled", outcome: "canceled")
|
|
162
300
|
transition_workflow!("canceled")
|
|
163
301
|
next nil
|
|
@@ -182,6 +320,22 @@ class GenevaDrive::Executor
|
|
|
182
320
|
next nil
|
|
183
321
|
end
|
|
184
322
|
|
|
323
|
+
# Resumable steps need the cursor and continues_from_id columns. Fail
|
|
324
|
+
# loudly (instead of degrading) - without cursor persistence an
|
|
325
|
+
# interrupted iteration would silently restart from the beginning.
|
|
326
|
+
if step_def.resumable? && !GenevaDrive::StepExecution.resumable_columns?
|
|
327
|
+
error_message = "Step '#{step_execution.step_name}' is a resumable_step, but the cursor and " \
|
|
328
|
+
"continues_from_id columns are missing from geneva_drive_step_executions. " \
|
|
329
|
+
"Run `bin/rails generate geneva_drive:install` and migrate."
|
|
330
|
+
logger.error(error_message)
|
|
331
|
+
|
|
332
|
+
step_execution.update!(error_message: error_message)
|
|
333
|
+
transition_step!("failed", outcome: "failed")
|
|
334
|
+
transition_workflow!("paused")
|
|
335
|
+
exception_to_raise = GenevaDrive::StepConfigurationError.new(error_message)
|
|
336
|
+
next nil
|
|
337
|
+
end
|
|
338
|
+
|
|
185
339
|
# Evaluate preconditions with instrumentation
|
|
186
340
|
precondition_result = evaluate_preconditions(step_def)
|
|
187
341
|
if precondition_result[:abort]
|
|
@@ -201,6 +355,8 @@ class GenevaDrive::Executor
|
|
|
201
355
|
# completion, so that resume! on a failed step will retry it.
|
|
202
356
|
workflow.update!(current_step_name: step_def.name)
|
|
203
357
|
|
|
358
|
+
logger.info("Resuming from cursor: #{step_execution.cursor_value.inspect}") if step_execution.resuming?
|
|
359
|
+
|
|
204
360
|
step_def
|
|
205
361
|
rescue => e
|
|
206
362
|
exception_to_raise = handle_prepare_exception(e)
|
|
@@ -282,13 +438,23 @@ class GenevaDrive::Executor
|
|
|
282
438
|
logger.warn(
|
|
283
439
|
"Workflow #{workflow.id} state unexpectedly changed during execution: #{workflow.state}"
|
|
284
440
|
)
|
|
285
|
-
|
|
441
|
+
if flow_result.is_a?(Interruption) && workflow.paused?
|
|
442
|
+
# A resumable step was interrupted because the workflow got paused
|
|
443
|
+
# externally mid-iteration. Preserve the cursor handoff: complete
|
|
444
|
+
# this execution with the "workflow_paused" marker so resume! can
|
|
445
|
+
# create a successor that continues from the cursor.
|
|
446
|
+
transition_step!("completed", outcome: "workflow_paused")
|
|
447
|
+
else
|
|
448
|
+
transition_step!("canceled", outcome: "canceled")
|
|
449
|
+
end
|
|
286
450
|
next
|
|
287
451
|
end
|
|
288
452
|
|
|
289
453
|
case flow_result
|
|
290
454
|
when :completed
|
|
291
455
|
handle_completion
|
|
456
|
+
when Interruption
|
|
457
|
+
handle_continuation(wait: flow_result.wait)
|
|
292
458
|
when GenevaDrive::FlowControlSignal
|
|
293
459
|
handle_flow_control_signal(flow_result)
|
|
294
460
|
when Hash
|
|
@@ -408,135 +574,80 @@ class GenevaDrive::Executor
|
|
|
408
574
|
end
|
|
409
575
|
end
|
|
410
576
|
|
|
577
|
+
# Default exception policy: pause the workflow. Used as the ultimate
|
|
578
|
+
# fallback at the bottom of every resolution stack.
|
|
579
|
+
DEFAULT_EXCEPTION_POLICY = GenevaDrive::ExceptionPolicy.new(:pause!).freeze
|
|
580
|
+
|
|
411
581
|
# Captures exception info without performing transitions.
|
|
412
582
|
#
|
|
413
583
|
# @param error [Exception]
|
|
414
584
|
# @param step_def [StepDefinition]
|
|
415
585
|
# @return [Hash] captured exception context
|
|
416
586
|
def capture_exception(error, step_def)
|
|
587
|
+
{type: :exception, error: error, step_def: step_def}
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Reports an exception via Rails.error.report based on the resolved policy's
|
|
591
|
+
# report setting. Called after policy resolution so that expected exceptions
|
|
592
|
+
# (e.g. rate limiting) can suppress error reporting.
|
|
593
|
+
#
|
|
594
|
+
# @param error [Exception]
|
|
595
|
+
# @param result [Hash] the policy result with :report and optional :terminal keys
|
|
596
|
+
# @return [void]
|
|
597
|
+
def report_exception(error, result)
|
|
598
|
+
case result[:report]
|
|
599
|
+
when :never
|
|
600
|
+
return
|
|
601
|
+
when :terminal_only
|
|
602
|
+
return unless result[:terminal]
|
|
603
|
+
end
|
|
417
604
|
Rails.error.report(error)
|
|
418
|
-
{
|
|
419
|
-
type: :exception,
|
|
420
|
-
error: error,
|
|
421
|
-
step_def: step_def,
|
|
422
|
-
on_exception: step_def.on_exception
|
|
423
|
-
}
|
|
424
605
|
end
|
|
425
606
|
|
|
426
607
|
# Handles exceptions that occur during pre-condition evaluation (cancel_if, skip_if).
|
|
427
|
-
#
|
|
428
|
-
#
|
|
608
|
+
# Builds a resolution policy, applies it, and returns the original exception
|
|
609
|
+
# to be re-raised after the transaction commits.
|
|
429
610
|
#
|
|
430
611
|
# @param error [Exception] the exception that occurred
|
|
431
612
|
# @param step_def [StepDefinition] the step definition
|
|
432
613
|
# @return [Exception] the original exception to be re-raised
|
|
433
614
|
def handle_precondition_exception(error, step_def)
|
|
434
615
|
logger.error("Pre-condition evaluation failed: #{error.class} - #{error.message}")
|
|
435
|
-
Rails.error.report(error)
|
|
436
|
-
|
|
437
|
-
on_exception = step_def.on_exception
|
|
438
|
-
logger.info("Precondition exception handling with on_exception: #{on_exception.inspect}")
|
|
439
616
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
logger.warn("Max reattempts (#{step_def.max_reattempts}) exceeded - pausing workflow instead")
|
|
444
|
-
step_execution.update!(error_attributes_for(error))
|
|
445
|
-
transition_step!("failed", outcome: "failed")
|
|
446
|
-
transition_workflow!("paused")
|
|
447
|
-
else
|
|
448
|
-
logger.info("Precondition exception policy: reattempt! - rescheduling step")
|
|
449
|
-
transition_step!("completed", outcome: "reattempted")
|
|
450
|
-
transition_workflow!("ready")
|
|
451
|
-
workflow.reschedule_current_step!
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
when :cancel!
|
|
455
|
-
logger.info("Precondition exception policy: cancel! - canceling workflow")
|
|
456
|
-
step_execution.update!(error_attributes_for(error))
|
|
457
|
-
transition_step!("failed", outcome: "canceled")
|
|
458
|
-
transition_workflow!("canceled")
|
|
459
|
-
|
|
460
|
-
when :skip!
|
|
461
|
-
logger.info("Precondition exception policy: skip! - skipping to next step")
|
|
462
|
-
transition_step!("skipped", outcome: "skipped")
|
|
463
|
-
transition_workflow!("ready")
|
|
464
|
-
workflow.schedule_next_step!
|
|
617
|
+
result = apply_resolution_policy(error, step_def)
|
|
618
|
+
report_exception(error, result)
|
|
619
|
+
apply_policy_result(result, reattempt_reason: "precondition")
|
|
465
620
|
|
|
466
|
-
when :pause!
|
|
467
|
-
logger.info("Precondition exception policy: pause! - pausing workflow")
|
|
468
|
-
step_execution.update!(error_attributes_for(error))
|
|
469
|
-
transition_step!("failed", outcome: "failed")
|
|
470
|
-
transition_workflow!("paused")
|
|
471
|
-
|
|
472
|
-
else
|
|
473
|
-
# Default: pause
|
|
474
|
-
logger.info("Precondition exception policy: default (pause!) - pausing workflow")
|
|
475
|
-
step_execution.update!(error_attributes_for(error))
|
|
476
|
-
transition_step!("failed", outcome: "failed")
|
|
477
|
-
transition_workflow!("paused")
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
# Return original exception to be re-raised after transaction commits
|
|
481
621
|
error
|
|
482
622
|
end
|
|
483
623
|
|
|
484
624
|
# Handles unexpected exceptions that occur during prepare_execution before
|
|
485
625
|
# the step actually runs (e.g., NameError when hero_type references a
|
|
486
|
-
# non-existent class).
|
|
487
|
-
# otherwise defaults to :pause!.
|
|
626
|
+
# non-existent class).
|
|
488
627
|
# Returns the original exception to be re-raised after the transaction commits.
|
|
489
628
|
#
|
|
490
629
|
# @param error [Exception] the exception that occurred
|
|
491
630
|
# @return [Exception] the original exception to be re-raised
|
|
492
631
|
def handle_prepare_exception(error)
|
|
493
632
|
logger.error("Unexpected exception during prepare_execution: #{error.class} - #{error.message}")
|
|
494
|
-
Rails.error.report(error)
|
|
495
633
|
|
|
496
|
-
# Try to get step_def for
|
|
634
|
+
# Try to get step_def for policy resolution, but it may not be available yet
|
|
497
635
|
step_def = begin
|
|
498
636
|
step_execution.step_definition
|
|
499
637
|
rescue
|
|
500
638
|
nil
|
|
501
639
|
end
|
|
502
|
-
on_exception = step_def&.on_exception || :pause!
|
|
503
|
-
|
|
504
|
-
logger.info("Prepare exception handling with on_exception: #{on_exception.inspect}")
|
|
505
640
|
|
|
506
641
|
step_execution.update!(error_attributes_for(error))
|
|
507
642
|
transition_step!("failed", outcome: "failed")
|
|
508
643
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
logger.info("Prepare exception policy: reattempt! - rescheduling step")
|
|
516
|
-
transition_workflow!("ready")
|
|
517
|
-
workflow.reschedule_current_step!
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
when :cancel!
|
|
521
|
-
logger.info("Prepare exception policy: cancel! - canceling workflow")
|
|
522
|
-
transition_workflow!("canceled")
|
|
523
|
-
|
|
524
|
-
when :skip!
|
|
525
|
-
logger.info("Prepare exception policy: skip! - skipping to next step")
|
|
526
|
-
transition_workflow!("ready")
|
|
527
|
-
workflow.schedule_next_step!
|
|
528
|
-
|
|
529
|
-
when :pause!
|
|
530
|
-
logger.info("Prepare exception policy: pause! - pausing workflow")
|
|
531
|
-
transition_workflow!("paused")
|
|
644
|
+
combined = build_resolution_policy(step_def)
|
|
645
|
+
count = consecutive_reattempt_count(step_def&.name || step_execution.step_name)
|
|
646
|
+
result = combined.apply(error, reattempt_count: count, workflow: workflow)
|
|
647
|
+
result ||= {action: :pause, error: error}
|
|
648
|
+
report_exception(error, result)
|
|
649
|
+
apply_prepare_policy_result(result)
|
|
532
650
|
|
|
533
|
-
else
|
|
534
|
-
# Default: pause
|
|
535
|
-
logger.info("Prepare exception policy: default (pause!) - pausing workflow")
|
|
536
|
-
transition_workflow!("paused")
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
# Return original exception to be re-raised after transaction commits
|
|
540
651
|
error
|
|
541
652
|
end
|
|
542
653
|
|
|
@@ -550,7 +661,21 @@ class GenevaDrive::Executor
|
|
|
550
661
|
workflow.schedule_next_step!
|
|
551
662
|
end
|
|
552
663
|
|
|
553
|
-
# Handles a
|
|
664
|
+
# Handles a resumable step interrupting itself mid-iteration: completes the
|
|
665
|
+
# current execution with outcome "continued" and creates a successor
|
|
666
|
+
# execution that carries on from the persisted cursor.
|
|
667
|
+
#
|
|
668
|
+
# @param wait [ActiveSupport::Duration, Numeric, nil] optional delay before the successor runs
|
|
669
|
+
# @return [void]
|
|
670
|
+
def handle_continuation(wait: nil)
|
|
671
|
+
wait_msg = wait ? " (continuing after #{wait.inspect})" : ""
|
|
672
|
+
logger.info("Resumable step interrupted at cursor #{step_execution.cursor_value.inspect}#{wait_msg}")
|
|
673
|
+
transition_step!("completed", outcome: "continued")
|
|
674
|
+
transition_workflow!("ready")
|
|
675
|
+
workflow.create_successor_execution!(step_execution, wait: wait)
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Handles a captured exception based on the resolved exception policy.
|
|
554
679
|
# Returns the original exception to be re-raised after the transaction commits.
|
|
555
680
|
#
|
|
556
681
|
# @param context [Hash] captured exception context
|
|
@@ -558,56 +683,157 @@ class GenevaDrive::Executor
|
|
|
558
683
|
def handle_captured_exception(context)
|
|
559
684
|
error = context[:error]
|
|
560
685
|
step_def = context[:step_def]
|
|
561
|
-
on_exception = context[:on_exception]
|
|
562
686
|
|
|
563
|
-
|
|
687
|
+
result = apply_resolution_policy(error, step_def)
|
|
688
|
+
report_exception(error, result)
|
|
689
|
+
apply_policy_result(result, reattempt_reason: "exception_policy")
|
|
564
690
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
691
|
+
error
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Builds a combined exception policy from step-level, class-level, and
|
|
695
|
+
# default policies, then applies it to the error.
|
|
696
|
+
#
|
|
697
|
+
# @param error [Exception] the exception that was raised
|
|
698
|
+
# @param step_def [StepDefinition, nil] the step definition
|
|
699
|
+
# @return [Hash] policy result (see {ExceptionPolicy#apply})
|
|
700
|
+
def apply_resolution_policy(error, step_def)
|
|
701
|
+
combined = build_resolution_policy(step_def)
|
|
702
|
+
count = consecutive_reattempt_count(step_def&.name || step_execution.step_name)
|
|
703
|
+
combined.apply(error, reattempt_count: count, workflow: workflow)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Builds a combined exception policy that includes step-level, class-level,
|
|
707
|
+
# and default policies in precedence order.
|
|
708
|
+
#
|
|
709
|
+
# The resulting {CombinedExceptionPolicy} walks policies in order:
|
|
710
|
+
# 1. Step-level policies (if explicitly set, in definition order)
|
|
711
|
+
# 2. Class-level specific policies (most recently defined first)
|
|
712
|
+
# 3. Class-level blanket policies (most recently defined first)
|
|
713
|
+
# 4. Default pause policy (always matches — guarantees a result)
|
|
714
|
+
#
|
|
715
|
+
# @param step_def [StepDefinition, nil] the step definition
|
|
716
|
+
# @return [GenevaDrive::CombinedExceptionPolicy]
|
|
717
|
+
def build_resolution_policy(step_def)
|
|
718
|
+
policies = []
|
|
719
|
+
|
|
720
|
+
policies << step_def.exception_policy if step_def&.exception_policy
|
|
721
|
+
|
|
722
|
+
class_reversed = workflow.class._exception_policies.reverse
|
|
723
|
+
policies.concat(class_reversed.select(&:specific?))
|
|
724
|
+
policies.concat(class_reversed.select(&:blanket?))
|
|
725
|
+
|
|
726
|
+
policies << DEFAULT_EXCEPTION_POLICY
|
|
727
|
+
|
|
728
|
+
GenevaDrive::CombinedExceptionPolicy.new(policies)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# Applies a policy result to the step execution and workflow.
|
|
732
|
+
# This is the single place where policy decisions become state transitions.
|
|
733
|
+
#
|
|
734
|
+
# @param result [Hash] policy result with :action, :wait, and :error keys
|
|
735
|
+
# @param reattempt_reason [String] reason for reattempt metadata
|
|
736
|
+
# @return [void]
|
|
737
|
+
def apply_policy_result(result, reattempt_reason:)
|
|
738
|
+
error = result[:error]
|
|
739
|
+
|
|
740
|
+
case result[:action]
|
|
741
|
+
when :reattempt
|
|
742
|
+
logger.info("Exception policy: reattempt!")
|
|
743
|
+
transition_step!("completed", outcome: "reattempted")
|
|
744
|
+
write_reattempt_metadata(reattempt_reason, error: error)
|
|
745
|
+
transition_workflow!("ready")
|
|
746
|
+
if resumable_step?
|
|
747
|
+
# Resumable steps continue from the persisted cursor via a successor
|
|
748
|
+
# execution instead of a fresh (cursor-less) execution.
|
|
749
|
+
workflow.create_successor_execution!(step_execution, wait: result[:wait])
|
|
572
750
|
else
|
|
573
|
-
|
|
574
|
-
transition_step!("completed", outcome: "reattempted")
|
|
575
|
-
transition_workflow!("ready")
|
|
576
|
-
workflow.reschedule_current_step!
|
|
751
|
+
workflow.reschedule_current_step!(wait: result[:wait])
|
|
577
752
|
end
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
logger.info("Exception policy: cancel! - canceling workflow")
|
|
753
|
+
when :cancel
|
|
754
|
+
logger.info("Exception policy: cancel!")
|
|
581
755
|
step_execution.update!(error_attributes_for(error))
|
|
582
756
|
transition_step!("failed", outcome: "canceled")
|
|
583
757
|
transition_workflow!("canceled")
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
logger.info("Exception policy: skip! - skipping to next step")
|
|
758
|
+
when :skip
|
|
759
|
+
logger.info("Exception policy: skip!")
|
|
587
760
|
transition_step!("skipped", outcome: "skipped")
|
|
588
761
|
transition_workflow!("ready")
|
|
589
762
|
workflow.schedule_next_step!
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
logger.info("Exception policy: pause! - pausing workflow")
|
|
763
|
+
when :pause
|
|
764
|
+
logger.info("Exception policy: pause!")
|
|
593
765
|
step_execution.update!(error_attributes_for(error))
|
|
594
766
|
transition_step!("failed", outcome: "failed")
|
|
595
767
|
transition_workflow!("paused")
|
|
596
|
-
|
|
768
|
+
when :finished
|
|
769
|
+
logger.info("Exception policy: finished!")
|
|
770
|
+
transition_step!("completed", outcome: "success")
|
|
771
|
+
transition_workflow!("finished")
|
|
597
772
|
else
|
|
598
|
-
|
|
599
|
-
logger.info("Exception policy: default (pause!) - pausing workflow")
|
|
773
|
+
logger.info("Exception policy: default (pause!)")
|
|
600
774
|
step_execution.update!(error_attributes_for(error))
|
|
601
775
|
transition_step!("failed", outcome: "failed")
|
|
602
776
|
transition_workflow!("paused")
|
|
603
777
|
end
|
|
778
|
+
end
|
|
604
779
|
|
|
605
|
-
|
|
606
|
-
|
|
780
|
+
# Applies a policy result during the prepare phase.
|
|
781
|
+
# In prepare phase, the step has already been transitioned to "failed",
|
|
782
|
+
# so only the workflow state transition is needed.
|
|
783
|
+
#
|
|
784
|
+
# @param result [Hash] policy result with :action and :wait keys
|
|
785
|
+
# @return [void]
|
|
786
|
+
def apply_prepare_policy_result(result)
|
|
787
|
+
case result[:action]
|
|
788
|
+
when :reattempt
|
|
789
|
+
logger.info("Prepare exception policy: reattempt!")
|
|
790
|
+
transition_workflow!("ready")
|
|
791
|
+
if resumable_step?
|
|
792
|
+
workflow.create_successor_execution!(step_execution, wait: result[:wait])
|
|
793
|
+
else
|
|
794
|
+
workflow.reschedule_current_step!(wait: result[:wait])
|
|
795
|
+
end
|
|
796
|
+
when :cancel
|
|
797
|
+
logger.info("Prepare exception policy: cancel!")
|
|
798
|
+
transition_workflow!("canceled")
|
|
799
|
+
when :skip
|
|
800
|
+
logger.info("Prepare exception policy: skip!")
|
|
801
|
+
transition_workflow!("ready")
|
|
802
|
+
workflow.schedule_next_step!
|
|
803
|
+
else
|
|
804
|
+
logger.info("Prepare exception policy: pause!")
|
|
805
|
+
transition_workflow!("paused")
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Writes reattempt reason (and exception info when present) to step
|
|
810
|
+
# execution metadata and persists.
|
|
811
|
+
#
|
|
812
|
+
# @param reason [String] the reattempt reason
|
|
813
|
+
# @param error [Exception, nil] the exception that triggered the reattempt
|
|
814
|
+
# @return [void]
|
|
815
|
+
def write_reattempt_metadata(reason, error: nil)
|
|
816
|
+
step_execution.write_metadata("reattempt_reason", reason)
|
|
817
|
+
write_exception_metadata(error) if error
|
|
818
|
+
step_execution.save! if step_execution.changed?
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# Writes exception details into metadata for future migration away
|
|
822
|
+
# from dedicated error columns. Silent no-op without the column.
|
|
823
|
+
#
|
|
824
|
+
# @param error [Exception]
|
|
825
|
+
# @return [void]
|
|
826
|
+
def write_exception_metadata(error)
|
|
827
|
+
step_execution.write_metadata("exception", {
|
|
828
|
+
"class" => error.class.name,
|
|
829
|
+
"message" => error.message,
|
|
830
|
+
"backtrace" => error.backtrace
|
|
831
|
+
})
|
|
607
832
|
end
|
|
608
833
|
|
|
609
834
|
# Builds the attributes hash for storing error information on a step execution.
|
|
610
835
|
# Conditionally includes error_class_name if the column exists (migration may not have run yet).
|
|
836
|
+
# Also double-writes the error into metadata for future migration away from dedicated columns.
|
|
611
837
|
#
|
|
612
838
|
# @param error [Exception]
|
|
613
839
|
# @return [Hash]
|
|
@@ -617,14 +843,15 @@ class GenevaDrive::Executor
|
|
|
617
843
|
error_backtrace: error.backtrace&.join("\n")
|
|
618
844
|
}
|
|
619
845
|
attrs[:error_class_name] = error.class.name if step_execution.has_attribute?(:error_class_name)
|
|
846
|
+
write_exception_metadata(error)
|
|
620
847
|
attrs
|
|
621
848
|
end
|
|
622
849
|
|
|
623
|
-
# Counts consecutive reattempts for the current step
|
|
624
|
-
#
|
|
850
|
+
# Counts consecutive exception-policy reattempts for the current step.
|
|
851
|
+
# Only counts reattempts caused by exception policies (not flow-control reattempts).
|
|
625
852
|
#
|
|
626
853
|
# @param step_name [String] the step name to count reattempts for
|
|
627
|
-
# @return [Integer] the number of consecutive reattempts
|
|
854
|
+
# @return [Integer] the number of consecutive exception-policy reattempts
|
|
628
855
|
def consecutive_reattempt_count(step_name)
|
|
629
856
|
# Find the most recent non-reattempt completion for this step
|
|
630
857
|
# (success, skipped, canceled, failed - anything that isn't a reattempt)
|
|
@@ -637,22 +864,12 @@ class GenevaDrive::Executor
|
|
|
637
864
|
# Count reattempts after that point (or all reattempts if no prior success)
|
|
638
865
|
scope = workflow.step_executions.where(step_name: step_name, outcome: "reattempted")
|
|
639
866
|
scope = scope.where("id > ?", last_non_reattempt_id) if last_non_reattempt_id
|
|
640
|
-
scope.count
|
|
641
|
-
end
|
|
642
|
-
|
|
643
|
-
# Checks if the max reattempts limit has been exceeded for the current step.
|
|
644
|
-
# Returns true if we should fall back to :pause! instead of reattempting.
|
|
645
|
-
#
|
|
646
|
-
# @param step_def [StepDefinition] the step definition
|
|
647
|
-
# @return [Boolean] true if limit exceeded, false otherwise
|
|
648
|
-
def reattempt_limit_exceeded?(step_def)
|
|
649
|
-
max_reattempts = step_def.max_reattempts
|
|
650
|
-
return false if max_reattempts.nil? # Limit disabled
|
|
651
867
|
|
|
652
|
-
|
|
653
|
-
count >= max_reattempts
|
|
868
|
+
GenevaDrive::StepExecution.count_error_reattempts(scope)
|
|
654
869
|
end
|
|
655
870
|
|
|
871
|
+
# Checks if the max reattempts limit has been exceeded using policy-level max_reattempts.
|
|
872
|
+
#
|
|
656
873
|
# Handles a flow control signal.
|
|
657
874
|
#
|
|
658
875
|
# @param signal [FlowControlSignal]
|
|
@@ -668,15 +885,30 @@ class GenevaDrive::Executor
|
|
|
668
885
|
|
|
669
886
|
when :pause
|
|
670
887
|
logger.info("Processing pause signal: pausing workflow")
|
|
671
|
-
|
|
888
|
+
if resumable_step?
|
|
889
|
+
# Complete with the "workflow_paused" marker so the cursor is
|
|
890
|
+
# preserved and resume! can continue from it via a successor.
|
|
891
|
+
transition_step!("completed", outcome: "workflow_paused")
|
|
892
|
+
else
|
|
893
|
+
transition_step!("canceled", outcome: "canceled")
|
|
894
|
+
end
|
|
672
895
|
transition_workflow!("paused")
|
|
673
896
|
|
|
674
897
|
when :reattempt
|
|
675
898
|
wait_msg = signal.options[:wait] ? " after #{signal.options[:wait].inspect}" : ""
|
|
676
899
|
logger.info("Processing reattempt signal: rescheduling step#{wait_msg}")
|
|
900
|
+
if resumable_step? && signal.options[:rewind]
|
|
901
|
+
logger.info("Rewinding cursor for reattempt")
|
|
902
|
+
step_execution.update!(cursor: nil)
|
|
903
|
+
end
|
|
677
904
|
transition_step!("completed", outcome: "reattempted")
|
|
905
|
+
write_reattempt_metadata("flow_control")
|
|
678
906
|
transition_workflow!("ready")
|
|
679
|
-
|
|
907
|
+
if resumable_step?
|
|
908
|
+
workflow.create_successor_execution!(step_execution, wait: signal.options[:wait])
|
|
909
|
+
else
|
|
910
|
+
workflow.reschedule_current_step!(wait: signal.options[:wait])
|
|
911
|
+
end
|
|
680
912
|
|
|
681
913
|
when :skip
|
|
682
914
|
logger.info("Processing skip signal: scheduling next step")
|
|
@@ -688,7 +920,57 @@ class GenevaDrive::Executor
|
|
|
688
920
|
logger.info("Processing finished signal: finishing workflow")
|
|
689
921
|
transition_step!("completed", outcome: "success")
|
|
690
922
|
transition_workflow!("finished")
|
|
923
|
+
|
|
924
|
+
when :suspend
|
|
925
|
+
# Only reachable from resumable steps: FlowControl#suspend! raises
|
|
926
|
+
# when called from a non-resumable step.
|
|
927
|
+
wait = signal.options[:wait]
|
|
928
|
+
logger.info("Processing suspend signal: completing and creating successor")
|
|
929
|
+
transition_step!("completed", outcome: "continued")
|
|
930
|
+
transition_workflow!("ready")
|
|
931
|
+
workflow.create_successor_execution!(step_execution, wait: wait)
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# Whether the step being executed is a resumable (cursor-iterating) step.
|
|
936
|
+
# Falls back to looking up the step definition when called before
|
|
937
|
+
# prepare_execution has completed (e.g. from precondition handling).
|
|
938
|
+
#
|
|
939
|
+
# @return [Boolean]
|
|
940
|
+
def resumable_step?
|
|
941
|
+
step_def = @step_definition || begin
|
|
942
|
+
step_execution.step_definition
|
|
943
|
+
rescue
|
|
944
|
+
nil
|
|
691
945
|
end
|
|
946
|
+
!!step_def&.resumable?
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# Checks if max runtime has been exceeded for the current execution.
|
|
950
|
+
#
|
|
951
|
+
# @return [Boolean]
|
|
952
|
+
def max_runtime_exceeded?
|
|
953
|
+
max_runtime = @step_definition.respond_to?(:max_runtime) ? @step_definition.max_runtime : nil
|
|
954
|
+
return false unless max_runtime
|
|
955
|
+
return false unless @start_time
|
|
956
|
+
Time.current - @start_time > max_runtime
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# Checks if the job should exit (e.g., Sidekiq shutdown).
|
|
960
|
+
#
|
|
961
|
+
# @return [Boolean]
|
|
962
|
+
def job_should_exit?
|
|
963
|
+
Thread.current[:geneva_drive_should_exit] ||
|
|
964
|
+
(defined?(Sidekiq) && Sidekiq.const_defined?(:CLI) &&
|
|
965
|
+
Sidekiq::CLI.instance&.stopping?)
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# Checks if the workflow has been externally paused or canceled.
|
|
969
|
+
#
|
|
970
|
+
# @return [Boolean]
|
|
971
|
+
def workflow_interrupted?
|
|
972
|
+
workflow.reload
|
|
973
|
+
workflow.paused? || workflow.canceled?
|
|
692
974
|
end
|
|
693
975
|
end
|
|
694
976
|
|