geneva_drive 0.5.0 → 0.7.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 +14 -1
- data/MANUAL.md +449 -11
- data/README.md +5 -1
- data/lib/generators/geneva_drive/install/install_generator.rb +20 -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_signals_support.rb +94 -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/create_workflows_migration.rb +11 -0
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
- data/lib/geneva_drive/combined_exception_policy.rb +1 -1
- data/lib/geneva_drive/exception_policy.rb +36 -10
- data/lib/geneva_drive/executor.rb +352 -26
- data/lib/geneva_drive/flow_control.rb +87 -7
- 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 +120 -7
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +96 -2
- data/lib/geneva_drive/step_execution.rb +141 -2
- data/lib/geneva_drive/test_helpers.rb +212 -6
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +559 -43
- data/lib/geneva_drive.rb +37 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dsl/step_definition_test.rb +70 -0
- data/test/jobs/housekeeping_job_test.rb +119 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +4 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/instrumentation_test.rb +28 -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/signal_consumption_test.rb +397 -0
- data/test/workflow/signal_delivery_test.rb +255 -0
- data/test/workflow/signal_durability_test.rb +164 -0
- data/test/workflow/signal_rendezvous_test.rb +581 -0
- data/test/workflow/signals_without_migration_test.rb +97 -0
- data/test/workflow/workflow_test.rb +72 -1
- metadata +25 -15
- data/test/dummy/config/initializers/geneva_drive.rb +0 -44
- data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
- data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
- data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
- data/test/dummy/db/schema.rb +0 -73
- data/test/dummy/log/development.log +0 -202
- data/test/dummy/log/test.log +0 -79471
- data/test/dummy/log/test.log.0 +0 -4
- data/test/dummy/tmp/local_secret.txt +0 -1
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
class GenevaDrive::Executor
|
|
16
16
|
# Valid state transitions for step executions
|
|
17
17
|
STEP_TRANSITIONS = {
|
|
18
|
-
"scheduled" => %w[scheduled in_progress canceled skipped failed completed],
|
|
18
|
+
"scheduled" => %w[scheduled waiting in_progress canceled skipped failed completed],
|
|
19
|
+
"waiting" => %w[scheduled canceled skipped],
|
|
19
20
|
"in_progress" => %w[in_progress completed failed canceled skipped]
|
|
20
21
|
}.freeze
|
|
21
22
|
|
|
@@ -25,7 +26,14 @@ class GenevaDrive::Executor
|
|
|
25
26
|
"performing" => %w[ready performing canceled paused finished]
|
|
26
27
|
}.freeze
|
|
27
28
|
|
|
29
|
+
# Result of a resumable step interrupting itself mid-iteration: the current
|
|
30
|
+
# execution completes with outcome "continued" and a successor execution is
|
|
31
|
+
# created to carry on from the persisted cursor. `wait` optionally delays
|
|
32
|
+
# the successor.
|
|
33
|
+
Interruption = Struct.new(:wait)
|
|
34
|
+
|
|
28
35
|
# Executes a step execution with full flow control and exception handling.
|
|
36
|
+
# Handles both regular steps and resumable (cursor-iterating) steps.
|
|
29
37
|
#
|
|
30
38
|
# @param step_execution [GenevaDrive::StepExecution] the step to execute
|
|
31
39
|
# @param logger [Logger, nil] optional base logger to inject into the workflow.
|
|
@@ -33,28 +41,39 @@ class GenevaDrive::Executor
|
|
|
33
41
|
# with workflow and step-specific tags added on top. This allows callers
|
|
34
42
|
# (background jobs, controllers, etc.) to pass in a logger that already
|
|
35
43
|
# has appropriate context tags (e.g., job_id, request_id).
|
|
44
|
+
# @param interruptible [Boolean] whether resumable steps respect interruption
|
|
45
|
+
# conditions (max_iterations, max_runtime, shutdown, external pause/cancel).
|
|
46
|
+
# Test helpers pass false to run a resumable step to completion in one call.
|
|
47
|
+
# @param max_iterations [Integer, nil] overrides the step definition's
|
|
48
|
+
# max_iterations for this execution (used by the run_iterations test helper)
|
|
36
49
|
# @return [void]
|
|
37
50
|
#
|
|
38
51
|
# @example Execute with a pre-tagged logger from a background job
|
|
39
52
|
# logger = Rails.logger.tagged("job_id=#{job_id}")
|
|
40
53
|
# GenevaDrive::Executor.execute!(step_execution, logger: logger)
|
|
41
|
-
def self.execute!(step_execution, logger: nil)
|
|
42
|
-
new.call(step_execution, logger: logger)
|
|
54
|
+
def self.execute!(step_execution, logger: nil, interruptible: true, max_iterations: nil)
|
|
55
|
+
new.call(step_execution, logger: logger, interruptible: interruptible, max_iterations: max_iterations)
|
|
43
56
|
end
|
|
44
57
|
|
|
45
58
|
# Performs the step execution.
|
|
46
59
|
#
|
|
47
60
|
# @param step_execution [GenevaDrive::StepExecution] the step to execute
|
|
48
61
|
# @param logger [Logger, nil] optional base logger to inject into the workflow
|
|
62
|
+
# @param interruptible [Boolean] whether resumable steps respect interruption conditions
|
|
63
|
+
# @param max_iterations [Integer, nil] per-execution override of the step's max_iterations
|
|
49
64
|
# @return [void]
|
|
50
|
-
def call(step_execution, logger: nil)
|
|
65
|
+
def call(step_execution, logger: nil, interruptible: true, max_iterations: nil)
|
|
51
66
|
@step_execution = step_execution
|
|
52
67
|
@workflow = step_execution.workflow
|
|
68
|
+
@interruptible = interruptible
|
|
69
|
+
@max_iterations_override = max_iterations
|
|
53
70
|
|
|
54
71
|
# Build the full logger chain (base -> workflow -> step tags) and inject
|
|
55
72
|
# it so step code calling `logger` gets the fully-tagged step execution
|
|
56
73
|
# logger. Falls back to Rails.logger if no logger is provided.
|
|
57
74
|
step_logger = build_step_logger(logger || Rails.logger, step_execution)
|
|
75
|
+
Rails.error.set_context(geneva_drive: error_context)
|
|
76
|
+
|
|
58
77
|
@workflow.with_logger(step_logger) do
|
|
59
78
|
step_execution.with_logger(step_logger) do
|
|
60
79
|
execute_with_logger(step_execution)
|
|
@@ -62,6 +81,23 @@ class GenevaDrive::Executor
|
|
|
62
81
|
end
|
|
63
82
|
end
|
|
64
83
|
|
|
84
|
+
# Checks if a resumable step should be interrupted at a checkpoint.
|
|
85
|
+
# Called by IterableStep during checkpoint!.
|
|
86
|
+
#
|
|
87
|
+
# @param iterations [Integer, nil] iterations completed so far in this execution
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def should_interrupt?(iterations: nil)
|
|
90
|
+
# If interruption is disabled (e.g., in tests), never interrupt
|
|
91
|
+
return false unless @interruptible
|
|
92
|
+
|
|
93
|
+
max_iterations = @max_iterations_override || (@step_definition.respond_to?(:max_iterations) ? @step_definition.max_iterations : nil)
|
|
94
|
+
return true if max_iterations && iterations && iterations >= max_iterations
|
|
95
|
+
return true if max_runtime_exceeded?
|
|
96
|
+
return true if job_should_exit?
|
|
97
|
+
return true if workflow_interrupted?
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
65
101
|
private
|
|
66
102
|
|
|
67
103
|
# Executes the step with the current logger configuration.
|
|
@@ -75,17 +111,26 @@ class GenevaDrive::Executor
|
|
|
75
111
|
step_def = prepare_execution
|
|
76
112
|
return unless step_def
|
|
77
113
|
|
|
78
|
-
|
|
79
|
-
@
|
|
80
|
-
@workflow.before_step_execution(@step_execution)
|
|
114
|
+
@step_definition = step_def
|
|
115
|
+
@start_time = Time.current
|
|
81
116
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
# Phase 2: Execute step block (locks released). The signal this execution
|
|
118
|
+
# is attached to (if any) is injected for the duration, so step code can
|
|
119
|
+
# read `received_signal`.
|
|
120
|
+
flow_result = @workflow.with_received_signal(@received_signal) do
|
|
121
|
+
@logger.debug("Running before_step_execution hook")
|
|
122
|
+
@workflow.before_step_execution(@step_execution)
|
|
86
123
|
|
|
87
|
-
|
|
88
|
-
|
|
124
|
+
@logger.debug("Running the actual step code")
|
|
125
|
+
result = @workflow.around_step_execution(@step_execution) do
|
|
126
|
+
execute_step(step_def)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@logger.debug("Running after_step_execution hook")
|
|
130
|
+
@workflow.after_step_execution(@step_execution)
|
|
131
|
+
|
|
132
|
+
result
|
|
133
|
+
end
|
|
89
134
|
|
|
90
135
|
# Phase 3: Acquire locks and handle result
|
|
91
136
|
@logger.info("Finished step with outcome #{flow_result.inspect}")
|
|
@@ -94,6 +139,20 @@ class GenevaDrive::Executor
|
|
|
94
139
|
|
|
95
140
|
attr_reader :step_execution, :workflow, :logger
|
|
96
141
|
|
|
142
|
+
# Builds execution context to attach to Error Monitoring reports.
|
|
143
|
+
#
|
|
144
|
+
# @return [Hash]
|
|
145
|
+
def error_context
|
|
146
|
+
{
|
|
147
|
+
workflow_id: workflow.id,
|
|
148
|
+
workflow_class: workflow.class.name,
|
|
149
|
+
step_execution_id: step_execution.id,
|
|
150
|
+
step_name: step_execution.step_name,
|
|
151
|
+
hero_type: workflow.hero_type,
|
|
152
|
+
hero_id: workflow.hero_id
|
|
153
|
+
}.compact
|
|
154
|
+
end
|
|
155
|
+
|
|
97
156
|
# Builds a fully-tagged logger for the step execution.
|
|
98
157
|
# Adds workflow tags and step execution tags to the base logger.
|
|
99
158
|
#
|
|
@@ -118,16 +177,19 @@ class GenevaDrive::Executor
|
|
|
118
177
|
|
|
119
178
|
# Executes the step block with instrumentation.
|
|
120
179
|
#
|
|
180
|
+
# For resumable steps, the block receives an IterableStep and runs inside a
|
|
181
|
+
# catch(:interrupt) so that checkpoints (and skip_to!) can interrupt the
|
|
182
|
+
# iteration. An interrupt is normalized into an {Interruption} result.
|
|
183
|
+
#
|
|
121
184
|
# @param step_def [StepDefinition]
|
|
122
|
-
# @return [Symbol, FlowControlSignal, Hash] the execution result
|
|
185
|
+
# @return [Symbol, FlowControlSignal, Hash, Interruption] the execution result
|
|
123
186
|
def execute_step(step_def)
|
|
124
187
|
payload = instrumentation_payload
|
|
125
188
|
payload[:step_name] = step_def.name
|
|
126
189
|
|
|
127
190
|
ActiveSupport::Notifications.instrument("step.geneva_drive", payload) do |p|
|
|
128
191
|
result = catch(:flow_control) do
|
|
129
|
-
step_def
|
|
130
|
-
:completed
|
|
192
|
+
run_step_code(step_def)
|
|
131
193
|
rescue => e
|
|
132
194
|
logger.error("Encountered #{e.class}, cleaning up and re-raising")
|
|
133
195
|
# Don't transition here - just capture the error info
|
|
@@ -136,15 +198,47 @@ class GenevaDrive::Executor
|
|
|
136
198
|
|
|
137
199
|
p[:outcome] = case result
|
|
138
200
|
when :completed then :completed
|
|
201
|
+
when GenevaDrive::Executor::Interruption then :interrupted
|
|
139
202
|
when GenevaDrive::FlowControlSignal then result.action
|
|
140
203
|
when Hash then :exception
|
|
141
204
|
end
|
|
142
205
|
p[:exception] = result[:error] if result.is_a?(Hash)
|
|
206
|
+
p[:wait] = result.wait if result.is_a?(GenevaDrive::Executor::Interruption)
|
|
143
207
|
|
|
144
208
|
result
|
|
145
209
|
end
|
|
146
210
|
end
|
|
147
211
|
|
|
212
|
+
# Runs the user's step code, handling the resumable interrupt channel.
|
|
213
|
+
#
|
|
214
|
+
# @param step_def [StepDefinition]
|
|
215
|
+
# @return [Symbol, Interruption] :completed, or an Interruption for resumable steps
|
|
216
|
+
def run_step_code(step_def)
|
|
217
|
+
unless step_def.resumable?
|
|
218
|
+
step_def.execute_in_context(workflow)
|
|
219
|
+
return :completed
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
iter = GenevaDrive::IterableStep.new(
|
|
223
|
+
step_def.name,
|
|
224
|
+
step_execution.cursor_value,
|
|
225
|
+
execution: step_execution,
|
|
226
|
+
resumed: step_execution.resuming?,
|
|
227
|
+
interrupter: self
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# catch(:interrupt) returns:
|
|
231
|
+
# - :completed if the block finishes normally
|
|
232
|
+
# - nil if throw :interrupt (immediate successor)
|
|
233
|
+
# - Duration/Numeric if throw :interrupt, wait (delayed successor)
|
|
234
|
+
result = catch(:interrupt) do
|
|
235
|
+
step_def.execute_in_context(workflow, iter)
|
|
236
|
+
:completed
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
(result == :completed) ? :completed : Interruption.new(result)
|
|
240
|
+
end
|
|
241
|
+
|
|
148
242
|
# Finalizes execution with instrumentation.
|
|
149
243
|
#
|
|
150
244
|
# @param flow_result [Symbol, FlowControlSignal, Hash]
|
|
@@ -166,11 +260,13 @@ class GenevaDrive::Executor
|
|
|
166
260
|
#
|
|
167
261
|
# @return [Hash]
|
|
168
262
|
def instrumentation_payload
|
|
169
|
-
{
|
|
263
|
+
payload = {
|
|
170
264
|
execution_id: step_execution.id,
|
|
171
265
|
workflow_id: workflow.id,
|
|
172
266
|
workflow_class: workflow.class.name
|
|
173
267
|
}
|
|
268
|
+
payload[:resumable] = true if resumable_step?
|
|
269
|
+
payload
|
|
174
270
|
end
|
|
175
271
|
|
|
176
272
|
# Phase 1: Validates states and transitions to executing.
|
|
@@ -231,6 +327,38 @@ class GenevaDrive::Executor
|
|
|
231
327
|
next nil
|
|
232
328
|
end
|
|
233
329
|
|
|
330
|
+
# Resumable steps need the cursor and continues_from_id columns. Fail
|
|
331
|
+
# loudly (instead of degrading) - without cursor persistence an
|
|
332
|
+
# interrupted iteration would silently restart from the beginning.
|
|
333
|
+
if step_def.resumable? && !GenevaDrive::StepExecution.resumable_columns?
|
|
334
|
+
error_message = "Step '#{step_execution.step_name}' is a resumable_step, but the cursor and " \
|
|
335
|
+
"continues_from_id columns are missing from geneva_drive_step_executions. " \
|
|
336
|
+
"Run `bin/rails generate geneva_drive:install` and migrate."
|
|
337
|
+
logger.error(error_message)
|
|
338
|
+
|
|
339
|
+
step_execution.update!(error_message: error_message)
|
|
340
|
+
transition_step!("failed", outcome: "failed")
|
|
341
|
+
transition_workflow!("paused")
|
|
342
|
+
exception_to_raise = GenevaDrive::StepConfigurationError.new(error_message)
|
|
343
|
+
next nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Steps that wait for a signal need the signal_id and waiting_since
|
|
347
|
+
# columns. Fail loudly (instead of degrading) - without them a step
|
|
348
|
+
# would run without the event it was written to react to.
|
|
349
|
+
if step_def.waits_for_signal? && !GenevaDrive::StepExecution.signal_columns?
|
|
350
|
+
error_message = "Step '#{step_execution.step_name}' declares wait_for:, but the signal_id and " \
|
|
351
|
+
"waiting_since columns are missing from geneva_drive_step_executions. " \
|
|
352
|
+
"Run `bin/rails generate geneva_drive:install` and migrate."
|
|
353
|
+
logger.error(error_message)
|
|
354
|
+
|
|
355
|
+
step_execution.update!(error_message: error_message)
|
|
356
|
+
transition_step!("failed", outcome: "failed")
|
|
357
|
+
transition_workflow!("paused")
|
|
358
|
+
exception_to_raise = GenevaDrive::StepConfigurationError.new(error_message)
|
|
359
|
+
next nil
|
|
360
|
+
end
|
|
361
|
+
|
|
234
362
|
# Evaluate preconditions with instrumentation
|
|
235
363
|
precondition_result = evaluate_preconditions(step_def)
|
|
236
364
|
if precondition_result[:abort]
|
|
@@ -238,6 +366,13 @@ class GenevaDrive::Executor
|
|
|
238
366
|
next nil
|
|
239
367
|
end
|
|
240
368
|
|
|
369
|
+
# The rendezvous, receiver side: skip_if has had its say (skip beats
|
|
370
|
+
# wait), so now find the signal or park. Parking ends the job without
|
|
371
|
+
# enqueueing anything - dispatch will wake us.
|
|
372
|
+
if step_def.waits_for_signal? && run_signal_gate(step_def) == :parked
|
|
373
|
+
next nil
|
|
374
|
+
end
|
|
375
|
+
|
|
241
376
|
# All checks passed - transition to in_progress
|
|
242
377
|
logger.debug("Step may be performed - changing state flags and proceeding to perform")
|
|
243
378
|
|
|
@@ -250,6 +385,8 @@ class GenevaDrive::Executor
|
|
|
250
385
|
# completion, so that resume! on a failed step will retry it.
|
|
251
386
|
workflow.update!(current_step_name: step_def.name)
|
|
252
387
|
|
|
388
|
+
logger.info("Resuming from cursor: #{step_execution.cursor_value.inspect}") if step_execution.resuming?
|
|
389
|
+
|
|
253
390
|
step_def
|
|
254
391
|
rescue => e
|
|
255
392
|
exception_to_raise = handle_prepare_exception(e)
|
|
@@ -260,6 +397,69 @@ class GenevaDrive::Executor
|
|
|
260
397
|
result
|
|
261
398
|
end
|
|
262
399
|
|
|
400
|
+
# The receiver side of the rendezvous. Runs under the workflow and step
|
|
401
|
+
# execution locks, inside prepare_execution.
|
|
402
|
+
#
|
|
403
|
+
# An execution that already carries an attachment pin (dispatch woke it, or
|
|
404
|
+
# it inherited the pin from its predecessor) runs straight away. Otherwise
|
|
405
|
+
# the oldest matching non-consumed signal is attached, and failing that the
|
|
406
|
+
# execution parks.
|
|
407
|
+
#
|
|
408
|
+
# @param step_def [StepDefinition] the waiting step's definition
|
|
409
|
+
# @return [Symbol] :attached or :parked
|
|
410
|
+
def run_signal_gate(step_def)
|
|
411
|
+
if step_execution.signal_id.present?
|
|
412
|
+
@received_signal = step_execution.signal
|
|
413
|
+
logger.info("Step is attached to signal #{step_execution.signal_id}, proceeding")
|
|
414
|
+
return :attached
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
matcher = step_def.signal_matcher
|
|
418
|
+
candidate = workflow.attachable_signals.detect { |signal| matcher.matches?(signal) }
|
|
419
|
+
|
|
420
|
+
unless candidate
|
|
421
|
+
logger.info("No signal matching #{matcher} has arrived, parking step execution")
|
|
422
|
+
transition_step!("waiting")
|
|
423
|
+
step_execution.update!(waiting_since: Time.current)
|
|
424
|
+
return :parked
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
logger.info("Attaching signal #{candidate.id} (#{candidate.name}) to step execution")
|
|
428
|
+
candidate.claim!
|
|
429
|
+
step_execution.update!(signal_id: candidate.id)
|
|
430
|
+
@received_signal = candidate
|
|
431
|
+
:attached
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Books one clean resolution against the attached signal. Called from
|
|
435
|
+
# finalization on clean outcomes only (completion, skip, finished!), right
|
|
436
|
+
# after the step's own terminal transition and in the same transaction: a
|
|
437
|
+
# crash before commit resolves nothing and completes nothing, so replay
|
|
438
|
+
# stays coherent.
|
|
439
|
+
#
|
|
440
|
+
# The signal only flips to consumed once every chain attached to it has
|
|
441
|
+
# resolved - for a single claimant that is this very moment, and when one
|
|
442
|
+
# signal has been dispatched onto several executions it is whichever of
|
|
443
|
+
# them finishes last. Reattempts, failures and cancellations deliberately
|
|
444
|
+
# leave the signal claimed, so the retry (or resume) re-attaches to it and
|
|
445
|
+
# sees the same payload while the audit trail reads truthfully.
|
|
446
|
+
#
|
|
447
|
+
# @return [void]
|
|
448
|
+
def resolve_attached_signal!
|
|
449
|
+
return unless GenevaDrive::StepExecution.signal_columns?
|
|
450
|
+
return if step_execution.signal_id.blank?
|
|
451
|
+
|
|
452
|
+
signal = step_execution.signal
|
|
453
|
+
return unless signal
|
|
454
|
+
|
|
455
|
+
signal.record_consumption!
|
|
456
|
+
if signal.state == "consumed"
|
|
457
|
+
logger.info("Consumed signal #{signal.id} (#{signal.name}) after #{signal.consumed} clean resolution(s)")
|
|
458
|
+
else
|
|
459
|
+
logger.info("Signal #{signal.id} (#{signal.name}) stays claimed - other attached executions are unresolved")
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
263
463
|
# Evaluates preconditions (cancel_if and skip_if) with instrumentation.
|
|
264
464
|
#
|
|
265
465
|
# @param step_def [StepDefinition]
|
|
@@ -292,6 +492,11 @@ class GenevaDrive::Executor
|
|
|
292
492
|
if step_def.should_skip?(workflow)
|
|
293
493
|
logger.info("skip_if condition matched, skipping step")
|
|
294
494
|
transition_step!("skipped", outcome: "skipped")
|
|
495
|
+
# The step may have been woken by a signal and only then decided it
|
|
496
|
+
# had nothing to do (or a retry re-attached before skip_if flipped).
|
|
497
|
+
# Skipping settles that event instead of leaving it for the next
|
|
498
|
+
# waiter on the same name.
|
|
499
|
+
workflow.settle_signal_for_skipped!(step_execution)
|
|
295
500
|
transition_workflow!("ready")
|
|
296
501
|
workflow.schedule_next_step!
|
|
297
502
|
p[:outcome] = :skipped
|
|
@@ -331,13 +536,23 @@ class GenevaDrive::Executor
|
|
|
331
536
|
logger.warn(
|
|
332
537
|
"Workflow #{workflow.id} state unexpectedly changed during execution: #{workflow.state}"
|
|
333
538
|
)
|
|
334
|
-
|
|
539
|
+
if flow_result.is_a?(Interruption) && workflow.paused?
|
|
540
|
+
# A resumable step was interrupted because the workflow got paused
|
|
541
|
+
# externally mid-iteration. Preserve the cursor handoff: complete
|
|
542
|
+
# this execution with the "workflow_paused" marker so resume! can
|
|
543
|
+
# create a successor that continues from the cursor.
|
|
544
|
+
transition_step!("completed", outcome: "workflow_paused")
|
|
545
|
+
else
|
|
546
|
+
transition_step!("canceled", outcome: "canceled")
|
|
547
|
+
end
|
|
335
548
|
next
|
|
336
549
|
end
|
|
337
550
|
|
|
338
551
|
case flow_result
|
|
339
552
|
when :completed
|
|
340
553
|
handle_completion
|
|
554
|
+
when Interruption
|
|
555
|
+
handle_continuation(wait: flow_result.wait)
|
|
341
556
|
when GenevaDrive::FlowControlSignal
|
|
342
557
|
handle_flow_control_signal(flow_result)
|
|
343
558
|
when Hash
|
|
@@ -467,10 +682,26 @@ class GenevaDrive::Executor
|
|
|
467
682
|
# @param step_def [StepDefinition]
|
|
468
683
|
# @return [Hash] captured exception context
|
|
469
684
|
def capture_exception(error, step_def)
|
|
470
|
-
Rails.error.report(error)
|
|
471
685
|
{type: :exception, error: error, step_def: step_def}
|
|
472
686
|
end
|
|
473
687
|
|
|
688
|
+
# Reports an exception via Rails.error.report based on the resolved policy's
|
|
689
|
+
# report setting. Called after policy resolution so that expected exceptions
|
|
690
|
+
# (e.g. rate limiting) can suppress error reporting.
|
|
691
|
+
#
|
|
692
|
+
# @param error [Exception]
|
|
693
|
+
# @param result [Hash] the policy result with :report and optional :terminal keys
|
|
694
|
+
# @return [void]
|
|
695
|
+
def report_exception(error, result)
|
|
696
|
+
case result[:report]
|
|
697
|
+
when :never
|
|
698
|
+
return
|
|
699
|
+
when :terminal_only
|
|
700
|
+
return unless result[:terminal]
|
|
701
|
+
end
|
|
702
|
+
Rails.error.report(error)
|
|
703
|
+
end
|
|
704
|
+
|
|
474
705
|
# Handles exceptions that occur during pre-condition evaluation (cancel_if, skip_if).
|
|
475
706
|
# Builds a resolution policy, applies it, and returns the original exception
|
|
476
707
|
# to be re-raised after the transaction commits.
|
|
@@ -480,9 +711,9 @@ class GenevaDrive::Executor
|
|
|
480
711
|
# @return [Exception] the original exception to be re-raised
|
|
481
712
|
def handle_precondition_exception(error, step_def)
|
|
482
713
|
logger.error("Pre-condition evaluation failed: #{error.class} - #{error.message}")
|
|
483
|
-
Rails.error.report(error)
|
|
484
714
|
|
|
485
715
|
result = apply_resolution_policy(error, step_def)
|
|
716
|
+
report_exception(error, result)
|
|
486
717
|
apply_policy_result(result, reattempt_reason: "precondition")
|
|
487
718
|
|
|
488
719
|
error
|
|
@@ -497,7 +728,6 @@ class GenevaDrive::Executor
|
|
|
497
728
|
# @return [Exception] the original exception to be re-raised
|
|
498
729
|
def handle_prepare_exception(error)
|
|
499
730
|
logger.error("Unexpected exception during prepare_execution: #{error.class} - #{error.message}")
|
|
500
|
-
Rails.error.report(error)
|
|
501
731
|
|
|
502
732
|
# Try to get step_def for policy resolution, but it may not be available yet
|
|
503
733
|
step_def = begin
|
|
@@ -512,7 +742,9 @@ class GenevaDrive::Executor
|
|
|
512
742
|
combined = build_resolution_policy(step_def)
|
|
513
743
|
count = consecutive_reattempt_count(step_def&.name || step_execution.step_name)
|
|
514
744
|
result = combined.apply(error, reattempt_count: count, workflow: workflow)
|
|
515
|
-
|
|
745
|
+
result ||= {action: :pause, error: error}
|
|
746
|
+
report_exception(error, result)
|
|
747
|
+
apply_prepare_policy_result(result)
|
|
516
748
|
|
|
517
749
|
error
|
|
518
750
|
end
|
|
@@ -523,10 +755,25 @@ class GenevaDrive::Executor
|
|
|
523
755
|
def handle_completion
|
|
524
756
|
logger.info("Step completed successfully, scheduling next step")
|
|
525
757
|
transition_step!("completed", outcome: "success")
|
|
758
|
+
resolve_attached_signal!
|
|
526
759
|
transition_workflow!("ready")
|
|
527
760
|
workflow.schedule_next_step!
|
|
528
761
|
end
|
|
529
762
|
|
|
763
|
+
# Handles a resumable step interrupting itself mid-iteration: completes the
|
|
764
|
+
# current execution with outcome "continued" and creates a successor
|
|
765
|
+
# execution that carries on from the persisted cursor.
|
|
766
|
+
#
|
|
767
|
+
# @param wait [ActiveSupport::Duration, Numeric, nil] optional delay before the successor runs
|
|
768
|
+
# @return [void]
|
|
769
|
+
def handle_continuation(wait: nil)
|
|
770
|
+
wait_msg = wait ? " (continuing after #{wait.inspect})" : ""
|
|
771
|
+
logger.info("Resumable step interrupted at cursor #{step_execution.cursor_value.inspect}#{wait_msg}")
|
|
772
|
+
transition_step!("completed", outcome: "continued")
|
|
773
|
+
transition_workflow!("ready")
|
|
774
|
+
workflow.create_successor_execution!(step_execution, wait: wait)
|
|
775
|
+
end
|
|
776
|
+
|
|
530
777
|
# Handles a captured exception based on the resolved exception policy.
|
|
531
778
|
# Returns the original exception to be re-raised after the transaction commits.
|
|
532
779
|
#
|
|
@@ -537,6 +784,7 @@ class GenevaDrive::Executor
|
|
|
537
784
|
step_def = context[:step_def]
|
|
538
785
|
|
|
539
786
|
result = apply_resolution_policy(error, step_def)
|
|
787
|
+
report_exception(error, result)
|
|
540
788
|
apply_policy_result(result, reattempt_reason: "exception_policy")
|
|
541
789
|
|
|
542
790
|
error
|
|
@@ -594,7 +842,13 @@ class GenevaDrive::Executor
|
|
|
594
842
|
transition_step!("completed", outcome: "reattempted")
|
|
595
843
|
write_reattempt_metadata(reattempt_reason, error: error)
|
|
596
844
|
transition_workflow!("ready")
|
|
597
|
-
|
|
845
|
+
if resumable_step?
|
|
846
|
+
# Resumable steps continue from the persisted cursor via a successor
|
|
847
|
+
# execution instead of a fresh (cursor-less) execution.
|
|
848
|
+
workflow.create_successor_execution!(step_execution, wait: result[:wait])
|
|
849
|
+
else
|
|
850
|
+
workflow.reschedule_current_step!(wait: result[:wait])
|
|
851
|
+
end
|
|
598
852
|
when :cancel
|
|
599
853
|
logger.info("Exception policy: cancel!")
|
|
600
854
|
step_execution.update!(error_attributes_for(error))
|
|
@@ -603,6 +857,7 @@ class GenevaDrive::Executor
|
|
|
603
857
|
when :skip
|
|
604
858
|
logger.info("Exception policy: skip!")
|
|
605
859
|
transition_step!("skipped", outcome: "skipped")
|
|
860
|
+
resolve_attached_signal!
|
|
606
861
|
transition_workflow!("ready")
|
|
607
862
|
workflow.schedule_next_step!
|
|
608
863
|
when :pause
|
|
@@ -613,6 +868,7 @@ class GenevaDrive::Executor
|
|
|
613
868
|
when :finished
|
|
614
869
|
logger.info("Exception policy: finished!")
|
|
615
870
|
transition_step!("completed", outcome: "success")
|
|
871
|
+
resolve_attached_signal!
|
|
616
872
|
transition_workflow!("finished")
|
|
617
873
|
else
|
|
618
874
|
logger.info("Exception policy: default (pause!)")
|
|
@@ -633,7 +889,11 @@ class GenevaDrive::Executor
|
|
|
633
889
|
when :reattempt
|
|
634
890
|
logger.info("Prepare exception policy: reattempt!")
|
|
635
891
|
transition_workflow!("ready")
|
|
636
|
-
|
|
892
|
+
if resumable_step?
|
|
893
|
+
workflow.create_successor_execution!(step_execution, wait: result[:wait])
|
|
894
|
+
else
|
|
895
|
+
workflow.reschedule_current_step!(wait: result[:wait])
|
|
896
|
+
end
|
|
637
897
|
when :cancel
|
|
638
898
|
logger.info("Prepare exception policy: cancel!")
|
|
639
899
|
transition_workflow!("canceled")
|
|
@@ -726,28 +986,94 @@ class GenevaDrive::Executor
|
|
|
726
986
|
|
|
727
987
|
when :pause
|
|
728
988
|
logger.info("Processing pause signal: pausing workflow")
|
|
729
|
-
|
|
989
|
+
if resumable_step?
|
|
990
|
+
# Complete with the "workflow_paused" marker so the cursor is
|
|
991
|
+
# preserved and resume! can continue from it via a successor.
|
|
992
|
+
transition_step!("completed", outcome: "workflow_paused")
|
|
993
|
+
else
|
|
994
|
+
transition_step!("canceled", outcome: "canceled")
|
|
995
|
+
end
|
|
730
996
|
transition_workflow!("paused")
|
|
731
997
|
|
|
732
998
|
when :reattempt
|
|
733
999
|
wait_msg = signal.options[:wait] ? " after #{signal.options[:wait].inspect}" : ""
|
|
734
1000
|
logger.info("Processing reattempt signal: rescheduling step#{wait_msg}")
|
|
1001
|
+
if resumable_step? && signal.options[:rewind]
|
|
1002
|
+
logger.info("Rewinding cursor for reattempt")
|
|
1003
|
+
step_execution.update!(cursor: nil)
|
|
1004
|
+
end
|
|
735
1005
|
transition_step!("completed", outcome: "reattempted")
|
|
736
1006
|
write_reattempt_metadata("flow_control")
|
|
737
1007
|
transition_workflow!("ready")
|
|
738
|
-
|
|
1008
|
+
if resumable_step?
|
|
1009
|
+
workflow.create_successor_execution!(step_execution, wait: signal.options[:wait])
|
|
1010
|
+
else
|
|
1011
|
+
workflow.reschedule_current_step!(wait: signal.options[:wait])
|
|
1012
|
+
end
|
|
739
1013
|
|
|
740
1014
|
when :skip
|
|
741
1015
|
logger.info("Processing skip signal: scheduling next step")
|
|
742
1016
|
transition_step!("skipped", outcome: "skipped")
|
|
1017
|
+
resolve_attached_signal!
|
|
743
1018
|
transition_workflow!("ready")
|
|
744
1019
|
workflow.schedule_next_step!
|
|
745
1020
|
|
|
746
1021
|
when :finished
|
|
747
1022
|
logger.info("Processing finished signal: finishing workflow")
|
|
748
1023
|
transition_step!("completed", outcome: "success")
|
|
1024
|
+
resolve_attached_signal!
|
|
749
1025
|
transition_workflow!("finished")
|
|
1026
|
+
|
|
1027
|
+
when :suspend
|
|
1028
|
+
# Only reachable from resumable steps: FlowControl#suspend! raises
|
|
1029
|
+
# when called from a non-resumable step.
|
|
1030
|
+
wait = signal.options[:wait]
|
|
1031
|
+
logger.info("Processing suspend signal: completing and creating successor")
|
|
1032
|
+
transition_step!("completed", outcome: "continued")
|
|
1033
|
+
transition_workflow!("ready")
|
|
1034
|
+
workflow.create_successor_execution!(step_execution, wait: wait)
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
# Whether the step being executed is a resumable (cursor-iterating) step.
|
|
1039
|
+
# Falls back to looking up the step definition when called before
|
|
1040
|
+
# prepare_execution has completed (e.g. from precondition handling).
|
|
1041
|
+
#
|
|
1042
|
+
# @return [Boolean]
|
|
1043
|
+
def resumable_step?
|
|
1044
|
+
step_def = @step_definition || begin
|
|
1045
|
+
step_execution.step_definition
|
|
1046
|
+
rescue
|
|
1047
|
+
nil
|
|
750
1048
|
end
|
|
1049
|
+
!!step_def&.resumable?
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# Checks if max runtime has been exceeded for the current execution.
|
|
1053
|
+
#
|
|
1054
|
+
# @return [Boolean]
|
|
1055
|
+
def max_runtime_exceeded?
|
|
1056
|
+
max_runtime = @step_definition.respond_to?(:max_runtime) ? @step_definition.max_runtime : nil
|
|
1057
|
+
return false unless max_runtime
|
|
1058
|
+
return false unless @start_time
|
|
1059
|
+
Time.current - @start_time > max_runtime
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Checks if the job should exit (e.g., Sidekiq shutdown).
|
|
1063
|
+
#
|
|
1064
|
+
# @return [Boolean]
|
|
1065
|
+
def job_should_exit?
|
|
1066
|
+
Thread.current[:geneva_drive_should_exit] ||
|
|
1067
|
+
(defined?(Sidekiq) && Sidekiq.const_defined?(:CLI) &&
|
|
1068
|
+
Sidekiq::CLI.instance&.stopping?)
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# Checks if the workflow has been externally paused or canceled.
|
|
1072
|
+
#
|
|
1073
|
+
# @return [Boolean]
|
|
1074
|
+
def workflow_interrupted?
|
|
1075
|
+
workflow.reload
|
|
1076
|
+
workflow.paused? || workflow.canceled?
|
|
751
1077
|
end
|
|
752
1078
|
end
|
|
753
1079
|
|