geneva_drive 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/MANUAL.md +449 -11
  4. data/README.md +5 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
  6. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
  9. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  12. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  13. data/lib/geneva_drive/exception_policy.rb +36 -10
  14. data/lib/geneva_drive/executor.rb +352 -26
  15. data/lib/geneva_drive/flow_control.rb +87 -7
  16. data/lib/geneva_drive/iterable_step.rb +199 -0
  17. data/lib/geneva_drive/job_options.rb +70 -0
  18. data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
  19. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  20. data/lib/geneva_drive/signal.rb +243 -0
  21. data/lib/geneva_drive/signal_matcher.rb +60 -0
  22. data/lib/geneva_drive/step_definition.rb +96 -2
  23. data/lib/geneva_drive/step_execution.rb +141 -2
  24. data/lib/geneva_drive/test_helpers.rb +212 -6
  25. data/lib/geneva_drive/version.rb +1 -1
  26. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  27. data/lib/geneva_drive/workflow.rb +559 -43
  28. data/lib/geneva_drive.rb +37 -0
  29. data/test/dsl/signal_step_definition_test.rb +132 -0
  30. data/test/dsl/step_definition_test.rb +70 -0
  31. data/test/jobs/housekeeping_job_test.rb +119 -0
  32. data/test/jobs/housekeeping_signals_test.rb +198 -0
  33. data/test/jobs/perform_step_job_test.rb +25 -2
  34. data/test/migration_helpers_test.rb +1 -1
  35. data/test/test_helper.rb +4 -0
  36. data/test/test_helper_test.rb +281 -0
  37. data/test/workflow/cursor_size_limit_test.rb +75 -0
  38. data/test/workflow/instrumentation_test.rb +28 -0
  39. data/test/workflow/resumable_step_integration_test.rb +341 -0
  40. data/test/workflow/resumable_step_test.rb +615 -0
  41. data/test/workflow/resumable_without_migration_test.rb +103 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. data/test/workflow/workflow_test.rb +72 -1
  48. metadata +25 -15
  49. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  50. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  51. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  52. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  53. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  54. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  55. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  56. data/test/dummy/db/schema.rb +0 -73
  57. data/test/dummy/log/development.log +0 -202
  58. data/test/dummy/log/test.log +0 -79471
  59. data/test/dummy/log/test.log.0 +0 -4
  60. data/test/dummy/tmp/local_secret.txt +0 -1
data/lib/geneva_drive.rb CHANGED
@@ -31,15 +31,23 @@ module GenevaDrive
31
31
  autoload :StepConfigurationError, "geneva_drive/flow_control"
32
32
  autoload :StepExecutionError, "geneva_drive/flow_control"
33
33
  autoload :StepNotDefinedError, "geneva_drive/flow_control"
34
+ autoload :CursorTooLargeError, "geneva_drive/flow_control"
35
+ autoload :SignalPayloadTooLargeError, "geneva_drive/flow_control"
36
+ autoload :WorkflowNotOngoing, "geneva_drive/flow_control"
34
37
  autoload :StepFailedError, "geneva_drive/flow_control"
35
38
  autoload :PreconditionError, "geneva_drive/flow_control"
36
39
  autoload :FlowControl, "geneva_drive/flow_control"
37
40
  autoload :StepDefinition, "geneva_drive/step_definition"
41
+ autoload :ResumableStepDefinition, "geneva_drive/resumable_step_definition"
38
42
  autoload :StepCollection, "geneva_drive/step_collection"
39
43
  autoload :Workflow, "geneva_drive/workflow"
40
44
  autoload :StepExecution, "geneva_drive/step_execution"
45
+ autoload :Signal, "geneva_drive/signal"
46
+ autoload :SignalMatcher, "geneva_drive/signal_matcher"
47
+ autoload :IterableStep, "geneva_drive/iterable_step"
41
48
  autoload :Executor, "geneva_drive/executor"
42
49
  autoload :ExceptionPolicy, "geneva_drive/exception_policy"
50
+ autoload :JobOptions, "geneva_drive/job_options"
43
51
  autoload :CombinedExceptionPolicy, "geneva_drive/combined_exception_policy"
