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 +4 -4
- data/CHANGELOG.md +6 -0
- data/MANUAL.md +37 -0
- data/lib/geneva_drive/executor.rb +119 -8
- data/lib/geneva_drive/step_definition.rb +32 -0
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +63 -3
- data/test/dsl/step_definition_test.rb +96 -0
- data/test/dummy/log/test.log +98387 -0
- data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +25 -0
- data/test/dummy_install/db/schema.rb +5 -1
- data/test/dummy_install/log/test.log +621 -0
- data/test/workflow/executor_test.rb +93 -0
- data/test/workflow/max_reattempts_test.rb +325 -0
- data/test/workflow/resume_and_skip_test.rb +112 -0
- metadata +7 -5
- /data/test/dummy_install/db/migrate/{20260126164025_create_geneva_drive_workflows.rb → 20260128104738_create_geneva_drive_workflows.rb} +0 -0
- /data/test/dummy_install/db/migrate/{20260126164026_create_geneva_drive_step_executions.rb → 20260128104739_create_geneva_drive_step_executions.rb} +0 -0
- /data/test/dummy_install/db/migrate/{20260126164027_add_finished_at_to_geneva_drive_step_executions.rb → 20260128104740_add_finished_at_to_geneva_drive_step_executions.rb} +0 -0
- /data/test/dummy_install/db/migrate/{20260126164028_add_error_class_name_to_geneva_drive_step_executions.rb → 20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 277866fbccabdb2fc26e37af632234df751620057a2b42d94607d8ad5fa180e7
|
|
4
|
+
data.tar.gz: 054417cf89b79e8534e6e3268bb8577df7f5d21da6b0ccc37b8dce1862596377
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
data/lib/geneva_drive/version.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|