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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/MANUAL.md +1037 -355
  4. data/README.md +3 -1
  5. data/Rakefile +13 -2
  6. data/bin/md2html +96 -19
  7. data/lib/generators/geneva_drive/install/install_generator.rb +25 -0
  8. data/lib/generators/geneva_drive/install/templates/add_metadata_to_step_executions.rb +17 -0
  9. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  10. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  11. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  12. data/lib/generators/geneva_drive/install/templates/allow_null_hero_on_workflows.rb +66 -0
  13. data/lib/generators/geneva_drive/install/templates/create_step_executions_migration.rb +25 -5
  14. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +14 -3
  15. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +22 -2
  16. data/lib/geneva_drive/combined_exception_policy.rb +106 -0
  17. data/lib/geneva_drive/exception_policy.rb +354 -0
  18. data/lib/geneva_drive/executor.rb +430 -148
  19. data/lib/geneva_drive/flow_control.rb +82 -11
  20. data/lib/geneva_drive/iterable_step.rb +199 -0
  21. data/lib/geneva_drive/job_options.rb +70 -0
  22. data/lib/geneva_drive/jobs/housekeeping_job.rb +174 -52
  23. data/lib/geneva_drive/jobs/perform_step_job.rb +91 -3
  24. data/lib/geneva_drive/migration_helpers.rb +55 -20
  25. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  26. data/lib/geneva_drive/step_definition.rb +160 -40
  27. data/lib/geneva_drive/step_execution/metadata_accessor.rb +121 -0
  28. data/lib/geneva_drive/step_execution.rb +124 -2
  29. data/lib/geneva_drive/test_helpers.rb +123 -4
  30. data/lib/geneva_drive/version.rb +1 -1
  31. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  32. data/lib/geneva_drive/workflow.rb +485 -73
  33. data/lib/geneva_drive.rb +100 -2
  34. data/test/dsl/class_level_on_exception_test.rb +200 -0
  35. data/test/dsl/exception_policy_test.rb +331 -0
  36. data/test/dsl/step_definition_test.rb +126 -2
  37. data/test/dsl/step_level_exception_policy_test.rb +143 -0
  38. data/test/dummy/config/storage.yml +7 -0
  39. data/test/dummy_install/config/storage.yml +7 -0
  40. data/test/jobs/housekeeping_job_test.rb +451 -5
  41. data/test/jobs/perform_step_job_double_deferral_test.rb +268 -0
  42. data/test/jobs/perform_step_job_test.rb +25 -2
  43. data/test/migration_helpers_test.rb +137 -0
  44. data/test/step_execution/metadata_accessor_test.rb +147 -0
  45. data/test/test_helper.rb +37 -0
  46. data/test/test_helper_test.rb +281 -0
  47. data/test/workflow/class_level_on_exception_integration_test.rb +313 -0
  48. data/test/workflow/composable_exception_policy_test.rb +231 -0
  49. data/test/workflow/cursor_size_limit_test.rb +75 -0
  50. data/test/workflow/executor_test.rb +100 -4
  51. data/test/workflow/external_cancel_test.rb +245 -0
  52. data/test/workflow/flow_control_test.rb +4 -3
  53. data/test/workflow/instrumentation_test.rb +28 -0
  54. data/test/workflow/max_reattempts_test.rb +51 -0
  55. data/test/workflow/pause_resume_test.rb +574 -0
  56. data/test/workflow/reattempt_on_exception_integration_test.rb +68 -0
  57. data/test/workflow/resumable_step_integration_test.rb +341 -0
  58. data/test/workflow/resumable_step_test.rb +615 -0
  59. data/test/workflow/resumable_without_migration_test.rb +103 -0
  60. data/test/workflow/resume_and_skip_test.rb +57 -41
  61. data/test/workflow/with_inline_enqueue_test.rb +137 -0
  62. data/test/workflow/workflow_test.rb +117 -2
  63. metadata +47 -61
  64. data/test/dummy/db/migrate/20241217000001_create_geneva_drive_workflows.rb +0 -68
  65. data/test/dummy/db/migrate/20241217000002_create_geneva_drive_step_executions.rb +0 -90
  66. data/test/dummy/db/migrate/20241217000003_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  67. data/test/dummy/db/migrate/20241217000004_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  68. data/test/dummy/db/schema.rb +0 -72
  69. data/test/dummy/log/development.log +0 -112
  70. data/test/dummy/log/test.log +0 -4
  71. data/test/dummy/tmp/local_secret.txt +0 -1
  72. data/test/dummy_install/Rakefile +0 -6
  73. data/test/dummy_install/app/assets/stylesheets/application.css +0 -15
  74. data/test/dummy_install/app/controllers/application_controller.rb +0 -4
  75. data/test/dummy_install/app/helpers/application_helper.rb +0 -2
  76. data/test/dummy_install/app/jobs/application_job.rb +0 -7
  77. data/test/dummy_install/app/models/application_record.rb +0 -3
  78. data/test/dummy_install/app/models/user.rb +0 -9
  79. data/test/dummy_install/app/views/layouts/application.html.erb +0 -27
  80. data/test/dummy_install/app/views/layouts/mailer.html.erb +0 -13
  81. data/test/dummy_install/app/views/layouts/mailer.text.erb +0 -1
  82. data/test/dummy_install/app/views/pwa/manifest.json.erb +0 -22
  83. data/test/dummy_install/app/views/pwa/service-worker.js +0 -26
  84. data/test/dummy_install/bin/dev +0 -2
  85. data/test/dummy_install/bin/rails +0 -4
  86. data/test/dummy_install/bin/rake +0 -4
  87. data/test/dummy_install/bin/setup +0 -34
  88. data/test/dummy_install/config/application.rb +0 -29
  89. data/test/dummy_install/config/boot.rb +0 -5
  90. data/test/dummy_install/config/cable.yml +0 -10
  91. data/test/dummy_install/config/database.yml +0 -13
  92. data/test/dummy_install/config/environment.rb +0 -5
  93. data/test/dummy_install/config/environments/development.rb +0 -51
  94. data/test/dummy_install/config/environments/production.rb +0 -70
  95. data/test/dummy_install/config/environments/test.rb +0 -39
  96. data/test/dummy_install/config/initializers/assets.rb +0 -7
  97. data/test/dummy_install/config/initializers/content_security_policy.rb +0 -25
  98. data/test/dummy_install/config/initializers/filter_parameter_logging.rb +0 -8
  99. data/test/dummy_install/config/initializers/geneva_drive.rb +0 -76
  100. data/test/dummy_install/config/initializers/inflections.rb +0 -16
  101. data/test/dummy_install/config/locales/en.yml +0 -31
  102. data/test/dummy_install/config/puma.rb +0 -38
  103. data/test/dummy_install/config/routes.rb +0 -3
  104. data/test/dummy_install/config.ru +0 -6
  105. data/test/dummy_install/db/migrate/20241217000000_create_users.rb +0 -12
  106. data/test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb +0 -74
  107. data/test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb +0 -97
  108. data/test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  109. data/test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  110. data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +0 -25
  111. data/test/dummy_install/db/schema.rb +0 -76
  112. data/test/dummy_install/log/test.log +0 -4031
  113. data/test/dummy_install/public/400.html +0 -114
  114. data/test/dummy_install/public/404.html +0 -114
  115. data/test/dummy_install/public/406-unsupported-browser.html +0 -114
  116. data/test/dummy_install/public/422.html +0 -114
  117. data/test/dummy_install/public/500.html +0 -114
  118. data/test/dummy_install/public/icon.png +0 -0
  119. data/test/dummy_install/public/icon.svg +0 -3
  120. data/test/dummy_install/tmp/local_secret.txt +0 -1
  121. 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
