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
|
@@ -537,4 +537,97 @@ class ExecutorTest < ActiveSupport::TestCase
|
|
|
537
537
|
assert_equal [:around_before, :step_body, :around_after], events
|
|
538
538
|
assert_equal step_execution.id, AroundHookTrackingWorkflow.step_execution_id
|
|
539
539
|
end
|
|
540
|
+
|
|
541
|
+
test "marks step as failed and pauses workflow when hero class does not exist" do
|
|
542
|
+
# Create a workflow with a hero_type that references a non-existent class
|
|
543
|
+
workflow = BasicWorkflow.create!(hero: @user)
|
|
544
|
+
step_execution = workflow.step_executions.first
|
|
545
|
+
|
|
546
|
+
# Manually set hero_type to a non-existent class
|
|
547
|
+
workflow.update_column(:hero_type, "NonExistentClass")
|
|
548
|
+
|
|
549
|
+
# Execute the job - should raise but also record the failure
|
|
550
|
+
error = assert_raises(NameError) do
|
|
551
|
+
GenevaDrive::Executor.execute!(step_execution)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
assert_match(/NonExistentClass/, error.message)
|
|
555
|
+
|
|
556
|
+
step_execution.reload
|
|
557
|
+
workflow.reload
|
|
558
|
+
|
|
559
|
+
assert_equal "failed", step_execution.state
|
|
560
|
+
assert_equal "paused", workflow.state
|
|
561
|
+
assert_match(/uninitialized constant/, step_execution.error_message)
|
|
562
|
+
assert_equal "NameError", step_execution.error_class_name
|
|
563
|
+
assert_not_nil step_execution.error_backtrace
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Workflow that raises during prepare_execution but has on_exception: :skip!
|
|
567
|
+
class PrepareExceptionSkipWorkflow < GenevaDrive::Workflow
|
|
568
|
+
step :first_step, on_exception: :skip! do
|
|
569
|
+
# This won't run - exception happens during prepare
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
step :second_step do
|
|
573
|
+
Thread.current[:prepare_exception_skip_ran] = true
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def self.second_ran?
|
|
577
|
+
Thread.current[:prepare_exception_skip_ran]
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def self.reset_tracking!
|
|
581
|
+
Thread.current[:prepare_exception_skip_ran] = nil
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
test "respects on_exception: :skip! policy for prepare_execution exceptions" do
|
|
586
|
+
PrepareExceptionSkipWorkflow.reset_tracking!
|
|
587
|
+
workflow = PrepareExceptionSkipWorkflow.create!(hero: @user)
|
|
588
|
+
step_execution = workflow.step_executions.first
|
|
589
|
+
|
|
590
|
+
# Manually set hero_type to a non-existent class
|
|
591
|
+
workflow.update_column(:hero_type, "NonExistentClass")
|
|
592
|
+
|
|
593
|
+
error = assert_raises(NameError) do
|
|
594
|
+
GenevaDrive::Executor.execute!(step_execution)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
assert_match(/NonExistentClass/, error.message)
|
|
598
|
+
|
|
599
|
+
step_execution.reload
|
|
600
|
+
workflow.reload
|
|
601
|
+
|
|
602
|
+
assert_equal "failed", step_execution.state
|
|
603
|
+
assert_equal "ready", workflow.state
|
|
604
|
+
# Should have scheduled the next step
|
|
605
|
+
assert_equal 2, workflow.step_executions.count
|
|
606
|
+
assert_equal "second_step", workflow.step_executions.order(:id).last.step_name
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Workflow that raises during prepare_execution but has on_exception: :cancel!
|
|
610
|
+
class PrepareExceptionCancelWorkflow < GenevaDrive::Workflow
|
|
611
|
+
step :first_step, on_exception: :cancel! do
|
|
612
|
+
# This won't run - exception happens during prepare
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
test "respects on_exception: :cancel! policy for prepare_execution exceptions" do
|
|
617
|
+
workflow = PrepareExceptionCancelWorkflow.create!(hero: @user)
|
|
618
|
+
step_execution = workflow.step_executions.first
|
|
619
|
+
|
|
620
|
+
# Manually set hero_type to a non-existent class
|
|
621
|
+
workflow.update_column(:hero_type, "NonExistentClass")
|
|
622
|
+
|
|
623
|
+
assert_raises(NameError) do
|
|
624
|
+
GenevaDrive::Executor.execute!(step_execution)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
step_execution.reload
|
|
628
|
+
workflow.reload
|
|
629
|
+
|
|
630
|
+
assert_equal "failed", step_execution.state
|
|
631
|
+
assert_equal "canceled", workflow.state
|
|
632
|
+
end
|
|
540
633
|
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class MaxReattemptsTest < ActiveSupport::TestCase
|
|
6
|
+
include ActiveJob::TestHelper
|
|
7
|
+
|
|
8
|
+
# Disable transactional tests to test proper locking flows
|
|
9
|
+
self.use_transactional_tests = false
|
|
10
|
+
|
|
11
|
+
class TransientError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Workflow that always fails with reattempt limit
|
|
14
|
+
class LimitedReattemptWorkflow < GenevaDrive::Workflow
|
|
15
|
+
step :failing_step, on_exception: :reattempt!, max_reattempts: 3 do
|
|
16
|
+
raise TransientError, "Always failing"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Workflow with unlimited reattempts (nil)
|
|
21
|
+
class UnlimitedReattemptWorkflow < GenevaDrive::Workflow
|
|
22
|
+
step :failing_step, on_exception: :reattempt!, max_reattempts: nil do
|
|
23
|
+
self.class.increment_execution_count!
|
|
24
|
+
raise TransientError, "Always failing"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def execution_count
|
|
29
|
+
@execution_count ||= 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def increment_execution_count!
|
|
33
|
+
@execution_count ||= 0
|
|
34
|
+
@execution_count += 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset_tracking!
|
|
38
|
+
@execution_count = 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Workflow that fails a few times then succeeds
|
|
44
|
+
class EventuallySucceedsWorkflow < GenevaDrive::Workflow
|
|
45
|
+
step :flaky_step, on_exception: :reattempt!, max_reattempts: 10 do
|
|
46
|
+
self.class.increment_execution_count!
|
|
47
|
+
|
|
48
|
+
# Fail first 5 times, succeed on 6th
|
|
49
|
+
if self.class.execution_count <= 5
|
|
50
|
+
raise TransientError, "Transient failure #{self.class.execution_count}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
def execution_count
|
|
56
|
+
@execution_count ||= 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def increment_execution_count!
|
|
60
|
+
@execution_count ||= 0
|
|
61
|
+
@execution_count += 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reset_tracking!
|
|
65
|
+
@execution_count = 0
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Workflow that uses manual reattempt! calls
|
|
71
|
+
class ManualReattemptWorkflow < GenevaDrive::Workflow
|
|
72
|
+
class << self
|
|
73
|
+
def execution_count
|
|
74
|
+
@execution_count ||= 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def increment_execution_count!
|
|
78
|
+
@execution_count ||= 0
|
|
79
|
+
@execution_count += 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reset_tracking!
|
|
83
|
+
@execution_count = 0
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# max_reattempts: 2 for automatic exception handling
|
|
88
|
+
step :manual_reattempt_step, on_exception: :reattempt!, max_reattempts: 2 do
|
|
89
|
+
self.class.increment_execution_count!
|
|
90
|
+
|
|
91
|
+
# Use manual reattempt! for first 5 attempts, then succeed
|
|
92
|
+
if self.class.execution_count <= 5
|
|
93
|
+
reattempt!
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Workflow with precondition that fails
|
|
99
|
+
class PreconditionReattemptWorkflow < GenevaDrive::Workflow
|
|
100
|
+
step :step_with_flaky_precondition, on_exception: :reattempt!, max_reattempts: 2, skip_if: :flaky_check do
|
|
101
|
+
# Step body - never reached
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class << self
|
|
105
|
+
def execution_count
|
|
106
|
+
@execution_count ||= 0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def increment_execution_count!
|
|
110
|
+
@execution_count ||= 0
|
|
111
|
+
@execution_count += 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reset_tracking!
|
|
115
|
+
@execution_count = 0
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def flaky_check
|
|
120
|
+
self.class.increment_execution_count!
|
|
121
|
+
raise TransientError, "Precondition failure"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
setup do
|
|
126
|
+
clean_database!
|
|
127
|
+
@user = User.create!(email: "test@example.com", name: "Test User")
|
|
128
|
+
UnlimitedReattemptWorkflow.reset_tracking!
|
|
129
|
+
EventuallySucceedsWorkflow.reset_tracking!
|
|
130
|
+
PreconditionReattemptWorkflow.reset_tracking!
|
|
131
|
+
ManualReattemptWorkflow.reset_tracking!
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
teardown do
|
|
135
|
+
clean_database!
|
|
136
|
+
UnlimitedReattemptWorkflow.reset_tracking!
|
|
137
|
+
EventuallySucceedsWorkflow.reset_tracking!
|
|
138
|
+
PreconditionReattemptWorkflow.reset_tracking!
|
|
139
|
+
ManualReattemptWorkflow.reset_tracking!
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
test "pauses workflow when max_reattempts is exceeded" do
|
|
143
|
+
workflow = LimitedReattemptWorkflow.create!(hero: @user)
|
|
144
|
+
|
|
145
|
+
# Execute 3 attempts (max_reattempts: 3)
|
|
146
|
+
3.times do |i|
|
|
147
|
+
step_execution = workflow.step_executions.order(:created_at).last
|
|
148
|
+
assert_raises(TransientError) do
|
|
149
|
+
GenevaDrive::PerformStepJob.perform_now(step_execution.id)
|
|
150
|
+
end
|
|
151
|
+
workflow.reload
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# After 3 reattempts, workflow should still be ready with 4 step executions
|
|
155
|
+
assert_equal "ready", workflow.state
|
|
156
|
+
assert_equal 4, workflow.step_executions.count
|
|
157
|
+
|
|
158
|
+
# 4th attempt should trigger the limit and pause
|
|
159
|
+
fourth_step_execution = workflow.step_executions.order(:created_at).last
|
|
160
|
+
assert_raises(TransientError) do
|
|
161
|
+
GenevaDrive::PerformStepJob.perform_now(fourth_step_execution.id)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
workflow.reload
|
|
165
|
+
fourth_step_execution.reload
|
|
166
|
+
|
|
167
|
+
# Workflow should now be paused
|
|
168
|
+
assert_equal "paused", workflow.state
|
|
169
|
+
|
|
170
|
+
# Step execution should be failed with error stored
|
|
171
|
+
assert_equal "failed", fourth_step_execution.state
|
|
172
|
+
assert_equal "failed", fourth_step_execution.outcome
|
|
173
|
+
assert_equal "Always failing", fourth_step_execution.error_message
|
|
174
|
+
|
|
175
|
+
# No new step execution should be created
|
|
176
|
+
assert_equal 4, workflow.step_executions.count
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
test "allows unlimited reattempts when max_reattempts is nil" do
|
|
180
|
+
workflow = UnlimitedReattemptWorkflow.create!(hero: @user)
|
|
181
|
+
|
|
182
|
+
# Execute many attempts - should never pause due to limit
|
|
183
|
+
10.times do
|
|
184
|
+
step_execution = workflow.step_executions.order(:created_at).last
|
|
185
|
+
assert_raises(TransientError) do
|
|
186
|
+
GenevaDrive::PerformStepJob.perform_now(step_execution.id)
|
|
187
|
+
end
|
|
188
|
+
workflow.reload
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# After 10 reattempts, workflow should still be ready
|
|
192
|
+
assert_equal "ready", workflow.state
|
|
193
|
+
assert_equal 11, workflow.step_executions.count
|
|
194
|
+
|
|
195
|
+
# All completed step executions should have "reattempted" outcome
|
|
196
|
+
completed_executions = workflow.step_executions.where(state: "completed")
|
|
197
|
+
assert_equal 10, completed_executions.count
|
|
198
|
+
assert completed_executions.all? { |se| se.outcome == "reattempted" }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
test "resets reattempt count after successful execution" do
|
|
202
|
+
workflow = EventuallySucceedsWorkflow.create!(hero: @user)
|
|
203
|
+
|
|
204
|
+
# Execute 5 failing attempts
|
|
205
|
+
5.times do
|
|
206
|
+
step_execution = workflow.step_executions.order(:created_at).last
|
|
207
|
+
assert_raises(TransientError) do
|
|
208
|
+
GenevaDrive::PerformStepJob.perform_now(step_execution.id)
|
|
209
|
+
end
|
|
210
|
+
workflow.reload
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# 6th attempt should succeed
|
|
214
|
+
sixth_step_execution = workflow.step_executions.order(:created_at).last
|
|
215
|
+
GenevaDrive::PerformStepJob.perform_now(sixth_step_execution.id)
|
|
216
|
+
|
|
217
|
+
workflow.reload
|
|
218
|
+
sixth_step_execution.reload
|
|
219
|
+
|
|
220
|
+
# Workflow should be finished (only one step)
|
|
221
|
+
assert_equal "finished", workflow.state
|
|
222
|
+
|
|
223
|
+
# Step execution should be completed with success
|
|
224
|
+
assert_equal "completed", sixth_step_execution.state
|
|
225
|
+
assert_equal "success", sixth_step_execution.outcome
|
|
226
|
+
|
|
227
|
+
# Should have 6 step executions total
|
|
228
|
+
assert_equal 6, workflow.step_executions.count
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
test "counts only consecutive reattempts since last success" do
|
|
232
|
+
# First, create a workflow that will eventually succeed
|
|
233
|
+
workflow = EventuallySucceedsWorkflow.create!(hero: @user)
|
|
234
|
+
|
|
235
|
+
# Execute 5 failing attempts + 1 success
|
|
236
|
+
5.times do
|
|
237
|
+
step_execution = workflow.step_executions.order(:created_at).last
|
|
238
|
+
assert_raises(TransientError) do
|
|
239
|
+
GenevaDrive::PerformStepJob.perform_now(step_execution.id)
|
|
240
|
+
end
|
|
241
|
+
workflow.reload
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# 6th attempt succeeds
|
|
245
|
+
sixth_execution = workflow.step_executions.order(:created_at).last
|
|
246
|
+
GenevaDrive::PerformStepJob.perform_now(sixth_execution.id)
|
|
247
|
+
workflow.reload
|
|
248
|
+
|
|
249
|
+
# At this point we have 5 reattempts + 1 success
|
|
250
|
+
assert_equal "finished", workflow.state
|
|
251
|
+
|
|
252
|
+
# Verify the counts
|
|
253
|
+
reattempted_count = workflow.step_executions.where(outcome: "reattempted").count
|
|
254
|
+
success_count = workflow.step_executions.where(outcome: "success").count
|
|
255
|
+
|
|
256
|
+
assert_equal 5, reattempted_count
|
|
257
|
+
assert_equal 1, success_count
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
test "enforces limit on precondition exceptions" do
|
|
261
|
+
workflow = PreconditionReattemptWorkflow.create!(hero: @user)
|
|
262
|
+
|
|
263
|
+
# First 2 attempts should reattempt
|
|
264
|
+
2.times do
|
|
265
|
+
step_execution = workflow.step_executions.order(:created_at).last
|
|
266
|
+
assert_raises(TransientError) do
|
|
267
|
+
GenevaDrive::PerformStepJob.perform_now(step_execution.id)
|
|
268
|
+
end
|
|
269
|
+
workflow.reload
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
assert_equal "ready", workflow.state
|
|
273
|
+
assert_equal 3, workflow.step_executions.count
|
|
274
|
+
|
|
275
|
+
# 3rd attempt should trigger limit and pause
|
|
276
|
+
third_step_execution = workflow.step_executions.order(:created_at).last
|
|
277
|
+
assert_raises(TransientError) do
|
|
278
|
+
GenevaDrive::PerformStepJob.perform_now(third_step_execution.id)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
workflow.reload
|
|
282
|
+
third_step_execution.reload
|
|
283
|
+
|
|
284
|
+
# Workflow should be paused
|
|
285
|
+
assert_equal "paused", workflow.state
|
|
286
|
+
assert_equal "failed", third_step_execution.state
|
|
287
|
+
assert_equal "failed", third_step_execution.outcome
|
|
288
|
+
assert_match(/Precondition failure/, third_step_execution.error_message)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
test "manual reattempt! flow control is not limited" do
|
|
292
|
+
ManualReattemptWorkflow.reset_tracking!
|
|
293
|
+
|
|
294
|
+
workflow = ManualReattemptWorkflow.create!(hero: @user)
|
|
295
|
+
|
|
296
|
+
# Execute 5 manual reattempts - should not be limited
|
|
297
|
+
5.times do
|
|
298
|
+
step_execution = workflow.step_executions.order(:created_at).last
|
|
299
|
+
GenevaDrive::PerformStepJob.perform_now(step_execution.id)
|
|
300
|
+
workflow.reload
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# After 5 manual reattempts, workflow should still be ready
|
|
304
|
+
assert_equal "ready", workflow.state
|
|
305
|
+
assert_equal 6, workflow.step_executions.count
|
|
306
|
+
|
|
307
|
+
# 6th attempt should succeed
|
|
308
|
+
sixth_execution = workflow.step_executions.order(:created_at).last
|
|
309
|
+
GenevaDrive::PerformStepJob.perform_now(sixth_execution.id)
|
|
310
|
+
|
|
311
|
+
workflow.reload
|
|
312
|
+
|
|
313
|
+
assert_equal "finished", workflow.state
|
|
314
|
+
|
|
315
|
+
ManualReattemptWorkflow.reset_tracking!
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
private
|
|
319
|
+
|
|
320
|
+
def clean_database!
|
|
321
|
+
GenevaDrive::StepExecution.delete_all
|
|
322
|
+
GenevaDrive::Workflow.delete_all
|
|
323
|
+
User.delete_all
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -224,6 +224,118 @@ class ResumeAndSkipTest < ActiveSupport::TestCase
|
|
|
224
224
|
assert step_two_executions.exists?(state: "scheduled")
|
|
225
225
|
end
|
|
226
226
|
|
|
227
|
+
test "resume! preserves remaining wait time when original scheduled time is still in the future" do
|
|
228
|
+
# This test verifies Option B behavior for pause/resume:
|
|
229
|
+
# When a workflow is paused and then resumed BEFORE the original scheduled time,
|
|
230
|
+
# the step should be rescheduled for the REMAINING time, not run immediately.
|
|
231
|
+
#
|
|
232
|
+
# Timeline:
|
|
233
|
+
# - T+0: step_one completes, step_two scheduled for T+2days
|
|
234
|
+
# - T+1day: workflow paused (1 day remaining until step_two)
|
|
235
|
+
# - T+1day: workflow resumed -> step_two should be scheduled for T+2days (original time)
|
|
236
|
+
|
|
237
|
+
start_time = Time.current
|
|
238
|
+
workflow = nil
|
|
239
|
+
|
|
240
|
+
travel_to(start_time) do
|
|
241
|
+
workflow = WaitingWorkflow.create!(hero: @user)
|
|
242
|
+
|
|
243
|
+
# Execute step_one - this schedules step_two for 2 days from now
|
|
244
|
+
perform_next_step(workflow)
|
|
245
|
+
assert_equal "step_two", workflow.next_step_name
|
|
246
|
+
|
|
247
|
+
original_execution = workflow.step_executions.where(step_name: "step_two", state: "scheduled").first
|
|
248
|
+
assert original_execution, "Should have a scheduled step_two execution"
|
|
249
|
+
|
|
250
|
+
# step_two should be scheduled for 2 days from start_time
|
|
251
|
+
expected_original_time = start_time + 2.days
|
|
252
|
+
assert_in_delta expected_original_time.to_f, original_execution.scheduled_for.to_f, 1.0,
|
|
253
|
+
"step_two should be scheduled for 2 days from now"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Fast forward 1 day (halfway through the wait) and pause
|
|
257
|
+
travel_to(start_time + 1.day) do
|
|
258
|
+
workflow.pause!
|
|
259
|
+
|
|
260
|
+
assert_equal "paused", workflow.state
|
|
261
|
+
|
|
262
|
+
# The original execution should be canceled
|
|
263
|
+
canceled_execution = workflow.step_executions.where(step_name: "step_two", outcome: "workflow_paused").first
|
|
264
|
+
assert canceled_execution, "Should have a canceled step_two execution with outcome 'workflow_paused'"
|
|
265
|
+
|
|
266
|
+
# Now resume - step_two should be scheduled for the REMAINING time (1 day from now),
|
|
267
|
+
# which means at the ORIGINAL absolute time (start_time + 2.days)
|
|
268
|
+
workflow.resume!
|
|
269
|
+
|
|
270
|
+
assert_equal "ready", workflow.state
|
|
271
|
+
|
|
272
|
+
new_execution = workflow.step_executions.where(step_name: "step_two", state: "scheduled").first
|
|
273
|
+
assert new_execution, "Should have a new scheduled step_two execution"
|
|
274
|
+
assert_not_equal canceled_execution.id, new_execution.id, "Should be a different execution record"
|
|
275
|
+
|
|
276
|
+
# The new execution should be scheduled for the original time (start_time + 2.days),
|
|
277
|
+
# NOT for "now" (start_time + 1.day) and NOT for "now + 2.days" (start_time + 3.days)
|
|
278
|
+
expected_new_time = start_time + 2.days
|
|
279
|
+
assert_in_delta expected_new_time.to_f, new_execution.scheduled_for.to_f, 1.0,
|
|
280
|
+
"Resumed step_two should be scheduled for the original time (remaining wait preserved), not immediately"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
test "resume! runs step immediately when paused before scheduled time but resumed after" do
|
|
285
|
+
# This test verifies the scenario where:
|
|
286
|
+
# - A step is scheduled for a future time
|
|
287
|
+
# - Workflow is paused BEFORE that time arrives
|
|
288
|
+
# - Workflow is resumed AFTER that time has passed
|
|
289
|
+
# - The step should run immediately (not wait for a negative duration)
|
|
290
|
+
#
|
|
291
|
+
# Timeline:
|
|
292
|
+
# - T+0: step_one completes, step_two scheduled for T+2days
|
|
293
|
+
# - T+1day: workflow paused (step_two was supposed to run in 1 more day)
|
|
294
|
+
# - T+3days: workflow resumed (original T+2days has passed)
|
|
295
|
+
# - Result: step_two runs immediately
|
|
296
|
+
|
|
297
|
+
start_time = Time.current
|
|
298
|
+
workflow = nil
|
|
299
|
+
|
|
300
|
+
travel_to(start_time) do
|
|
301
|
+
workflow = WaitingWorkflow.create!(hero: @user)
|
|
302
|
+
|
|
303
|
+
# Execute step_one - this schedules step_two for 2 days from now
|
|
304
|
+
perform_next_step(workflow)
|
|
305
|
+
assert_equal "step_two", workflow.next_step_name
|
|
306
|
+
|
|
307
|
+
original_execution = workflow.step_executions.where(step_name: "step_two", state: "scheduled").first
|
|
308
|
+
assert_in_delta (start_time + 2.days).to_f, original_execution.scheduled_for.to_f, 1.0,
|
|
309
|
+
"step_two should be scheduled for T+2days"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# T+1day: Pause BEFORE the scheduled time (1 day remaining)
|
|
313
|
+
travel_to(start_time + 1.day) do
|
|
314
|
+
workflow.pause!
|
|
315
|
+
assert_equal "paused", workflow.state
|
|
316
|
+
|
|
317
|
+
# The canceled execution should have the original scheduled_for time
|
|
318
|
+
canceled_execution = workflow.step_executions.where(step_name: "step_two", outcome: "workflow_paused").first
|
|
319
|
+
assert canceled_execution, "Should have a canceled execution"
|
|
320
|
+
assert_in_delta (start_time + 2.days).to_f, canceled_execution.scheduled_for.to_f, 1.0,
|
|
321
|
+
"Canceled execution should preserve original scheduled_for time"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# T+3days: Resume AFTER the original scheduled time has passed
|
|
325
|
+
travel_to(start_time + 3.days) do
|
|
326
|
+
workflow.resume!
|
|
327
|
+
assert_equal "ready", workflow.state
|
|
328
|
+
|
|
329
|
+
new_execution = workflow.step_executions.where(step_name: "step_two", state: "scheduled").first
|
|
330
|
+
assert new_execution, "Should have a new scheduled step_two execution"
|
|
331
|
+
|
|
332
|
+
# The step should run immediately since the original time (T+2days) has passed
|
|
333
|
+
# It should NOT be scheduled for T+5days (now + 2.days) or any other future time
|
|
334
|
+
assert_in_delta Time.current.to_f, new_execution.scheduled_for.to_f, 1.0,
|
|
335
|
+
"Resumed step_two should run immediately when original scheduled time has passed"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
227
339
|
test "skip! on externally paused workflow advances to next step" do
|
|
228
340
|
workflow = WaitingWorkflow.create!(hero: @user)
|
|
229
341
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: geneva_drive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julik Tarkhanov
|
|
@@ -189,10 +189,11 @@ files:
|
|
|
189
189
|
- test/dummy_install/config/puma.rb
|
|
190
190
|
- test/dummy_install/config/routes.rb
|
|
191
191
|
- test/dummy_install/db/migrate/20241217000000_create_users.rb
|
|
192
|
-
- test/dummy_install/db/migrate/
|
|
193
|
-
- test/dummy_install/db/migrate/
|
|
194
|
-
- test/dummy_install/db/migrate/
|
|
195
|
-
- test/dummy_install/db/migrate/
|
|
192
|
+
- test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb
|
|
193
|
+
- test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb
|
|
194
|
+
- test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb
|
|
195
|
+
- test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb
|
|
196
|
+
- test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb
|
|
196
197
|
- test/dummy_install/db/schema.rb
|
|
197
198
|
- test/dummy_install/log/test.log
|
|
198
199
|
- test/dummy_install/public/400.html
|
|
@@ -213,6 +214,7 @@ files:
|
|
|
213
214
|
- test/workflow/executor_test.rb
|
|
214
215
|
- test/workflow/flow_control_test.rb
|
|
215
216
|
- test/workflow/instrumentation_test.rb
|
|
217
|
+
- test/workflow/max_reattempts_test.rb
|
|
216
218
|
- test/workflow/missing_sti_class_resolution_test.rb
|
|
217
219
|
- test/workflow/precondition_exception_test.rb
|
|
218
220
|
- test/workflow/reattempt_on_exception_integration_test.rb
|
|
File without changes
|
|
File without changes
|
|
File without changes
|