geneva_drive 0.6.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 +10 -1
- data/MANUAL.md +260 -1
- data/README.md +2 -0
- data/Rakefile +14 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -0
- data/lib/generators/geneva_drive/install/install_generator.rb +5 -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 +89 -0
- data/lib/geneva_drive/executor.rb +131 -10
- data/lib/geneva_drive/flow_control.rb +43 -3
- data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
- 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 +245 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +91 -1
- data/lib/geneva_drive/step_execution.rb +39 -2
- data/lib/geneva_drive/test_helpers.rb +89 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +497 -24
- data/lib/geneva_drive.rb +24 -0
- data/test/dsl/signal_step_definition_test.rb +132 -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/jobs/housekeeping_signals_test.rb +198 -0
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +53 -13
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -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
- metadata +26 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Signals are wiped with their workflows, and parked executions - which the
|
|
6
|
+
# stuck sweeps deliberately ignore - are reported through gauges instead.
|
|
7
|
+
class HousekeepingSignalsTest < ActiveSupport::TestCase
|
|
8
|
+
include GenevaDrive::TestHelpers
|
|
9
|
+
|
|
10
|
+
class GaugeRecorder
|
|
11
|
+
attr_reader :gauges
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@gauges = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def set_gauge(name, value, tags = {})
|
|
18
|
+
@gauges << {name: name.to_s, value: value, tags: tags}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def instrument(*, &blk) = blk&.call
|
|
22
|
+
|
|
23
|
+
def add_distribution_value(*) = nil
|
|
24
|
+
|
|
25
|
+
def increment_counter(*) = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class WaitingWorkflow < GenevaDrive::Workflow
|
|
29
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class OtherWaitingWorkflow < GenevaDrive::Workflow
|
|
34
|
+
step :await_approval, wait_for: :approved do
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
setup do
|
|
39
|
+
@user = create_user
|
|
40
|
+
@original_delete_after = GenevaDrive.delete_completed_workflows_after
|
|
41
|
+
@original_batch_size = GenevaDrive.housekeeping_batch_size
|
|
42
|
+
@original_waiting_threshold = GenevaDrive.waiting_visibility_threshold
|
|
43
|
+
@recorder = GaugeRecorder.new
|
|
44
|
+
Measurometer.drivers << @recorder
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
teardown do
|
|
48
|
+
GenevaDrive.delete_completed_workflows_after = @original_delete_after
|
|
49
|
+
GenevaDrive.housekeeping_batch_size = @original_batch_size
|
|
50
|
+
GenevaDrive.waiting_visibility_threshold = @original_waiting_threshold
|
|
51
|
+
Measurometer.drivers.delete(@recorder)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def gauge(name, workflow: nil)
|
|
55
|
+
found = @recorder.gauges.select { |g| g[:name] == name && g[:tags][:workflow] == workflow }
|
|
56
|
+
found.last&.fetch(:value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def park_workflow(workflow_class, user)
|
|
60
|
+
workflow = workflow_class.create!(hero: user)
|
|
61
|
+
workflow.current_execution.execute!
|
|
62
|
+
workflow.reload
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "signals of old workflows are deleted before the workflows themselves" do
|
|
66
|
+
GenevaDrive.delete_completed_workflows_after = 30.days
|
|
67
|
+
|
|
68
|
+
workflow = WaitingWorkflow.create!(hero: @user)
|
|
69
|
+
workflow.signal!(:payment_confirmed)
|
|
70
|
+
workflow.signal!(:unrelated)
|
|
71
|
+
workflow.cancel!
|
|
72
|
+
workflow.update!(transitioned_at: 60.days.ago)
|
|
73
|
+
|
|
74
|
+
results = GenevaDrive::HousekeepingJob.perform_now
|
|
75
|
+
|
|
76
|
+
assert_equal 2, results[:signals_cleaned_up]
|
|
77
|
+
assert_equal 1, results[:workflows_cleaned_up]
|
|
78
|
+
assert_equal 0, GenevaDrive::Signal.where(workflow_id: workflow.id).count
|
|
79
|
+
assert_not GenevaDrive::Workflow.exists?(workflow.id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
test "signals of recent workflows are kept" do
|
|
83
|
+
GenevaDrive.delete_completed_workflows_after = 30.days
|
|
84
|
+
|
|
85
|
+
workflow = WaitingWorkflow.create!(hero: @user)
|
|
86
|
+
workflow.signal!(:payment_confirmed)
|
|
87
|
+
workflow.cancel!
|
|
88
|
+
|
|
89
|
+
results = GenevaDrive::HousekeepingJob.perform_now
|
|
90
|
+
|
|
91
|
+
assert_equal 0, results[:signals_cleaned_up]
|
|
92
|
+
assert_equal 1, GenevaDrive::Signal.where(workflow_id: workflow.id).count
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
test "signal deletion is batched" do
|
|
96
|
+
GenevaDrive.delete_completed_workflows_after = 30.days
|
|
97
|
+
GenevaDrive.housekeeping_batch_size = 2
|
|
98
|
+
|
|
99
|
+
workflow = WaitingWorkflow.create!(hero: @user)
|
|
100
|
+
4.times { |n| workflow.signal!(:noise, idempotency_key: "evt_#{n}") }
|
|
101
|
+
workflow.cancel!
|
|
102
|
+
workflow.update!(transitioned_at: 60.days.ago)
|
|
103
|
+
|
|
104
|
+
results = GenevaDrive::HousekeepingJob.perform_now
|
|
105
|
+
|
|
106
|
+
assert_equal 4, results[:signals_cleaned_up]
|
|
107
|
+
assert_equal 0, GenevaDrive::Signal.where(workflow_id: workflow.id).count
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
test "parked executions are counted per workflow class" do
|
|
111
|
+
park_workflow(WaitingWorkflow, @user)
|
|
112
|
+
park_workflow(OtherWaitingWorkflow, create_user(email: "two@example.com"))
|
|
113
|
+
|
|
114
|
+
GenevaDrive::HousekeepingJob.perform_now
|
|
115
|
+
|
|
116
|
+
assert_equal 1, gauge("geneva_drive.waiting_step_executions", workflow: WaitingWorkflow.name)
|
|
117
|
+
assert_equal 1, gauge("geneva_drive.waiting_step_executions", workflow: OtherWaitingWorkflow.name)
|
|
118
|
+
assert_equal 2, gauge("geneva_drive.waiting_step_executions")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
test "parked executions past the visibility threshold are reported as overdue" do
|
|
122
|
+
GenevaDrive.waiting_visibility_threshold = 7.days
|
|
123
|
+
|
|
124
|
+
fresh = park_workflow(WaitingWorkflow, @user)
|
|
125
|
+
stale = park_workflow(OtherWaitingWorkflow, create_user(email: "two@example.com"))
|
|
126
|
+
stale.current_execution.update!(waiting_since: 30.days.ago)
|
|
127
|
+
|
|
128
|
+
GenevaDrive::HousekeepingJob.perform_now
|
|
129
|
+
|
|
130
|
+
assert_equal 1, gauge("geneva_drive.waiting_overdue")
|
|
131
|
+
assert_equal 1, gauge("geneva_drive.waiting_overdue", workflow: OtherWaitingWorkflow.name)
|
|
132
|
+
assert_nil gauge("geneva_drive.waiting_overdue", workflow: WaitingWorkflow.name)
|
|
133
|
+
assert_equal "waiting", fresh.current_execution.state
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
test "the overdue gauge can be disabled" do
|
|
137
|
+
GenevaDrive.waiting_visibility_threshold = nil
|
|
138
|
+
park_workflow(WaitingWorkflow, @user)
|
|
139
|
+
|
|
140
|
+
GenevaDrive::HousekeepingJob.perform_now
|
|
141
|
+
|
|
142
|
+
assert_nil gauge("geneva_drive.waiting_overdue")
|
|
143
|
+
assert_equal 1, gauge("geneva_drive.waiting_step_executions")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
test "parked executions are never treated as stuck" do
|
|
147
|
+
GenevaDrive.delete_completed_workflows_after = nil
|
|
148
|
+
workflow = park_workflow(WaitingWorkflow, @user)
|
|
149
|
+
workflow.current_execution.update!(scheduled_for: 30.days.ago, waiting_since: 30.days.ago)
|
|
150
|
+
|
|
151
|
+
results = GenevaDrive::HousekeepingJob.perform_now
|
|
152
|
+
|
|
153
|
+
assert_equal 0, results[:stuck_scheduled_recovered]
|
|
154
|
+
assert_equal 0, results[:stuck_in_progress_recovered]
|
|
155
|
+
assert_equal "waiting", workflow.reload.current_execution.state
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
test "a worker crashing mid-step re-attaches to the same claimed signal" do
|
|
159
|
+
GenevaDrive.delete_completed_workflows_after = nil
|
|
160
|
+
workflow = park_workflow(WaitingWorkflow, @user)
|
|
161
|
+
signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 7})
|
|
162
|
+
|
|
163
|
+
# Pretend the worker died after the gate attached and the step started
|
|
164
|
+
crashed = workflow.reload.current_execution
|
|
165
|
+
crashed.update!(state: "in_progress", started_at: 30.days.ago)
|
|
166
|
+
workflow.update!(state: "performing", current_step_name: "await_payment")
|
|
167
|
+
|
|
168
|
+
results = GenevaDrive::HousekeepingJob.perform_now
|
|
169
|
+
|
|
170
|
+
assert_equal 1, results[:stuck_in_progress_recovered]
|
|
171
|
+
assert_equal "claimed", signal.reload.state
|
|
172
|
+
|
|
173
|
+
recovered = workflow.reload.current_execution
|
|
174
|
+
recovered.execute!
|
|
175
|
+
|
|
176
|
+
assert_equal signal.id, recovered.reload.signal_id
|
|
177
|
+
assert_equal "consumed", signal.reload.state
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
test "a dispatched execution whose job got lost is recovered as a stuck scheduled one" do
|
|
181
|
+
GenevaDrive.delete_completed_workflows_after = nil
|
|
182
|
+
workflow = park_workflow(WaitingWorkflow, @user)
|
|
183
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
184
|
+
|
|
185
|
+
# Pretend the enqueue evaporated and the threshold has passed
|
|
186
|
+
workflow.current_execution.update!(scheduled_for: 30.days.ago)
|
|
187
|
+
|
|
188
|
+
results = GenevaDrive::HousekeepingJob.perform_now
|
|
189
|
+
|
|
190
|
+
assert_equal 1, results[:stuck_scheduled_recovered]
|
|
191
|
+
|
|
192
|
+
recovered = workflow.reload.current_execution
|
|
193
|
+
assert_equal "scheduled", recovered.state
|
|
194
|
+
# The fresh execution re-attaches to the still-claimed signal at its gate
|
|
195
|
+
recovered.execute!
|
|
196
|
+
assert_equal "consumed", signal.reload.state
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "test_helper"
|
|
4
4
|
|
|
5
5
|
class MigrationHelpersTest < ActiveSupport::TestCase
|
|
6
|
-
FakeColumn = Struct.new(:name, :sql_type, :default_function
|
|
6
|
+
FakeColumn = Struct.new(:name, :sql_type, :default_function)
|
|
7
7
|
|
|
8
8
|
# Creates a fresh harness with the given fake columns as the app id columns.
|
|
9
9
|
def build_migration(columns = [])
|
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
|
|
@@ -55,6 +93,8 @@ class ActiveSupport::TestCase
|
|
|
55
93
|
setup do
|
|
56
94
|
GenevaDrive::StepExecution.reset_metadata_column_cache!
|
|
57
95
|
GenevaDrive::StepExecution.reset_resumable_columns_cache!
|
|
96
|
+
GenevaDrive::StepExecution.reset_signal_columns_cache!
|
|
97
|
+
GenevaDrive::Signal.reset_table_available_cache!
|
|
58
98
|
GenevaDrive::Workflow.reset_metadata_column_cache!
|
|
59
99
|
end
|
|
60
100
|
|
|
@@ -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
|