geneva_drive 0.6.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.
@@ -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
@@ -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
@@ -55,6 +55,8 @@ class ActiveSupport::TestCase
55
55
  setup do
56
56
  GenevaDrive::StepExecution.reset_metadata_column_cache!
57
57
  GenevaDrive::StepExecution.reset_resumable_columns_cache!
58
+ GenevaDrive::StepExecution.reset_signal_columns_cache!
59
+ GenevaDrive::Signal.reset_table_available_cache!
58
60
  GenevaDrive::Workflow.reset_metadata_column_cache!
59
61
  end
60
62