geneva_drive 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/MANUAL.md +449 -11
  4. data/README.md +5 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
  6. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
  9. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  12. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  13. data/lib/geneva_drive/exception_policy.rb +36 -10
  14. data/lib/geneva_drive/executor.rb +352 -26
  15. data/lib/geneva_drive/flow_control.rb +87 -7
  16. data/lib/geneva_drive/iterable_step.rb +199 -0
  17. data/lib/geneva_drive/job_options.rb +70 -0
  18. data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
  19. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  20. data/lib/geneva_drive/signal.rb +243 -0
  21. data/lib/geneva_drive/signal_matcher.rb +60 -0
  22. data/lib/geneva_drive/step_definition.rb +96 -2
  23. data/lib/geneva_drive/step_execution.rb +141 -2
  24. data/lib/geneva_drive/test_helpers.rb +212 -6
  25. data/lib/geneva_drive/version.rb +1 -1
  26. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  27. data/lib/geneva_drive/workflow.rb +559 -43
  28. data/lib/geneva_drive.rb +37 -0
  29. data/test/dsl/signal_step_definition_test.rb +132 -0
  30. data/test/dsl/step_definition_test.rb +70 -0
  31. data/test/jobs/housekeeping_job_test.rb +119 -0
  32. data/test/jobs/housekeeping_signals_test.rb +198 -0
  33. data/test/jobs/perform_step_job_test.rb +25 -2
  34. data/test/migration_helpers_test.rb +1 -1
  35. data/test/test_helper.rb +4 -0
  36. data/test/test_helper_test.rb +281 -0
  37. data/test/workflow/cursor_size_limit_test.rb +75 -0
  38. data/test/workflow/instrumentation_test.rb +28 -0
  39. data/test/workflow/resumable_step_integration_test.rb +341 -0
  40. data/test/workflow/resumable_step_test.rb +615 -0
  41. data/test/workflow/resumable_without_migration_test.rb +103 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. data/test/workflow/workflow_test.rb +72 -1
  48. metadata +25 -15
  49. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  50. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  51. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  52. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  53. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  54. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  55. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  56. data/test/dummy/db/schema.rb +0 -73
  57. data/test/dummy/log/development.log +0 -202
  58. data/test/dummy/log/test.log +0 -79471
  59. data/test/dummy/log/test.log.0 +0 -4
  60. data/test/dummy/tmp/local_secret.txt +0 -1
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Integration tests for resumable steps going through the unified Executor:
6
+ # exception policies, job options, pause/resume and housekeeping recovery.
7
+ class ResumableStepIntegrationTest < ActiveSupport::TestCase
8
+ include GenevaDrive::TestHelpers
9
+ include ActiveJob::TestHelper
10
+
11
+ class TransientError < StandardError; end
12
+
13
+ # Class-level exception policy must apply inside resumable steps
14
+ class ClassPolicyResumableWorkflow < GenevaDrive::Workflow
15
+ on_exception :reattempt!, max_reattempts: 5
16
+
17
+ resumable_step :process do |iter|
18
+ iter.iterate_over((1..5).to_a) do |item|
19
+ Thread.current[:class_policy_items] ||= []
20
+ Thread.current[:class_policy_items] << item
21
+
22
+ if item == 3 && !Thread.current[:class_policy_raised]
23
+ Thread.current[:class_policy_raised] = true
24
+ raise TransientError, "flaky at item 3"
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ # Step-level reattempt with a terminal action, inside a resumable step
31
+ class TerminalSkipResumableWorkflow < GenevaDrive::Workflow
32
+ resumable_step :always_fails, on_exception: :reattempt!, max_reattempts: 2, terminal_action: :skip! do |iter|
33
+ raise TransientError, "always fails"
34
+ end
35
+
36
+ step :after do
37
+ Thread.current[:terminal_skip_after_ran] = true
38
+ end
39
+ end
40
+
41
+ # Per-step job options must be preserved on successor enqueues
42
+ class CriticalQueueResumableWorkflow < GenevaDrive::Workflow
43
+ resumable_step :process, job_options: {queue: :critical} do |iter|
44
+ current = iter.cursor || 0
45
+ while current < 4
46
+ current += 1
47
+ iter.set!(current)
48
+ # Suspend once, on the first pass - the successor resumes past 2
49
+ suspend! if current == 2
50
+ end
51
+ end
52
+ end
53
+
54
+ # A failing resumable step with the default policy (pause)
55
+ class FailingResumableWorkflow < GenevaDrive::Workflow
56
+ resumable_step :process do |iter|
57
+ iter.iterate_over(%w[a b c d]) do |item|
58
+ Thread.current[:failing_items] ||= []
59
+ Thread.current[:failing_items] << item
60
+
61
+ if item == "c" && !Thread.current[:failing_raised]
62
+ Thread.current[:failing_raised] = true
63
+ raise TransientError, "boom at c"
64
+ end
65
+ end
66
+ end
67
+
68
+ step :after do
69
+ Thread.current[:failing_after_ran] = true
70
+ end
71
+ end
72
+
73
+ # Simulates the workflow being paused externally mid-iteration
74
+ class ExternallyPausedResumableWorkflow < GenevaDrive::Workflow
75
+ resumable_step :process do |iter|
76
+ iter.iterate_over((1..5).to_a) do |item|
77
+ Thread.current[:ext_pause_items] ||= []
78
+ Thread.current[:ext_pause_items] << item
79
+
80
+ if item == 2 && !Thread.current[:ext_paused]
81
+ Thread.current[:ext_paused] = true
82
+ # Out-of-band state change (e.g. an operator or another process)
83
+ GenevaDrive::Workflow.where(id: id).update_all(state: "paused")
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ # A normal step misusing suspend!
90
+ class SuspendMisuseWorkflow < GenevaDrive::Workflow
91
+ step :not_resumable do
92
+ suspend!
93
+ end
94
+ end
95
+
96
+ # Suspends after every item - speedrun_current_step must follow the chain
97
+ class SuspendEveryItemWorkflow < GenevaDrive::Workflow
98
+ resumable_step :drip do |iter|
99
+ current = iter.cursor || 0
100
+ while current < 3
101
+ current += 1
102
+ Thread.current[:drip_items] ||= []
103
+ Thread.current[:drip_items] << current
104
+ iter.set!(current)
105
+ suspend! if current < 3
106
+ end
107
+ end
108
+ end
109
+
110
+ # skip_if applies to continuation executions like to any other execution
111
+ class SkipIfMidwayWorkflow < GenevaDrive::Workflow
112
+ resumable_step :process, skip_if: -> { Thread.current[:skipif_now] } do |iter|
113
+ iter.iterate_over((1..6).to_a) do |item|
114
+ Thread.current[:skipif_items] ||= []
115
+ Thread.current[:skipif_items] << item
116
+
117
+ if item == 2
118
+ Thread.current[:skipif_now] = true
119
+ suspend!
120
+ end
121
+ end
122
+ end
123
+
124
+ step :after do
125
+ Thread.current[:skipif_after_ran] = true
126
+ end
127
+ end
128
+
129
+ class PlainResumableWorkflow < GenevaDrive::Workflow
130
+ resumable_step :process do |iter|
131
+ iter.iterate_over((1..3).to_a) do |item|
132
+ Thread.current[:plain_items] ||= []
133
+ Thread.current[:plain_items] << item
134
+ end
135
+ end
136
+ end
137
+
138
+ setup do
139
+ @user = create_user
140
+ reset_thread_tracking!
141
+ end
142
+
143
+ teardown do
144
+ reset_thread_tracking!
145
+ end
146
+
147
+ def reset_thread_tracking!
148
+ %i[
149
+ class_policy_items class_policy_raised
150
+ terminal_skip_after_ran
151
+ critical_suspended
152
+ failing_items failing_raised failing_after_ran
153
+ ext_pause_items ext_paused
154
+ drip_items
155
+ plain_items
156
+ skipif_now skipif_items skipif_after_ran
157
+ ].each { |key| Thread.current[key] = nil }
158
+ end
159
+
160
+ test "class-level on_exception applies inside resumable steps and reattempts from the cursor" do
161
+ workflow = ClassPolicyResumableWorkflow.create!(hero: @user)
162
+
163
+ assert_raises(TransientError) { perform_next_step(workflow) }
164
+
165
+ workflow.reload
166
+ assert_equal "ready", workflow.state, "class-level reattempt! policy should apply, not the default pause"
167
+
168
+ first_exec = workflow.step_executions.find_by(step_name: "process", continues_from_id: nil)
169
+ assert_equal "completed", first_exec.state
170
+ assert_equal "reattempted", first_exec.outcome
171
+
172
+ successor = first_exec.successor
173
+ assert_not_nil successor, "reattempt of a resumable step should continue via a successor"
174
+ assert_equal 2, successor.cursor_value, "cursor should be preserved across the reattempt"
175
+
176
+ perform_next_step(workflow)
177
+
178
+ workflow.reload
179
+ assert_equal "finished", workflow.state
180
+ # Items 1, 2 done before the failure; item 3 is re-done after the reattempt
181
+ assert_equal [1, 2, 3, 3, 4, 5], Thread.current[:class_policy_items]
182
+ end
183
+
184
+ test "max_reattempts with terminal_action skip! terminates a failing resumable step" do
185
+ workflow = TerminalSkipResumableWorkflow.create!(hero: @user)
186
+
187
+ # Attempts 1 and 2 reattempt (count 0 and 1), attempt 3 hits the limit and skips
188
+ 3.times do
189
+ assert_raises(TransientError) { perform_next_step(workflow) }
190
+ workflow.reload
191
+ end
192
+
193
+ skipped = workflow.step_executions.where(step_name: "always_fails", state: "skipped")
194
+ assert skipped.exists?, "step should be skipped once max_reattempts is exhausted"
195
+
196
+ perform_next_step(workflow)
197
+
198
+ workflow.reload
199
+ assert_equal "finished", workflow.state
200
+ assert Thread.current[:terminal_skip_after_ran]
201
+ end
202
+
203
+ test "successor executions are enqueued with the per-step job options" do
204
+ workflow = CriticalQueueResumableWorkflow.create!(hero: @user)
205
+
206
+ clear_enqueued_jobs
207
+ perform_next_step(workflow)
208
+
209
+ workflow.reload
210
+ successor = workflow.current_execution
211
+ assert_not_nil successor
212
+ assert successor.resuming?
213
+
214
+ assert_enqueued_with(job: GenevaDrive::PerformStepJob, args: [successor.id], queue: "critical")
215
+ end
216
+
217
+ test "a failed resumable step is retried from its cursor on resume, not skipped" do
218
+ workflow = FailingResumableWorkflow.create!(hero: @user)
219
+
220
+ assert_raises(TransientError) { perform_next_step(workflow) }
221
+
222
+ workflow.reload
223
+ assert_equal "paused", workflow.state
224
+ failed_exec = workflow.step_executions.find_by(step_name: "process", state: "failed")
225
+ assert_not_nil failed_exec
226
+ assert_equal 2, failed_exec.cursor_value, "cursor persisted up to the last checkpoint before the failure"
227
+
228
+ workflow.resume!
229
+
230
+ workflow.reload
231
+ successor = workflow.current_execution
232
+ assert_not_nil successor
233
+ assert_equal "process", successor.step_name, "resume must retry the failed step, not skip to the next one"
234
+ assert_equal failed_exec.id, successor.continues_from_id
235
+ assert_equal 2, successor.cursor_value
236
+
237
+ speedrun_workflow(workflow)
238
+
239
+ assert_equal "finished", workflow.state
240
+ # a, b done before failure; c re-done after resume
241
+ assert_equal %w[a b c c d], Thread.current[:failing_items]
242
+ assert Thread.current[:failing_after_ran]
243
+ end
244
+
245
+ test "external pause mid-iteration preserves the cursor and resume continues from it" do
246
+ workflow = ExternallyPausedResumableWorkflow.create!(hero: @user)
247
+
248
+ perform_next_step(workflow)
249
+
250
+ workflow.reload
251
+ assert_equal "paused", workflow.state
252
+ assert_equal [1, 2], Thread.current[:ext_pause_items]
253
+
254
+ interrupted = workflow.step_executions.find_by(step_name: "process")
255
+ assert_equal "completed", interrupted.state
256
+ assert_equal "workflow_paused", interrupted.outcome
257
+ assert_equal 2, interrupted.cursor_value, "cursor handoff must survive an external pause"
258
+
259
+ workflow.resume!
260
+
261
+ workflow.reload
262
+ successor = workflow.current_execution
263
+ assert_not_nil successor
264
+ assert_equal interrupted.id, successor.continues_from_id
265
+ assert_equal 2, successor.cursor_value
266
+
267
+ speedrun_workflow(workflow)
268
+
269
+ assert_equal "finished", workflow.state
270
+ assert_equal [1, 2, 3, 4, 5], Thread.current[:ext_pause_items], "no items should be reprocessed"
271
+ end
272
+
273
+ test "skip_if is evaluated on continuation executions too" do
274
+ workflow = SkipIfMidwayWorkflow.create!(hero: @user)
275
+
276
+ # Items 1, 2 processed, then the step suspends with the skip flag now set
277
+ perform_next_step(workflow)
278
+ # The continuation sees skip_if as true and skips the rest of the step
279
+ perform_next_step(workflow)
280
+
281
+ workflow.reload
282
+ assert_equal [1, 2], Thread.current[:skipif_items]
283
+ assert workflow.step_executions.exists?(step_name: "process", state: "skipped")
284
+
285
+ perform_next_step(workflow)
286
+
287
+ workflow.reload
288
+ assert_equal "finished", workflow.state
289
+ assert Thread.current[:skipif_after_ran]
290
+ end
291
+
292
+ test "suspend! raises when called from a non-resumable step" do
293
+ workflow = SuspendMisuseWorkflow.create!(hero: @user)
294
+
295
+ error = assert_raises(GenevaDrive::InvalidStateError) { perform_next_step(workflow) }
296
+ assert_match(/resumable_step/, error.message)
297
+ end
298
+
299
+ test "speedrun_current_step follows the successor chain across suspensions" do
300
+ workflow = SuspendEveryItemWorkflow.create!(hero: @user)
301
+
302
+ execution = speedrun_current_step(workflow)
303
+
304
+ assert_equal [1, 2, 3], Thread.current[:drip_items]
305
+ assert_equal "completed", execution.state
306
+ assert_equal "success", execution.outcome
307
+ assert_nil execution.successor
308
+ workflow.reload
309
+ assert_equal "finished", workflow.state
310
+ end
311
+
312
+ test "housekeeping recovers a stuck resumable execution by continuing from its cursor" do
313
+ workflow = PlainResumableWorkflow.create!(hero: @user)
314
+
315
+ execution = workflow.current_execution
316
+ # Simulate a worker that died mid-iteration: in_progress past the
317
+ # threshold, with a persisted cursor
318
+ execution.update!(state: "in_progress", started_at: 3.hours.ago)
319
+ execution.cursor_value = 2
320
+ execution.save!
321
+ workflow.update!(state: "performing", current_step_name: "process")
322
+
323
+ GenevaDrive::HousekeepingJob.perform_now
324
+
325
+ execution.reload
326
+ workflow.reload
327
+ assert_equal "completed", execution.state
328
+ assert_equal "recovered", execution.outcome
329
+ assert_equal "ready", workflow.state
330
+
331
+ successor = execution.successor
332
+ assert_not_nil successor, "recovery must continue the iteration, not restart it"
333
+ assert_equal "scheduled", successor.state
334
+ assert_equal 2, successor.cursor_value
335
+
336
+ speedrun_workflow(workflow)
337
+
338
+ assert_equal "finished", workflow.state
339
+ assert_equal [3], Thread.current[:plain_items], "only the remaining item should be processed"
340
+ end
341
+ end