stepper_motor 0.1.7 → 0.1.8
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/.github/dependabot.yml +12 -0
- data/.github/workflows/ci.yml +51 -0
- data/CHANGELOG.md +77 -2
- data/Gemfile +11 -0
- data/README.md +13 -374
- data/Rakefile +21 -3
- data/bin/test +5 -0
- data/lib/generators/install_generator.rb +6 -1
- data/lib/generators/stepper_motor_migration_003.rb.erb +6 -0
- data/lib/generators/stepper_motor_migration_004.rb.erb +26 -0
- data/lib/stepper_motor/forward_scheduler.rb +8 -4
- data/lib/stepper_motor/journey/flow_control.rb +58 -0
- data/lib/stepper_motor/journey/recovery.rb +34 -0
- data/lib/stepper_motor/journey.rb +85 -84
- data/lib/stepper_motor/perform_step_job_v2.rb +2 -2
- data/lib/stepper_motor/recover_stuck_journeys_job_v1.rb +3 -1
- data/lib/stepper_motor/step.rb +70 -5
- data/lib/stepper_motor/version.rb +1 -1
- data/lib/stepper_motor.rb +0 -1
- data/lib/tasks/stepper_motor_tasks.rake +8 -0
- data/manual/MANUAL.md +538 -0
- data/rbi/stepper_motor.rbi +459 -0
- data/sig/stepper_motor.rbs +406 -3
- data/stepper_motor.gemspec +49 -0
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/assets/stylesheets/application.css +1 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/helpers/application_helper.rb +4 -0
- data/test/dummy/app/jobs/application_job.rb +9 -0
- data/test/dummy/app/mailers/application_mailer.rb +6 -0
- data/test/dummy/app/models/application_record.rb +5 -0
- data/test/dummy/app/views/layouts/application.html.erb +27 -0
- data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/dev +2 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +34 -0
- data/test/dummy/config/application.rb +28 -0
- data/test/dummy/config/boot.rb +7 -0
- data/test/dummy/config/cable.yml +10 -0
- data/test/dummy/config/database.yml +32 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/environments/development.rb +71 -0
- data/test/dummy/config/environments/production.rb +91 -0
- data/test/dummy/config/environments/test.rb +55 -0
- data/test/dummy/config/initializers/content_security_policy.rb +27 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
- data/test/dummy/config/initializers/inflections.rb +18 -0
- data/test/dummy/config/initializers/stepper_motor.rb +3 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/puma.rb +40 -0
- data/test/dummy/config/routes.rb +16 -0
- data/test/dummy/config/storage.yml +34 -0
- data/test/dummy/config.ru +8 -0
- data/test/dummy/db/migrate/20250520094921_stepper_motor_migration_001.rb +38 -0
- data/test/dummy/db/migrate/20250520094922_stepper_motor_migration_002.rb +8 -0
- data/test/dummy/db/migrate/20250522212312_stepper_motor_migration_003.rb +7 -0
- data/test/dummy/db/migrate/20250525110812_stepper_motor_migration_004.rb +28 -0
- data/test/dummy/db/schema.rb +37 -0
- data/test/dummy/public/400.html +114 -0
- data/test/dummy/public/404.html +114 -0
- data/test/dummy/public/406-unsupported-browser.html +114 -0
- data/test/dummy/public/422.html +114 -0
- data/test/dummy/public/500.html +114 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/side_effects_helper.rb +67 -0
- data/test/stepper_motor/cyclic_scheduler_test.rb +77 -0
- data/{spec/stepper_motor/forward_scheduler_spec.rb → test/stepper_motor/forward_scheduler_test.rb} +9 -10
- data/test/stepper_motor/journey/exception_handling_test.rb +89 -0
- data/test/stepper_motor/journey/flow_control_test.rb +78 -0
- data/test/stepper_motor/journey/idempotency_test.rb +65 -0
- data/test/stepper_motor/journey/step_definition_test.rb +187 -0
- data/test/stepper_motor/journey/uniqueness_test.rb +48 -0
- data/test/stepper_motor/journey_test.rb +352 -0
- data/{spec/stepper_motor/recover_stuck_journeys_job_spec.rb → test/stepper_motor/recover_stuck_journeys_job_test.rb} +14 -14
- data/{spec/stepper_motor/recovery_spec.rb → test/stepper_motor/recovery_test.rb} +27 -27
- data/test/stepper_motor/test_helper_test.rb +44 -0
- data/test/stepper_motor_test.rb +9 -0
- data/test/test_helper.rb +46 -0
- metadata +120 -24
- data/.rspec +0 -3
- data/.ruby-version +0 -1
- data/.standard.yml +0 -4
- data/.yardopts +0 -1
- data/spec/helpers/side_effects.rb +0 -85
- data/spec/spec_helper.rb +0 -90
- data/spec/stepper_motor/cyclic_scheduler_spec.rb +0 -68
- data/spec/stepper_motor/generator_spec.rb +0 -16
- data/spec/stepper_motor/journey_spec.rb +0 -401
- data/spec/stepper_motor/test_helper_spec.rb +0 -48
- data/spec/stepper_motor_spec.rb +0 -7
data/sig/stepper_motor.rbs
CHANGED
@@ -1,4 +1,407 @@
|
|
1
|
+
# StepperMotor is a module for building multi-step flows where steps are sequential and only
|
2
|
+
# ever progress forward. The building block of StepperMotor is StepperMotor::Journey
|
1
3
|
module StepperMotor
|
2
|
-
VERSION:
|
3
|
-
|
4
|
-
|
4
|
+
VERSION: untyped
|
5
|
+
|
6
|
+
class Error < StandardError
|
7
|
+
end
|
8
|
+
|
9
|
+
class JourneyNotPersisted < StepperMotor::Error
|
10
|
+
end
|
11
|
+
|
12
|
+
class StepConfigurationError < ArgumentError
|
13
|
+
end
|
14
|
+
|
15
|
+
# Describes a step in a journey. These objects get stored inside the `step_definitions`
|
16
|
+
# array of the Journey subclass. When the step gets performed, the block passed to the
|
17
|
+
# constructor will be instance_exec'd with the Journey model being the context
|
18
|
+
class Step
|
19
|
+
# sord omit - no YARD type given for "seq:", using untyped
|
20
|
+
# sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
|
21
|
+
# Creates a new step definition
|
22
|
+
#
|
23
|
+
# _@param_ `name` — the name of the Step
|
24
|
+
#
|
25
|
+
# _@param_ `wait` — the amount of time to wait before entering the step
|
26
|
+
#
|
27
|
+
# _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising.
|
28
|
+
def initialize: (
|
29
|
+
name: (String | Symbol),
|
30
|
+
seq: untyped,
|
31
|
+
on_exception: Symbol,
|
32
|
+
?wait: (Numeric | ActiveSupport::Duration)
|
33
|
+
) -> void
|
34
|
+
|
35
|
+
# Performs the step on the passed Journey, wrapping the step with the required context.
|
36
|
+
#
|
37
|
+
# _@param_ `journey` — the journey to perform the step in. If a `step_block` is passed in, it is going to be executed in the context of the journey using `instance_exec`. If only the name of the step has been provided, an accordingly named public method on the journey will be called
|
38
|
+
#
|
39
|
+
# _@return_ — void
|
40
|
+
def perform_in_context_of: (StepperMotor::Journey journey) -> untyped
|
41
|
+
|
42
|
+
# _@return_ — the name of the step or method to call on the Journey
|
43
|
+
attr_reader name: String
|
44
|
+
|
45
|
+
# sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
|
46
|
+
# _@return_ — how long to wait before performing the step
|
47
|
+
attr_reader wait: (Numeric | ActiveSupport::Duration)
|
48
|
+
|
49
|
+
# sord omit - no YARD type given for :seq, using untyped
|
50
|
+
attr_reader seq: untyped
|
51
|
+
|
52
|
+
class MissingDefinition < NoMethodError
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# A Journey is the main building block of StepperMotor. You create a journey to guide a particular model
|
57
|
+
# ("hero") through a sequence of steps. Any of your model can be the hero and have multiple Journeys. To create
|
58
|
+
# your own Journey, subclass the `StepperMotor::Journey` class and define your steps. For example, a drip mail
|
59
|
+
# campaign can look like this:
|
60
|
+
#
|
61
|
+
#
|
62
|
+
# class ResubscribeCampaign < StepperMotor::Journey
|
63
|
+
# step do
|
64
|
+
# ReinviteMailer.with(recipient: hero).deliver_later
|
65
|
+
# end
|
66
|
+
#
|
67
|
+
# step wait: 3.days do
|
68
|
+
# cancel! if hero.active?
|
69
|
+
# ReinviteMailer.with(recipient: hero).deliver_later
|
70
|
+
# end
|
71
|
+
#
|
72
|
+
# step wait: 3.days do
|
73
|
+
# cancel! if hero.active?
|
74
|
+
# ReinviteMailer.with(recipient: hero).deliver_later
|
75
|
+
# end
|
76
|
+
#
|
77
|
+
# step wait: 3.days do
|
78
|
+
# cancel! if hero.active?
|
79
|
+
# hero.close_account!
|
80
|
+
# end
|
81
|
+
# end
|
82
|
+
#
|
83
|
+
# Creating a record for the Journey (just using `create!`) will instantly send your hero on their way:
|
84
|
+
#
|
85
|
+
# ResubscribeCampaign.create!(hero: current_account)
|
86
|
+
#
|
87
|
+
# To stop the journey forcibly, delete it from your database - or call `cancel!` within any of the steps.
|
88
|
+
class Journey < ActiveRecord::Base
|
89
|
+
include StepperMotor::Journey::FlowControl
|
90
|
+
include StepperMotor::Journey::Recovery
|
91
|
+
STATES: untyped
|
92
|
+
|
93
|
+
# sord omit - no YARD return type given, using untyped
|
94
|
+
# Alias for the class attribute, for brevity
|
95
|
+
#
|
96
|
+
# _@see_ `Journey.step_definitions`
|
97
|
+
def step_definitions: () -> untyped
|
98
|
+
|
99
|
+
# sord duck - #to_f looks like a duck type, replacing with untyped
|
100
|
+
# sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
|
101
|
+
# sord duck - #to_f looks like a duck type, replacing with untyped
|
102
|
+
# sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
|
103
|
+
# Defines a step in the journey.
|
104
|
+
# Steps are stacked top to bottom and get performed in sequence.
|
105
|
+
#
|
106
|
+
# _@param_ `name` — the name of the step. If none is provided, a name will be automatically generated based on the position of the step in the list of `step_definitions`. The name can also be used to call a method on the `Journey` instead of calling the provided block.
|
107
|
+
#
|
108
|
+
# _@param_ `wait` — the amount of time this step should wait before getting performed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time, and the `next_step_to_be_performed_at` attribute will be set to the current time plus the wait duration. Mutually exclusive with `after:`
|
109
|
+
#
|
110
|
+
# _@param_ `after` — the amount of time this step should wait before getting performed including all the previous waits. This allows you to set the wait time based on the time after the journey started, as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at` attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition. Mutually exclusive with `wait:`
|
111
|
+
#
|
112
|
+
# _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
|
113
|
+
#
|
114
|
+
# _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
115
|
+
#
|
116
|
+
# _@return_ — the step definition that has been created
|
117
|
+
def self.step: (
|
118
|
+
?String? name,
|
119
|
+
?wait: (Float | untyped | ActiveSupport::Duration)?,
|
120
|
+
?after: (Float | untyped | ActiveSupport::Duration)?,
|
121
|
+
?on_exception: Symbol,
|
122
|
+
**untyped additional_step_definition_options
|
123
|
+
) -> StepperMotor::Step
|
124
|
+
|
125
|
+
# sord warn - "StepperMotor::Step?" does not appear to be a type
|
126
|
+
# Returns the `Step` object for a named step. This is used when performing a step, but can also
|
127
|
+
# be useful in other contexts.
|
128
|
+
#
|
129
|
+
# _@param_ `by_step_name` — the name of the step to find
|
130
|
+
def self.lookup_step_definition: ((Symbol | String) by_step_name) -> SORD_ERROR_StepperMotorStep
|
131
|
+
|
132
|
+
# sord omit - no YARD type given for "by_step_name", using untyped
|
133
|
+
# sord omit - no YARD return type given, using untyped
|
134
|
+
# Alias for the class method, for brevity
|
135
|
+
#
|
136
|
+
# _@see_ `Journey.lookup_step_definition`
|
137
|
+
def lookup_step_definition: (untyped by_step_name) -> untyped
|
138
|
+
|
139
|
+
# Performs the next step in the journey. Will check whether any other process has performed the step already
|
140
|
+
# and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
|
141
|
+
#
|
142
|
+
# After setting the state, it will determine the next step to perform, and perform it. Depending on the outcome of
|
143
|
+
# the step another `PerformStepJob` may get enqueued. If the journey ends here, the journey record will set its state
|
144
|
+
# to 'finished'.
|
145
|
+
#
|
146
|
+
# _@param_ `idempotency_key` — If provided, the step will only be performed if the idempotency key matches the current idempotency key. This ensures that the only the triggering job that was scheduled for this step can trigger the step and not any other.
|
147
|
+
def perform_next_step!: (?idempotency_key: String?) -> void
|
148
|
+
|
149
|
+
# sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
|
150
|
+
def time_remaining_until_final_step: () -> ActiveSupport::Duration
|
151
|
+
|
152
|
+
# sord omit - no YARD type given for "next_step_definition", using untyped
|
153
|
+
# sord omit - no YARD type given for "wait:", using untyped
|
154
|
+
# sord omit - no YARD return type given, using untyped
|
155
|
+
def set_next_step_and_enqueue: (untyped next_step_definition, ?wait: untyped) -> untyped
|
156
|
+
|
157
|
+
# sord omit - no YARD return type given, using untyped
|
158
|
+
def logger: () -> untyped
|
159
|
+
|
160
|
+
# sord omit - no YARD type given for "step_name", using untyped
|
161
|
+
# sord omit - no YARD return type given, using untyped
|
162
|
+
def after_locking_for_step: (untyped step_name) -> untyped
|
163
|
+
|
164
|
+
# sord omit - no YARD type given for "step_name", using untyped
|
165
|
+
# sord omit - no YARD type given for "exception", using untyped
|
166
|
+
# sord omit - no YARD return type given, using untyped
|
167
|
+
def after_performing_step_with_exception: (untyped step_name, untyped exception) -> untyped
|
168
|
+
|
169
|
+
# sord omit - no YARD type given for "step_name", using untyped
|
170
|
+
# sord omit - no YARD return type given, using untyped
|
171
|
+
def before_step_starts: (untyped step_name) -> untyped
|
172
|
+
|
173
|
+
# sord omit - no YARD type given for "step_name", using untyped
|
174
|
+
# sord omit - no YARD return type given, using untyped
|
175
|
+
def after_performing_step_without_exception: (untyped step_name) -> untyped
|
176
|
+
|
177
|
+
# sord omit - no YARD return type given, using untyped
|
178
|
+
def schedule!: () -> untyped
|
179
|
+
|
180
|
+
# sord omit - no YARD return type given, using untyped
|
181
|
+
def recover!: () -> untyped
|
182
|
+
|
183
|
+
# Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
|
184
|
+
# to rejoin the platform, and are just about to send them an email - but they have already joined. You
|
185
|
+
# can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
|
186
|
+
#
|
187
|
+
# Calling `cancel!` within a step will abort the execution of the current step.
|
188
|
+
#
|
189
|
+
# _@return_ — void
|
190
|
+
def cancel!: () -> untyped
|
191
|
+
|
192
|
+
# sord omit - no YARD type given for "wait:", using untyped
|
193
|
+
# 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
|
194
|
+
# (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
|
195
|
+
# Reattempting will resume the step from the beginning, so the step should be idempotent.
|
196
|
+
#
|
197
|
+
# `reattempt!` may only be called within a step.
|
198
|
+
#
|
199
|
+
# _@return_ — void
|
200
|
+
def reattempt!: (?wait: untyped) -> untyped
|
201
|
+
|
202
|
+
# Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
|
203
|
+
# journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
|
204
|
+
#
|
205
|
+
# * The hero of the journey is in a compliance procedure, and their Journeys should not continue
|
206
|
+
# * The external resource a Journey will be calling is not available
|
207
|
+
# * There is a bug in the Journey implementation and you need some time to get it fixed without canceling or recreating existing Journeys
|
208
|
+
#
|
209
|
+
# Calling `pause!` within a step will abort the execution of the current step.
|
210
|
+
#
|
211
|
+
# _@return_ — void
|
212
|
+
def pause!: () -> untyped
|
213
|
+
|
214
|
+
# Is used to resume a paused Journey. It places the Journey into the `ready` state and schedules the job to perform that step.
|
215
|
+
#
|
216
|
+
# Calling `resume!` is only permitted outside of a step
|
217
|
+
#
|
218
|
+
# _@return_ — void
|
219
|
+
def resume!: () -> untyped
|
220
|
+
|
221
|
+
module Recovery
|
222
|
+
extend ActiveSupport::Concern
|
223
|
+
|
224
|
+
# sord omit - no YARD return type given, using untyped
|
225
|
+
def recover!: () -> untyped
|
226
|
+
end
|
227
|
+
|
228
|
+
module FlowControl
|
229
|
+
# Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
|
230
|
+
# to rejoin the platform, and are just about to send them an email - but they have already joined. You
|
231
|
+
# can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
|
232
|
+
#
|
233
|
+
# Calling `cancel!` within a step will abort the execution of the current step.
|
234
|
+
#
|
235
|
+
# _@return_ — void
|
236
|
+
def cancel!: () -> untyped
|
237
|
+
|
238
|
+
# sord omit - no YARD type given for "wait:", using untyped
|
239
|
+
# 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
|
240
|
+
# (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
|
241
|
+
# Reattempting will resume the step from the beginning, so the step should be idempotent.
|
242
|
+
#
|
243
|
+
# `reattempt!` may only be called within a step.
|
244
|
+
#
|
245
|
+
# _@return_ — void
|
246
|
+
def reattempt!: (?wait: untyped) -> untyped
|
247
|
+
|
248
|
+
# Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
|
249
|
+
# journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
|
250
|
+
#
|
251
|
+
# * The hero of the journey is in a compliance procedure, and their Journeys should not continue
|
252
|
+
# * The external resource a Journey will be calling is not available
|
253
|
+
# * There is a bug in the Journey implementation and you need some time to get it fixed without canceling or recreating existing Journeys
|
254
|
+
#
|
255
|
+
# Calling `pause!` within a step will abort the execution of the current step.
|
256
|
+
#
|
257
|
+
# _@return_ — void
|
258
|
+
def pause!: () -> untyped
|
259
|
+
|
260
|
+
# Is used to resume a paused Journey. It places the Journey into the `ready` state and schedules the job to perform that step.
|
261
|
+
#
|
262
|
+
# Calling `resume!` is only permitted outside of a step
|
263
|
+
#
|
264
|
+
# _@return_ — void
|
265
|
+
def resume!: () -> untyped
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
class Railtie < Rails::Railtie
|
270
|
+
end
|
271
|
+
|
272
|
+
module TestHelper
|
273
|
+
# Allows running a given Journey to completion, skipping across the waiting periods.
|
274
|
+
# This is useful to evaluate all side effects of a Journey. The helper will ensure
|
275
|
+
# that the number of steps performed is equal to the number of steps defined - this way
|
276
|
+
# it will not enter into an endless loop. If, after completing all the steps, the journey
|
277
|
+
# has neither canceled nor finished, an exception will be raised.
|
278
|
+
#
|
279
|
+
# _@param_ `journey` — the journey to speedrun
|
280
|
+
#
|
281
|
+
# _@return_ — void
|
282
|
+
def speedrun_journey: (StepperMotor::Journey journey) -> untyped
|
283
|
+
|
284
|
+
# Performs the named step of the journey without waiting for the time to perform the step.
|
285
|
+
#
|
286
|
+
# _@param_ `journey` — the journey to speedrun
|
287
|
+
#
|
288
|
+
# _@param_ `step_name` — the name of the step to run
|
289
|
+
#
|
290
|
+
# _@return_ — void
|
291
|
+
def immediately_perform_single_step: (StepperMotor::Journey journey, Symbol step_name) -> untyped
|
292
|
+
end
|
293
|
+
|
294
|
+
# The generator is used to install StepperMotor. It adds an example Journey, a configing
|
295
|
+
# initializer and the migration that creates tables.
|
296
|
+
# Run it with `bin/rails g stepper_motor:install` in your console.
|
297
|
+
class InstallGenerator < Rails::Generators::Base
|
298
|
+
include ActiveRecord::Generators::Migration
|
299
|
+
UUID_MESSAGE: untyped
|
300
|
+
|
301
|
+
# sord omit - no YARD return type given, using untyped
|
302
|
+
# Generates monolithic migration file that contains all database changes.
|
303
|
+
def create_migration_file: () -> untyped
|
304
|
+
|
305
|
+
# sord omit - no YARD return type given, using untyped
|
306
|
+
def create_initializer: () -> untyped
|
307
|
+
|
308
|
+
def uuid_fk?: () -> bool
|
309
|
+
|
310
|
+
# sord omit - no YARD return type given, using untyped
|
311
|
+
def migration_version: () -> untyped
|
312
|
+
end
|
313
|
+
|
314
|
+
# The cyclic scheduler is designed to be run regularly via a cron job. On every
|
315
|
+
# cycle, it is going to look for Journeys which are going to come up for step execution
|
316
|
+
# before the next cycle is supposed to run. Then it is going to enqueue jobs to perform
|
317
|
+
# steps on those journeys. Since the scheduler gets run at a discrete interval, but we
|
318
|
+
# still them to be processed on time, if we only picked up the journeys which have the
|
319
|
+
# step execution time set to now or earlier, we will always have delays. This is why
|
320
|
+
# this scheduler enqueues jobs for journeys whose time to run is between now and the
|
321
|
+
# next cycle.
|
322
|
+
#
|
323
|
+
# Once the job gets created, it then gets enqueued and gets picked up by the ActiveJob
|
324
|
+
# worker normally. If you are using SQS, which has a limit of 900 seconds for the `wait:`
|
325
|
+
# value, you need to run the scheduler at least (!) every 900 seconds, and preferably
|
326
|
+
# more frequently (for example, once every 5 minutes). This scheduler is also going to be
|
327
|
+
# more gentle with ActiveJob adapters that may get slower with large queue depths, such as
|
328
|
+
# good_job. This scheduler is a good fit if you are using an ActiveJob adapter which:
|
329
|
+
#
|
330
|
+
# * Does not allow easy introspection of jobs in the future (like Redis-based queues)
|
331
|
+
# * Limits the value of the `wait:` parameter
|
332
|
+
#
|
333
|
+
# The scheduler needs to be configured in your cron table.
|
334
|
+
class CyclicScheduler < StepperMotor::ForwardScheduler
|
335
|
+
# sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
|
336
|
+
# Creates a new scheduler. The scheduler needs to know how frequently it is going to be running -
|
337
|
+
# you define that frequency when you configure your cron job that calls `run_scheduling_cycle`. Journeys which
|
338
|
+
# have to perform their steps between the runs of the cycles will generate jobs. The more frequent the scheduling
|
339
|
+
# cycle, the fewer jobs are going to be created per cycle.
|
340
|
+
#
|
341
|
+
# _@param_ `cycle_duration` — how frequently the scheduler runs
|
342
|
+
def initialize: (cycle_duration: ActiveSupport::Duration) -> void
|
343
|
+
|
344
|
+
# Run a scheduling cycle. This should be called from your ActiveJob that runs on a regular Cron cadence. Ideally you
|
345
|
+
# would call the instance of the scheduler configured for the whole StepperMotor (so that the `cycle_duration` gets
|
346
|
+
# correctly applied, as it is necessary to pick the journeys to step). Normally, you would do this:
|
347
|
+
def run_scheduling_cycle: () -> void
|
348
|
+
|
349
|
+
# sord omit - no YARD type given for "journey", using untyped
|
350
|
+
# sord omit - no YARD return type given, using untyped
|
351
|
+
def schedule: (untyped journey) -> untyped
|
352
|
+
|
353
|
+
class RunSchedulingCycleJob < ActiveJob::Base
|
354
|
+
# sord omit - no YARD return type given, using untyped
|
355
|
+
def perform: () -> untyped
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
class PerformStepJob < ActiveJob::Base
|
360
|
+
# sord omit - no YARD type given for "journey_gid", using untyped
|
361
|
+
# sord omit - no YARD return type given, using untyped
|
362
|
+
def perform: (untyped journey_gid) -> untyped
|
363
|
+
end
|
364
|
+
|
365
|
+
# The forward scheduler enqueues a job for every Journey that
|
366
|
+
# gets sent to the `#schedule`. The job is then stored in the queue
|
367
|
+
# and gets picked up by the ActiveJob worker normally. This is the simplest
|
368
|
+
# option if your ActiveJob adapter supports far-ahead scheduling. Some adapters,
|
369
|
+
# such as SQS, have limitations regarding the maximum delay after which a message
|
370
|
+
# will become visible. For SQS, the limit is 900 seconds. If the job is further in the future,
|
371
|
+
# it is likely going to fail to get enqueued. If you are working with a queue adapter that:
|
372
|
+
#
|
373
|
+
# * Does not allow easy introspection of jobs in the future (like Redis-based queues)
|
374
|
+
# * Limits the value of the `wait:` parameter
|
375
|
+
#
|
376
|
+
# this scheduler may not be a good fit for you, and you will need to use the {CyclicScheduler} instead.
|
377
|
+
# Note that this scheduler is also likely to populate your queue with a high number of "far out"
|
378
|
+
# jobs to be performed in the future. Different ActiveJob adapters are known to have varying
|
379
|
+
# performance depending on the number of jobs in the queue. For example, good_job is known to
|
380
|
+
# struggle a bit if the queue contains a large number of jobs (even if those jobs are not yet
|
381
|
+
# scheduled to be performed). For good_job the {CyclicScheduler} is also likely to be a better option.
|
382
|
+
class ForwardScheduler
|
383
|
+
# sord omit - no YARD type given for "journey", using untyped
|
384
|
+
# sord omit - no YARD return type given, using untyped
|
385
|
+
def schedule: (untyped journey) -> untyped
|
386
|
+
end
|
387
|
+
|
388
|
+
class PerformStepJobV2 < ActiveJob::Base
|
389
|
+
# sord omit - no YARD type given for "journey_id:", using untyped
|
390
|
+
# sord omit - no YARD type given for "journey_class_name:", using untyped
|
391
|
+
# sord omit - no YARD type given for "idempotency_key:", using untyped
|
392
|
+
# sord omit - no YARD return type given, using untyped
|
393
|
+
def perform: (journey_id: untyped, journey_class_name: untyped, ?idempotency_key: untyped) -> untyped
|
394
|
+
end
|
395
|
+
|
396
|
+
# The purpose of this job is to find journeys which have, for whatever reason, remained in the
|
397
|
+
# `performing` state for far longer than the journey is supposed to. At the moment it assumes
|
398
|
+
# any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
|
399
|
+
# cron table and perform it regularly.
|
400
|
+
class RecoverStuckJourneysJobV1 < ActiveJob::Base
|
401
|
+
DEFAULT_STUCK_FOR: untyped
|
402
|
+
|
403
|
+
# sord omit - no YARD type given for "stuck_for:", using untyped
|
404
|
+
# sord omit - no YARD return type given, using untyped
|
405
|
+
def perform: (?stuck_for: untyped) -> untyped
|
406
|
+
end
|
407
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/stepper_motor/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "stepper_motor"
|
7
|
+
spec.version = StepperMotor::VERSION
|
8
|
+
spec.authors = ["Julik Tarkhanov"]
|
9
|
+
spec.email = ["me@julik.nl"]
|
10
|
+
spec.license = "LGPL"
|
11
|
+
|
12
|
+
spec.summary = "Effortless step workflows that embed nicely inside Rails"
|
13
|
+
spec.description = "Step workflows for Rails/ActiveRecord"
|
14
|
+
spec.homepage = "https://steppermotor.dev"
|
15
|
+
spec.required_ruby_version = ">= 2.7.0"
|
16
|
+
|
17
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
18
|
+
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
20
|
+
spec.metadata["source_code_uri"] = "https://github.com/stepper-motor/stepper_motor"
|
21
|
+
spec.metadata["changelog_uri"] = "https://github.com/stepper-motor/stepper_motor/blob/main/CHANGELOG.md"
|
22
|
+
|
23
|
+
spec.files = Dir.chdir(__dir__) do
|
24
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
25
|
+
File.basename(f).start_with?(".")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
spec.bindir = "exe"
|
30
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
31
|
+
spec.require_paths = ["lib"]
|
32
|
+
|
33
|
+
spec.add_dependency "activerecord", ">= 7"
|
34
|
+
spec.add_dependency "activejob"
|
35
|
+
spec.add_dependency "railties"
|
36
|
+
spec.add_dependency "globalid"
|
37
|
+
|
38
|
+
spec.add_development_dependency "minitest"
|
39
|
+
spec.add_development_dependency "rails", "~> 7.0"
|
40
|
+
spec.add_development_dependency "sqlite3"
|
41
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
42
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
43
|
+
spec.add_development_dependency "rspec-rails"
|
44
|
+
spec.add_development_dependency "standard"
|
45
|
+
spec.add_development_dependency "magic_frozen_string_literal"
|
46
|
+
spec.add_development_dependency "yard"
|
47
|
+
spec.add_development_dependency "redcarpet" # needed for the yard gem to enable Github Flavored Markdown
|
48
|
+
spec.add_development_dependency "sord"
|
49
|
+
end
|
data/test/dummy/Rakefile
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Add your own tasks in files placed in lib/tasks ending in .rake,
|
4
|
+
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
5
|
+
|
6
|
+
require_relative "config/application"
|
7
|
+
|
8
|
+
Rails.application.load_tasks
|
@@ -0,0 +1 @@
|
|
1
|
+
/* Application styles */
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ApplicationJob < ActiveJob::Base
|
4
|
+
# Automatically retry jobs that encountered a deadlock
|
5
|
+
# retry_on ActiveRecord::Deadlocked
|
6
|
+
|
7
|
+
# Most jobs are safe to ignore if the underlying records are no longer available
|
8
|
+
# discard_on ActiveJob::DeserializationError
|
9
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title><%= content_for(:title) || "Dummy" %></title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
7
|
+
<meta name="mobile-web-app-capable" content="yes">
|
8
|
+
<%= csrf_meta_tags %>
|
9
|
+
<%= csp_meta_tag %>
|
10
|
+
|
11
|
+
<%= yield :head %>
|
12
|
+
|
13
|
+
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
14
|
+
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
15
|
+
|
16
|
+
<link rel="icon" href="/icon.png" type="image/png">
|
17
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
18
|
+
<link rel="apple-touch-icon" href="/icon.png">
|
19
|
+
|
20
|
+
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
21
|
+
<%= stylesheet_link_tag "application" %>
|
22
|
+
</head>
|
23
|
+
|
24
|
+
<body>
|
25
|
+
<%= yield %>
|
26
|
+
</body>
|
27
|
+
</html>
|
@@ -0,0 +1 @@
|
|
1
|
+
<%= yield %>
|
@@ -0,0 +1,22 @@
|
|
1
|
+
{
|
2
|
+
"name": "Dummy",
|
3
|
+
"icons": [
|
4
|
+
{
|
5
|
+
"src": "/icon.png",
|
6
|
+
"type": "image/png",
|
7
|
+
"sizes": "512x512"
|
8
|
+
},
|
9
|
+
{
|
10
|
+
"src": "/icon.png",
|
11
|
+
"type": "image/png",
|
12
|
+
"sizes": "512x512",
|
13
|
+
"purpose": "maskable"
|
14
|
+
}
|
15
|
+
],
|
16
|
+
"start_url": "/",
|
17
|
+
"display": "standalone",
|
18
|
+
"scope": "/",
|
19
|
+
"description": "Dummy.",
|
20
|
+
"theme_color": "red",
|
21
|
+
"background_color": "red"
|
22
|
+
}
|
@@ -0,0 +1,26 @@
|
|
1
|
+
// Add a service worker for processing Web Push notifications:
|
2
|
+
//
|
3
|
+
// self.addEventListener("push", async (event) => {
|
4
|
+
// const { title, options } = await event.data.json()
|
5
|
+
// event.waitUntil(self.registration.showNotification(title, options))
|
6
|
+
// })
|
7
|
+
//
|
8
|
+
// self.addEventListener("notificationclick", function(event) {
|
9
|
+
// event.notification.close()
|
10
|
+
// event.waitUntil(
|
11
|
+
// clients.matchAll({ type: "window" }).then((clientList) => {
|
12
|
+
// for (let i = 0; i < clientList.length; i++) {
|
13
|
+
// let client = clientList[i]
|
14
|
+
// let clientPath = (new URL(client.url)).pathname
|
15
|
+
//
|
16
|
+
// if (clientPath == event.notification.data.path && "focus" in client) {
|
17
|
+
// return client.focus()
|
18
|
+
// }
|
19
|
+
// }
|
20
|
+
//
|
21
|
+
// if (clients.openWindow) {
|
22
|
+
// return clients.openWindow(event.notification.data.path)
|
23
|
+
// }
|
24
|
+
// })
|
25
|
+
// )
|
26
|
+
// })
|
data/test/dummy/bin/dev
ADDED
data/test/dummy/bin/rake
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require "fileutils"
|
3
|
+
|
4
|
+
APP_ROOT = File.expand_path("..", __dir__)
|
5
|
+
|
6
|
+
def system!(*args)
|
7
|
+
system(*args, exception: true)
|
8
|
+
end
|
9
|
+
|
10
|
+
FileUtils.chdir APP_ROOT do
|
11
|
+
# This script is a way to set up or update your development environment automatically.
|
12
|
+
# This script is idempotent, so that you can run it at any time and get an expectable outcome.
|
13
|
+
# Add necessary setup steps to this file.
|
14
|
+
|
15
|
+
puts "== Installing dependencies =="
|
16
|
+
system("bundle check") || system!("bundle install")
|
17
|
+
|
18
|
+
# puts "\n== Copying sample files =="
|
19
|
+
# unless File.exist?("config/database.yml")
|
20
|
+
# FileUtils.cp "config/database.yml.sample", "config/database.yml"
|
21
|
+
# end
|
22
|
+
|
23
|
+
puts "\n== Preparing database =="
|
24
|
+
system! "bin/rails db:prepare"
|
25
|
+
|
26
|
+
puts "\n== Removing old logs and tempfiles =="
|
27
|
+
system! "bin/rails log:clear tmp:clear"
|
28
|
+
|
29
|
+
unless ARGV.include?("--skip-server")
|
30
|
+
puts "\n== Starting development server =="
|
31
|
+
$stdout.flush # flush the output before exec(2) so that it displays
|
32
|
+
exec "bin/dev"
|
33
|
+
end
|
34
|
+
end
|