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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -1
- data/MANUAL.md +449 -11
- data/README.md +5 -1
- data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
- data/lib/geneva_drive/combined_exception_policy.rb +1 -1
- data/lib/geneva_drive/exception_policy.rb +36 -10
- data/lib/geneva_drive/executor.rb +352 -26
- data/lib/geneva_drive/flow_control.rb +87 -7
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +96 -2
- data/lib/geneva_drive/step_execution.rb +141 -2
- data/lib/geneva_drive/test_helpers.rb +212 -6
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +559 -43
- data/lib/geneva_drive.rb +37 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dsl/step_definition_test.rb +70 -0
- data/test/jobs/housekeeping_job_test.rb +119 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +4 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -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
- data/test/workflow/workflow_test.rb +72 -1
- metadata +25 -15
- data/test/dummy/config/initializers/geneva_drive.rb +0 -44
- data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
- data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
- data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
- data/test/dummy/db/schema.rb +0 -73
- data/test/dummy/log/development.log +0 -202
- data/test/dummy/log/test.log +0 -79471
- data/test/dummy/log/test.log.0 +0 -4
- 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
|
|
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 = [])
|