stepper_motor 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.standard.yml +8 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.md +3 -0
- data/README.md +421 -0
- data/Rakefile +10 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/generators/install_generator.rb +38 -0
- data/lib/generators/stepper_motor_migration_001.rb.erb +41 -0
- data/lib/stepper_motor/cyclic_scheduler.rb +63 -0
- data/lib/stepper_motor/forward_scheduler.rb +20 -0
- data/lib/stepper_motor/journey.rb +274 -0
- data/lib/stepper_motor/perform_step_job.rb +14 -0
- data/lib/stepper_motor/railtie.rb +49 -0
- data/lib/stepper_motor/reap_hung_journeys_job.rb +12 -0
- data/lib/stepper_motor/step.rb +17 -0
- data/lib/stepper_motor/test_helper.rb +34 -0
- data/lib/stepper_motor/version.rb +5 -0
- data/lib/stepper_motor.rb +22 -0
- data/sig/stepper_motor.rbs +4 -0
- data/spec/helpers/side_effects.rb +76 -0
- data/spec/spec_helper.rb +53 -0
- data/spec/stepper_motor/cyclic_scheduler_spec.rb +67 -0
- data/spec/stepper_motor/generator_spec.rb +14 -0
- data/spec/stepper_motor/journey_spec.rb +426 -0
- data/spec/stepper_motor/test_helper_spec.rb +44 -0
- data/spec/stepper_motor_spec.rb +7 -0
- metadata +203 -0
@@ -0,0 +1,274 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# StepperMotor is a module for building multi-step flows where steps are sequential and only
|
4
|
+
# ever progress forward. The building block of StepperMotor is StepperMotor::Journey
|
5
|
+
module StepperMotor
|
6
|
+
# A Journey is the main building block of StepperMotor. You create a journey to guide a particular model
|
7
|
+
# ("hero") through a sequence of steps. Any of your model can be the hero and have multiple Journeys. To create
|
8
|
+
# your own Journey, subclass the `StepperMotor::Journey` class and define your steps. For example, a drip mail
|
9
|
+
# campaign can look like this:
|
10
|
+
#
|
11
|
+
#
|
12
|
+
# class ResubscribeCampaign < StepperMotor::Journey
|
13
|
+
# step do
|
14
|
+
# ReinviteMailer.with(recipient: hero).deliver_later
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# step, wait: 3.days do
|
18
|
+
# cancel! if hero.active?
|
19
|
+
# ReinviteMailer.with(recipient: hero).deliver_later
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# step, wait: 3.days do
|
23
|
+
# cancel! if hero.active?
|
24
|
+
# ReinviteMailer.with(recipient: hero).deliver_later
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# step, wait: 3.days do
|
28
|
+
# cancel! if hero.active?
|
29
|
+
# hero.close_account!
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# Creating a record for the Journey (just using `create!`) will instantly send your hero on their way:
|
34
|
+
#
|
35
|
+
# ResubscribeCampaign.create!(hero: current_account)
|
36
|
+
#
|
37
|
+
# To stop the journey forcibly, delete it from your database - or call `cancel!` within any of the steps.
|
38
|
+
class Journey < ActiveRecord::Base
|
39
|
+
self.table_name = "stepper_motor_journeys"
|
40
|
+
|
41
|
+
# @return [Array] the step definitions defined so far
|
42
|
+
class_attribute :step_definitions, default: []
|
43
|
+
|
44
|
+
belongs_to :hero, polymorphic: true, optional: true
|
45
|
+
|
46
|
+
STATES = %w[ready performing canceled finished]
|
47
|
+
enum state: STATES.zip(STATES).to_h, _default: "ready"
|
48
|
+
|
49
|
+
# Allows querying for journeys for this specific hero. This uses a scope for convenience as the hero
|
50
|
+
# is referenced using it's global ID (same ID that ActiveJob uses for serialization)
|
51
|
+
scope :for_hero, ->(hero) {
|
52
|
+
where(hero: hero)
|
53
|
+
}
|
54
|
+
|
55
|
+
after_create do |journey|
|
56
|
+
journey.step_definitions.any? ? journey.set_next_step_and_enqueue(journey.step_definitions.first) : journey.finished!
|
57
|
+
end
|
58
|
+
|
59
|
+
# Defines a step in the journey.
|
60
|
+
# Steps are stacked top to bottom and get performed in sequence.
|
61
|
+
def self.step(name = nil, wait: nil, after: nil, &blk)
|
62
|
+
wait = if wait && after
|
63
|
+
raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
|
64
|
+
elsif !wait && !after
|
65
|
+
0
|
66
|
+
elsif after
|
67
|
+
accumulated = step_definitions.map(&:wait).sum
|
68
|
+
after - accumulated
|
69
|
+
else
|
70
|
+
wait
|
71
|
+
end
|
72
|
+
raise StepConfigurationError, "wait: cannot be negative, but computed was #{wait}s" if wait.negative?
|
73
|
+
name ||= "step_%d" % (step_definitions.length + 1)
|
74
|
+
name = name.to_s
|
75
|
+
|
76
|
+
known_step_names = step_definitions.map(&:name)
|
77
|
+
raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
|
78
|
+
|
79
|
+
# Create the step definition
|
80
|
+
step_definition = StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, &blk)
|
81
|
+
|
82
|
+
# As per Rails docs: you need to be aware when using class_attribute with mutable structures
|
83
|
+
# as Array or Hash. In such cases, you don’t want to do changes in place. Instead use setters.
|
84
|
+
# See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
|
85
|
+
self.step_definitions = step_definitions + [step_definition]
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns the `Step` object for a named step. This is used when performing a step, but can also
|
89
|
+
# be useful in other contexts.
|
90
|
+
#
|
91
|
+
# @param by_step_name[Symbol,String] the name of the step to find
|
92
|
+
# @return [StepperMotor::Step?]
|
93
|
+
def self.lookup_step_definition(by_step_name)
|
94
|
+
step_definitions.find { |d| d.name.to_s == by_step_name.to_s }
|
95
|
+
end
|
96
|
+
|
97
|
+
# Alias for the class attribute, for brevity
|
98
|
+
#
|
99
|
+
# @see Journey.step_definitions
|
100
|
+
def step_definitions
|
101
|
+
self.class.step_definitions
|
102
|
+
end
|
103
|
+
|
104
|
+
# Alias for the class method, for brevity
|
105
|
+
#
|
106
|
+
# @see Journey.lookup_step_definition
|
107
|
+
def lookup_step_definition(by_step_name)
|
108
|
+
self.class.lookup_step_definition(by_step_name)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
|
112
|
+
# to rejoin the platform, and are just about to send them an email - but they have already joined. You
|
113
|
+
# can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
|
114
|
+
#
|
115
|
+
# Calling `cancel!` will abort the execution of the current step.
|
116
|
+
def cancel!
|
117
|
+
canceled!
|
118
|
+
throw :abort_step
|
119
|
+
end
|
120
|
+
|
121
|
+
# Inside a step it is possible to ask StepperMotor to retry to start the step at a later point in time. Maybe now is an inconvenient moment
|
122
|
+
# (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
|
123
|
+
# Reattempting will resume the step from the beginning, so the step should be idempotent.
|
124
|
+
#
|
125
|
+
# Calling `reattempt!` will abort the execution of the current step.
|
126
|
+
def reattempt!(wait: nil)
|
127
|
+
# The default `wait` is the one for the step definition
|
128
|
+
@reattempt_after = wait || @current_step_definition.wait || 0
|
129
|
+
throw :abort_step
|
130
|
+
end
|
131
|
+
|
132
|
+
# Performs the next step in the journey. Will check whether any other process has performed the step already
|
133
|
+
# and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
|
134
|
+
#
|
135
|
+
# After setting the state, it will determine the next step to perform, and perform it. Depending on the outcome of
|
136
|
+
# the step another `PerformStepJob` may get enqueued. If the journey ends here, the journey record will set its state
|
137
|
+
# to 'finished'.
|
138
|
+
#
|
139
|
+
# @return [void]
|
140
|
+
def perform_next_step!
|
141
|
+
# Make sure we can't start running the same step of the same journey twice
|
142
|
+
next_step_name_before_locking = next_step_name
|
143
|
+
with_lock do
|
144
|
+
# Make sure no other worker has snatched this journey and made steps instead of us
|
145
|
+
return unless ready? && next_step_name == next_step_name_before_locking
|
146
|
+
performing!
|
147
|
+
after_locking_for_step(next_step_name)
|
148
|
+
end
|
149
|
+
current_step_name = next_step_name
|
150
|
+
|
151
|
+
if current_step_name
|
152
|
+
logger.debug { "preparing to perform step #{current_step_name}" }
|
153
|
+
else
|
154
|
+
logger.debug { "no next step - finishing journey" }
|
155
|
+
# If there is no step set - just terminate the journey
|
156
|
+
return finished! unless current_step_name
|
157
|
+
end
|
158
|
+
|
159
|
+
before_step_starts(current_step_name)
|
160
|
+
|
161
|
+
# Recover the step definition
|
162
|
+
@current_step_definition = lookup_step_definition(current_step_name)
|
163
|
+
|
164
|
+
unless @current_step_definition
|
165
|
+
logger.debug { "no definition for #{current_step_name} - finishing journey" }
|
166
|
+
return finished!
|
167
|
+
end
|
168
|
+
|
169
|
+
# Is we tried to run the step but it is not yet time to do so,
|
170
|
+
# enqueue a new job to perform it and stop
|
171
|
+
if next_step_to_be_performed_at > Time.current
|
172
|
+
logger.warn { "tried to perform #{current_step_name} prematurely" }
|
173
|
+
schedule!
|
174
|
+
return ready!
|
175
|
+
end
|
176
|
+
|
177
|
+
# Perform the actual step
|
178
|
+
increment!(:steps_entered)
|
179
|
+
logger.debug { "entering step #{current_step_name}" }
|
180
|
+
|
181
|
+
catch(:abort_step) do
|
182
|
+
instance_exec(&@current_step_definition)
|
183
|
+
end
|
184
|
+
|
185
|
+
# By the end of the step the Journey must either be untouched or saved
|
186
|
+
if changed?
|
187
|
+
raise StepperMotor::JourneyNotPersisted, <<~MSG
|
188
|
+
#{self} had its attributes changed but was not saved inside step #{current_step_name.inspect}
|
189
|
+
this means that the subsequent execution (which may be done asynchronously) is likely to see
|
190
|
+
a stale Journey, and will execute incorrectly. If you mutate the Journey inside
|
191
|
+
of a step, make sure to call `save!` or use methods that save in-place
|
192
|
+
(such as `increment!`).
|
193
|
+
MSG
|
194
|
+
end
|
195
|
+
|
196
|
+
increment!(:steps_completed)
|
197
|
+
logger.debug { "completed #{current_step_name} without exceptions" }
|
198
|
+
|
199
|
+
if canceled?
|
200
|
+
# The step aborted the journey, nothing to do
|
201
|
+
logger.info { "has been canceled inside #{current_step_name}" }
|
202
|
+
elsif @reattempt_after
|
203
|
+
# The step asked the actions to be attempted at a later time
|
204
|
+
logger.info { "will reattempt #{current_step_name} in #{@reattempt_after} seconds" }
|
205
|
+
update!(previous_step_name: current_step_name, next_step_name: current_step_name, next_step_to_be_performed_at: Time.current + @reattempt_after)
|
206
|
+
schedule!
|
207
|
+
ready!
|
208
|
+
elsif finished?
|
209
|
+
logger.info { "was marked finished inside the step" }
|
210
|
+
update!(previous_step_name: current_step_name, next_step_name: nil)
|
211
|
+
elsif (next_step_definition = step_definitions[@current_step_definition.seq + 1])
|
212
|
+
logger.info { "will continue to #{next_step_definition.name}" }
|
213
|
+
set_next_step_and_enqueue(next_step_definition)
|
214
|
+
ready!
|
215
|
+
else
|
216
|
+
# The hero's journey is complete
|
217
|
+
logger.info { "journey completed" }
|
218
|
+
finished!
|
219
|
+
update!(previous_step_name: current_step_name, next_step_name: nil)
|
220
|
+
end
|
221
|
+
ensure
|
222
|
+
# The instance variables must not be present if `perform_next_step!` gets called
|
223
|
+
# on this same object again. This will be the case if the steps are performed inline
|
224
|
+
# and not via background jobs (which reload the model)
|
225
|
+
@reattempt_after = nil
|
226
|
+
@current_step_definition = nil
|
227
|
+
after_step_completes(current_step_name) if current_step_name
|
228
|
+
end
|
229
|
+
|
230
|
+
# @return [ActiveSupport::Duration]
|
231
|
+
def time_remaining_until_final_step
|
232
|
+
current_step_seq = @current_step_definition&.seq || -1
|
233
|
+
subsequent_steps = step_definitions.select { |definition| definition.seq > current_step_seq }
|
234
|
+
seconds_remaining = subsequent_steps.map { |definition| definition.wait.to_f }.sum
|
235
|
+
seconds_remaining.seconds # Convert to ActiveSupport::Duration
|
236
|
+
end
|
237
|
+
|
238
|
+
def set_next_step_and_enqueue(next_step_definition)
|
239
|
+
wait = next_step_definition.wait
|
240
|
+
update!(previous_step_name: next_step_name, next_step_name: next_step_definition.name, next_step_to_be_performed_at: Time.current + wait)
|
241
|
+
schedule!
|
242
|
+
end
|
243
|
+
|
244
|
+
def logger
|
245
|
+
if (logger_from_parent = super)
|
246
|
+
tag = [self.class.to_s, to_param].join(":")
|
247
|
+
tag << " at " << @current_step_definition.name if @current_step_definition
|
248
|
+
logger_from_parent.tagged(tag)
|
249
|
+
else
|
250
|
+
# Furnish a "null logger"
|
251
|
+
ActiveSupport::Logger.new(nil)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
def after_locking_for_step(step_name)
|
256
|
+
end
|
257
|
+
|
258
|
+
def before_step_starts(step_name)
|
259
|
+
end
|
260
|
+
|
261
|
+
def after_step_completes(step_name)
|
262
|
+
end
|
263
|
+
|
264
|
+
def schedule!
|
265
|
+
StepperMotor.scheduler.schedule(self)
|
266
|
+
end
|
267
|
+
|
268
|
+
def to_global_id
|
269
|
+
# This gets included into ActiveModel during Rails bootstrap,
|
270
|
+
# for now do this manually
|
271
|
+
GlobalID.create(self, app: "stepper-motor")
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require "active_job"
|
2
|
+
|
3
|
+
class StepperMotor::PerformStepJob < ActiveJob::Base
|
4
|
+
def perform(journey_gid)
|
5
|
+
# Pass the GlobalID instead of the record itself, so that we can rescue the non-existing record
|
6
|
+
# exception here as opposed to the job deserialization
|
7
|
+
journey = begin
|
8
|
+
GlobalID::Locator.locate(journey_gid)
|
9
|
+
rescue ActiveRecord::RecordNotFound
|
10
|
+
return # The journey has been canceled and destroyed previously or elsewhere
|
11
|
+
end
|
12
|
+
journey.perform_next_step!
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StepperMotor
|
4
|
+
UNINITIALISED_DATABASE_EXCEPTIONS = [
|
5
|
+
ActiveRecord::NoDatabaseError,
|
6
|
+
ActiveRecord::StatementInvalid,
|
7
|
+
ActiveRecord::ConnectionNotEstablished
|
8
|
+
]
|
9
|
+
|
10
|
+
class Railtie < Rails::Railtie
|
11
|
+
rake_tasks do
|
12
|
+
task preload: :setup do
|
13
|
+
if defined?(Rails) && Rails.respond_to?(:application)
|
14
|
+
if Rails.application.config.eager_load
|
15
|
+
ActiveSupport.run_load_hooks(:before_eager_load, Rails.application)
|
16
|
+
Rails.application.config.eager_load_namespaces.each(&:eager_load!)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
generators do
|
23
|
+
require "generators/install_generator"
|
24
|
+
end
|
25
|
+
|
26
|
+
# The `to_prepare` block which is executed once in production
|
27
|
+
# and before each request in development.
|
28
|
+
config.to_prepare do
|
29
|
+
if defined?(Rails) && Rails.respond_to?(:application)
|
30
|
+
_config_from_rails = Rails.application.config.try(:gouda)
|
31
|
+
# if config_from_rails
|
32
|
+
# StepperMotor.config.scheduling_mode = config_from_rails[:scheduling_mode]
|
33
|
+
# end
|
34
|
+
else
|
35
|
+
# Set default configuration
|
36
|
+
end
|
37
|
+
|
38
|
+
begin
|
39
|
+
# Perform any tasks which touch the database here
|
40
|
+
rescue *StepperMotor::UNINITIALISED_DATABASE_EXCEPTIONS
|
41
|
+
# Do nothing. On a freshly checked-out Rails app, running even unrelated Rails tasks
|
42
|
+
# (such as asset compilation) - or, more importantly, initial db:create -
|
43
|
+
# will cause a NoDatabaseError, as this is a chicken-and-egg problem. That error
|
44
|
+
# is safe to ignore in this instance - we should let the outer task proceed,
|
45
|
+
# because if there is no database we should allow it to get created.
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# The purpose of this job is to find journeys which have, for whatever reason, remained in the
|
2
|
+
# `performing` state for far longer than the journey is supposed to. At the moment it assumes
|
3
|
+
# any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
|
4
|
+
# cron table and perform it regularly.
|
5
|
+
class StepperMotor::ReapHungJourneysJob < ActiveJob::Base
|
6
|
+
def perform
|
7
|
+
StepperMotor::Journey.where("state = 'performing' AND updated_at < ?", 1.hour.ago).find_each do |hung_journey|
|
8
|
+
hung_journey.update!(state: "ready")
|
9
|
+
StepperMotor.scheduler.schedule(hung_journey)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# Describes a step in a journey. These objects get stored inside the `step_definitions`
|
2
|
+
# array of the Journey subclass. When the step gets performed, the block passed to the
|
3
|
+
# constructor will be instance_exec'd with the Journey model being the context
|
4
|
+
class StepperMotor::Step
|
5
|
+
attr_reader :name, :wait, :seq
|
6
|
+
def initialize(name:, seq:, wait: 0, &step_block)
|
7
|
+
@step_block = step_block
|
8
|
+
@name = name.to_s
|
9
|
+
@wait = wait
|
10
|
+
@seq = seq
|
11
|
+
end
|
12
|
+
|
13
|
+
# Makes the Step object itself callable
|
14
|
+
def to_proc
|
15
|
+
@step_block
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module StepperMotor::TestHelper
|
2
|
+
# Allows running a given Journey to completion, skipping across the waiting periods.
|
3
|
+
# This is useful to evaluate all side effects of a Journey. The helper will ensure
|
4
|
+
# that the number of steps performed is equal to the number of steps defined - this way
|
5
|
+
# it will not enter into an endless loop. If, after completing all the steps, the journey
|
6
|
+
# has neither canceled nor finished, an exception will be raised.
|
7
|
+
#
|
8
|
+
# @param journey[StepperMotor::Journey] the journey to speedrun
|
9
|
+
# @return void
|
10
|
+
def speedrun_journey(journey)
|
11
|
+
journey.save!
|
12
|
+
n_steps = journey.step_definitions.length
|
13
|
+
n_steps.times do
|
14
|
+
journey.reload
|
15
|
+
break if journey.canceled? || journey.finished?
|
16
|
+
journey.update(next_step_to_be_performed_at: Time.current)
|
17
|
+
journey.perform_next_step!
|
18
|
+
end
|
19
|
+
journey.reload
|
20
|
+
journey_did_complete = journey.canceled? || journey.finished?
|
21
|
+
raise "Journey #{journey} did not finish or cancel after performing #{n_steps} steps" unless journey_did_complete
|
22
|
+
end
|
23
|
+
|
24
|
+
# Performs the named step of the journey without waiting for the time to perform the step.
|
25
|
+
#
|
26
|
+
# @param journey[StepperMotor::Journey] the journey to speedrun
|
27
|
+
# @param step_name[Symbol] the name of the step to run
|
28
|
+
# @return void
|
29
|
+
def immediately_perform_single_step(journey, step_name)
|
30
|
+
journey.save!
|
31
|
+
journey.update!(next_step_name: step_name, next_step_to_be_performed_at: Time.current)
|
32
|
+
journey.perform_next_step!
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "stepper_motor/version"
|
4
|
+
require "active_support"
|
5
|
+
|
6
|
+
module StepperMotor
|
7
|
+
class Error < StandardError; end
|
8
|
+
|
9
|
+
class JourneyNotPersisted < Error; end
|
10
|
+
|
11
|
+
class StepConfigurationError < ArgumentError; end
|
12
|
+
|
13
|
+
autoload :Journey, File.dirname(__FILE__) + "/stepper_motor/journey.rb"
|
14
|
+
autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
|
15
|
+
autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
|
16
|
+
autoload :InstallGenerator, File.dirname(__FILE__) + "/generators/install_generator.rb"
|
17
|
+
autoload :ForwardScheduler, File.dirname(__FILE__) + "/stepper_motor/forward_scheduler.rb"
|
18
|
+
autoload :CyclicScheduler, File.dirname(__FILE__) + "/stepper_motor/cyclic_scheduler.rb"
|
19
|
+
autoload :TestHelper, File.dirname(__FILE__) + "/stepper_motor/test_helper.rb"
|
20
|
+
|
21
|
+
mattr_accessor :scheduler, default: ForwardScheduler.new
|
22
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module SideEffects
|
2
|
+
module SpecHelper
|
3
|
+
def self.included(into)
|
4
|
+
into.before(:each) { SideEffects.clear! }
|
5
|
+
into.after(:each) { SideEffects.clear! }
|
6
|
+
super
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.produced?(name)
|
11
|
+
Thread.current[:side_effects].to_h.key?(name.to_s)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.none?
|
15
|
+
Thread.current[:side_effects].to_h.empty?
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.names
|
19
|
+
Thread.current[:side_effects].to_h.keys.map(&:to_s)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.clear!
|
23
|
+
Thread.current[:side_effects] = {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.touch!(name)
|
27
|
+
Thread.current[:side_effects][name.to_s] = true
|
28
|
+
end
|
29
|
+
|
30
|
+
RSpec::Matchers.define :have_produced_side_effects_named do |*side_effect_names|
|
31
|
+
match(notify_expectation_failures: true) do |actual|
|
32
|
+
SideEffects.clear!
|
33
|
+
actual.call
|
34
|
+
side_effect_names.each do |side_effect_name|
|
35
|
+
expect(SideEffects).to be_produced(side_effect_name), "The side effect named #{side_effect_name.inspect} should have been produced, but wasn't"
|
36
|
+
end
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
def supports_block_expectations?
|
41
|
+
true
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
RSpec::Matchers.define :not_have_produced_side_effects_named do |*side_effect_names|
|
46
|
+
match(notify_expectation_failures: true) do |actual|
|
47
|
+
expect(side_effect_names).not_to be_empty
|
48
|
+
|
49
|
+
SideEffects.clear!
|
50
|
+
actual.call
|
51
|
+
|
52
|
+
side_effect_names.each do |side_effect_name|
|
53
|
+
expect(SideEffects).not_to be_produced(side_effect_name), "The side effect named #{side_effect_name.inspect} should not have been produced, but was"
|
54
|
+
end
|
55
|
+
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
def supports_block_expectations?
|
60
|
+
true
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
RSpec::Matchers.define :not_have_produced_any_side_effects do
|
65
|
+
match(notify_expectation_failures: true) do |actual|
|
66
|
+
SideEffects.clear!
|
67
|
+
actual.call
|
68
|
+
expect(SideEffects).to be_none
|
69
|
+
true
|
70
|
+
end
|
71
|
+
|
72
|
+
def supports_block_expectations?
|
73
|
+
true
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "stepper_motor"
|
4
|
+
require "active_support/testing/time_helpers"
|
5
|
+
require "active_job"
|
6
|
+
require "active_record"
|
7
|
+
require "globalid"
|
8
|
+
require_relative "helpers/side_effects"
|
9
|
+
|
10
|
+
module StepperMotorRailtieTestHelpers
|
11
|
+
def establish_test_connection
|
12
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: fake_app_root + "/db.sqlite3")
|
13
|
+
StepperMotor::InstallGenerator.source_root(File.dirname(__FILE__) + "/../../lib")
|
14
|
+
end
|
15
|
+
|
16
|
+
def fake_app_root
|
17
|
+
File.dirname(__FILE__) + "/app"
|
18
|
+
end
|
19
|
+
|
20
|
+
def run_generator
|
21
|
+
generator = StepperMotor::InstallGenerator.new
|
22
|
+
generator.destination_root = fake_app_root
|
23
|
+
generator.create_migration_file
|
24
|
+
end
|
25
|
+
|
26
|
+
def run_migrations
|
27
|
+
# Before running the migrations we need to require the migration files, since there is no
|
28
|
+
# "full" Rails environment available
|
29
|
+
Dir.glob(fake_app_root + "/db/migrate/*.rb").sort.each do |migration_file_path|
|
30
|
+
require migration_file_path
|
31
|
+
end
|
32
|
+
|
33
|
+
ActiveRecord::Migrator.migrations_paths = [File.join(fake_app_root + "/db/migrate")]
|
34
|
+
ActiveRecord::Tasks::DatabaseTasks.root = fake_app_root
|
35
|
+
ActiveRecord::Tasks::DatabaseTasks.migrate
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
RSpec.configure do |config|
|
40
|
+
# Enable flags like --only-failures and --next-failure
|
41
|
+
config.example_status_persistence_file_path = ".rspec_status"
|
42
|
+
|
43
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
44
|
+
config.disable_monkey_patching!
|
45
|
+
|
46
|
+
config.expect_with :rspec do |c|
|
47
|
+
c.syntax = :expect
|
48
|
+
end
|
49
|
+
|
50
|
+
config.include ActiveSupport::Testing::TimeHelpers
|
51
|
+
config.include StepperMotorRailtieTestHelpers
|
52
|
+
config.include SideEffects::SpecHelper
|
53
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
|
3
|
+
RSpec.describe "StepperMotor::CyclicScheduler" do
|
4
|
+
include ActiveJob::TestHelper
|
5
|
+
|
6
|
+
class FarFutureJourney < StepperMotor::Journey
|
7
|
+
step :do_thing, wait: 40.minutes do
|
8
|
+
raise "We do not test this so it should never run"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
before do
|
13
|
+
@previous_scheduler = StepperMotor.scheduler
|
14
|
+
establish_test_connection
|
15
|
+
run_generator
|
16
|
+
run_migrations
|
17
|
+
StepperMotor::Journey.delete_all
|
18
|
+
end
|
19
|
+
|
20
|
+
after do
|
21
|
+
StepperMotor.scheduler = @previous_scheduler
|
22
|
+
end
|
23
|
+
|
24
|
+
it "does not schedule a journey which is too far in the future" do
|
25
|
+
scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 30.seconds)
|
26
|
+
StepperMotor.scheduler = scheduler
|
27
|
+
|
28
|
+
expect(scheduler).to receive(:schedule).with(instance_of(FarFutureJourney)).once.and_call_original
|
29
|
+
_journey = FarFutureJourney.create!
|
30
|
+
|
31
|
+
expect(scheduler).not_to receive(:schedule)
|
32
|
+
scheduler.run_scheduling_cycle
|
33
|
+
end
|
34
|
+
|
35
|
+
it "only schedules journeys which are within its execution window" do
|
36
|
+
scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 40.minutes)
|
37
|
+
StepperMotor.scheduler = scheduler
|
38
|
+
|
39
|
+
expect(scheduler).to receive(:schedule).with(instance_of(FarFutureJourney)).once.and_call_original
|
40
|
+
journey = FarFutureJourney.create!
|
41
|
+
|
42
|
+
expect(scheduler).to receive(:schedule).with(journey).and_call_original
|
43
|
+
scheduler.run_scheduling_cycle
|
44
|
+
end
|
45
|
+
|
46
|
+
it "also schedules journeys which had to run in the past" do
|
47
|
+
scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 10.seconds)
|
48
|
+
StepperMotor.scheduler = scheduler
|
49
|
+
|
50
|
+
expect(scheduler).to receive(:schedule).with(instance_of(FarFutureJourney)).once.and_call_original
|
51
|
+
journey = FarFutureJourney.create!
|
52
|
+
journey.update!(next_step_to_be_performed_at: 10.minutes.ago)
|
53
|
+
|
54
|
+
expect(scheduler).to receive(:schedule).with(journey).and_call_original
|
55
|
+
scheduler.run_scheduling_cycle
|
56
|
+
end
|
57
|
+
|
58
|
+
it "performs the scheduling job" do
|
59
|
+
scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 10.seconds)
|
60
|
+
StepperMotor.scheduler = scheduler
|
61
|
+
job_class = StepperMotor::CyclicScheduler::RunSchedulingCycleJob
|
62
|
+
expect(scheduler).to receive(:run_scheduling_cycle).and_call_original
|
63
|
+
job_class.perform_now
|
64
|
+
end
|
65
|
+
|
66
|
+
it "does not perform the job if the configured scheduler is not the CyclicScheduler"
|
67
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
|
3
|
+
RSpec.describe "StepperMotor::InstallGenerator" do
|
4
|
+
it "is able to set up a test database" do
|
5
|
+
expect {
|
6
|
+
establish_test_connection
|
7
|
+
run_generator
|
8
|
+
run_migrations
|
9
|
+
}.not_to raise_error
|
10
|
+
expect(ActiveRecord::Base.connection.tables).to include("stepper_motor_journeys")
|
11
|
+
ensure
|
12
|
+
FileUtils.rm_rf(fake_app_root)
|
13
|
+
end
|
14
|
+
end
|