geneva_drive 0.7.0 → 0.8.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 +7 -0
- data/MANUAL.md +84 -1
- data/Rakefile +14 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +5 -10
- data/lib/geneva_drive/executor.rb +18 -0
- data/lib/geneva_drive/json_column.rb +72 -0
- data/lib/geneva_drive/migration_helpers.rb +27 -0
- data/lib/geneva_drive/removed_step_definition.rb +34 -0
- data/lib/geneva_drive/signal.rb +4 -2
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +9 -0
- data/lib/geneva_drive/step_execution.rb +3 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +170 -2
- data/lib/geneva_drive.rb +2 -0
- data/test/dummy/config/database.yml +40 -5
- data/test/dummy/config/initializers/geneva_drive.rb +46 -0
- data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
- data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
- data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
- data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
- data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
- data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
- data/test/dummy_install/db/schema.rb +75 -0
- data/test/dummy_install/log/test.log +1852 -0
- data/test/test_helper.rb +51 -13
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -0
- metadata +16 -2
data/test/test_helper.rb
CHANGED
|
@@ -7,29 +7,52 @@ require_relative "../test/dummy/config/environment"
|
|
|
7
7
|
ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)]
|
|
8
8
|
ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__)
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# Bring the dummy app's database in line with the migrations the install
|
|
11
|
+
# generator produces, recreating it from scratch whenever it has drifted.
|
|
12
|
+
#
|
|
13
|
+
# Rebuilding rather than migrating forward is deliberate. The generated
|
|
14
|
+
# migrations are gitignored and get fresh timestamps every time they are
|
|
15
|
+
# regenerated, so a database left behind by an earlier generation records
|
|
16
|
+
# versions that no longer exist on disk: every migration reads as pending, and
|
|
17
|
+
# replaying them onto the existing tables just fails on "table already exists".
|
|
12
18
|
unless defined?(GENEVA_DRIVE_TEST_DB_PREPARED)
|
|
13
19
|
GENEVA_DRIVE_TEST_DB_PREPARED = true
|
|
14
20
|
|
|
15
21
|
dummy_root = File.expand_path("../test/dummy", __dir__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
false
|
|
21
|
-
end
|
|
22
|
+
db_config = ActiveRecord::Base.connection_db_config
|
|
23
|
+
# Adapter-specific, and deliberately so - see the comment in the dummy app's
|
|
24
|
+
# config/database.yml. Ask Rails for the path rather than rebuilding it here.
|
|
25
|
+
schema_file = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config)
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
# The generator is the single source of truth for migrations.
|
|
28
|
+
if Dir.glob("#{dummy_root}/db/migrate/*geneva_drive*.rb").empty?
|
|
24
29
|
puts "Generating GenevaDrive migrations..."
|
|
25
30
|
Dir.chdir(dummy_root) do
|
|
26
31
|
system("bin/rails", "generate", "geneva_drive:install", "--skip") || abort("Failed to generate migrations")
|
|
27
|
-
system("bin/rails", "db:migrate") || abort("Failed to run migrations")
|
|
28
32
|
end
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
on_disk = Dir.glob("#{dummy_root}/db/migrate/*.rb").map { |path| File.basename(path)[/\A\d+/] }
|
|
36
|
+
applied = begin
|
|
37
|
+
ActiveRecord::Base.connection.select_values("SELECT version FROM schema_migrations").map(&:to_s)
|
|
38
|
+
rescue
|
|
39
|
+
[] # No database, or no schema_migrations in it yet
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if (on_disk - applied).any? || !File.exist?(schema_file)
|
|
43
|
+
puts "Recreating the #{db_config.adapter} test database..."
|
|
44
|
+
# db:migrate seeds an empty database from the schema dump before applying
|
|
45
|
+
# anything, so the outdated dump has to go first - otherwise it recreates
|
|
46
|
+
# exactly the tables the migrations are about to create.
|
|
47
|
+
File.delete(schema_file) if File.exist?(schema_file)
|
|
48
|
+
# bin/rails cannot drop a database this process still holds a connection to.
|
|
49
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
31
50
|
Dir.chdir(dummy_root) do
|
|
32
|
-
|
|
51
|
+
# One bin/rails per task, deliberately. Asking a single process to drop,
|
|
52
|
+
# recreate and migrate carries its column cache across the drop, and the
|
|
53
|
+
# schema it then dumps silently loses column defaults.
|
|
54
|
+
system("bin/rails", "db:drop") || abort("Failed to drop the test database")
|
|
55
|
+
system("bin/rails", "db:prepare") || abort("Failed to recreate the test database")
|
|
33
56
|
end
|
|
34
57
|
end
|
|
35
58
|
end
|
|
@@ -44,6 +67,21 @@ if ActiveSupport::TestCase.respond_to?(:fixture_paths=)
|
|
|
44
67
|
ActiveSupport::TestCase.fixtures :all
|
|
45
68
|
end
|
|
46
69
|
|
|
70
|
+
# Rails 8.1 clears Active Record's connections before forking the parallel test
|
|
71
|
+
# workers. 7.2 and 8.0 fork with them still open, and a libpq connection
|
|
72
|
+
# inherited across the fork takes the child down with a segfault in
|
|
73
|
+
# PG::Connection#connect_start as it opens its own - a race, so it only bites
|
|
74
|
+
# some of the time, which makes it maddening rather than merely broken. Where
|
|
75
|
+
# upstream has no pre-fork hook to register on, wrap the fork point itself.
|
|
76
|
+
unless ActiveSupport::Testing::Parallelization.respond_to?(:before_fork_hook)
|
|
77
|
+
ActiveSupport::Testing::Parallelization.prepend(Module.new do
|
|
78
|
+
def start
|
|
79
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
80
|
+
super
|
|
81
|
+
end
|
|
82
|
+
end)
|
|
83
|
+
end
|
|
84
|
+
|
|
47
85
|
# Test helper methods
|
|
48
86
|
class ActiveSupport::TestCase
|
|
49
87
|
# Run tests in parallel with specified workers
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class RemovedStepTest < ActiveSupport::TestCase
|
|
6
|
+
include GenevaDrive::TestHelpers
|
|
7
|
+
|
|
8
|
+
# The shape a workflow has after `capture_payment` was taken out of the
|
|
9
|
+
# middle of it and a gravestone left behind.
|
|
10
|
+
class PaymentWorkflow < GenevaDrive::Workflow
|
|
11
|
+
step :authorize do
|
|
12
|
+
Thread.current[:ran] << :authorize
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
removed_step :capture_payment
|
|
16
|
+
|
|
17
|
+
step :send_receipt do
|
|
18
|
+
Thread.current[:ran] << :send_receipt
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A gravestone in first position, so nothing runnable precedes it.
|
|
23
|
+
class LeadingRemovalWorkflow < GenevaDrive::Workflow
|
|
24
|
+
removed_step :old_preflight
|
|
25
|
+
|
|
26
|
+
step :only_step do
|
|
27
|
+
Thread.current[:ran] << :only_step
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A gravestone in last position, so nothing runnable follows it.
|
|
32
|
+
class TrailingRemovalWorkflow < GenevaDrive::Workflow
|
|
33
|
+
step :only_step do
|
|
34
|
+
Thread.current[:ran] << :only_step
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
removed_step :old_teardown
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Every step removed - there is nothing left to run at all.
|
|
41
|
+
class FullyRemovedWorkflow < GenevaDrive::Workflow
|
|
42
|
+
removed_step :gone_one
|
|
43
|
+
removed_step :gone_two
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A step deleted outright, with no gravestone. This is the situation
|
|
47
|
+
# removed_step exists to prevent, kept here to prove the default is
|
|
48
|
+
# unchanged: the workflow still pauses rather than guessing.
|
|
49
|
+
class UndeclaredRemovalWorkflow < GenevaDrive::Workflow
|
|
50
|
+
step :step_one do
|
|
51
|
+
Thread.current[:ran] << :step_one
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
step :step_two do
|
|
55
|
+
Thread.current[:ran] << :step_two
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
setup do
|
|
60
|
+
@user = create_user
|
|
61
|
+
Thread.current[:ran] = []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
teardown do
|
|
65
|
+
Thread.current[:ran] = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ===========================================
|
|
69
|
+
# The declaration itself
|
|
70
|
+
# ===========================================
|
|
71
|
+
|
|
72
|
+
test "removed_step registers a gravestone that reports itself as removed" do
|
|
73
|
+
step_def = PaymentWorkflow.steps.named("capture_payment")
|
|
74
|
+
|
|
75
|
+
assert_not_nil step_def
|
|
76
|
+
assert_predicate step_def, :removed?
|
|
77
|
+
assert_equal "capture_payment", step_def.name
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
test "an ordinary step is not removed" do
|
|
81
|
+
assert_not_predicate PaymentWorkflow.steps.named("authorize"), :removed?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
test "a removed step keeps its name resolvable" do
|
|
85
|
+
assert PaymentWorkflow.steps.key?("capture_payment")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "a removed step holds its position in the declared order" do
|
|
89
|
+
assert_equal %w[authorize capture_payment send_receipt], PaymentWorkflow.steps.map(&:name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test "runnable omits removed steps and removed lists them" do
|
|
93
|
+
assert_equal %w[authorize send_receipt], PaymentWorkflow.steps.runnable.map(&:name)
|
|
94
|
+
assert_equal %w[capture_payment], PaymentWorkflow.steps.removed.map(&:name)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
test "removed_step refuses a name already taken by a step" do
|
|
98
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) do
|
|
99
|
+
Class.new(GenevaDrive::Workflow) do
|
|
100
|
+
def self.name = "DoubleDeclarationWorkflow"
|
|
101
|
+
|
|
102
|
+
step :thing do
|
|
103
|
+
# no-op
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
removed_step :thing
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
assert_match(/already defined/, error.message)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
test "removed_step requires a name" do
|
|
114
|
+
assert_raises(ArgumentError) do
|
|
115
|
+
Class.new(GenevaDrive::Workflow) do
|
|
116
|
+
removed_step nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
test "before_step can still reference a removed step" do
|
|
122
|
+
klass = Class.new(GenevaDrive::Workflow) do
|
|
123
|
+
def self.name = "PositionedAroundRemovalWorkflow"
|
|
124
|
+
|
|
125
|
+
removed_step :gone
|
|
126
|
+
|
|
127
|
+
step :inserted, before_step: :gone do
|
|
128
|
+
# no-op
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
assert_equal %w[inserted gone], klass.steps.map(&:name)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ===========================================
|
|
136
|
+
# Navigation never lands on a gravestone
|
|
137
|
+
# ===========================================
|
|
138
|
+
|
|
139
|
+
test "next_after steps over a removed step" do
|
|
140
|
+
assert_equal "send_receipt", PaymentWorkflow.steps.next_after("authorize").name
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
test "next_after from a removed step returns the next runnable one" do
|
|
144
|
+
assert_equal "send_receipt", PaymentWorkflow.steps.next_after("capture_payment").name
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
test "next_after returns nil when only removed steps remain" do
|
|
148
|
+
assert_nil TrailingRemovalWorkflow.steps.next_after("only_step")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
test "next_after from the beginning skips a leading removed step" do
|
|
152
|
+
assert_equal "only_step", LeadingRemovalWorkflow.steps.next_after(nil).name
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
test "next_after still returns nil for a name the collection never had" do
|
|
156
|
+
assert_nil PaymentWorkflow.steps.next_after("never_existed")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
test "previous_before steps over a removed step" do
|
|
160
|
+
assert_equal "authorize", PaymentWorkflow.steps.previous_before("send_receipt").name
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
test "previous_before returns nil when only removed steps precede" do
|
|
164
|
+
assert_nil LeadingRemovalWorkflow.steps.previous_before("only_step")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ===========================================
|
|
168
|
+
# Scheduling never spools a removed step
|
|
169
|
+
# ===========================================
|
|
170
|
+
|
|
171
|
+
test "a new workflow runs straight past a removed middle step" do
|
|
172
|
+
workflow = PaymentWorkflow.create!(hero: @user)
|
|
173
|
+
speedrun_workflow(workflow)
|
|
174
|
+
|
|
175
|
+
assert_equal %i[authorize send_receipt], Thread.current[:ran]
|
|
176
|
+
assert_workflow_state(workflow, :finished)
|
|
177
|
+
assert_empty workflow.step_executions.where(step_name: "capture_payment")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
test "a new workflow starts at the first runnable step, not a leading gravestone" do
|
|
181
|
+
workflow = LeadingRemovalWorkflow.create!(hero: @user)
|
|
182
|
+
|
|
183
|
+
assert_equal "only_step", workflow.next_step_name
|
|
184
|
+
|
|
185
|
+
speedrun_workflow(workflow)
|
|
186
|
+
|
|
187
|
+
assert_equal %i[only_step], Thread.current[:ran]
|
|
188
|
+
assert_workflow_state(workflow, :finished)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
test "a trailing gravestone finishes the workflow instead of being scheduled" do
|
|
192
|
+
workflow = TrailingRemovalWorkflow.create!(hero: @user)
|
|
193
|
+
speedrun_workflow(workflow)
|
|
194
|
+
|
|
195
|
+
assert_workflow_state(workflow, :finished)
|
|
196
|
+
assert_empty workflow.step_executions.where(step_name: "old_teardown")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
test "a workflow whose every step was removed finishes immediately" do
|
|
200
|
+
workflow = FullyRemovedWorkflow.create!(hero: @user)
|
|
201
|
+
|
|
202
|
+
assert_workflow_state(workflow, :finished)
|
|
203
|
+
assert_empty workflow.step_executions
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ===========================================
|
|
207
|
+
# Executions scheduled before the removal shipped
|
|
208
|
+
# ===========================================
|
|
209
|
+
|
|
210
|
+
test "an execution scheduled before the removal is skipped and the workflow continues" do
|
|
211
|
+
workflow = PaymentWorkflow.create!(hero: @user)
|
|
212
|
+
|
|
213
|
+
# Stand in for a rolling deploy: this row was written by the old code,
|
|
214
|
+
# which still had capture_payment as a real step.
|
|
215
|
+
execution = reschedule_at(workflow, "capture_payment")
|
|
216
|
+
|
|
217
|
+
execution.execute!
|
|
218
|
+
|
|
219
|
+
assert_equal "skipped", execution.reload.state
|
|
220
|
+
assert_equal "skipped", execution.outcome
|
|
221
|
+
|
|
222
|
+
speedrun_workflow(workflow)
|
|
223
|
+
|
|
224
|
+
assert_equal %i[send_receipt], Thread.current[:ran]
|
|
225
|
+
assert_workflow_state(workflow, :finished)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
test "an execution for a trailing removed step finishes the workflow" do
|
|
229
|
+
workflow = TrailingRemovalWorkflow.create!(hero: @user)
|
|
230
|
+
execution = reschedule_at(workflow, "old_teardown")
|
|
231
|
+
|
|
232
|
+
execution.execute!
|
|
233
|
+
|
|
234
|
+
assert_equal "skipped", execution.reload.state
|
|
235
|
+
assert_workflow_state(workflow, :finished)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
test "a step deleted without a gravestone still pauses the workflow" do
|
|
239
|
+
workflow = UndeclaredRemovalWorkflow.create!(hero: @user)
|
|
240
|
+
execution = reschedule_at(workflow, "deleted_without_a_trace")
|
|
241
|
+
|
|
242
|
+
assert_raises(GenevaDrive::StepNotDefinedError) { execution.execute! }
|
|
243
|
+
|
|
244
|
+
assert_workflow_state(workflow, :paused)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# ===========================================
|
|
248
|
+
# Knowing when the gravestone can go
|
|
249
|
+
# ===========================================
|
|
250
|
+
|
|
251
|
+
test "removed_steps_in_flight counts rows still pointing at a removed step" do
|
|
252
|
+
workflow = PaymentWorkflow.create!(hero: @user)
|
|
253
|
+
reschedule_at(workflow, "capture_payment")
|
|
254
|
+
|
|
255
|
+
assert_equal({"capture_payment" => 1}, PaymentWorkflow.removed_steps_in_flight)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
test "removed_steps_in_flight ignores workflows that will never run again" do
|
|
259
|
+
workflow = PaymentWorkflow.create!(hero: @user)
|
|
260
|
+
reschedule_at(workflow, "capture_payment")
|
|
261
|
+
workflow.update!(state: "canceled")
|
|
262
|
+
|
|
263
|
+
assert_empty PaymentWorkflow.removed_steps_in_flight
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
test "removed_steps_in_flight is empty once nothing references the name" do
|
|
267
|
+
PaymentWorkflow.create!(hero: @user)
|
|
268
|
+
|
|
269
|
+
assert_empty PaymentWorkflow.removed_steps_in_flight
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
test "removed_steps_in_flight is empty for a workflow with no removals" do
|
|
273
|
+
assert_empty UndeclaredRemovalWorkflow.removed_steps_in_flight
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private
|
|
277
|
+
|
|
278
|
+
# Replaces whatever is scheduled with an execution for the given step name,
|
|
279
|
+
# the way a row written by a previous deploy would look.
|
|
280
|
+
def reschedule_at(workflow, step_name)
|
|
281
|
+
workflow.step_executions.where(state: %w[scheduled waiting]).update_all(
|
|
282
|
+
state: "canceled",
|
|
283
|
+
outcome: "canceled",
|
|
284
|
+
canceled_at: Time.current
|
|
285
|
+
)
|
|
286
|
+
execution = workflow.step_executions.create!(
|
|
287
|
+
step_name: step_name,
|
|
288
|
+
state: "scheduled",
|
|
289
|
+
scheduled_for: Time.current
|
|
290
|
+
)
|
|
291
|
+
workflow.update!(next_step_name: step_name, current_step_name: nil)
|
|
292
|
+
execution
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class ResumeAtTest < ActiveSupport::TestCase
|
|
6
|
+
include GenevaDrive::TestHelpers
|
|
7
|
+
|
|
8
|
+
class ThreeStepWorkflow < GenevaDrive::Workflow
|
|
9
|
+
step :step_one do
|
|
10
|
+
Thread.current[:ran] << :step_one
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
step :step_two do
|
|
14
|
+
Thread.current[:ran] << :step_two
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
step :step_three do
|
|
18
|
+
Thread.current[:ran] << :step_three
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class WorkflowWithRemoval < GenevaDrive::Workflow
|
|
23
|
+
step :step_one do
|
|
24
|
+
Thread.current[:ran] << :step_one
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
removed_step :parked_forever
|
|
28
|
+
|
|
29
|
+
step :step_three do
|
|
30
|
+
Thread.current[:ran] << :step_three
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
setup do
|
|
35
|
+
@user = create_user
|
|
36
|
+
Thread.current[:ran] = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
teardown do
|
|
40
|
+
Thread.current[:ran] = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ===========================================
|
|
44
|
+
# resume_at!
|
|
45
|
+
# ===========================================
|
|
46
|
+
|
|
47
|
+
test "resume_at! continues from the named step" do
|
|
48
|
+
workflow = paused_at(ThreeStepWorkflow.create!(hero: @user), "step_two")
|
|
49
|
+
|
|
50
|
+
execution = workflow.resume_at!(:step_three)
|
|
51
|
+
|
|
52
|
+
assert_equal "step_three", execution.step_name
|
|
53
|
+
assert_equal "step_three", workflow.reload.next_step_name
|
|
54
|
+
assert_equal "ready", workflow.state
|
|
55
|
+
|
|
56
|
+
speedrun_workflow(workflow)
|
|
57
|
+
|
|
58
|
+
assert_equal %i[step_three], Thread.current[:ran]
|
|
59
|
+
assert_workflow_state(workflow, :finished)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
test "resume_at! cancels the execution the workflow was pointed at" do
|
|
63
|
+
workflow = ThreeStepWorkflow.create!(hero: @user)
|
|
64
|
+
stale = workflow.current_execution
|
|
65
|
+
workflow.pause!
|
|
66
|
+
|
|
67
|
+
workflow.resume_at!(:step_three)
|
|
68
|
+
|
|
69
|
+
assert_equal "canceled", stale.reload.state
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
test "resume_at! accepts a string as readily as a symbol" do
|
|
73
|
+
workflow = paused_at(ThreeStepWorkflow.create!(hero: @user), "step_two")
|
|
74
|
+
|
|
75
|
+
assert_equal "step_three", workflow.resume_at!("step_three").step_name
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
test "resume_at! rejects a step that is not defined" do
|
|
79
|
+
workflow = paused_at(ThreeStepWorkflow.create!(hero: @user), "step_two")
|
|
80
|
+
|
|
81
|
+
error = assert_raises(GenevaDrive::StepNotDefinedError) { workflow.resume_at!(:nowhere) }
|
|
82
|
+
|
|
83
|
+
assert_match(/not defined/, error.message)
|
|
84
|
+
assert_match(/step_one, step_two, step_three/, error.message)
|
|
85
|
+
assert_equal "paused", workflow.reload.state
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "resume_at! refuses to land on a removed step" do
|
|
89
|
+
workflow = paused_at(WorkflowWithRemoval.create!(hero: @user), "step_one")
|
|
90
|
+
|
|
91
|
+
error = assert_raises(GenevaDrive::StepNotDefinedError) { workflow.resume_at!(:parked_forever) }
|
|
92
|
+
|
|
93
|
+
assert_match(/removed_step/, error.message)
|
|
94
|
+
assert_equal "paused", workflow.reload.state
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
test "resume_at! only applies to paused workflows" do
|
|
98
|
+
workflow = ThreeStepWorkflow.create!(hero: @user)
|
|
99
|
+
|
|
100
|
+
assert_raises(GenevaDrive::InvalidStateError) { workflow.resume_at!(:step_three) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ===========================================
|
|
104
|
+
# resume! on a workflow pointed at a vanished step
|
|
105
|
+
# ===========================================
|
|
106
|
+
|
|
107
|
+
test "resume! explains itself when the step it points at is gone" do
|
|
108
|
+
workflow = paused_at(ThreeStepWorkflow.create!(hero: @user), "deleted_without_a_trace")
|
|
109
|
+
|
|
110
|
+
error = assert_raises(GenevaDrive::StepNotDefinedError) { workflow.resume! }
|
|
111
|
+
|
|
112
|
+
assert_match(/no longer defined/, error.message)
|
|
113
|
+
assert_match(/resume_at!/, error.message)
|
|
114
|
+
assert_match(/removed_step :deleted_without_a_trace/, error.message)
|
|
115
|
+
assert_match(/step_one, step_two, step_three/, error.message)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
test "resume_at! rescues a workflow stranded by a vanished step" do
|
|
119
|
+
workflow = paused_at(ThreeStepWorkflow.create!(hero: @user), "deleted_without_a_trace")
|
|
120
|
+
|
|
121
|
+
workflow.resume_at!(:step_three)
|
|
122
|
+
speedrun_workflow(workflow)
|
|
123
|
+
|
|
124
|
+
assert_equal %i[step_three], Thread.current[:ran]
|
|
125
|
+
assert_workflow_state(workflow, :finished)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ===========================================
|
|
129
|
+
# A step removed while an execution was parked
|
|
130
|
+
# ===========================================
|
|
131
|
+
|
|
132
|
+
test "resume! releases an execution parked on a since-removed step" do
|
|
133
|
+
workflow = WorkflowWithRemoval.create!(hero: @user)
|
|
134
|
+
|
|
135
|
+
# The old code parked here waiting for a signal; the new code has only a
|
|
136
|
+
# gravestone, so nothing can ever match.
|
|
137
|
+
workflow.step_executions.where(state: %w[scheduled waiting]).update_all(
|
|
138
|
+
state: "canceled", outcome: "canceled", canceled_at: Time.current
|
|
139
|
+
)
|
|
140
|
+
parked = workflow.step_executions.create!(
|
|
141
|
+
step_name: "parked_forever",
|
|
142
|
+
state: "waiting",
|
|
143
|
+
scheduled_for: Time.current,
|
|
144
|
+
waiting_since: Time.current
|
|
145
|
+
)
|
|
146
|
+
workflow.update!(next_step_name: "parked_forever", current_step_name: nil)
|
|
147
|
+
workflow.pause!
|
|
148
|
+
|
|
149
|
+
workflow.resume!
|
|
150
|
+
|
|
151
|
+
assert_equal "skipped", parked.reload.state
|
|
152
|
+
|
|
153
|
+
speedrun_workflow(workflow)
|
|
154
|
+
|
|
155
|
+
assert_equal %i[step_three], Thread.current[:ran]
|
|
156
|
+
assert_workflow_state(workflow, :finished)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# Puts the workflow in the state it would be in after pausing while pointed
|
|
162
|
+
# at the given step name, with no live execution left behind.
|
|
163
|
+
def paused_at(workflow, step_name)
|
|
164
|
+
workflow.step_executions.where(state: %w[scheduled waiting]).update_all(
|
|
165
|
+
state: "canceled", outcome: "canceled", canceled_at: Time.current
|
|
166
|
+
)
|
|
167
|
+
workflow.update!(state: "paused", next_step_name: step_name, current_step_name: nil)
|
|
168
|
+
workflow
|
|
169
|
+
end
|
|
170
|
+
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: geneva_drive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julik Tarkhanov
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2026-10-15 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -94,6 +94,7 @@ files:
|
|
|
94
94
|
- MANUAL.md
|
|
95
95
|
- README.md
|
|
96
96
|
- Rakefile
|
|
97
|
+
- bin/matrix
|
|
97
98
|
- bin/md2html
|
|
98
99
|
- bin/rails
|
|
99
100
|
- bin/rubocop
|
|
@@ -121,7 +122,9 @@ files:
|
|
|
121
122
|
- lib/geneva_drive/job_options.rb
|
|
122
123
|
- lib/geneva_drive/jobs/housekeeping_job.rb
|
|
123
124
|
- lib/geneva_drive/jobs/perform_step_job.rb
|
|
125
|
+
- lib/geneva_drive/json_column.rb
|
|
124
126
|
- lib/geneva_drive/migration_helpers.rb
|
|
127
|
+
- lib/geneva_drive/removed_step_definition.rb
|
|
125
128
|
- lib/geneva_drive/resumable_step_definition.rb
|
|
126
129
|
- lib/geneva_drive/signal.rb
|
|
127
130
|
- lib/geneva_drive/signal_matcher.rb
|
|
@@ -168,6 +171,7 @@ files:
|
|
|
168
171
|
- test/dummy/config/initializers/assets.rb
|
|
169
172
|
- test/dummy/config/initializers/content_security_policy.rb
|
|
170
173
|
- test/dummy/config/initializers/filter_parameter_logging.rb
|
|
174
|
+
- test/dummy/config/initializers/geneva_drive.rb
|
|
171
175
|
- test/dummy/config/initializers/inflections.rb
|
|
172
176
|
- test/dummy/config/locales/en.yml
|
|
173
177
|
- test/dummy/config/puma.rb
|
|
@@ -182,6 +186,14 @@ files:
|
|
|
182
186
|
- test/dummy/public/icon.png
|
|
183
187
|
- test/dummy/public/icon.svg
|
|
184
188
|
- test/dummy_install/config/storage.yml
|
|
189
|
+
- test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb
|
|
190
|
+
- test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb
|
|
191
|
+
- test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb
|
|
192
|
+
- test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb
|
|
193
|
+
- test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb
|
|
194
|
+
- test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb
|
|
195
|
+
- test/dummy_install/db/schema.rb
|
|
196
|
+
- test/dummy_install/log/test.log
|
|
185
197
|
- test/geneva_drive_test.rb
|
|
186
198
|
- test/jobs/housekeeping_job_test.rb
|
|
187
199
|
- test/jobs/housekeeping_signals_test.rb
|
|
@@ -205,10 +217,12 @@ files:
|
|
|
205
217
|
- test/workflow/pause_resume_test.rb
|
|
206
218
|
- test/workflow/precondition_exception_test.rb
|
|
207
219
|
- test/workflow/reattempt_on_exception_integration_test.rb
|
|
220
|
+
- test/workflow/removed_step_test.rb
|
|
208
221
|
- test/workflow/resumable_step_integration_test.rb
|
|
209
222
|
- test/workflow/resumable_step_test.rb
|
|
210
223
|
- test/workflow/resumable_without_migration_test.rb
|
|
211
224
|
- test/workflow/resume_and_skip_test.rb
|
|
225
|
+
- test/workflow/resume_at_test.rb
|
|
212
226
|
- test/workflow/signal_consumption_test.rb
|
|
213
227
|
- test/workflow/signal_delivery_test.rb
|
|
214
228
|
- test/workflow/signal_durability_test.rb
|