geneva_drive 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70c9ef44e4b9666d6eb8e4ad93e7e06a7bcc2b00c96ad2271572ab0fc0b11714
4
- data.tar.gz: d6e2439f6d272d74eb9e155876416a22d28a097a992f7153954cd6be2eac9b02
3
+ metadata.gz: 277866fbccabdb2fc26e37af632234df751620057a2b42d94607d8ad5fa180e7
4
+ data.tar.gz: 054417cf89b79e8534e6e3268bb8577df7f5d21da6b0ccc37b8dce1862596377
5
5
  SHA512:
6
- metadata.gz: f4fc719769aa9cd69604d9ac55d24bbdb06c390bb4009728b1f809247ed284e5aa23e155ab1cdcbf80b3b2e975c30e5d486c67953674550f3a0b58e063283796
7
- data.tar.gz: bd0c110f3f0158e1fe8e48faaf957b81c4084b4dfa479be8704ec37f5717e5db54bc754de5738904fc06ef9b7a9bc297883505355274950e9671bc208e1d1373
6
+ metadata.gz: '013668a74a368b8ed7b75b75d0536c5ffd3d7f4ed8a6f110ff740f186ede6dbe79eb6f3e480295c895b280dd64f804ca4e701427c05543aa03e5b3637ee1c702'
7
+ data.tar.gz: 7bc7f954847dd03f4b2501ae84bc4395a7c51d22681a289e58f0d49e3ae0d5dc00f66bd14f437abfb99de114b7d72aac22afafb7bc7aeebcc60e56e9b83c7d7b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0]
6
+
7
+ - Preserve original scheduled time when resuming a paused workflow. When a workflow with a future-scheduled step is paused and then resumed before that time, the step is rescheduled for the original time (not run immediately). If the original time has passed, the step runs immediately.
8
+ - Add `max_reattempts:` option for steps with `on_exception: :reattempt!` to limit consecutive reattempts before pausing the workflow (default: 100, set to `nil` to disable)
9
+ - Handle unexpected exceptions during prepare_execution (e.g., NameError from invalid hero_type) by marking step as failed and transitioning workflow based on on_exception policy
10
+
5
11
  ## [0.3.0]
6
12
 
7
13
  - Fix `resume!` to retry the failed step instead of skipping it
data/MANUAL.md CHANGED
@@ -676,6 +676,42 @@ Available exception handlers:
676
676
  - `:reattempt!` — Retry the step
677
677
  - `:skip!` — Skip the step and continue to the next
678
678
 
679
+ ### Limiting Reattempts
680
+
681
+ When using `on_exception: :reattempt!`, you can limit the number of consecutive reattempts before the workflow pauses. This prevents infinite retry loops when an error is persistent rather than transient.
682
+
683
+ ```ruby
684
+ class ExternalApiWorkflow < GenevaDrive::Workflow
685
+ # Will pause after 5 consecutive failures
686
+ step :sync_to_crm, on_exception: :reattempt!, max_reattempts: 5 do
687
+ CrmApi.sync(hero)
688
+ end
689
+ end
690
+ ```
691
+
692
+ The `max_reattempts:` option:
693
+
694
+ - **Defaults to 100** when `on_exception: :reattempt!` is used — a safety net against infinite loops
695
+ - **Set to `nil`** to disable the limit and allow unlimited reattempts
696
+ - **Only counts consecutive reattempts** — if the step succeeds, the count resets
697
+ - **Does not affect manual `reattempt!` calls** — only automatic exception handling respects this limit
698
+
699
+ When the limit is exceeded, GenevaDrive logs a warning and pauses the workflow, storing the original exception for debugging:
700
+
701
+ ```ruby
702
+ # Unlimited reattempts (explicit opt-out of the safety limit)
703
+ step :polling_step, on_exception: :reattempt!, max_reattempts: nil do
704
+ check_external_status!
705
+ end
706
+
707
+ # Custom limit
708
+ step :flaky_api, on_exception: :reattempt!, max_reattempts: 10 do
709
+ FlakyService.call(hero)
710
+ end
711
+ ```
712
+
713
+ This option only makes sense with `on_exception: :reattempt!` — specifying `max_reattempts:` with other exception handlers will raise a `StepConfigurationError`.
714
+
679
715
  ### Manual Exception Handling
