stepper_motor 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|