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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -1
- data/MANUAL.md +449 -11
- data/README.md +5 -1
- data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
- data/lib/geneva_drive/combined_exception_policy.rb +1 -1
- data/lib/geneva_drive/exception_policy.rb +36 -10
- data/lib/geneva_drive/executor.rb +352 -26
- data/lib/geneva_drive/flow_control.rb +87 -7
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +96 -2
- data/lib/geneva_drive/step_execution.rb +141 -2
- data/lib/geneva_drive/test_helpers.rb +212 -6
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +559 -43
- data/lib/geneva_drive.rb +37 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dsl/step_definition_test.rb +70 -0
- data/test/jobs/housekeeping_job_test.rb +119 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +4 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -0
- data/test/workflow/signal_consumption_test.rb +397 -0
- data/test/workflow/signal_delivery_test.rb +255 -0
- data/test/workflow/signal_durability_test.rb +164 -0
- data/test/workflow/signal_rendezvous_test.rb +581 -0
- data/test/workflow/signals_without_migration_test.rb +97 -0
- data/test/workflow/workflow_test.rb +72 -1
- metadata +25 -15
- data/test/dummy/config/initializers/geneva_drive.rb +0 -44
- data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
- data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
- data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
- data/test/dummy/db/schema.rb +0 -73
- data/test/dummy/log/development.log +0 -202
- data/test/dummy/log/test.log +0 -79471
- data/test/dummy/log/test.log.0 +0 -4
- 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
|