680
716
 
681
717
  For granular control, handle exceptions within the step:
@@ -1280,5 +1316,6 @@ end
1280
1316
  | `wait:` | Duration | Delay before step executes |
1281
1317
  | `skip_if:` | Proc, Symbol, Boolean | Condition to skip step |
1282
1318
  | `on_exception:` | Symbol | Exception handler (`:pause!`, `:cancel!`, `:reattempt!`, `:skip!`) |
1319
+ | `max_reattempts:` | Integer, nil | Max consecutive reattempts before pausing (default: 100, `nil` = unlimited) |
1283
1320
  | `before_step:` | Symbol | Insert before this step |
1284
1321
  | `after_step:` | Symbol | Insert after this step |
@@ -202,6 +202,9 @@ class GenevaDrive::Executor
202
202
  workflow.update!(current_step_name: step_def.name)
203
203
 
204
204
  step_def
205
+ rescue => e
206
+ exception_to_raise = handle_prepare_exception(e)
207
+ nil
205
208
  end
206
209
 
207
210
  raise exception_to_raise if exception_to_raise
@@ -415,6 +418,7 @@ class GenevaDrive::Executor
415
418
  {
416
419
  type: :exception,
417
420
  error: error,
421
+ step_def: step_def,
418
422
  on_exception: step_def.on_exception
419
423
  }
420
424
  end
@@ -435,10 +439,17 @@ class GenevaDrive::Executor
435
439
 
436
440
  case on_exception
437
441
  when :reattempt!
438
- logger.info("Precondition exception policy: reattempt! - rescheduling step")
439
- transition_step!("completed", outcome: "reattempted")
440
- transition_workflow!("ready")
441
- workflow.reschedule_current_step!
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
442
453
 
443
454
  when :cancel!
444
455
  logger.info("Precondition exception policy: cancel! - canceling workflow")
@@ -470,6 +481,65 @@ class GenevaDrive::Executor
470
481
  error
471
482
  end
472
483
 
484
+ # Handles unexpected exceptions that occur during prepare_execution before
485
+ # 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!.
488
+ # Returns the original exception to be re-raised after the transaction commits.
489
+ #
490
+ # @param error [Exception] the exception that occurred
491
+ # @return [Exception] the original exception to be re-raised
492
+ def handle_prepare_exception(error)
493
+ logger.error("Unexpected exception during prepare_execution: #{error.class} - #{error.message}")
494
+ Rails.error.report(error)
495
+
496
+ # Try to get step_def for on_exception policy, but it may not be available yet
497
+ step_def = begin
498
+ step_execution.step_definition
499
+ rescue
500
+ nil
501
+ end
502
+ on_exception = step_def&.on_exception || :pause!
503
+
504
+ logger.info("Prepare exception handling with on_exception: #{on_exception.inspect}")
505
+
506
+ step_execution.update!(error_attributes_for(error))
507
+ transition_step!("failed", outcome: "failed")
508
+
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")
532
+
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
+ error
541
+ end
542
+
473
543
  # Handles successful step completion.
474
544
  #
475
545
  # @return [void]
@@ -487,16 +557,24 @@ class GenevaDrive::Executor
487
557
  # @return [Exception] the original exception to be re-raised
488
558
  def handle_captured_exception(context)
489
559
  error = context[:error]
560
+ step_def = context[:step_def]
490
561
  on_exception = context[:on_exception]
491
562
 
492
563
  logger.info("Handling exception with on_exception: #{on_exception.inspect}")
493
564
 
494
565
  case on_exception
495
566
  when :reattempt!
496
- logger.info("Exception policy: reattempt! - rescheduling step")
497
- transition_step!("completed", outcome: "reattempted")
498
- transition_workflow!("ready")
499
- workflow.reschedule_current_step!
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")
572
+ else
573
+ logger.info("Exception policy: reattempt! - rescheduling step")
574
+ transition_step!("completed", outcome: "reattempted")
575
+ transition_workflow!("ready")
576
+ workflow.reschedule_current_step!
577
+ end
500
578
 