44
52
  autoload :MigrationHelpers, "geneva_drive/migration_helpers"
45
53
  autoload :TestHelpers, "geneva_drive/test_helpers"
@@ -74,6 +82,32 @@ module GenevaDrive
74
82
  # @return [Symbol]
75
83
  attr_accessor :stuck_recovery_action
76
84
 
85
+ # Maximum size in bytes of a resumable step cursor once serialized to
86
+ # JSON. The cursor is meant to be a compact position marker (an id, a
87
+ # page number, a pagination token) - not a payload. It is rewritten on
88
+ # every checkpoint and copied to every successor execution, so an
89
+ # oversized cursor multiplies write load and row size. Exceeding the
90
+ # limit raises CursorTooLargeError from set!/checkpoint!.
91
+ # Set to nil to disable the check.
92
+ # @return [Integer, nil]
93
+ attr_accessor :max_cursor_size
94
+
95
+ # Maximum size in bytes of a signal payload once serialized to JSON.
96
+ # A payload describes an event - an order id, an amount, a decision -
97
+ # not the data the step is going to work on. Exceeding the limit raises
98
+ # SignalPayloadTooLargeError from Workflow#signal!, before anything is
99
+ # persisted. Set to nil to disable the check.
100
+ # @return [Integer, nil]
101
+ attr_accessor :max_signal_payload_size
102
+
103
+ # How long a step execution may sit in the "waiting" state before
104
+ # housekeeping counts it in the `geneva_drive.waiting_overdue` gauge.
105
+ # Waiting indefinitely is legitimate, so this is not a timeout - it is
106
+ # the floor that keeps a stalled rendezvous from being silent.
107
+ # Set to nil to disable the gauge.
108
+ # @return [ActiveSupport::Duration, nil]
109
+ attr_accessor :waiting_visibility_threshold
110
+
77
111
  # Whether to defer job enqueueing to after the database transaction commits.
78
112
  # When true (the default in non-test environments), jobs are enqueued inside
79
113
  # an `after_all_transactions_commit` callback to ensure the step execution
@@ -160,5 +194,8 @@ module GenevaDrive
160
194
  self.stuck_scheduled_threshold = 15.minutes
161
195
  self.housekeeping_batch_size = 1000
162
196
  self.stuck_recovery_action = :reattempt
197
+ self.max_cursor_size = 128 * 1024
198
+ self.max_signal_payload_size = 128 * 1024
199
+ self.waiting_visibility_threshold = 7.days
163
200
  self.enqueue_after_commit = !Rails.env.test?
