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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -1
- data/MANUAL.md +176 -0
- data/README.md +2 -0
- data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
- data/lib/geneva_drive/executor.rb +113 -10
- data/lib/geneva_drive/flow_control.rb +43 -3
- data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +82 -1
- data/lib/geneva_drive/step_execution.rb +36 -0
- data/lib/geneva_drive/test_helpers.rb +89 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +327 -22
- data/lib/geneva_drive.rb +22 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +2 -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 +11 -1
|
@@ -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
|
|
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
|
|