501
579
  when :cancel!
502
580
  logger.info("Exception policy: cancel! - canceling workflow")
@@ -542,6 +620,39 @@ class GenevaDrive::Executor
542
620
  attrs
543
621
  end
544
622
 
623
+ # Counts consecutive reattempts for the current step since the last successful execution.
624
+ # This is used to enforce the max_reattempts limit.
625
+ #
626
+ # @param step_name [String] the step name to count reattempts for
627
+ # @return [Integer] the number of consecutive reattempts
628
+ def consecutive_reattempt_count(step_name)
629
+ # Find the most recent non-reattempt completion for this step
630
+ # (success, skipped, canceled, failed - anything that isn't a reattempt)
631
+ last_non_reattempt_id = workflow.step_executions
632
+ .where(step_name: step_name, state: "completed")
633
+ .where.not(outcome: "reattempted")
634
+ .order(created_at: :desc)
635
+ .pick(:id)
636
+
637
+ # Count reattempts after that point (or all reattempts if no prior success)
638
+ scope = workflow.step_executions.where(step_name: step_name, outcome: "reattempted")
639
+ 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
+
652
+ count = consecutive_reattempt_count(step_def.name)
653
+ count >= max_reattempts
654
+ end
655
+
545
656
  # Handles a flow control signal.
546
657
  #
547
658
  # @param signal [FlowControlSignal]
@@ -33,6 +33,9 @@ class GenevaDrive::StepDefinition
33
33
  # @return [String, nil] name of step this should be placed after
34
34
  attr_reader :after_step
35
35
 
36
+ # @return [Integer, nil] maximum consecutive reattempts before pausing (nil = unlimited)
37
+ attr_reader :max_reattempts
38
+
36
39
  # @return [Array<String, Integer>, nil] source location where step was called [path, lineno]
37
40
  attr_reader :call_location
38
41
 
@@ -65,6 +68,7 @@ class GenevaDrive::StepDefinition
65
68
  @on_exception = options[:on_exception] || :pause!
66
69
  @before_step = options[:before_step]&.to_s
67
70
  @after_step = options[:after_step]&.to_s
71
+ @max_reattempts = options.key?(:max_reattempts) ? options[:max_reattempts] : default_max_reattempts
68
72
 
69
73
  validate!
70
74
  end
@@ -101,6 +105,14 @@ class GenevaDrive::StepDefinition
101
105
  validate_exception_handler!
102
106
  validate_positioning!
103
107
  validate_skip_condition!
108
+ validate_max_reattempts!
109
+ end
110
+
111
+ # Returns the default max_reattempts value based on on_exception setting.
112
+ #
113
+ # @return [Integer, nil] 100 if on_exception is :reattempt!, nil otherwise
114
+ def default_max_reattempts
115
+ (@on_exception == :reattempt!) ? 100 : nil
104
116
  end
105
117
 
106
118
  # Validates that the callable is present and valid.
@@ -162,6 +174,26 @@ class GenevaDrive::StepDefinition
162
174
  "but was #{@skip_condition.class}"
163
175
  end
164
176
 
177
+ # Validates the max_reattempts option.
178
+ #
179
+ # @raise [StepConfigurationError] if max_reattempts is invalid
180
+ def validate_max_reattempts!
181
+ # nil is always valid (disables the check)
182
+ return if @max_reattempts.nil?
183
+
184
+ # max_reattempts only makes sense with on_exception: :reattempt!
185
+ unless @on_exception == :reattempt!
186
+ raise GenevaDrive::StepConfigurationError,
187
+ "Step '#{@name}' has max_reattempts: but on_exception: is not :reattempt!"
188
+ end
189
+
190
+ # Must be a positive integer
191
+ unless @max_reattempts.is_a?(Integer) && @max_reattempts > 0
192
+ raise GenevaDrive::StepConfigurationError,
193
+ "Step '#{@name}' has invalid max_reattempts: must be a positive integer or nil"
194
+ end
195
+ end
196
+
165
197
  # Evaluates a condition in the workflow context.
166
198
  #
