stepper_motor 0.1.17 → 0.1.19
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 +12 -0
- data/lib/stepper_motor/conditional.rb +42 -0
- data/lib/stepper_motor/journey/flow_control.rb +1 -2
- data/lib/stepper_motor/journey.rb +110 -26
- data/lib/stepper_motor/step.rb +3 -19
- data/lib/stepper_motor/test_helper.rb +28 -3
- data/lib/stepper_motor/version.rb +1 -1
- data/lib/stepper_motor.rb +1 -0
- data/manual/MANUAL.md +182 -13
- data/rbi/stepper_motor.rbi +59 -12
- data/sig/stepper_motor.rbs +49 -7
- data/test/stepper_motor/journey/cancel_if_test.rb +293 -0
- data/test/stepper_motor/journey/if_condition_test.rb +1 -1
- data/test/stepper_motor/journey/step_definition_test.rb +1 -1
- data/test/stepper_motor/journey/step_ordering_test.rb +267 -0
- data/test/stepper_motor/test_helper_test.rb +59 -0
- data/test/stepper_motor/wrap_conditional_test.rb +190 -0
- data/test/test_helper.rb +3 -2
- metadata +10 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b27839f0ee5621c5da6e694e2ee3e790f9f4b96383d05bf5ebbfc0a7ddf99928
|
4
|
+
data.tar.gz: 60bf0af4da25cf6f6c7ef2d835bcbd717ec662215da2bf14aea50da6949dc316
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 275d2ba0cc4ff6710f3d87189853f9836a767bfea6e56ac6a635135f7aa8e8043038f3cff1b28fd3b548ea06dd65392b37adb4f6d5aa4a310e7e7d95f53cb444
|
7
|
+
data.tar.gz: dd3f67919bf6daca240b6eb32d4c83ca720fc8ae9f2b7e9c571a7913e2e640840c7f1dc4c497f5114f14694b0a413ccdba758330c418844a3adc77b5312289ce
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,18 @@
|
|
2
2
|
|
3
3
|
## [Unreleased]
|
4
4
|
|
5
|
+
## [0.1.19] - 2025-06-25
|
6
|
+
|
7
|
+
- With `speedrun_journey`, allow configuring the maximum number of steps - for testing journeys
|
8
|
+
that iterate or reattempt a lot.
|
9
|
+
- With `speedrun_journey`, use time travel by default
|
10
|
+
- Allow steps to be placed relative to other steps using `before_step:` and `after_step:` keyword arguments
|
11
|
+
- Use just-in-time index lookup instead of `Step#seq`
|
12
|
+
|
13
|
+
## [0.1.18] - 2025-06-20
|
14
|
+
|
15
|
+
- Add `cancel_if` at Journey class level for blanket journey cancellation conditions. This makes it very easy to abort journeys across multiple steps.
|
16
|
+
|
5
17
|
## [0.1.17] - 2025-06-20
|
6
18
|
|
7
19
|
- Mandate Ruby 3.1+ because we are using 3.x syntax and there are shenanigans with sqlite3 gem version and locking. Compatibility with 2.7 can be assured but is too much hassle at the moment.
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StepperMotor
|
4
|
+
# A wrapper for conditional logic that can be evaluated against an object.
|
5
|
+
# This class encapsulates different types of conditions (booleans, symbols, callables, arrays)
|
6
|
+
# and provides a unified interface for checking if a condition is satisfied by a given object.
|
7
|
+
# It handles negation and ensures proper context when evaluating conditions.
|
8
|
+
class Conditional
|
9
|
+
def initialize(condition, negate: false)
|
10
|
+
@condition = condition
|
11
|
+
@negate = negate
|
12
|
+
validate_condition
|
13
|
+
end
|
14
|
+
|
15
|
+
def satisfied_by?(object)
|
16
|
+
result = case @condition
|
17
|
+
when Array
|
18
|
+
@condition.all? { |c| Conditional.new(c).satisfied_by?(object) }
|
19
|
+
when Symbol
|
20
|
+
!!object.send(@condition)
|
21
|
+
when Conditional
|
22
|
+
@condition.satisfied_by?(object)
|
23
|
+
else
|
24
|
+
if @condition.respond_to?(:call)
|
25
|
+
!!object.instance_exec(&@condition)
|
26
|
+
else
|
27
|
+
!!@condition
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
@negate ? !result : result
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def validate_condition
|
37
|
+
unless [true, false, nil].include?(@condition) || @condition.is_a?(Symbol) || @condition.is_a?(Array) || @condition.is_a?(Conditional) || @condition.respond_to?(:call)
|
38
|
+
raise ArgumentError, "condition must be a boolean, nil, Symbol, Array, Conditional or a callable object, but was a #{@condition.inspect}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -58,8 +58,7 @@ module StepperMotor::Journey::FlowControl
|
|
58
58
|
return
|
59
59
|
end
|
60
60
|
|
61
|
-
|
62
|
-
next_step_definition = step_definitions[current_step_seq + 1]
|
61
|
+
next_step_definition = self.class.step_definitions_following(current_step_definition).first
|
63
62
|
|
64
63
|
if next_step_definition
|
65
64
|
# There are more steps after this one - schedule the next step
|
@@ -47,6 +47,9 @@ module StepperMotor
|
|
47
47
|
# @return [Array<StepperMotor::Step>] the step definitions defined so far
|
48
48
|
class_attribute :step_definitions, default: []
|
49
49
|
|
50
|
+
# @return [Array<StepperMotor::Conditional>] the cancel_if conditions defined for this journey class
|
51
|
+
class_attribute :cancel_if_conditions, default: []
|
52
|
+
|
50
53
|
belongs_to :hero, polymorphic: true, optional: true
|
51
54
|
|
52
55
|
STATES = %w[ready paused performing canceled finished]
|
@@ -76,7 +79,13 @@ module StepperMotor
|
|
76
79
|
# as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to
|
77
80
|
# be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at`
|
78
81
|
# attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition.
|
79
|
-
# Mutually exclusive with `wait
|
82
|
+
# Mutually exclusive with `wait:`.
|
83
|
+
# @param before_step[String,Symbol,nil] the name of the step before which this step should be inserted.
|
84
|
+
# This allows you to control the order of steps by inserting a step before a specific existing step.
|
85
|
+
# The step name can be provided as a string or symbol. Mutually exclusive with `after_step:`.
|
86
|
+
# @param after_step[String,Symbol,nil] the name of the step after which this step should be inserted.
|
87
|
+
# This allows you to control the order of steps by inserting a step after a specific existing step.
|
88
|
+
# The step name can be provided as a string or symbol. Mutually exclusive with `before_step:`.
|
80
89
|
# @param on_exception[Symbol] See {StepperMotor::Step#on_exception}
|
81
90
|
# @param skip_if[TrueClass,FalseClass,Symbol,Proc] condition to check before performing the step. If a symbol is provided,
|
82
91
|
# it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
|
@@ -86,32 +95,41 @@ module StepperMotor
|
|
86
95
|
# The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
|
87
96
|
# @param additional_step_definition_options[Hash] Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
88
97
|
# @return [StepperMotor::Step] the step definition that has been created
|
89
|
-
def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk)
|
98
|
+
def self.step(name = nil, wait: nil, after: nil, before_step: nil, after_step: nil, **additional_step_definition_options, &blk)
|
90
99
|
# Handle the if: alias for backward compatibility
|
91
100
|
if additional_step_definition_options.key?(:if) && additional_step_definition_options.key?(:skip_if)
|
92
101
|
raise StepConfigurationError, "Either skip_if: or if: can be specified, but not both"
|
93
102
|
end
|
94
103
|
if additional_step_definition_options.key?(:if)
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
104
|
+
# Convert if: to skip_if:
|
105
|
+
additional_step_definition_options[:skip_if] = StepperMotor::Conditional.new(additional_step_definition_options.delete(:if), negate: true)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Validate before_step and after_step parameters
|
109
|
+
if before_step && after_step
|
110
|
+
raise StepConfigurationError, "Either before_step: or after_step: can be specified, but not both"
|
111
|
+
end
|
112
|
+
|
113
|
+
# Validate that referenced steps exist
|
114
|
+
if before_step
|
115
|
+
referenced_step = lookup_step_definition(before_step)
|
116
|
+
unless referenced_step
|
117
|
+
raise StepConfigurationError, "Step named #{before_step.inspect} not found for before_step: parameter"
|
107
118
|
end
|
108
119
|
end
|
109
120
|
|
110
|
-
|
121
|
+
if after_step
|
122
|
+
referenced_step = lookup_step_definition(after_step)
|
123
|
+
unless referenced_step
|
124
|
+
raise StepConfigurationError, "Step named #{after_step.inspect} not found for after_step: parameter"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
wait = if wait && after&.respond_to?(:to_f)
|
111
129
|
raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
|
112
|
-
elsif !wait && !after
|
130
|
+
elsif !wait && (!after || !after.respond_to?(:to_f))
|
113
131
|
0
|
114
|
-
elsif after
|
132
|
+
elsif after&.respond_to?(:to_f)
|
115
133
|
accumulated = step_definitions.map(&:wait).sum
|
116
134
|
after - accumulated
|
117
135
|
else
|
@@ -135,12 +153,27 @@ module StepperMotor
|
|
135
153
|
raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
|
136
154
|
|
137
155
|
# Create the step definition
|
138
|
-
StepperMotor::Step.new(name: name, wait: wait,
|
139
|
-
|
140
|
-
|
141
|
-
|
156
|
+
step_definition = StepperMotor::Step.new(name: name, wait: wait, **additional_step_definition_options, &blk)
|
157
|
+
|
158
|
+
# Determine insertion position based on before_step or after_step parameters
|
159
|
+
if before_step
|
160
|
+
target_step = lookup_step_definition(before_step)
|
161
|
+
target_index = step_definitions.index(target_step)
|
162
|
+
new_step_definitions = step_definitions.dup
|
163
|
+
new_step_definitions.insert(target_index, step_definition)
|
164
|
+
self.step_definitions = new_step_definitions
|
165
|
+
elsif after_step
|
166
|
+
target_step = lookup_step_definition(after_step)
|
167
|
+
target_index = step_definitions.index(target_step)
|
168
|
+
new_step_definitions = step_definitions.dup
|
169
|
+
new_step_definitions.insert(target_index + 1, step_definition)
|
170
|
+
self.step_definitions = new_step_definitions
|
171
|
+
else
|
172
|
+
# Default behavior: append to the end
|
142
173
|
self.step_definitions = step_definitions + [step_definition]
|
143
174
|
end
|
175
|
+
|
176
|
+
step_definition
|
144
177
|
end
|
145
178
|
|
146
179
|
# Returns the `Step` object for a named step. This is used when performing a step, but can also
|
@@ -152,6 +185,16 @@ module StepperMotor
|
|
152
185
|
step_definitions.find { |d| d.name.to_s == by_step_name.to_s }
|
153
186
|
end
|
154
187
|
|
188
|
+
# Returns all step definitions that follow the given step in the journey
|
189
|
+
#
|
190
|
+
# @param step_definition[StepperMotor::Step] the step to find the following steps for
|
191
|
+
# @return [Array<StepperMotor::Step>] the following steps, or empty array if this is the last step
|
192
|
+
def self.step_definitions_following(step_definition)
|
193
|
+
current_index = step_definitions.index(step_definition)
|
194
|
+
return [] unless current_index
|
195
|
+
step_definitions[(current_index + 1)..]
|
196
|
+
end
|
197
|
+
|
155
198
|
# Alias for the class attribute, for brevity
|
156
199
|
#
|
157
200
|
# @see Journey.step_definitions
|
@@ -166,6 +209,41 @@ module StepperMotor
|
|
166
209
|
self.class.lookup_step_definition(by_step_name)
|
167
210
|
end
|
168
211
|
|
212
|
+
# Defines a condition that will cause the journey to cancel if satisfied.
|
213
|
+
# This works like Rails' `etag` - it's class-inheritable and appendable.
|
214
|
+
# Multiple `cancel_if` calls can be made to a Journey definition.
|
215
|
+
# All conditions are evaluated after setting the state to `performing`.
|
216
|
+
# If any condition is satisfied, the journey will cancel.
|
217
|
+
#
|
218
|
+
# @param condition_arg [TrueClass, FalseClass, Symbol, Proc, Array, Conditional] the condition to check
|
219
|
+
# @param condition_blk [Proc] a block that will be evaluated as a condition
|
220
|
+
# @return [void]
|
221
|
+
def self.cancel_if(condition_arg = :__no_argument_given__, &condition_blk)
|
222
|
+
# Check if neither argument nor block is provided
|
223
|
+
if condition_arg == :__no_argument_given__ && !condition_blk
|
224
|
+
raise ArgumentError, "cancel_if requires either a condition argument or a block"
|
225
|
+
end
|
226
|
+
|
227
|
+
# Check if both argument and block are provided
|
228
|
+
if condition_arg != :__no_argument_given__ && condition_blk
|
229
|
+
raise ArgumentError, "cancel_if accepts either a condition argument or a block, but not both"
|
230
|
+
end
|
231
|
+
|
232
|
+
# Select the condition: positional argument takes precedence if not sentinel
|
233
|
+
condition = if condition_arg != :__no_argument_given__
|
234
|
+
condition_arg
|
235
|
+
else
|
236
|
+
condition_blk
|
237
|
+
end
|
238
|
+
|
239
|
+
conditional = StepperMotor::Conditional.new(condition)
|
240
|
+
|
241
|
+
# As per Rails docs: you need to be aware when using class_attribute with mutable structures
|
242
|
+
# as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
|
243
|
+
# See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
|
244
|
+
self.cancel_if_conditions = cancel_if_conditions + [conditional]
|
245
|
+
end
|
246
|
+
|
169
247
|
# Performs the next step in the journey. Will check whether any other process has performed the step already
|
170
248
|
# and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
|
171
249
|
#
|
@@ -188,6 +266,14 @@ module StepperMotor
|
|
188
266
|
performing!
|
189
267
|
after_locking_for_step(next_step_name)
|
190
268
|
end
|
269
|
+
|
270
|
+
# Check cancel_if conditions after setting state to performing
|
271
|
+
if cancel_if_conditions.any? { |conditional| conditional.satisfied_by?(self) }
|
272
|
+
logger.info { "cancel_if condition satisfied, canceling journey" }
|
273
|
+
cancel!
|
274
|
+
return
|
275
|
+
end
|
276
|
+
|
191
277
|
current_step_name = next_step_name
|
192
278
|
|
193
279
|
if current_step_name
|
@@ -257,8 +343,7 @@ module StepperMotor
|
|
257
343
|
ready!
|
258
344
|
elsif @skip_current_step
|
259
345
|
# The step asked to be skipped
|
260
|
-
|
261
|
-
next_step_definition = step_definitions[current_step_seq + 1]
|
346
|
+
next_step_definition = self.class.step_definitions_following(@current_step_definition).first
|
262
347
|
|
263
348
|
if next_step_definition
|
264
349
|
# There are more steps after this one - schedule the next step
|
@@ -274,7 +359,7 @@ module StepperMotor
|
|
274
359
|
elsif finished?
|
275
360
|
logger.info { "was marked finished inside the step" }
|
276
361
|
update!(previous_step_name: current_step_name, next_step_name: nil)
|
277
|
-
elsif (next_step_definition =
|
362
|
+
elsif (next_step_definition = self.class.step_definitions_following(@current_step_definition).first)
|
278
363
|
logger.info { "will continue to #{next_step_definition.name}" }
|
279
364
|
set_next_step_and_enqueue(next_step_definition)
|
280
365
|
ready!
|
@@ -302,8 +387,7 @@ module StepperMotor
|
|
302
387
|
|
303
388
|
# @return [ActiveSupport::Duration]
|
304
389
|
def time_remaining_until_final_step
|
305
|
-
|
306
|
-
subsequent_steps = step_definitions.select { |definition| definition.seq > current_step_seq }
|
390
|
+
subsequent_steps = @current_step_definition ? self.class.step_definitions_following(@current_step_definition) : step_definitions
|
307
391
|
seconds_remaining = subsequent_steps.map { |definition| definition.wait.to_f }.sum
|
308
392
|
seconds_remaining.seconds # Convert to ActiveSupport::Duration
|
309
393
|
end
|
data/lib/stepper_motor/step.rb
CHANGED
@@ -13,9 +13,6 @@ class StepperMotor::Step
|
|
13
13
|
# @return [Numeric,ActiveSupport::Duration] how long to wait before performing the step
|
14
14
|
attr_reader :wait
|
15
15
|
|
16
|
-
# @private
|
17
|
-
attr_reader :seq
|
18
|
-
|
19
16
|
# Creates a new step definition
|
20
17
|
#
|
21
18
|
# @param name[String,Symbol] the name of the Step
|
@@ -30,18 +27,12 @@ class StepperMotor::Step
|
|
30
27
|
# it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided,
|
31
28
|
# it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
|
32
29
|
# The step will only be performed if the condition returns a truthy value.
|
33
|
-
def initialize(name:,
|
30
|
+
def initialize(name:, on_exception: :pause!, wait: 0, skip_if: false, &step_block)
|
34
31
|
@step_block = step_block
|
35
32
|
@name = name.to_s
|
36
33
|
@wait = wait
|
37
|
-
@seq = seq
|
38
34
|
@on_exception = on_exception # TODO: Validate?
|
39
|
-
@skip_if_condition = skip_if
|
40
|
-
|
41
|
-
# Validate the skip_if condition
|
42
|
-
if ![true, false, nil].include?(@skip_if_condition) && !@skip_if_condition.is_a?(Symbol) && !@skip_if_condition.respond_to?(:call)
|
43
|
-
raise ArgumentError, "skip_if: condition must be a boolean, nil, Symbol or a callable object, but was a #{@skip_if_condition.inspect}"
|
44
|
-
end
|
35
|
+
@skip_if_condition = StepperMotor::Conditional.new(skip_if)
|
45
36
|
end
|
46
37
|
|
47
38
|
# Checks if the step should be skipped based on the skip_if condition
|
@@ -49,14 +40,7 @@ class StepperMotor::Step
|
|
49
40
|
# @param journey[StepperMotor::Journey] the journey to check the condition for
|
50
41
|
# @return [Boolean] true if the step should be skipped, false otherwise
|
51
42
|
def should_skip?(journey)
|
52
|
-
|
53
|
-
when true, false, nil
|
54
|
-
!!@skip_if_condition
|
55
|
-
when Symbol
|
56
|
-
journey.send(@skip_if_condition) # Allow private methods
|
57
|
-
else
|
58
|
-
journey.instance_exec(&@skip_if_condition)
|
59
|
-
end
|
43
|
+
@skip_if_condition.satisfied_by?(journey)
|
60
44
|
end
|
61
45
|
|
62
46
|
# Performs the step on the passed Journey, wrapping the step with the required context.
|
@@ -8,14 +8,39 @@ module StepperMotor::TestHelper
|
|
8
8
|
# has neither canceled nor finished, an exception will be raised.
|
9
9
|
#
|
10
10
|
# @param journey[StepperMotor::Journey] the journey to speedrun
|
11
|
+
# @param time_travel[Boolean] whether to use ActiveSupport time travel (default: true)
|
12
|
+
# Note: When time_travel is true, this method will permanently travel time forward
|
13
|
+
# and will not reset it back to the original time when the method exits.
|
14
|
+
# @param maximum_steps[Symbol,Integer] how many steps can we take until we assume the
|
15
|
+
# journey has hung and fail the test. Default value is :reasonable, which is 10x the
|
16
|
+
# number of steps. :unlimited allows "a ton", but can make your test hang if your logic
|
17
|
+
# lets a step reattempt indefinitely
|
11
18
|
# @return void
|
12
|
-
def speedrun_journey(journey)
|
19
|
+
def speedrun_journey(journey, time_travel: true, maximum_steps: :reasonable)
|
13
20
|
journey.save!
|
14
|
-
n_steps =
|
21
|
+
n_steps = case maximum_steps
|
22
|
+
when :reasonable
|
23
|
+
journey.step_definitions.length * 10
|
24
|
+
when :unlimited
|
25
|
+
0xFFFF
|
26
|
+
when Integer
|
27
|
+
maximum_steps
|
28
|
+
else
|
29
|
+
raise ArgumentError, "maximum_steps may be :reasonable, :unlimited or an Integer, was #{maximum_steps.inspect}"
|
30
|
+
end
|
31
|
+
|
15
32
|
n_steps.times do
|
16
33
|
journey.reload
|
17
34
|
break if journey.canceled? || journey.finished?
|
18
|
-
|
35
|
+
|
36
|
+
if time_travel
|
37
|
+
# Use time travel to move slightly ahead of the time when the next step should be performed
|
38
|
+
next_step_time = journey.next_step_to_be_performed_at
|
39
|
+
travel_to(next_step_time + 1.second)
|
40
|
+
else
|
41
|
+
# Update the journey's timestamp to bypass waiting periods
|
42
|
+
journey.update(next_step_to_be_performed_at: Time.current)
|
43
|
+
end
|
19
44
|
journey.perform_next_step!
|
20
45
|
end
|
21
46
|
journey.reload
|
data/lib/stepper_motor.rb
CHANGED
@@ -13,6 +13,7 @@ module StepperMotor
|
|
13
13
|
|
14
14
|
autoload :Journey, File.dirname(__FILE__) + "/stepper_motor/journey.rb"
|
15
15
|
autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
|
16
|
+
autoload :Conditional, File.dirname(__FILE__) + "/stepper_motor/conditional.rb"
|
16
17
|
|
17
18
|
autoload :BaseJob, File.dirname(__FILE__) + "/stepper_motor/base_job.rb"
|
18
19
|
autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
|
data/manual/MANUAL.md
CHANGED
@@ -27,7 +27,9 @@ So, stepper_motor aims to give you "just enough of Temporal-like functionality"
|
|
27
27
|
|
28
28
|
## A brief introduction to stepper_motor
|
29
29
|
|
30
|
-
stepper_motor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels.
|
30
|
+
stepper_motor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels. But also - a `Journey` is an `ActiveRecord` model, with all the persistence methods you already know and use. Every `Journey` has a speficic identity - its primary key, just like any other database row, and carries state information.
|
31
|
+
|
32
|
+
A `Journey` changes states during asynchronous invocations, and can lay dormant for months until the time comes to take the next step.
|
31
33
|
|
32
34
|
Steps are defined inside the Journey subclasses as blocks, and they run in the context of that subclass' instance. The following constraints apply:
|
33
35
|
|
@@ -40,7 +42,7 @@ The `step` blocks get executed in the context of the `Journey` model instance. T
|
|
40
42
|
|
41
43
|
The steps are performed asynchronously, via ActiveJob. When a Journey is created, it gets scheduled for its initial step. The job then gets picked up by the ActiveJob queue worker (whichever you are using) and triggers the step on the `Journey`. If the journey decides to continue to the next step, it schedules another ActiveJob for itself with the step name and other details necessary.
|
42
44
|
|
43
|
-
No state is carried inside the job.
|
45
|
+
No state is carried inside the job - both the state transition management and the steps are handled by the `Journey` itself.
|
44
46
|
|
45
47
|
## Installation
|
46
48
|
|
@@ -70,7 +72,7 @@ class SignupJourney < StepperMotor::Journey
|
|
70
72
|
end
|
71
73
|
end
|
72
74
|
|
73
|
-
class SignupController
|
75
|
+
class SignupController < ApplicationController
|
74
76
|
def create
|
75
77
|
# ...your other business actions
|
76
78
|
SignupJourney.create!(hero: current_user)
|
@@ -371,23 +373,54 @@ step :two do
|
|
371
373
|
end
|
372
374
|
```
|
373
375
|
|
374
|
-
|
376
|
+
Your existing `Journey` is already primed to perform step `two`. However, a `Journey` which is about to perform step `one` will now set `one_bis` as the next step to perform. This allows limited reordering and editing of `Journey` definitions after they have already begun.
|
377
|
+
|
378
|
+
### Step ordering with `before_step:` and `after_step:`
|
379
|
+
|
380
|
+
You can control the order of steps by inserting them before or after specific existing steps using the `before_step:` and `after_step:` parameters. This is useful when you want to add steps in the middle of a sequence without manually reordering all the steps.
|
381
|
+
|
382
|
+
The step names can be provided as strings or symbols, and only one of `before_step:` or `after_step:` can be specified:
|
375
383
|
|
376
384
|
```ruby
|
377
|
-
|
378
|
-
|
379
|
-
|
385
|
+
class UserOnboardingJourney < StepperMotor::Journey
|
386
|
+
step :send_welcome_email do
|
387
|
+
WelcomeMailer.welcome(hero).deliver_later
|
388
|
+
end
|
380
389
|
|
381
|
-
step :
|
382
|
-
|
383
|
-
end
|
390
|
+
step :send_premium_offer, after: 2.days do
|
391
|
+
PremiumOfferMailer.exclusive_offer(hero).deliver_later
|
392
|
+
end
|
384
393
|
|
385
|
-
|
386
|
-
|
394
|
+
# Insert a compliance check before the premium offer
|
395
|
+
step :compliance_check, before_step: "send_premium_offer" do
|
396
|
+
ComplianceService.verify_eligibility(hero)
|
397
|
+
end
|
398
|
+
|
399
|
+
# Insert a reminder after the welcome email
|
400
|
+
step :send_reminder, after_step: "send_welcome_email", wait: 1.day do
|
401
|
+
ReminderMailer.follow_up(hero).deliver_later
|
402
|
+
end
|
403
|
+
|
404
|
+
step :complete_onboarding, after: 7.days do
|
405
|
+
hero.update!(onboarding_completed_at: Time.current)
|
406
|
+
end
|
387
407
|
end
|
388
408
|
```
|
409
|
+
This approach is particularly useful when extending journey classes through inheritance, as you can insert steps relative to steps defined in the parent class.
|
410
|
+
Should you ever get confused and find yourself in need to retrace your steps, call `UserOnboardingJourney.step_definitions.map(&:name)`:
|
389
411
|
|
390
|
-
|
412
|
+
```ruby
|
413
|
+
UserOnboardingJourney.step_definitions.map(&:name)
|
414
|
+
# => ["send_welcome_email", "send_reminder", "compliance_check", "send_premium_offer", "complete_onboarding"]
|
415
|
+
```
|
416
|
+
|
417
|
+
This will show you the exact order in which steps will be executed. In this case:
|
418
|
+
|
419
|
+
1. `send_welcome_email`
|
420
|
+
2. `send_reminder` (inserted after `send_welcome_email`)
|
421
|
+
3. `compliance_check` (inserted before `send_premium_offer`)
|
422
|
+
4. `send_premium_offer`
|
423
|
+
5. `complete_onboarding`
|
391
424
|
|
392
425
|
### Using instance methods as steps
|
393
426
|
|
@@ -515,6 +548,123 @@ It is possible to store instance variables on the `Journey` instance, but they d
|
|
515
548
|
> This means that the volatile state such as instance variables is not going to be available between steps. Always assume that
|
516
549
|
> the `Journey` you are inside of does not have any instance variables set by previous steps and has just been freshly loaded from the database.
|
517
550
|
|
551
|
+
### Blanket step conditions with `cancel_if`
|
552
|
+
|
553
|
+
In addition to conditional steps that can be skipped, you can define blanket conditions that apply to all steps in a journey using `cancel_if`. These conditions are evaluated after the journey's state is set to `performing` but before any step execution begins. If any `cancel_if` condition is satisfied, the entire journey is canceled immediately.
|
554
|
+
|
555
|
+
`cancel_if` works like Rails' `etag` - it's class-inheritable and appendable. You can call `cancel_if` multiple times in a journey definition, and all conditions will be evaluated.
|
556
|
+
|
557
|
+
#### Using blocks with `cancel_if`
|
558
|
+
|
559
|
+
The most common way to use `cancel_if` is with a block that gets evaluated in the context of the journey:
|
560
|
+
|
561
|
+
```ruby
|
562
|
+
class UserOnboardingJourney < StepperMotor::Journey
|
563
|
+
cancel_if { hero.deactivated? }
|
564
|
+
cancel_if { hero.account_closed? }
|
565
|
+
|
566
|
+
step :send_welcome_email do
|
567
|
+
WelcomeMailer.welcome(hero).deliver_later
|
568
|
+
end
|
569
|
+
|
570
|
+
step :send_premium_offer, wait: 2.days do
|
571
|
+
PremiumOfferMailer.exclusive_offer(hero).deliver_later
|
572
|
+
end
|
573
|
+
|
574
|
+
step :complete_onboarding, wait: 7.days do
|
575
|
+
hero.update!(onboarding_completed_at: Time.current)
|
576
|
+
end
|
577
|
+
end
|
578
|
+
```
|
579
|
+
|
580
|
+
In this example, if the user becomes deactivated or closes their account at any point during the onboarding journey, the entire journey will be canceled and no further steps will be executed.
|
581
|
+
|
582
|
+
#### Using positional arguments with `cancel_if`
|
583
|
+
|
584
|
+
You can also pass conditions directly as arguments to `cancel_if`:
|
585
|
+
|
586
|
+
```ruby
|
587
|
+
class PaymentProcessingJourney < StepperMotor::Journey
|
588
|
+
cancel_if :payment_canceled?
|
589
|
+
cancel_if { hero.amount > 10000 } # Cancel if amount exceeds limit
|
590
|
+
|
591
|
+
step :validate_payment do
|
592
|
+
PaymentValidator.validate(hero)
|
593
|
+
end
|
594
|
+
|
595
|
+
step :process_payment do
|
596
|
+
PaymentProcessor.charge(hero)
|
597
|
+
end
|
598
|
+
|
599
|
+
step :send_confirmation do
|
600
|
+
PaymentConfirmationMailer.confirm(hero).deliver_later
|
601
|
+
end
|
602
|
+
|
603
|
+
private
|
604
|
+
|
605
|
+
def payment_canceled?
|
606
|
+
hero.canceled_at.present?
|
607
|
+
end
|
608
|
+
end
|
609
|
+
```
|
610
|
+
|
611
|
+
#### Supported condition types
|
612
|
+
|
613
|
+
`cancel_if` accepts the same types of conditions as `skip_if`:
|
614
|
+
|
615
|
+
* **Symbols**: Method names that return a boolean
|
616
|
+
* **Blocks**: Callable blocks that return a boolean
|
617
|
+
* **Booleans**: Literal `true` or `false` values
|
618
|
+
* **Arrays**: Arrays of conditions (all must be true)
|
619
|
+
* **Procs/Lambdas**: Callable objects
|
620
|
+
* **Conditional objects**: `StepperMotor::Conditional` instances
|
621
|
+
* **Nil**: Treated as `false` (doesn't cancel)
|
622
|
+
|
623
|
+
#### Multiple `cancel_if` conditions
|
624
|
+
|
625
|
+
You can define multiple `cancel_if` conditions, and any one of them being satisfied will cancel the journey:
|
626
|
+
|
627
|
+
```ruby
|
628
|
+
class EmailCampaignJourney < StepperMotor::Journey
|
629
|
+
cancel_if { hero.unsubscribed? }
|
630
|
+
cancel_if { hero.bounced? }
|
631
|
+
cancel_if { hero.complained? }
|
632
|
+
cancel_if { hero.deactivated? }
|
633
|
+
|
634
|
+
step :send_initial_email do
|
635
|
+
CampaignMailer.initial(hero).deliver_later
|
636
|
+
end
|
637
|
+
|
638
|
+
step :send_follow_up, wait: 3.days do
|
639
|
+
CampaignMailer.follow_up(hero).deliver_later
|
640
|
+
end
|
641
|
+
|
642
|
+
step :send_final_reminder, wait: 7.days do
|
643
|
+
CampaignMailer.final_reminder(hero).deliver_later
|
644
|
+
end
|
645
|
+
end
|
646
|
+
```
|
647
|
+
|
648
|
+
#### Inheritance and appendability
|
649
|
+
|
650
|
+
`cancel_if` conditions are class-inheritable and appendable, just like Rails' `etag`:
|
651
|
+
|
652
|
+
```ruby
|
653
|
+
class BaseJourney < StepperMotor::Journey
|
654
|
+
cancel_if { hero.deactivated? }
|
655
|
+
end
|
656
|
+
|
657
|
+
class PremiumUserJourney < BaseJourney
|
658
|
+
cancel_if { hero.subscription_expired? } # Adds to parent's conditions
|
659
|
+
|
660
|
+
step :send_premium_content do
|
661
|
+
PremiumContentMailer.send_content(hero).deliver_later
|
662
|
+
end
|
663
|
+
end
|
664
|
+
```
|
665
|
+
|
666
|
+
In this example, `PremiumUserJourney` will cancel if either the user is deactivated (from the parent class) or if their subscription has expired (from the child class).
|
667
|
+
|
518
668
|
|
519
669
|
### Waiting for the start of the step
|
520
670
|
|
@@ -531,6 +681,25 @@ end
|
|
531
681
|
|
532
682
|
The `wait:` parameter defines the amount of time computed **from the moment the Journey gets created or the previous step is completed.**
|
533
683
|
|
684
|
+
## Execution model: assume that everything is async
|
685
|
+
|
686
|
+
stepper_motor is built on the assumption that steps should be able to execute asynchronously. This is a **very important** design trait which has a number of implications:
|
687
|
+
|
688
|
+
* You will almost never have the same `self` for a Journey between multiple steps
|
689
|
+
* Any instance variables you set inside a step will get discarded and will not be available in a subsequent step
|
690
|
+
* Any step may want to reattempt itself at an arbitrary point in the future. Any stack variables or local state will be discarded.
|
691
|
+
|
692
|
+
This is deliberate and intentional. At the moment, stepper_motor does not have any built-in primitives for executing multiple steps inline. The `speedrun_journey`
|
693
|
+
helper used in tests is going to `reload` your Journey between step entries, to allow for any state changes to be blown away - but it is not going to reset any instance variables.
|
694
|
+
|
695
|
+
A good way to think about it is: every step execution happens:
|
696
|
+
|
697
|
+
* On a different machine
|
698
|
+
* In a different thread
|
699
|
+
* In a different database transaction
|
700
|
+
* In the context of a different Journey instance
|
701
|
+
* Potentially - after a long time has passed since the previous attempt or previous step
|
702
|
+
|
534
703
|
## Journey states
|
535
704
|
|
536
705
|
The Journeys are managed using a state machine, which stepper_motor completely coordinates for you. The states are as follows:
|