- def self.execute!(step_execution)
33
- new.call(step_execution)
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.execute_in_context(workflow)
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 in #{step_execution.state.inspect} and can't be stepped, dropping through")
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 in #{workflow.state.inspect} state and can't be stepped, canceling and dropping through")
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("No hero present and this workflow is not set to run without one. Canceling and dropping through")
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
- transition_step!("canceled", outcome: "canceled")
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
- # Uses the step's on_exception policy to determine how to handle the exception.
428
- # Returns the original exception to be re-raised after the transaction commits.
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
- case on_exception
441
- when :reattempt!
442
- if reattempt_limit_exceeded?(step_def)
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). Uses the step's on_exception policy if available,
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 on_exception policy, but it may not be available yet
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
- case on_exception
510
- when :reattempt!
511
- if step_def && reattempt_limit_exceeded?(step_def)
512
- logger.warn("Max reattempts (#{step_def.max_reattempts}) exceeded - pausing workflow instead")
513
- transition_workflow!("paused")
514
- else
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 captured exception based on the step's on_exception configuration.
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
- logger.info("Handling exception with on_exception: #{on_exception.inspect}")
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
- case on_exception
566
- when :reattempt!
567
- if reattempt_limit_exceeded?(step_def)
568
- logger.warn("Max reattempts (#{step_def.max_reattempts}) exceeded - pausing workflow instead")
569
- step_execution.update!(error_attributes_for(error))
570
- transition_step!("failed", outcome: "failed")
571
- transition_workflow!("paused")
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
- logger.info("Exception policy: reattempt! - rescheduling step")
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
- when :cancel!
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
- when :skip!
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
- when :pause!
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
- # Default: pause
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
- # Return original exception to be re-raised after transaction commits
606
- error
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 since the last successful execution.
624
- # This is used to enforce the max_reattempts limit.
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
- count = consecutive_reattempt_count(step_def.name)
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
- transition_step!("canceled", outcome: "canceled")
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
- workflow.reschedule_current_step!(wait: signal.options[:wait])
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