167
199
  # @param condition [Proc, Symbol, Boolean, nil] the condition to evaluate
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GenevaDrive
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -265,11 +265,43 @@ class GenevaDrive::Workflow < ActiveRecord::Base
265
265
  create_step_execution(step_def, wait: wait)
266
266
  end
267
267
 
268
- # Resumes a paused workflow.
269
- # Creates a new step execution for the next step.
268
+ # Resumes a paused workflow by creating a new step execution for the next step.
269
+ #
270
+ # ## Scheduling behavior
271
+ #
272
+ # The scheduling depends on why the workflow was paused:
273
+ #
274
+ # - **Paused due to step failure** (`on_exception: :pause!`): The failed step is retried
275
+ # immediately. This allows quick retry after fixing the underlying issue.
276
+ #
277
+ # - **Paused externally** (via `pause!`): If the paused step had a future scheduled time
278
+ # that hasn't passed yet, the step is rescheduled for that original time (preserving
279
+ # remaining wait). If the time has passed, the step runs immediately.
280
+ #
281
+ # @example Resuming after step failure (runs immediately)
282
+ # # step_two fails with on_exception: :pause!
283
+ # workflow.state # => "paused"
284
+ # workflow.resume! # step_two retries immediately
285
+ #
286
+ # @example Resuming externally paused workflow (preserves wait time)
287
+ # # step_two has wait: 2.days, scheduled for tomorrow
288
+ # workflow.pause! # paused today
289
+ # workflow.resume! # step_two still scheduled for tomorrow
290
+ #
291
+ # @example Resuming after scheduled time passed (runs immediately)
292
+ # # step_two was scheduled for yesterday
293
+ # workflow.pause! # paused last week
294
+ # workflow.resume! # step_two runs immediately (time already passed)
270
295
  #
271
296
  # @return [StepExecution] the created step execution
272
297
  # @raise [InvalidStateError] if workflow is not paused
298
+ #
299
+ # ## Implementation details
300
+ #
301
+ # When pause! is called externally, it cancels the pending step execution with
302
+ # outcome "workflow_paused", preserving the original scheduled_for timestamp.
303
+ # On resume, we look for this canceled execution to calculate remaining wait time.
304
+ # See calculate_remaining_wait_for_resume for the full algorithm.
273
305
  def resume!
274
306
  raise GenevaDrive::InvalidStateError, "Cannot resume a #{state} workflow" unless state == "paused"
275
307
 
@@ -279,7 +311,9 @@ class GenevaDrive::Workflow < ActiveRecord::Base
279
311
  end
280
312
 
281
313
  step_def = steps.named(next_step_name)
282
- create_step_execution(step_def)
314
+ wait = calculate_remaining_wait_for_resume(next_step_name)
315
+
316
+ create_step_execution(step_def, wait: wait)
283
317
  end
284
318
 
285
319
  # Returns the current active step execution, if any.
@@ -407,6 +441,32 @@ class GenevaDrive::Workflow < ActiveRecord::Base
407
441
  create_step_execution(first_step, wait: first_step.wait)
408
442
  end
409
443
 
444
+ # Calculates the remaining wait time when resuming a paused workflow.
445
+ #
446
+ # When a workflow was paused externally (via pause!) while a step was scheduled for
447
+ # a future time, this method finds that original scheduled time and calculates how
448
+ # much wait time remains.
449
+ #
450
+ # @param step_name [String] the name of the step being resumed
451
+ # @return [ActiveSupport::Duration, nil] remaining wait time, or nil to run immediately
452
+ def calculate_remaining_wait_for_resume(step_name)
453
+ # Find the most recent canceled execution for this step that was paused externally.
454
+ # The "workflow_paused" outcome indicates external pause (vs "failed" for step failures).
455
+ paused_execution = step_executions
456
+ .where(step_name: step_name, outcome: "workflow_paused")
457
+ .order(created_at: :desc)
458
+ .first
459
+
460
+ return nil unless paused_execution&.scheduled_for
461
+
462
+ # Calculate remaining time until the original scheduled execution time
463
+ remaining_seconds = paused_execution.scheduled_for - Time.current
464
+
465
+ # If the original time has passed, run immediately (return nil)
466
+ # Otherwise, return the remaining time as a duration
467
+ (remaining_seconds > 0) ? remaining_seconds.seconds : nil
468
+ end
469
+
410
470
  # Creates a step execution and enqueues the job after transaction commits.
