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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/MANUAL.md +449 -11
  4. data/README.md +5 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
  6. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
  9. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  12. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  13. data/lib/geneva_drive/exception_policy.rb +36 -10
  14. data/lib/geneva_drive/executor.rb +352 -26
  15. data/lib/geneva_drive/flow_control.rb +87 -7
  16. data/lib/geneva_drive/iterable_step.rb +199 -0
  17. data/lib/geneva_drive/job_options.rb +70 -0
  18. data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
  19. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  20. data/lib/geneva_drive/signal.rb +243 -0
  21. data/lib/geneva_drive/signal_matcher.rb +60 -0
  22. data/lib/geneva_drive/step_definition.rb +96 -2
  23. data/lib/geneva_drive/step_execution.rb +141 -2
  24. data/lib/geneva_drive/test_helpers.rb +212 -6
  25. data/lib/geneva_drive/version.rb +1 -1
  26. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  27. data/lib/geneva_drive/workflow.rb +559 -43
  28. data/lib/geneva_drive.rb +37 -0
  29. data/test/dsl/signal_step_definition_test.rb +132 -0
  30. data/test/dsl/step_definition_test.rb +70 -0
  31. data/test/jobs/housekeeping_job_test.rb +119 -0
  32. data/test/jobs/housekeeping_signals_test.rb +198 -0
  33. data/test/jobs/perform_step_job_test.rb +25 -2
  34. data/test/migration_helpers_test.rb +1 -1
  35. data/test/test_helper.rb +4 -0
  36. data/test/test_helper_test.rb +281 -0
  37. data/test/workflow/cursor_size_limit_test.rb +75 -0
  38. data/test/workflow/instrumentation_test.rb +28 -0
  39. data/test/workflow/resumable_step_integration_test.rb +341 -0
  40. data/test/workflow/resumable_step_test.rb +615 -0
  41. data/test/workflow/resumable_without_migration_test.rb +103 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. data/test/workflow/workflow_test.rb +72 -1
  48. metadata +25 -15
  49. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  50. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  51. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  52. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  53. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  54. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  55. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  56. data/test/dummy/db/schema.rb +0 -73
  57. data/test/dummy/log/development.log +0 -202
  58. data/test/dummy/log/test.log +0 -79471
  59. data/test/dummy/log/test.log.0 +0 -4
  60. 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
- # Phase 2: Execute step block (locks released)
79
- @logger.debug("Running before_step_execution hook")
80
- @workflow.before_step_execution(@step_execution)
114
+ @step_definition = step_def
115
+ @start_time = Time.current
81
116
 
82
- @logger.debug("Running the actual step code")
83
- flow_result = @workflow.around_step_execution(@step_execution) do
84
- execute_step(step_def)
85
- end
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
- @logger.debug("Running after_step_execution hook")
88
- @workflow.after_step_execution(@step_execution)
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.execute_in_context(workflow)
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
- transition_step!("canceled", outcome: "canceled")
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
- apply_prepare_policy_result(result || {action: :pause, error: error})
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
- workflow.reschedule_current_step!(wait: result[:wait])
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
- workflow.reschedule_current_step!(wait: result[:wait])
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
- transition_step!("canceled", outcome: "canceled")
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
- workflow.reschedule_current_step!(wait: signal.options[:wait])
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