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.
@@ -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.3.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/20260126164025_create_geneva_drive_workflows.rb
193
- - test/dummy_install/db/migrate/20260126164026_create_geneva_drive_step_executions.rb
194
- - test/dummy_install/db/migrate/20260126164027_add_finished_at_to_geneva_drive_step_executions.rb
195
- - test/dummy_install/db/migrate/20260126164028_add_error_class_name_to_geneva_drive_step_executions.rb
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