411
471
  # Any existing scheduled step executions are canceled first.
412
472
  # In-progress step executions are left alone - they're being executed.
@@ -332,4 +332,100 @@ class StepDefinitionTest < ActiveSupport::TestCase
332
332
  assert_equal :should_skip?, step_def.skip_condition
333
333
  assert_equal :reattempt!, step_def.on_exception
334
334
  end
335
+
336
+ # Tests for max_reattempts validation
337
+ test "defaults max_reattempts to 100 when on_exception is reattempt!" do
338
+ workflow_class = Class.new(GenevaDrive::Workflow) do
339
+ step :reattempting, on_exception: :reattempt! do
340
+ end
341
+ end
342
+
343
+ step_def = workflow_class.step_definitions.first
344
+ assert_equal 100, step_def.max_reattempts
345
+ end
346
+
347
+ test "defaults max_reattempts to nil when on_exception is not reattempt!" do
348
+ workflow_class = Class.new(GenevaDrive::Workflow) do
349
+ step :pausing, on_exception: :pause! do
350
+ end
351
+
352
+ step :canceling, on_exception: :cancel! do
353
+ end
354
+
355
+ step :skipping, on_exception: :skip! do
356
+ end
357
+ end
358
+
359
+ workflow_class.step_definitions.each do |step_def|
360
+ assert_nil step_def.max_reattempts, "#{step_def.name} should have nil max_reattempts"
361
+ end
362
+ end
363
+
364
+ test "accepts custom max_reattempts value with on_exception reattempt!" do
365
+ workflow_class = Class.new(GenevaDrive::Workflow) do
366
+ step :custom_limit, on_exception: :reattempt!, max_reattempts: 5 do
367
+ end
368
+ end
369
+
370
+ step_def = workflow_class.step_definitions.first
371
+ assert_equal 5, step_def.max_reattempts
372
+ end
373
+
374
+ test "accepts nil max_reattempts to disable limit with on_exception reattempt!" do
375
+ workflow_class = Class.new(GenevaDrive::Workflow) do
376
+ step :unlimited, on_exception: :reattempt!, max_reattempts: nil do
377
+ end
378
+ end
379
+
380
+ step_def = workflow_class.step_definitions.first
381
+ assert_nil step_def.max_reattempts
382
+ end
383
+
384
+ test "rejects max_reattempts when on_exception is not reattempt!" do
385
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
386
+ Class.new(GenevaDrive::Workflow) do
387
+ step :invalid, on_exception: :pause!, max_reattempts: 10 do
388
+ end
389
+ end
390
+ end
391
+
392
+ assert_match(/max_reattempts:/, error.message)
393
+ assert_match(/on_exception: is not :reattempt!/, error.message)
394
+ end
395
+
396
+ test "rejects non-positive max_reattempts values" do
397
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
398
+ Class.new(GenevaDrive::Workflow) do
399
+ step :zero_limit, on_exception: :reattempt!, max_reattempts: 0 do
400
+ end
401
+ end
402
+ end
403
+
404
+ assert_match(/max_reattempts:/, error.message)
405
+ assert_match(/positive integer/, error.message)
406
+ end
407
+
408
+ test "rejects negative max_reattempts values" do
409
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
410
+ Class.new(GenevaDrive::Workflow) do
411
+ step :negative_limit, on_exception: :reattempt!, max_reattempts: -5 do
412
+ end
413
+ end
414
+ end
415
+
416
+ assert_match(/max_reattempts:/, error.message)
417
+ assert_match(/positive integer/, error.message)
418
+ end
419
+
420
+ test "rejects non-integer max_reattempts values" do
421
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
422
+ Class.new(GenevaDrive::Workflow) do
423
+ step :float_limit, on_exception: :reattempt!, max_reattempts: 5.5 do
424
+ end
425
+ end
426
+ end
427
+
428
+ assert_match(/max_reattempts:/, error.message)
429
+ assert_match(/positive integer/, error.message)
430
+ end
335
431
  end