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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/MANUAL.md +260 -1
  4. data/README.md +2 -0
  5. data/Rakefile +14 -1
  6. data/bin/matrix +76 -0
  7. data/bin/md2html +114 -0
  8. data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
  9. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  10. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +89 -0
  11. data/lib/geneva_drive/executor.rb +131 -10
  12. data/lib/geneva_drive/flow_control.rb +43 -3
  13. data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
  14. data/lib/geneva_drive/json_column.rb +72 -0
  15. data/lib/geneva_drive/migration_helpers.rb +27 -0
  16. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  17. data/lib/geneva_drive/signal.rb +245 -0
  18. data/lib/geneva_drive/signal_matcher.rb +60 -0
  19. data/lib/geneva_drive/step_collection.rb +31 -7
  20. data/lib/geneva_drive/step_definition.rb +91 -1
  21. data/lib/geneva_drive/step_execution.rb +39 -2
  22. data/lib/geneva_drive/test_helpers.rb +89 -2
  23. data/lib/geneva_drive/version.rb +1 -1
  24. data/lib/geneva_drive/workflow.rb +497 -24
  25. data/lib/geneva_drive.rb +24 -0
  26. data/test/dsl/signal_step_definition_test.rb +132 -0
  27. data/test/dummy/config/database.yml +40 -5
  28. data/test/dummy/config/initializers/geneva_drive.rb +46 -0
  29. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  30. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  31. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  32. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  33. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  34. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  35. data/test/dummy_install/db/schema.rb +75 -0
  36. data/test/dummy_install/log/test.log +1852 -0
  37. data/test/jobs/housekeeping_signals_test.rb +198 -0
  38. data/test/migration_helpers_test.rb +1 -1
  39. data/test/test_helper.rb +53 -13
  40. data/test/workflow/removed_step_test.rb +294 -0
  41. data/test/workflow/resume_at_test.rb +170 -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. 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, keyword_init: true)
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
- # Ensure GenevaDrive migrations exist and database is prepared.
11
- # The generator is the single source of truth for migrations.
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
- migrations = Dir.glob("#{dummy_root}/db/migrate/*geneva_drive*.rb")
17
- tables_exist = begin
18
- ActiveRecord::Base.connection.table_exists?("geneva_drive_workflows")
19
- rescue
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
- if migrations.empty? && !tables_exist
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
- elsif migrations.any? && !tables_exist
30
- puts "Running pending migrations..."
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
- system("bin/rails", "db:prepare") || abort("Failed to prepare database")
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