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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 503c1ad7d876deba41afff2e576d2b981271bdc52ec1d801c6f9be7fd2040108
|
4
|
+
data.tar.gz: b3a59f3dd38663fcaa7c09e0ce13860d9f243c2009233e7389d90fcd236b9b51
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: da032fcdb7b4d4dd0baeb0efb37c536e8b496d197007dd8d578a50abf606fb3cf75b7ab2e2a79ce8ba5bd3a3ba257f1a329eea66ef6384f7f5b2de51e6016e15
|
7
|
+
data.tar.gz: 7588f66ed4eb021b45657a98aea2273a7e2b996a68b6d997980b9d69c93f69e49909a5c9c7a871f4ef39409eebb7fc13650f3cf924fa09ed1278fad922908401
|
data/.rspec
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.7.7
|
data/.standard.yml
ADDED
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--markup markdown - docs/MANUAL.md
|
data/CHANGELOG.md
ADDED
data/LICENSE.md
ADDED
data/README.md
ADDED
@@ -0,0 +1,421 @@
|
|
1
|
+
# StepperMotor
|
2
|
+
|
3
|
+
Is a useful tool for running stepped or iterative workflows inside your Rails application.
|
4
|
+
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
Define a workflow and launch your user into it:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
class SignupJourney < StepperMotor::Journey
|
11
|
+
step :after_signup do
|
12
|
+
WelcomeMailer.welcome_email(hero).deliver_later
|
13
|
+
end
|
14
|
+
|
15
|
+
step :remind_of_tasks, wait: 2.days do
|
16
|
+
ServiceUpdateMailer.two_days_spent_email(hero).deliver_later
|
17
|
+
end
|
18
|
+
|
19
|
+
step :onboarding_complete_, wait: 15.days do
|
20
|
+
OnboardingCompleteMailer.onboarding_complete_email(hero).deliver_later
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
SignupJourney.create!(hero: current_user)
|
25
|
+
```
|
26
|
+
|
27
|
+
## Installation
|
28
|
+
|
29
|
+
Add the gem to the application's Gemfile, and then generate and run the migration
|
30
|
+
|
31
|
+
$ bundle add stepper_motor
|
32
|
+
$ bundle install
|
33
|
+
$ bin/rails g stepper_motor:install --uuid # Pass "uuid" if you are using UUID for your primary and foreign keys
|
34
|
+
$ bin/rails db:migrate
|
35
|
+
|
36
|
+
|
37
|
+
## Intro
|
38
|
+
|
39
|
+
`stepper_motor` solves a real, tangible problem in Rails apps - tracking activities over long periods of time. It does so in a durable, reentrant and consistent manner, utilizing the guarantees provided by your relational database you already have.
|
40
|
+
|
41
|
+
## Philosophy behind StepperMotor
|
42
|
+
|
43
|
+
Most of our applications have workflows which have to happen in steps. They pretty much always have some things in common:
|
44
|
+
|
45
|
+
* We want just one workflow of a certain type per user or per business transaction
|
46
|
+
* We want only one parallel execution of a unique workflow at a time
|
47
|
+
* We want the steps to be explicitly idempotent
|
48
|
+
* We want visibility into the step our workflow is in, what step it is going to enter, what step it has left
|
49
|
+
|
50
|
+
While Rails provides great abstractions for "inline" actions induced via APIs or web requests in the form of ActionController, and great abstractions for single "unit of work" tasks via ActiveJob - these are lacking if one wants true idempotency and correct state tracking throughout multiple steps. When a workflow like this has to be implemented in a system, the choice usually goes out to a number of possible solutions:
|
51
|
+
|
52
|
+
* Trying ActiveJob-specific "batch" workflows, such as [Sidekiq Pro's batches](https://github.com/sidekiq/sidekiq/wiki/Batches) or [good_job batches](https://github.com/bensheldon/good_job?tab=readme-ov-file#batches)
|
53
|
+
* State machines attached to an ActiveRecord, via tools like [aasm](https://github.com/aasm/aasm) or [state_machine_enum](https://github.com/cheddar-me/state_machine_enum) - locking and controlling transitions then usually falls on the developer
|
54
|
+
* Adopting a complex solution like [Temporal.io](https://temporal.io/), with the app relegated to just executing parts of the workflow
|
55
|
+
|
56
|
+
We believe all of these solutions do not quite hit the "sweet spot" where step workflows would integrate well with Rails.
|
57
|
+
|
58
|
+
* Most Rails apps already have a perfectly fit transactional, durable data store - the main database
|
59
|
+
* The devloper should not have intimate understanding of DB atomicity and ActiveRecord `with_lock` and `reload` to have step workflows
|
60
|
+
* It should not be necessary to configure a whole extra service (like Temporal.io) just for supporting those workflows. A service like that should be a part of your monolith, not an external application. It should not be necessary to talk to that service using complex, Ruby-unfriendly protocols and interfaces like gRPC.
|
61
|
+
|
62
|
+
So, StepperMotor aims to give you "just enough of Temporal-like functionality" for most Rails-bound workflows. Without extra dependencies, network calls, services or having to learn extra languages. So let's dive in.
|
63
|
+
|
64
|
+
## How we do it
|
65
|
+
|
66
|
+
StepperMotor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels. A `Journey` is just an `ActiveRecord` model, with all the persistence methods you already know and use.
|
67
|
+
|
68
|
+
Steps are defined inside the Journey as blocks, and they run in the context of the `Journey` model. The following constraints apply:
|
69
|
+
|
70
|
+
* For any one Journey, only one Fiber/Thread/Process may be performing a step on it
|
71
|
+
* For any one Journey, only one step can be executing at any given time
|
72
|
+
* For any `hero`, multiple different Journeys may exist and be in different stages of completion
|
73
|
+
|
74
|
+
The `step` blocks get executed in the context of the `Journey` model instance. This is done so that you can define helper methods in the `Journey` subclass, and make good use of them. A Journey links to just one record - the `hero`.
|
75
|
+
|
76
|
+
## Installation
|
77
|
+
|
78
|
+
Add the gem to the application's Gemfile, and then generate and run the migration
|
79
|
+
|
80
|
+
$ bundle add stepper_motor
|
81
|
+
$ bundle install
|
82
|
+
$ bin/rails g stepper_motor:install
|
83
|
+
$ bin/rails db:migrate
|
84
|
+
|
85
|
+
## Usage
|
86
|
+
|
87
|
+
Define a workflow and launch your user into it:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
class SignupJourney < StepperMotor::Journey
|
91
|
+
step :after_signup do
|
92
|
+
WelcomeMailer.welcome_email(hero).deliver_later
|
93
|
+
end
|
94
|
+
|
95
|
+
step :remind_of_tasks, wait: 2.days do
|
96
|
+
ServiceUpdateMailer.two_days_spent_email(hero).deliver_later
|
97
|
+
end
|
98
|
+
|
99
|
+
step :onboarding_complete_, wait: 15.days do
|
100
|
+
OnboardingCompleteMailer.onboarding_complete_email(hero).deliver_later
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
class SignupController
|
105
|
+
def create
|
106
|
+
# ...your other business actions
|
107
|
+
SignupJourney.create!(hero: current_user)
|
108
|
+
redirect_to user_root_path(current_user)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
```
|
112
|
+
|
113
|
+
## A few sample journeys: single step with repeats
|
114
|
+
|
115
|
+
Let's examine a simple single-step journey. Imagine you have a user that is about to churn, and you want to keep sending them drip emails until they churn in the hope that they will reconvert. The Journey will likely look like this:
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
class ChurnPreventionJourney < StepperMotor::Journey
|
119
|
+
step do
|
120
|
+
cancel! if hero.subscription_lapses_at > 120.days.from_now
|
121
|
+
|
122
|
+
time_remaining_until_expiry_ = hero.subscription_lapses_at - Time.current
|
123
|
+
if time_remaining_until_expiry > 1.days
|
124
|
+
ResubscribeReminderMailer.extend_subscription_reminder(hero).deliver_later
|
125
|
+
send_next_reminder_after = (time_remaining_until_expiry / 2).in_days.floor
|
126
|
+
reattempt!(wait: send_next_reminder_after.days)
|
127
|
+
else
|
128
|
+
# If the user has churned - let the journey finish, as there is nothing to do
|
129
|
+
SadToSeeYouGoMailer.farewell(hero).deliver_later
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
ChurnPreventionJourney.create(hero: user)
|
135
|
+
```
|
136
|
+
|
137
|
+
In this case we have just one `step` which is going to be repeated. When we decide to repeat a step (if the user still has time to reconnect with the business), we postpone its execution by a certain amount of time - in this case, half the days remaining on the user's subscription. If a user rescubscribes, we `cancel!` the only step of the `Journey`, after which it gets marked `finished` in the database.
|
138
|
+
|
139
|
+
## More sample journeys: email drip campaign
|
140
|
+
|
141
|
+
As our second example, let's check out a drip campaign which inceitivises a user with bonuses as their account nears termination.
|
142
|
+
|
143
|
+
```ruby
|
144
|
+
class ReengagementJourney < StepperMotor::Journey
|
145
|
+
step :first do
|
146
|
+
cancel! if reengaged?
|
147
|
+
hero.bonus_programs.create!(type: BonusProgram::REENGAGEMENT)
|
148
|
+
hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 1})
|
149
|
+
end
|
150
|
+
|
151
|
+
step :second, wait: 14.days do
|
152
|
+
cancel! if reengaged?
|
153
|
+
hero.bonus_programs.create!(type: BonusProgram::DISCOUNT)
|
154
|
+
hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 2})
|
155
|
+
end
|
156
|
+
|
157
|
+
step :third, wait: 7.days do
|
158
|
+
cancel! if reengaged?
|
159
|
+
hero.bonus_programs.create!(type: BonusProgram::DOUBLE_DISCOUNT)
|
160
|
+
hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 3})
|
161
|
+
end
|
162
|
+
|
163
|
+
step :final, wait: 3.days do
|
164
|
+
cancel! if reengaged?
|
165
|
+
hero.close_account!
|
166
|
+
hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 4})
|
167
|
+
end
|
168
|
+
|
169
|
+
def reengaged?
|
170
|
+
# If the user purchased anything after this journey started,
|
171
|
+
# consider them "re-engaged"
|
172
|
+
hero.purchases.where("created_at > ?", created_at).any?
|
173
|
+
end
|
174
|
+
end
|
175
|
+
```
|
176
|
+
|
177
|
+
In this instance, we split our workflow in a number of steps - 4 in total. After the first step (`:first`) we wait for 14 days before executing the next one. 7 days later - we run another one. We end with closing the user's account. If the user has reengaged at any step, we mark the `Journey` as `canceled`.
|
178
|
+
|
179
|
+
## More sample journeys: archiving and deleting user data
|
180
|
+
|
181
|
+
Imagine a user on your platform has requested their account to be deleted. Usually you do some archiving before deletion, to preserve some data that can be useful in aggregate - just scrubbing the PII. You also change the user information so that the user does not show up in the normal application flows anymore.
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
class AccountErasureJourney < StepperMotor::Journey
|
185
|
+
step :deactivate_user do
|
186
|
+
hero.deactivated!
|
187
|
+
end
|
188
|
+
|
189
|
+
step :remove_authentication_tokens do
|
190
|
+
hero.sessions.destroy_all
|
191
|
+
hero.authentication_tokens.destroy_all
|
192
|
+
end
|
193
|
+
|
194
|
+
step :archive_pseudonymized_data do
|
195
|
+
DatapointArchive.create(name> "user-#{hero.id}-datapoints.gz") do |io|
|
196
|
+
CSV(io) do |csv|
|
197
|
+
csv << hero.datapoints.first.attributes.keys
|
198
|
+
hero.datapoints.each do |datapoint|
|
199
|
+
csv << Pseudonymizer.scrub(datapoint.attributes.values)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
step :delete_data do
|
206
|
+
hero.datapoints.in_batches.destroy_all
|
207
|
+
end
|
208
|
+
|
209
|
+
step :send_deletion_email do
|
210
|
+
AccountErasureCompleteMailer.erasure_complete(hero).deliver_later
|
211
|
+
end
|
212
|
+
end
|
213
|
+
```
|
214
|
+
|
215
|
+
While this is seemingly overkill to have steps defined for this type of workflow, the basic premise of a `Journey` still offers you substantial benefits. For example, you never want to enter `delete_data` before `archive_pseudonymized_data` has completed. Same with the `send_deletion_email` - you do not want to notify the user berore their data is actually gone. Neither do you want there to ever be more than 1 process executing any of those steps.
|
216
|
+
|
217
|
+
## More sample journeys: performing an outgoing payment
|
218
|
+
|
219
|
+
Another fairly widely known use case for step workflows is initiating a payment. We first initiate a payment through an external provider, and then poll for its state to revert or complete the payment.
|
220
|
+
|
221
|
+
```ruby
|
222
|
+
class PaymentInitiationJourney < StepperMotor::Journey
|
223
|
+
step :initiate_payment do
|
224
|
+
ik = hero.idempotency_key # The `hero` in this case is a Payment, not the User
|
225
|
+
result = PaymentProvider.transfer!(
|
226
|
+
from_account: hero.sender.bank_account_details,
|
227
|
+
to_account: hero.recipient.bank_account_details,
|
228
|
+
amount: hero.amount,
|
229
|
+
idempotency_key: ik
|
230
|
+
)
|
231
|
+
if result.intermittent_error?
|
232
|
+
reattempt!(wait: 5.seconds)
|
233
|
+
elsif result.invalid_request?
|
234
|
+
hero.failed!
|
235
|
+
cancel!
|
236
|
+
else
|
237
|
+
hero.processing!
|
238
|
+
# and then do nothing and proceed to the next step
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
step :confirm_payment do
|
243
|
+
ik = hero.idempotency_key # The `hero` in this case is a Payment, not the User
|
244
|
+
payment_details = PaymentProvider.details(idempotency_key: ik)
|
245
|
+
case payment_details.state
|
246
|
+
when :complete
|
247
|
+
hero.complete!
|
248
|
+
PaymentSentNotification.notify_sender_of_success(hero.sender).deliver_later
|
249
|
+
when :failed
|
250
|
+
hero.failed!
|
251
|
+
PaymentSentNotification.notify_sender_of_failure(hero.sender).deliver_later
|
252
|
+
else
|
253
|
+
logger.info {"Payment #{hero} still confirming" }
|
254
|
+
reattempt!(wait: 30.seconds) if payment_details.state == :processing
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
```
|
259
|
+
|
260
|
+
Here, we first initiate a payment using an idempotency key, and then poll for its completion or failure repeatedly. When a payment fails or succeeds, we notify the sender and finish the `Journey`. Note that this `Journey` is of a _payment,_ not of the user. A user may have multiple Payments in flight, each with their own `Journey` being tracket transactionally and correctly.
|
261
|
+
|
262
|
+
## Transactional semantics
|
263
|
+
|
264
|
+
Getting the transactional semantics _right_ with a system like StepperMotor is crucial. We strike a decent balance between reliability/durability and performance, namely:
|
265
|
+
|
266
|
+
* The initial "checkout" of a `Journey` for performing a step is lock-guarded
|
267
|
+
* Inside the lock guard the `state` of the `Journey` gets set to `performing` - you can see that a journey is currently being performed, and no other processes will evern checkout that same `Journey`
|
268
|
+
* The transaction is only applied at the start of the step, _outside_ of that step's block. This means that you can perform long-running operations in your steps, as long as they are idempotent - and manage transactions inside of the steps.
|
269
|
+
|
270
|
+
We chose to make StepperMotor "transactionless" inside the steps because the operations and side effects we usually care about would be long-running and performing HTTP or RPC requests. Had the step been wrapped with a transaction, the transaction could become very long - creating a potential for a fairly large rollback in case the step fails.
|
271
|
+
|
272
|
+
Another reason why we avoid forced transactions is that if, for whatever reason, you need multiple idempotent actions _inside_ of a step the outer transaction would not permit you to have those. We prefer leaving that flexibility to the end application.
|
273
|
+
|
274
|
+
## Saving side-effects of steps
|
275
|
+
|
276
|
+
Right now, StepperMotor does not provide any specific tools for saving side-effects or inputs of a step or of the entire `Journey` except for the related `hero` record. The reason for that is that side effects can take many shapes. A side effect may be a file output to S3, a record saved into your database, a file on the filesystem, or a blob of JSON carried around. The way this data has to be persisted can also vary. For the moment, we don't see a good _generalized_ way to persist those side effects aside of the factual outputs. So:
|
277
|
+
|
278
|
+
* A record of the fact that a step has been performed to completion is sufficient to not re-enter that step
|
279
|
+
* If you need repeatable, but idempotent steps - idempotency is on you
|
280
|
+
|
281
|
+
## Unique Journeys
|
282
|
+
|
283
|
+
By default, StepperMotor will only allow you to have one active `Journey` per journey type for any given specific `hero`. This will fail, either with a uniqueness constraint violation or a validation error:
|
284
|
+
|
285
|
+
```ruby
|
286
|
+
SomeJourney.create!(hero: user)
|
287
|
+
SomeJourney.create!(hero: user)
|
288
|
+
```
|
289
|
+
|
290
|
+
Once a `Journey` becomes `canceled` or `finished`, another `Journey` of the same class can be created again for the same `hero`. If you need to create multiple `Journeys` of the same class for the same `hero`, pass the `allow_multiple` attribute set to `true`. This value gets persisted and affects the inclusion of the `Journey` into a partial index that enforces uniqueness:
|
291
|
+
|
292
|
+
```ruby
|
293
|
+
SomeJourney.create!(hero: user, allow_multiple: true)
|
294
|
+
SomeJourney.create!(hero: user, allow_multiple: true)
|
295
|
+
```
|
296
|
+
|
297
|
+
## Querying for Journeys already created
|
298
|
+
|
299
|
+
Frequently, you will encounter the need to select `heroes` to create `Journeys` for. You will likely want to create `Journeys` only for those `heroes` who do not have these `Journeys` yet. You can use a shortcut to generate you the SQL query to use in a `WHERE NOT EXISTS` SQL clause. Usually, your query will look something like this:
|
300
|
+
|
301
|
+
```sql
|
302
|
+
SELECT users.* FROM users WHERE NOT EXISTS (SELECT 1 FROM stepper_motor_journeys WHERE type = 'YourJourney' AND hero_id = users.id)
|
303
|
+
```
|
304
|
+
|
305
|
+
To make this simpler, we offer a special helper method:
|
306
|
+
|
307
|
+
```ruby
|
308
|
+
YourJourney.presence_sql_for(User) # => SELECT 1 FROM stepper_motor_journeys WHERE type = 'YourJourney' AND hero_id = users.id
|
309
|
+
```
|
310
|
+
|
311
|
+
## What to pick as the hero
|
312
|
+
|
313
|
+
If your use case requires complex associations, you may want to make your `hero` a record representing the business process that the `Journey` tracks, instead of making the "actor" (say, an `Account`) the hero. This will allow for better granularity and better-looking code that will be easier to understand.
|
314
|
+
|
315
|
+
So instead of doing this:
|
316
|
+
|
317
|
+
```ruby
|
318
|
+
class PurchaseJourney < StepperMotor::Journey
|
319
|
+
step :start_checkout do
|
320
|
+
hero.purchases.create!(sku: ...)
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
PurchaseJourney.create!(hero: user, allow_multiple: true)
|
325
|
+
```
|
326
|
+
|
327
|
+
try this:
|
328
|
+
|
329
|
+
```ruby
|
330
|
+
class PurchaseJourney < StepperMotor::Journey
|
331
|
+
step :start_checkout do
|
332
|
+
hero.checkout_started!
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
purchase = user.purchases.create!(sku: ...)
|
337
|
+
PurchaseJourney.create!(hero: purchase)
|
338
|
+
```
|
339
|
+
|
340
|
+
## Forward-scheduling or in-time scheduling
|
341
|
+
|
342
|
+
There are two known approaches for scheduling jobs far into the future. One approach is "in-time scheduling" - regularly run a _scheduling task_ which performs the steps that are up for execution. The code for such process would look roughly looks like this:
|
343
|
+
|
344
|
+
```ruby
|
345
|
+
Journey.where("state = 'ready' AND next_step_to_be_performed_at <= NOW()").find_each(&:perform_next_step!)
|
346
|
+
````
|
347
|
+
|
348
|
+
This scheduling task needs to be run with a high-enough frequency which matches your scheduling patterns.
|
349
|
+
|
350
|
+
Another is "forward-scheduling" - when it is known that a step of a journey will have to be performed at a certain point in time, enqueue a job which is going to perform the step:
|
351
|
+
|
352
|
+
```ruby
|
353
|
+
PerformStepJob.set(wait: journey.next_step_to_be_performed_at).perform_later(journey)
|
354
|
+
```
|
355
|
+
|
356
|
+
This creates a large number of jobs on your queue, but will be easier to manage. StepperMotor supports both approaches, and you can configure the one you like using the configuration:
|
357
|
+
|
358
|
+
```ruby
|
359
|
+
StepperMotor.configure do |c|
|
360
|
+
# Use jobs per journey step and enqueue them early
|
361
|
+
c.schedule_via = :waiting_jobs
|
362
|
+
end
|
363
|
+
```
|
364
|
+
|
365
|
+
or, for in-time scheduling
|
366
|
+
|
367
|
+
```ruby
|
368
|
+
StepperMotor.configure do |c|
|
369
|
+
# Use jobs per journey step and enqueue them early
|
370
|
+
c.schedule_via = :central_task
|
371
|
+
end
|
372
|
+
```
|
373
|
+
|
374
|
+
If you use in-time scheduling you will need to add the `StepperMotor::ScheduleLoopJob` to your cron jobs, and perform it frequently enough. Note that having just the granularity of your cron jobs (minutes) may not be enough as reattempts of the steps may get scheduled with a smaller delay - of a few seconds, for instance.
|
375
|
+
|
376
|
+
## Naming steps
|
377
|
+
|
378
|
+
stepper_motor will name steps for you. However, using named steps is useful because you then can insert steps between existing ones, and have your `Journey` correctly identify the right step. Steps are performed in the order they are defined. Imagine you start with this step sequence:
|
379
|
+
|
380
|
+
```ruby
|
381
|
+
step :one do
|
382
|
+
# perform some action
|
383
|
+
end
|
384
|
+
|
385
|
+
step :two do
|
386
|
+
# perform some other action
|
387
|
+
end
|
388
|
+
```
|
389
|
+
|
390
|
+
You have a `Journey` which is about to start step `one`. When the step gets performed, StepperMotor will do a lookup to find _the next step in order of definition._ In this case the step will be step `two`, so the name of that step will be saved with the `Journey`. Imagine you then edit the code to add an extra step between those:
|
391
|
+
|
392
|
+
```ruby
|
393
|
+
step :one do
|
394
|
+
# perform some action
|
395
|
+
end
|
396
|
+
|
397
|
+
step :one_bis_ do
|
398
|
+
# some compliance action
|
399
|
+
end
|
400
|
+
|
401
|
+
step :two do
|
402
|
+
# perform some other action
|
403
|
+
end
|
404
|
+
```
|
405
|
+
|
406
|
+
Your existing `Journey` is already primed to perform step `two`. However, a `Journey` which is about to perform step `one` will now set `one_bis` as the next step to perform. This allows limited reordering and editing of `Journey` definitions after they have already begun.
|
407
|
+
|
408
|
+
So, rules of thumb:
|
409
|
+
|
410
|
+
* When steps are recalled to be performed, they get recalled _by name._
|
411
|
+
* When preparing for the next step, _the next step from the current in order of definition_ is going to be used.
|
412
|
+
|
413
|
+
## Development
|
414
|
+
|
415
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
416
|
+
|
417
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
418
|
+
|
419
|
+
## Contributing
|
420
|
+
|
421
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/stepper-motor/stepper_motor.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "stepper_motor"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
require "irb"
|
11
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
module StepperMotor
|
7
|
+
# The generator is used to install StepperMotor. It adds an example Journey, a configing
|
8
|
+
# initializer and the migration that creates tables.
|
9
|
+
# Run it with `bin/rails g stepper_motor:install` in your console.
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
11
|
+
include ActiveRecord::Generators::Migration
|
12
|
+
|
13
|
+
source_paths << File.join(File.dirname(File.dirname(__FILE__)))
|
14
|
+
class_option :uuid, type: :boolean, desc: "The foreign key type to use for hero_id. Can be either bigint or uuid"
|
15
|
+
|
16
|
+
# Generates monolithic migration file that contains all database changes.
|
17
|
+
def create_migration_file
|
18
|
+
# Migration files are named "...migration_001.rb" etc. This allows them to be emitted
|
19
|
+
# as they get added, and the order of the migrations can be controlled using predictable sorting.
|
20
|
+
# Adding a new migration to the gem is then just adding a file.
|
21
|
+
migration_file_paths_in_order = Dir.glob(__dir__ + "/*_migration_*.rb.erb").sort
|
22
|
+
migration_file_paths_in_order.each do |migration_template_path|
|
23
|
+
untemplated_migration_filename = File.basename(migration_template_path).gsub(/\.erb$/, "")
|
24
|
+
migration_template(migration_template_path, File.join(db_migrate_path, untemplated_migration_filename))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def uuid_fk?
|
31
|
+
options["uuid"].present?
|
32
|
+
end
|
33
|
+
|
34
|
+
def migration_version
|
35
|
+
ActiveRecord::VERSION::STRING.split(".").take(2).join(".")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class StepperMotorMigration001 < ActiveRecord::Migration[<%= migration_version %>]
|
2
|
+
def change
|
3
|
+
<% if uuid_fk? %>
|
4
|
+
create_table :stepper_motor_journeys, id: :uuid do |t|
|
5
|
+
<% else %>
|
6
|
+
create_table :stepper_motor_journeys do |t|
|
7
|
+
<% end %>
|
8
|
+
t.string :type, null: false, index: true
|
9
|
+
t.string :state, default: "ready"
|
10
|
+
t.string :hero_type, null: true
|
11
|
+
<% if uuid_fk? %>
|
12
|
+
t.uuid :hero_id
|
13
|
+
<% else %>
|
14
|
+
t.bigint :hero_id
|
15
|
+
<% end %>
|
16
|
+
t.boolean :allow_multiple, default: false
|
17
|
+
t.string :previous_step_name
|
18
|
+
t.string :next_step_name
|
19
|
+
t.datetime :next_step_to_be_performed_at
|
20
|
+
t.bigint :steps_entered, default: 0, null: false
|
21
|
+
t.bigint :steps_completed, default: 0, null: false
|
22
|
+
t.timestamps
|
23
|
+
end
|
24
|
+
# Foreign key needs to be indexed for rapid lookups of journeys for a specific hero
|
25
|
+
add_index :stepper_motor_journeys, [:hero_type, :hero_id]
|
26
|
+
|
27
|
+
# An index is needed on the type/hero as well to check whether there is a journey
|
28
|
+
# for a specific hero of a specific class
|
29
|
+
add_index :stepper_motor_journeys, [:type, :hero_type, :hero_id]
|
30
|
+
|
31
|
+
# A unique index prevents multiple journeys of the same type from being created for a particular hero
|
32
|
+
add_index :stepper_motor_journeys, [:type, :hero_id, :hero_type], where: "allow_multiple = 'f' AND state IN ('ready', 'performing')", unique: true, name: :one_per_hero_index
|
33
|
+
|
34
|
+
# An index is also needed for cleaning up finished and canceled journeys quickly
|
35
|
+
# for a specific hero of a specific class
|
36
|
+
add_index :stepper_motor_journeys, [:updated_at], where: "state = 'canceled' OR state = 'finished'"
|
37
|
+
|
38
|
+
# An extra index is needed to speed up select-to-perform in case of central scheduling
|
39
|
+
add_index :stepper_motor_journeys, [:next_step_to_be_performed_at], where: "state = 'ready'"
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# The cyclic scheduler is designed to be run regularly via a cron job. On every
|
2
|
+
# cycle, it is going to look for Journeys which are going to come up for step execution
|
3
|
+
# before the next cycle is supposed to run. Then it is going to enqueue jobs to perform
|
4
|
+
# steps on those journeys. Since the scheduler gets run at a discrete interval, but we
|
5
|
+
# still them to be processed on time, if we only picked up the journeys which have the
|
6
|
+
# step execution time set to now or earlier, we will always have delays. This is why
|
7
|
+
# this scheduler enqueues jobs for journeys whose time to run is between now and the
|
8
|
+
# next cycle.
|
9
|
+
#
|
10
|
+
# Once the job gets created, it then gets enqueued and gets picked up by the ActiveJob
|
11
|
+
# worker normally. If you are using SQS, which has a limit of 900 seconds for the `wait:`
|
12
|
+
# value, you need to run the scheduler at least (!) every 900 seconds, and preferably
|
13
|
+
# more frequently (for example, once every 5 minutes). This scheduler is also going to be
|
14
|
+
# more gentle with ActiveJob adapters that may get slower with large queue depths, such as
|
15
|
+
# good_job. This scheduler is a good fit if you are using an ActiveJob adapter which:
|
16
|
+
#
|
17
|
+
# * Does not allow easy introspection of jobs in the future (like Redis-based queues)
|
18
|
+
# * Limits the value of the `wait:` parameter
|
19
|
+
#
|
20
|
+
# The scheduler needs to be configured in your cron table.
|
21
|
+
class StepperMotor::CyclicScheduler < StepperMotor::ForwardScheduler
|
22
|
+
class RunSchedulingCycleJob < ActiveJob::Base
|
23
|
+
def perform
|
24
|
+
scheduler = StepperMotor.scheduler
|
25
|
+
return unless scheduler.is_a?(StepperMotor::CyclicScheduler)
|
26
|
+
scheduler.run_scheduling_cycle
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Creates a new scheduler. The scheduler needs to know how frequently it is going to be running -
|
31
|
+
# you define that frequency when you configure your cron job that calls `run_scheduling_cycle`. Journeys which
|
32
|
+
# have to perform their steps between the runs of the cycles will generate jobs. The more frequent the scheduling
|
33
|
+
# cycle, the fewer jobs are going to be created per cycle.
|
34
|
+
#
|
35
|
+
# @param cycle_duration[ActiveSupport::Duration] how frequently the scheduler runs
|
36
|
+
def initialize(cycle_duration:)
|
37
|
+
@cycle_duration = cycle_duration
|
38
|
+
end
|
39
|
+
|
40
|
+
# Run a scheduling cycle. This should be called from your ActiveJob that runs on a regular Cron cadence. Ideally you
|
41
|
+
# would call the instance of the scheduler configured for the whole StepperMotor (so that the `cycle_duration` gets
|
42
|
+
# correctly applied, as it is necessary to pick the journeys to step). Normally, you would do this:
|
43
|
+
#
|
44
|
+
# @return [void]
|
45
|
+
def run_scheduling_cycle
|
46
|
+
# Find all the journeys that have to step before the next scheduling cycle. This also picks up journeys
|
47
|
+
# which haven't been scheduled or weren't scheduled on time. We don't want to only schedule
|
48
|
+
# journeys which are "past due", because this would make the timing of the steps very lax.
|
49
|
+
scope = StepperMotor::Journey.where("state = 'ready' AND next_step_name IS NOT NULL AND next_step_to_be_performed_at < ?", Time.current + @cycle_duration)
|
50
|
+
scope.find_each do |journey|
|
51
|
+
schedule(journey)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def schedule(journey)
|
56
|
+
# We assume that the previous `run_scheduling_cycle` has occured recently. The longest time it will take
|
57
|
+
# until the next `run_scheduling_cycle` is the duration of the cycle (`run_scheduling_cycle` did run
|
58
|
+
# just before `schedule` gets called). Therefore, it should be sufficient to assume that if the step
|
59
|
+
# has to run before the next `run_scheduling_cycle`, we have to schedule a job for it right now.
|
60
|
+
time_remaining = journey.next_step_to_be_performed_at - Time.current
|
61
|
+
super if time_remaining <= @cycle_duration
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# The forward scheduler enqueues a job for every Journey that
|
2
|
+
# gets sent to the `#schedule`. The job is then stored in the queue
|
3
|
+
# and gets picked up by the ActiveJob worker normally. This is the simplest
|
4
|
+
# option if your ActiveJob adapter supports far-ahead scheduling. Some adapters,
|
5
|
+
# such as SQS, have limitations regarding the maximum delay after which a message
|
6
|
+
# will become visible. For SQS, the limit is 900 seconds. If the job is further in the future,
|
7
|
+
# it is likely going to fail to get enqueued. If you are working with a queue adapter
|
8
|
+
# either:
|
9
|
+
#
|
10
|
+
# * Does not allow easy introspection of jobs in the future (like Redis-based queues)
|
11
|
+
# * Limits the value of the `wait:` parameter
|
12
|
+
#
|
13
|
+
# this scheduler is not a good fit for you, and you will need to use the {CyclicScheduler} instead.
|
14
|
+
class StepperMotor::ForwardScheduler
|
15
|
+
def schedule(journey)
|
16
|
+
wait = journey.next_step_to_be_performed_at - Time.current
|
17
|
+
wait = 0 if wait.negative?
|
18
|
+
StepperMotor::PerformStepJob.set(wait: wait).perform_later(journey.to_global_id.to_s)
|
19
|
+
end
|
20
|
+
end
|