164
201
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # wait: and wait_for: read alike and take disjoint types, so the step
6
+ # definition enforces the disjointness in both directions at class load.
7
+ class SignalStepDefinitionTest < ActiveSupport::TestCase
8
+ class NameSetMatcher
9
+ def initialize(*names)
10
+ @names = names.map(&:to_s)
11
+ end
12
+
13
+ def matches?(signal)
14
+ @names.include?(signal.name)
15
+ end
16
+ end
17
+
18
+ def build_step(**options)
19
+ GenevaDrive::StepDefinition.new(name: "capture", callable: proc {}, **options)
20
+ end
21
+
22
+ test "wait_for: with a symbol builds a name matcher" do
23
+ step_def = build_step(wait_for: :payment_confirmed)
24
+
25
+ assert step_def.waits_for_signal?
26
+ assert_equal "payment_confirmed", step_def.signal_matcher.name
27
+ assert_nil step_def.signal_matcher.condition
28
+ end
29
+
30
+ test "wait_for: with a string builds a name matcher" do
31
+ step_def = build_step(wait_for: "payment_confirmed")
32
+
33
+ assert step_def.waits_for_signal?
34
+ assert_equal "payment_confirmed", step_def.signal_matcher.name
35
+ end
36
+
37
+ test "wait_for: with a SignalMatcher keeps it as-is" do
38
+ matcher = GenevaDrive::SignalMatcher.new(:payment_confirmed) { |payload| payload[:order_id] == 1 }
39
+ step_def = build_step(wait_for: matcher)
40
+
41
+ assert_same matcher, step_def.signal_matcher
42
+ assert_equal "payment_confirmed", step_def.signal_matcher.name
43
+ assert step_def.signal_matcher.condition
44
+ end
45
+
46
+ test "a SignalMatcher needs a name" do
47
+ assert_raises(ArgumentError) { GenevaDrive::SignalMatcher.new(nil) }
48
+ assert_raises(ArgumentError) { GenevaDrive::SignalMatcher.new("") }
49
+ end
50
+
51
+ test "wait_for: with a custom matcher object delegates the whole predicate" do
52
+ matcher = NameSetMatcher.new(:a, :b)
53
+ step_def = build_step(wait_for: matcher)
54
+
55
+ assert step_def.waits_for_signal?
56
+ assert_same matcher, step_def.signal_matcher
57
+ end
58
+
59
+ test "steps without wait_for: do not wait for a signal" do
60
+ assert_not build_step.waits_for_signal?
61
+ assert_nil build_step.signal_matcher
62
+ end
63
+
64
+ test "wait_for: with a duration points at wait:" do
65
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: 2.days) }
66
+
67
+ assert_match(/wait_for: 2 days/, error.message)
68
+ assert_match(/to delay the step, use wait:/, error.message)
69
+ end
70
+
71
+ test "wait_for: with a number points at wait:" do
72
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: 30) }
73
+
74
+ assert_match(/to delay the step, use wait:/, error.message)
75
+ end
76
+
77
+ test "wait_for: with a time points at wait:" do
78
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: Time.current) }
79
+
80
+ assert_match(/to delay the step, use wait:/, error.message)
81
+ end
82
+
83
+ test "wait: with a symbol points at wait_for:" do
84
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: :payment_confirmed) }
85
+
86
+ assert_match(/wait: :payment_confirmed/, error.message)
87
+ assert_match(/to wait for a signal, use wait_for:/, error.message)
88
+ end
89
+
90
+ test "wait: with a string points at wait_for: instead of silently meaning zero seconds" do
91
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: "payment_confirmed") }
92
+
93
+ assert_match(/to wait for a signal, use wait_for:/, error.message)
94
+ end
95
+
96
+ test "wait: with a duration-shaped string is rejected too" do
97
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: "7200") }
98
+
99
+ assert_match(/to wait for a signal, use wait_for:/, error.message)
100
+ end
101
+
102
+ test "wait: with a matcher object points at wait_for:" do
103
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: NameSetMatcher.new(:a)) }
104
+
105
+ assert_match(/to wait for a signal, use wait_for:/, error.message)
106
+ end
107
+
108
+ test "wait: still accepts durations and numbers" do
109
+ assert_equal 2.days, build_step(wait: 2.days).wait
110
+ assert_equal 30, build_step(wait: 30).wait
111
+ end
112
+
113
+ test "wait_for: with an unusable value is rejected" do
114
+ error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: Object.new) }
115
+
116
+ assert_match(/must be a Symbol, String, GenevaDrive::SignalMatcher, or an object responding to #matches\?/, error.message)
117
+ end
118
+
119
+ test "wait: and wait_for: compose" do
120
+ step_def = build_step(wait: 2.days, wait_for: :payment_confirmed)
121
+
122
+ assert_equal 2.days, step_def.wait
123
+ assert step_def.waits_for_signal?
124
+ end
125
+
126
+ test "resumable steps accept wait_for:" do
127
+ step_def = GenevaDrive::ResumableStepDefinition.new(name: "iterate", wait_for: :go) { |iter| }
128
+
129
+ assert step_def.resumable?
130
+ assert step_def.waits_for_signal?
131
+ end
132
+ end
@@ -140,6 +140,76 @@ class StepDefinitionTest < ActiveSupport::TestCase
140
140
  assert_match(/non-negative/, error.message)
141
141
  end
142
142
 
143
+ test "rejects job_options that can't be coerced to a Hash" do
144
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
145
+ Class.new(GenevaDrive::Workflow) do
146
+ step(:invalid, job_options: :critical) {}
147
+ end
148
+ end
149
+
150
+ assert_match(/cannot coerce Symbol to a Hash/, error.message)
151
+ end
152
+
153
+ test "accepts anything responding to to_h as job_options" do
154
+ struct = Struct.new(:queue, :priority).new(:critical, 5)
155
+ workflow_class = Class.new(GenevaDrive::Workflow) do
156
+ step(:from_struct, job_options: struct) {}
157
+ end
158
+
159
+ assert_equal({queue: :critical, priority: 5}, workflow_class.step_definitions.first.job_options)
160
+ end
161
+
162
+ test "rejects unknown job_options keys" do
163
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
164
+ Class.new(GenevaDrive::Workflow) do
165
+ step(:typo, job_options: {piority: 10}) {}
166
+ end
167
+ end
168
+
169
+ assert_match(/unknown job_options key/, error.message)
170
+ assert_match(/piority/, error.message)
171
+ end
172
+
173
+ test "rejects non-integer priority" do
174
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
175
+ Class.new(GenevaDrive::Workflow) do
176
+ step(:bad_pri, job_options: {priority: "high"}) {}
177
+ end
178
+ end
179
+
180
+ assert_match(/job_options\[:priority\]/, error.message)
181
+ end
182
+
183
+ test "rejects non-symbol/string queue" do
184
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
185
+ Class.new(GenevaDrive::Workflow) do
186
+ step(:bad_queue, job_options: {queue: 42}) {}
187
+ end
188
+ end
189
+
190
+ assert_match(/job_options\[:queue\]/, error.message)
191
+ end
192
+
193
+ test "accepts valid queue and priority" do
194
+ workflow_class = Class.new(GenevaDrive::Workflow) do
195
+ step(:ok, job_options: {queue: :critical, priority: -1, wait: 5}) {}
196
+ end
197
+
198
+ assert_equal({queue: :critical, priority: -1, wait: 5}, workflow_class.step_definitions.first.job_options)
199
+ end
200
+
201
+ test "copies, symbolizes, and freezes job options" do
202
+ job_options = {"queue" => :critical}
203
+ workflow_class = Class.new(GenevaDrive::Workflow) do
204
+ step(:urgent, job_options: job_options) {}
205
+ end
206
+ job_options["queue"] = :default
207
+
208
+ options = workflow_class.step_definitions.first.job_options
209
+ assert_equal({queue: :critical}, options)
210
+ assert_predicate options, :frozen?
211
+ end
212
+
143
213
  # Tests for on_exception validation
144
214
  test "accepts valid exception handlers" do
145
215
  workflow_class = Class.new(GenevaDrive::Workflow) do
@@ -5,6 +5,16 @@ require "test_helper"
5
5
  class HousekeepingJobTest < ActiveSupport::TestCase
6
6
  include ActiveJob::TestHelper
7
7
 
8
+ # Captures Measurometer gauge/metric calls for assertions.
9
+ class GaugeRecorder
10
+ attr_reader :gauges
11
+ def initialize = @gauges = []
12
+ def set_gauge(name, value, tags = {}) = @gauges << {name: name.to_s, value: value, tags: tags}
13
+ def instrument(*, &blk) = blk&.call
14
+ def add_distribution_value(*) = nil
15
+ def increment_counter(*) = nil
16
+ end
17
+
8
18
  class SimpleWorkflow < GenevaDrive::Workflow
9
19
  step :step_one do
10
20
  # Step body
@@ -41,6 +51,8 @@ class HousekeepingJobTest < ActiveSupport::TestCase
41
51
  @original_stuck_scheduled = GenevaDrive.stuck_scheduled_threshold
42
52
  @original_recovery_action = GenevaDrive.stuck_recovery_action
43
53
  @original_batch_size = GenevaDrive.housekeeping_batch_size
54
+ @recorder = GaugeRecorder.new
55
+ Measurometer.drivers << @recorder
44
56
  end
45
57
 
46
58
  teardown do
@@ -49,6 +61,15 @@ class HousekeepingJobTest < ActiveSupport::TestCase
49
61
  GenevaDrive.stuck_scheduled_threshold = @original_stuck_scheduled
50
62
  GenevaDrive.stuck_recovery_action = @original_recovery_action
51
63
  GenevaDrive.housekeeping_batch_size = @original_batch_size
64
+ Measurometer.drivers.delete(@recorder)
65
+ end
66
+
67
+ # Returns the paused_ratio gauge value recorded for a given workflow class.
68
+ def paused_ratio_for(workflow_class)
69
+ gauge = @recorder.gauges.find do |g|
70
+ g[:name] == "geneva_drive.paused_ratio" && g[:tags][:workflow] == workflow_class.name
71
+ end
72
+ gauge&.fetch(:value)
52
73
  end
53
74
 
54
75
  # Cleanup tests
@@ -314,6 +335,59 @@ class HousekeepingJobTest < ActiveSupport::TestCase
314
335
  assert_equal "scheduled", step_execution.state
315
336
  end
316
337
 
338
+ test "recover_stuck_in_progress! orders by started_at ascending and terminates" do
339
+ GenevaDrive.stuck_in_progress_threshold = 1.hour
340
+ GenevaDrive.stuck_recovery_action = :cancel
341
+ GenevaDrive.housekeeping_batch_size = 2
342
+
343
+ # 3 stuck (old) + 2 fresh (within threshold, must not be touched)
344
+ stuck = 3.times.map do |i|
345
+ wf = SimpleWorkflow.create!(hero: @user, allow_multiple: true)
346
+ wf.update!(state: "performing")
347
+ se = wf.step_executions.first
348
+ se.update!(state: "in_progress", started_at: (3 + i).hours.ago)
349
+ se
350
+ end
351
+
352
+ fresh = 2.times.map do
353
+ wf = SimpleWorkflow.create!(hero: @user, allow_multiple: true)
354
+ wf.update!(state: "performing")
355
+ se = wf.step_executions.first
356
+ se.update!(state: "in_progress", started_at: 10.minutes.ago)
357
+ se
358
+ end
359
+
360
+ result = GenevaDrive::HousekeepingJob.perform_now
361
+
362
+ assert_equal 3, result[:stuck_in_progress_recovered]
363
+ stuck.each { |se| assert_equal "canceled", se.reload.state }
364
+ fresh.each { |se| assert_equal "in_progress", se.reload.state }
365
+ end
366
+
367
+ test "recover_stuck_scheduled! orders by scheduled_for ascending and terminates" do
368
+ GenevaDrive.stuck_scheduled_threshold = 1.hour
369
+ GenevaDrive.stuck_recovery_action = :cancel
370
+ GenevaDrive.housekeeping_batch_size = 2
371
+
372
+ stuck = 3.times.map do |i|
373
+ wf = SimpleWorkflow.create!(hero: @user, allow_multiple: true)
374
+ se = wf.step_executions.first
375
+ se.update!(scheduled_for: (2 + i).hours.ago)
376
+ se
377
+ end
378
+
379
+ fresh = 2.times.map do
380
+ wf = SimpleWorkflow.create!(hero: @user, allow_multiple: true)
381
+ wf.step_executions.first
382
+ end
383
+
384
+ result = GenevaDrive::HousekeepingJob.perform_now
385
+
386
+ assert_equal 3, result[:stuck_scheduled_recovered]
387
+ stuck.each { |se| assert_equal "canceled", se.reload.state }
388
+ fresh.each { |se| assert_equal "scheduled", se.reload.state }
389
+ end
390
+
317
391
  # Combined tests
318
392
 
319
393
  test "performs both cleanup and recovery in one run" do
@@ -373,6 +447,51 @@ class HousekeepingJobTest < ActiveSupport::TestCase
373
447
  assert result.key?(:stuck_scheduled_recovered)
374
448
  end
375
449
 
450
+ # Paused ratio gauge tests
451
+
452
+ test "emits per-class paused_ratio gauges for mixed states" do
453
+ # SimpleWorkflow: 1 paused of 4 total => 0.25
454
+ 4.times do |i|
455
+ workflow = SimpleWorkflow.create!(hero: @user, allow_multiple: true)
456
+ workflow.transition_to!("paused") if i.zero?
457
+ end
458
+
459
+ # ThreeStepWorkflow: 2 paused of 2 total => 1.0
460
+ 2.times do
461
+ workflow = ThreeStepWorkflow.create!(hero: @user, allow_multiple: true)
462
+ workflow.transition_to!("paused")
463
+ end
464
+
465
+ GenevaDrive::HousekeepingJob.perform_now
466
+
467
+ assert_in_delta 0.25, paused_ratio_for(SimpleWorkflow), 1e-9
468
+ assert_in_delta 1.0, paused_ratio_for(ThreeStepWorkflow), 1e-9
469
+ end
470
+
471
+ test "reports 0.0 paused_ratio for a class with no paused workflows" do
472
+ 3.times do
473
+ SimpleWorkflow.create!(hero: @user, allow_multiple: true)
474
+ end
475
+
476
+ GenevaDrive::HousekeepingJob.perform_now
477
+
478
+ assert_equal 0.0, paused_ratio_for(SimpleWorkflow)
479
+ end
480
+
481
+ test "denominator is total population, not ongoing-only" do
482
+ # Only finished/canceled workflows exist for this class - no paused, no ongoing.
483
+ # The ratio must still be reported as 0.0 (paused ÷ total), not skipped.
484
+ finished = SimpleWorkflow.create!(hero: @user, allow_multiple: true)
485
+ finished.transition_to!("finished")
486
+
487
+ canceled = SimpleWorkflow.create!(hero: @user, allow_multiple: true)
488
+ canceled.transition_to!("canceled")
489
+
490
+ GenevaDrive::HousekeepingJob.perform_now
491
+
492
+ assert_equal 0.0, paused_ratio_for(SimpleWorkflow)
493
+ end
494
+
376
495
  test "processes all stuck in_progress executions in multiple batches" do
377
496
  GenevaDrive.stuck_in_progress_threshold = 1.hour
378
497
  GenevaDrive.stuck_recovery_action = :cancel
@@ -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
@@ -11,6 +11,11 @@ class PerformStepJobTest < ActiveSupport::TestCase
11
11
  end
12
12
  end
13
13
 
14
+ class JobOptionsWorkflow < GenevaDrive::Workflow
15
+ set_step_job_options queue: :default, priority: 10
16
+ step(:test_step, job_options: {queue: :critical, priority: -1}) {}
17
+ end
18
+
14
19
  setup do
15
20
  @user = create_user
16
21
  Thread.current[:step_executed] = nil
@@ -102,7 +107,7 @@ class PerformStepJobTest < ActiveSupport::TestCase
102
107
 
103
108
  test "job passes job options from workflow" do
104
109
  workflow_class = Class.new(GenevaDrive::Workflow) do
105
- set_step_job_options queue: :high_priority
110
+ set_step_job_options queue: :high_priority, priority: 10
106
111
 
107
112
  step :test_step do
108
113
  # Step body
@@ -119,10 +124,28 @@ class PerformStepJobTest < ActiveSupport::TestCase
119
124
 
120
125
  assert_enqueued_with(
121
126
  job: GenevaDrive::PerformStepJob,
122
- queue: "high_priority"
127
+ queue: "high_priority",
128
+ priority: 10
123
129
  )
124
130
  ensure
125
131
  Object.send(:remove_const, :TestJobOptionsWorkflow)
126
132
  end
127
133
  end
134
+
135
+ test "step job options override workflow options when scheduled and resumed" do
136
+ clear_enqueued_jobs
137
+ workflow = JobOptionsWorkflow.new(hero: @user)
138
+ workflow.step_job_options = {queue: :instance, priority: 5}
139
+ workflow.save!
140
+
141
+ assert_equal("critical", enqueued_jobs.last[:queue])
142
+ assert_equal(-1, enqueued_jobs.last[:priority])
143
+
144
+ clear_enqueued_jobs
145
+ workflow.pause!
146
+ workflow.resume!
147
+
148
+ assert_equal("critical", enqueued_jobs.last[:queue])
149
+ assert_equal(-1, enqueued_jobs.last[:priority])
150
+ end
128
151
  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 = [])