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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +12 -0
  3. data/.github/workflows/ci.yml +51 -0
  4. data/CHANGELOG.md +77 -2
  5. data/Gemfile +11 -0
  6. data/README.md +13 -374
  7. data/Rakefile +21 -3
  8. data/bin/test +5 -0
  9. data/lib/generators/install_generator.rb +6 -1
  10. data/lib/generators/stepper_motor_migration_003.rb.erb +6 -0
  11. data/lib/generators/stepper_motor_migration_004.rb.erb +26 -0
  12. data/lib/stepper_motor/forward_scheduler.rb +8 -4
  13. data/lib/stepper_motor/journey/flow_control.rb +58 -0
  14. data/lib/stepper_motor/journey/recovery.rb +34 -0
  15. data/lib/stepper_motor/journey.rb +85 -84
  16. data/lib/stepper_motor/perform_step_job_v2.rb +2 -2
  17. data/lib/stepper_motor/recover_stuck_journeys_job_v1.rb +3 -1
  18. data/lib/stepper_motor/step.rb +70 -5
  19. data/lib/stepper_motor/version.rb +1 -1
  20. data/lib/stepper_motor.rb +0 -1
  21. data/lib/tasks/stepper_motor_tasks.rake +8 -0
  22. data/manual/MANUAL.md +538 -0
  23. data/rbi/stepper_motor.rbi +459 -0
  24. data/sig/stepper_motor.rbs +406 -3
  25. data/stepper_motor.gemspec +49 -0
  26. data/test/dummy/Rakefile +8 -0
  27. data/test/dummy/app/assets/stylesheets/application.css +1 -0
  28. data/test/dummy/app/controllers/application_controller.rb +6 -0
  29. data/test/dummy/app/helpers/application_helper.rb +4 -0
  30. data/test/dummy/app/jobs/application_job.rb +9 -0
  31. data/test/dummy/app/mailers/application_mailer.rb +6 -0
  32. data/test/dummy/app/models/application_record.rb +5 -0
  33. data/test/dummy/app/views/layouts/application.html.erb +27 -0
  34. data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  35. data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  36. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  37. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  38. data/test/dummy/bin/dev +2 -0
  39. data/test/dummy/bin/rails +4 -0
  40. data/test/dummy/bin/rake +4 -0
  41. data/test/dummy/bin/setup +34 -0
  42. data/test/dummy/config/application.rb +28 -0
  43. data/test/dummy/config/boot.rb +7 -0
  44. data/test/dummy/config/cable.yml +10 -0
  45. data/test/dummy/config/database.yml +32 -0
  46. data/test/dummy/config/environment.rb +7 -0
  47. data/test/dummy/config/environments/development.rb +71 -0
  48. data/test/dummy/config/environments/production.rb +91 -0
  49. data/test/dummy/config/environments/test.rb +55 -0
  50. data/test/dummy/config/initializers/content_security_policy.rb +27 -0
  51. data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  52. data/test/dummy/config/initializers/inflections.rb +18 -0
  53. data/test/dummy/config/initializers/stepper_motor.rb +3 -0
  54. data/test/dummy/config/locales/en.yml +31 -0
  55. data/test/dummy/config/puma.rb +40 -0
  56. data/test/dummy/config/routes.rb +16 -0
  57. data/test/dummy/config/storage.yml +34 -0
  58. data/test/dummy/config.ru +8 -0
  59. data/test/dummy/db/migrate/20250520094921_stepper_motor_migration_001.rb +38 -0
  60. data/test/dummy/db/migrate/20250520094922_stepper_motor_migration_002.rb +8 -0
  61. data/test/dummy/db/migrate/20250522212312_stepper_motor_migration_003.rb +7 -0
  62. data/test/dummy/db/migrate/20250525110812_stepper_motor_migration_004.rb +28 -0
  63. data/test/dummy/db/schema.rb +37 -0
  64. data/test/dummy/public/400.html +114 -0
  65. data/test/dummy/public/404.html +114 -0
  66. data/test/dummy/public/406-unsupported-browser.html +114 -0
  67. data/test/dummy/public/422.html +114 -0
  68. data/test/dummy/public/500.html +114 -0
  69. data/test/dummy/public/icon.png +0 -0
  70. data/test/dummy/public/icon.svg +3 -0
  71. data/test/side_effects_helper.rb +67 -0
  72. data/test/stepper_motor/cyclic_scheduler_test.rb +77 -0
  73. data/{spec/stepper_motor/forward_scheduler_spec.rb → test/stepper_motor/forward_scheduler_test.rb} +9 -10
  74. data/test/stepper_motor/journey/exception_handling_test.rb +89 -0
  75. data/test/stepper_motor/journey/flow_control_test.rb +78 -0
  76. data/test/stepper_motor/journey/idempotency_test.rb +65 -0
  77. data/test/stepper_motor/journey/step_definition_test.rb +187 -0
  78. data/test/stepper_motor/journey/uniqueness_test.rb +48 -0
  79. data/test/stepper_motor/journey_test.rb +352 -0
  80. data/{spec/stepper_motor/recover_stuck_journeys_job_spec.rb → test/stepper_motor/recover_stuck_journeys_job_test.rb} +14 -14
  81. data/{spec/stepper_motor/recovery_spec.rb → test/stepper_motor/recovery_test.rb} +27 -27
  82. data/test/stepper_motor/test_helper_test.rb +44 -0
  83. data/test/stepper_motor_test.rb +9 -0
  84. data/test/test_helper.rb +46 -0
  85. metadata +120 -24
  86. data/.rspec +0 -3
  87. data/.ruby-version +0 -1
  88. data/.standard.yml +0 -4
  89. data/.yardopts +0 -1
  90. data/spec/helpers/side_effects.rb +0 -85
  91. data/spec/spec_helper.rb +0 -90
  92. data/spec/stepper_motor/cyclic_scheduler_spec.rb +0 -68
  93. data/spec/stepper_motor/generator_spec.rb +0 -16
  94. data/spec/stepper_motor/journey_spec.rb +0 -401
  95. data/spec/stepper_motor/test_helper_spec.rb +0 -48
  96. data/spec/stepper_motor_spec.rb +0 -7
data/manual/MANUAL.md ADDED
@@ -0,0 +1,538 @@
1
+
2
+ ## Installation
3
+
4
+ Add the gem to the application's Gemfile, and then generate and run the migration
5
+
6
+ $ bundle add stepper_motor
7
+ $ bundle install
8
+ $ bin/rails g stepper_motor:install --uuid # Pass "uuid" if you are using UUID for your primary and foreign keys
9
+ $ bin/rails db:migrate
10
+
11
+
12
+ ## Intro
13
+
14
+ `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.
15
+
16
+ ## Philosophy behind stepper_motor
17
+
18
+ Most of our applications have workflows which have to happen in steps. They pretty much always have some things in common:
19
+
20
+ * We want just one workflow of a certain type per user or per business transaction
21
+ * We want only one parallel execution of a unique workflow at a time
22
+ * We want the steps to be explicitly idempotent
23
+ * We want visibility into the step our workflow is in, what step it is going to enter, what step it has left
24
+
25
+ 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:
26
+
27
+ * 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)
28
+ * 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
29
+ * Adopting a complex solution like [Temporal.io](https://temporal.io/), with the app relegated to just executing parts of the workflow
30
+
31
+ We believe all of these solutions do not quite hit the "sweet spot" where step workflows would integrate well with Rails.
32
+
33
+ * Most Rails apps already have a perfectly fit transactional, durable data store - the main database
34
+ * The devloper should not have intimate understanding of DB atomicity and ActiveRecord `with_lock` and `reload` to have step workflows
35
+ * 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. It should not be needed to run complex scheduling systems such as ZooKeeper either.
36
+
37
+ So, stepper_motor 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. We hope you will enjoy using it just as much as we do! Let's dive in!
38
+
39
+ ## A brief introduction to stepper_motor
40
+
41
+ stepper_motor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels. A `Journey` is just an `ActiveRecord` model, with all the persistence methods you already know and use.
42
+
43
+ Steps are defined inside the Journey subclasses as blocks, and they run in the context of that subclass' instance. The following constraints apply:
44
+
45
+ * For any one Journey, only one Fiber/Thread/Process may be performing a step on it
46
+ * For any one Journey, only one step can be executing at any given time
47
+ * For any `hero`, multiple different Journeys may exist and be in different stages of completion
48
+ * For any `hero`, multiple Journeys of the same class may exist and be in different stages of completion if that was permitted at Journey creation
49
+
50
+ 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`.
51
+
52
+ The steps are performed asynchronously, via ActiveJob. When a Journey is created, it gets scheduled for its initial step. The job then gets picked up by the ActiveJob queue worker (whichever you are using) and triggers the step on the `Journey`. If the journey decides to continue to the next step, it schedules another ActiveJob for itself with the step name and other details necessary.
53
+
54
+ No state is carried inside the job.
55
+
56
+ ## Installation
57
+
58
+ Add the gem to the application's Gemfile, and then generate and run the migration
59
+
60
+ $ bundle add stepper_motor
61
+ $ bundle install
62
+ $ bin/rails g stepper_motor:install
63
+ $ bin/rails db:migrate
64
+
65
+ ## Usage
66
+
67
+ Define a workflow and launch your user into it:
68
+
69
+ ```ruby
70
+ class SignupJourney < StepperMotor::Journey
71
+ step :after_signup do
72
+ WelcomeMailer.welcome_email(hero).deliver_later
73
+ end
74
+
75
+ step :remind_of_tasks, wait: 2.days do
76
+ ServiceUpdateMailer.two_days_spent_email(hero).deliver_later
77
+ end
78
+
79
+ step :onboarding_complete_, wait: 15.days do
80
+ OnboardingCompleteMailer.onboarding_complete_email(hero).deliver_later
81
+ end
82
+ end
83
+
84
+ class SignupController
85
+ def create
86
+ # ...your other business actions
87
+ SignupJourney.create!(hero: current_user)
88
+ redirect_to user_root_path(current_user)
89
+ end
90
+ end
91
+ ```
92
+
93
+ ## A few sample journeys
94
+
95
+ ### Single step with repeats
96
+
97
+ 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:
98
+
99
+ ```ruby
100
+ class ChurnPreventionJourney < StepperMotor::Journey
101
+ step do
102
+ cancel! if hero.subscription_lapses_at > 120.days.from_now
103
+
104
+ time_remaining_until_expiry_ = hero.subscription_lapses_at - Time.current
105
+ if time_remaining_until_expiry > 1.days
106
+ ResubscribeReminderMailer.extend_subscription_reminder(hero).deliver_later
107
+ send_next_reminder_after = (time_remaining_until_expiry / 2).in_days.floor
108
+ reattempt!(wait: send_next_reminder_after.days)
109
+ else
110
+ # If the user has churned - let the journey finish, as there is nothing to do
111
+ SadToSeeYouGoMailer.farewell(hero).deliver_later
112
+ end
113
+ end
114
+ end
115
+
116
+ ChurnPreventionJourney.create(hero: user)
117
+ ```
118
+
119
+ 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.
120
+
121
+ ### Email drip campaign
122
+
123
+ As our second example, let's check out a drip campaign which inceitivises a user with bonuses as their account nears termination.
124
+
125
+ ```ruby
126
+ class ReengagementJourney < StepperMotor::Journey
127
+ step :first do
128
+ cancel! if reengaged?
129
+ hero.bonus_programs.create!(type: BonusProgram::REENGAGEMENT)
130
+ hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 1})
131
+ end
132
+
133
+ step :second, wait: 14.days do
134
+ cancel! if reengaged?
135
+ hero.bonus_programs.create!(type: BonusProgram::DISCOUNT)
136
+ hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 2})
137
+ end
138
+
139
+ step :third, wait: 7.days do
140
+ cancel! if reengaged?
141
+ hero.bonus_programs.create!(type: BonusProgram::DOUBLE_DISCOUNT)
142
+ hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 3})
143
+ end
144
+
145
+ step :final, wait: 3.days do
146
+ cancel! if reengaged?
147
+ hero.close_account!
148
+ hero.push_anayltics_event!(event: "reengagement_touchpoint", properties: {step: 4})
149
+ end
150
+
151
+ def reengaged?
152
+ # If the user purchased anything after this journey started,
153
+ # consider them "re-engaged"
154
+ hero.purchases.where("created_at > ?", created_at).any?
155
+ end
156
+ end
157
+ ```
158
+
159
+ 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`.
160
+
161
+ ### Archiving and deleting user data
162
+
163
+ 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.
164
+
165
+ ```ruby
166
+ class AccountErasureJourney < StepperMotor::Journey
167
+ step :deactivate_user do
168
+ hero.deactivated!
169
+ end
170
+
171
+ step :remove_authentication_tokens do
172
+ hero.sessions.destroy_all
173
+ hero.authentication_tokens.destroy_all
174
+ end
175
+
176
+ step :archive_pseudonymized_data do
177
+ DatapointArchive.create(name> "user-#{hero.id}-datapoints.gz") do |io|
178
+ CSV(io) do |csv|
179
+ csv << hero.datapoints.first.attributes.keys
180
+ hero.datapoints.each do |datapoint|
181
+ csv << Pseudonymizer.scrub(datapoint.attributes.values)
182
+ end
183
+ end
184
+ end
185
+ end
186
+
187
+ step :delete_data do
188
+ hero.datapoints.in_batches.destroy_all
189
+ end
190
+
191
+ step :send_deletion_email do
192
+ AccountErasureCompleteMailer.erasure_complete(hero).deliver_later
193
+ end
194
+ end
195
+ ```
196
+
197
+ 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.
198
+
199
+ ### Performing an outgoing payment
200
+
201
+ 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.
202
+
203
+ ```ruby
204
+ class PaymentInitiationJourney < StepperMotor::Journey
205
+ step :initiate_payment do
206
+ ik = hero.idempotency_key # The `hero` in this case is a Payment, not the User
207
+ result = PaymentProvider.transfer!(
208
+ from_account: hero.sender.bank_account_details,
209
+ to_account: hero.recipient.bank_account_details,
210
+ amount: hero.amount,
211
+ idempotency_key: ik
212
+ )
213
+ if result.intermittent_error?
214
+ reattempt!(wait: 5.seconds)
215
+ elsif result.invalid_request?
216
+ hero.failed!
217
+ cancel!
218
+ else
219
+ hero.processing!
220
+ # and then do nothing and proceed to the next step
221
+ end
222
+ end
223
+
224
+ step :confirm_payment do
225
+ ik = hero.idempotency_key # The `hero` in this case is a Payment, not the User
226
+ payment_details = PaymentProvider.details(idempotency_key: ik)
227
+ case payment_details.state
228
+ when :complete
229
+ hero.complete!
230
+ PaymentSentNotification.notify_sender_of_success(hero.sender).deliver_later
231
+ when :failed
232
+ hero.failed!
233
+ PaymentSentNotification.notify_sender_of_failure(hero.sender).deliver_later
234
+ else
235
+ logger.info {"Payment #{hero} still confirming" }
236
+ reattempt!(wait: 30.seconds) if payment_details.state == :processing
237
+ end
238
+ end
239
+ end
240
+ ```
241
+
242
+ 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.
243
+
244
+ ## Flow control within steps
245
+
246
+ Inside a step, you currently can use the following flow control methods:
247
+
248
+ * `cancel!` - cancel the Journey immediately. It will be persisted and moved into the `canceled` state.
249
+ * `reattempt!` - reattempt the Journey immediately, triggering it asynchronously. It will be persisted
250
+ and returned into the `ready` state. You can specify the `wait:` interval, which may deviate from
251
+ the wait time defined for the current step
252
+ * `pause!` - pause the Journey either within a step or outside of one. This moves the Journey into the `paused` state.
253
+ In that state, the journey is still considered unique-per-hero (you won't be able to create an identical Journey)
254
+ but it will not be picked up by the scheduled step jobs. Should it get picked up, the step will not be performed.
255
+ You have to explicitly `resume!` the Journey to make it `ready` - once you do, a new job will be scheduled to
256
+ perform the step.
257
+
258
+ > [!IMPORTANT]
259
+ > Flow control methods use `throw` when they are called from inside a step. Unlike Rails `render` or `redirect` that require an explicit
260
+ > `return`, the code following a `reattempt!` or `cancel!` within the same scope will not be executed, so those methods may only be called once within a particular scope.
261
+
262
+ You can't call those methods outside of the context of a performing step, and an exception is going to be raised if you do.
263
+
264
+
265
+ ## Transactional semantics within steps
266
+
267
+ Getting the transactional semantics _right_ with a system like stepper_motor is crucial. We strike a decent balance between reliability/durability and performance, namely:
268
+
269
+ * The initial "checkout" of a `Journey` for performing a step is lock-guarded
270
+ * 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 ever checkout that same `Journey`
271
+ * 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.
272
+
273
+ We chose to make stepper_motor "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.
274
+
275
+ 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.
276
+
277
+ Should you need to wrap your entire step in a transaction, you can do so manually.
278
+
279
+ ## ActiveJob and transactions
280
+
281
+ We recommend using a "co-committing" ActiveJob adapter with stepper_motor (an adapter which has the queue in the same RDBMS as your business model tables). Queue adapters that support this:
282
+
283
+ * [gouda](https://github.com/cheddar-me/gouda)
284
+ * [good_job](https://github.com/bensheldon/good_job)
285
+ * [solid_queue](https://github.com/rails/solid_queue) - with the same database used for the queue as the one used for Journeys
286
+
287
+ While Rails core admittedly [insists on the stylistic choice of denying the users the option of co-committing their jobs](https://github.com/rails/rails/pull/53375#issuecomment-2555694252) we find this a highly inconsiderate choice, which has highly negative consequences for a system such as stepper_motor - where durability is paramount. Having good defaults is appropriate, but not removing a crucial affordance that a DB-based job queue provides is downright user-hostile.
288
+
289
+ In the future, stepper_motor _may_ move to a transactional outbox pattern whereby we emit events into a separate table and whichever queue adapter you have installed will be picking those messages up.
290
+
291
+ For its own "trigger" job (the `PerformStepJob` and its versions) stepper_motor is configured to commit it with the Journey state changes, within the same transaction.
292
+
293
+ This is done for the following reasons:
294
+
295
+ * Not having the Journey with up-to-date state in teh DB when the job performs will lead to the job silently skipping, which is undesirable
296
+ * But having the app crash between the Journey state committing and the trigger job committing is even less desirable. This would lead to jobs hanging in the `ready` state indefinitly, seemingly at random.
297
+
298
+ ## Saving side-effects of steps
299
+
300
+ Right now, stepper_motor 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:
301
+
302
+ * A record of the fact that a step has been performed to completion is sufficient to not re-enter that step
303
+ * If you need repeatable, but idempotent steps - idempotency is on you
304
+
305
+ ## Unique Journeys
306
+
307
+ By default, stepper_motor 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:
308
+
309
+ ```ruby
310
+ SomeJourney.create!(hero: user)
311
+ SomeJourney.create!(hero: user)
312
+ ```
313
+
314
+ 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:
315
+
316
+ ```ruby
317
+ SomeJourney.create!(hero: user, allow_multiple: true)
318
+ SomeJourney.create!(hero: user, allow_multiple: true)
319
+ ```
320
+
321
+ ## Querying for Journeys already created
322
+
323
+ 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:
324
+
325
+ ```sql
326
+ SELECT users.* FROM users WHERE NOT EXISTS (SELECT 1 FROM stepper_motor_journeys WHERE type = 'YourJourney' AND hero_id = users.id)
327
+ ```
328
+
329
+ To make this simpler, we offer a special helper method:
330
+
331
+ ```ruby
332
+ YourJourney.presence_sql_for(User) # => SELECT 1 FROM stepper_motor_journeys WHERE type = 'YourJourney' AND hero_id = users.id
333
+ ```
334
+
335
+ ## What to pick as the hero
336
+
337
+ 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.
338
+
339
+ So instead of doing this:
340
+
341
+ ```ruby
342
+ class PurchaseJourney < StepperMotor::Journey
343
+ step :start_checkout do
344
+ hero.purchases.create!(sku: ...)
345
+ end
346
+ end
347
+
348
+ PurchaseJourney.create!(hero: user, allow_multiple: true)
349
+ ```
350
+
351
+ try this:
352
+
353
+ ```ruby
354
+ class PurchaseJourney < StepperMotor::Journey
355
+ step :start_checkout do
356
+ hero.checkout_started!
357
+ end
358
+ end
359
+
360
+ purchase = user.purchases.create!(sku: ...)
361
+ PurchaseJourney.create!(hero: purchase)
362
+ ```
363
+
364
+ ## Forward-scheduling or in-time scheduling
365
+
366
+ 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:
367
+
368
+ ```ruby
369
+ Journey.where("state = 'ready' AND next_step_to_be_performed_at <= NOW()").find_each(&:perform_next_step!)
370
+ ```
371
+
372
+ This scheduling task needs to be run with a high-enough frequency which matches your scheduling patterns.
373
+
374
+ 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:
375
+
376
+ ```ruby
377
+ PerformStepJob.set(wait: journey.next_step_to_be_performed_at).perform_later(journey)
378
+ ```
379
+
380
+ This creates a large number of jobs on your queue, but will be easier to manage. stepper_motor supports both approaches, and you can configure the one you like using the configuration:
381
+
382
+ ```ruby
383
+ StepperMotor.configure do |c|
384
+ # Use jobs per journey step and enqueue them early
385
+ c.scheduler = StepperMotor::ForwardScheduler.new
386
+ end
387
+ ```
388
+
389
+ or, for cyclic scheduling (less jobs on the queue, but you need a decent scheduler for your background jobs to be present:
390
+
391
+ ```ruby
392
+ StepperMotor.configure do |c|
393
+ # Check for jobs to be created every 5 minutes
394
+ c.scheduler = StepperMotor::CyclicScheduler.new((cycle_duration: 5.minutes)
395
+ end
396
+ ```
397
+
398
+ 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.
399
+
400
+ ## Naming steps
401
+
402
+ 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:
403
+
404
+ ```ruby
405
+ step :one do
406
+ # perform some action
407
+ end
408
+
409
+ step :two do
410
+ # perform some other action
411
+ end
412
+ ```
413
+
414
+ You have a `Journey` which is about to start step `one`. When the step gets performed, stepper_motor 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:
415
+
416
+ ```ruby
417
+ step :one do
418
+ # perform some action
419
+ end
420
+
421
+ step :one_bis do
422
+ # some compliance action
423
+ end
424
+
425
+ step :two do
426
+ # perform some other action
427
+ end
428
+ ```
429
+
430
+ 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.
431
+
432
+ So, rules of thumb:
433
+
434
+ * When steps are recalled to be performed, they get recalled _by name._
435
+ * When preparing for the next step, _the next step from the current in order of definition_ is going to be used.
436
+
437
+ ## Using instance methods as steps
438
+
439
+ You can use instance methods as steps by passing their name as a symbol to the `step` method:
440
+
441
+ ```ruby
442
+ class Erasure < StepperMotor::Journey
443
+ step :erase_attachments
444
+ step :erase_emails
445
+
446
+ def erase_attachments
447
+ hero.uploaded_attachments.find_each(&:destroy)
448
+ end
449
+
450
+ def erase_emails
451
+ while hero.emails.count > 0
452
+ hero.emails.limit(5000).delete_all
453
+ end
454
+ end
455
+ end
456
+ ```
457
+
458
+ Since a method definition in Ruby returns a Symbol, you can use the return value of the `def` expression
459
+ to define a `step` immediately:
460
+
461
+ ```ruby
462
+ class Erasure < StepperMotor::Journey
463
+ step def erase_attachments
464
+ hero.uploaded_attachments.find_each(&:destroy)
465
+ end
466
+
467
+ step def erase_emails
468
+ while hero.emails.count > 0
469
+ hero.emails.limit(5000).delete_all
470
+ end
471
+ end
472
+ end
473
+ ```
474
+ ## Exception handling inside steps
475
+
476
+ > [!IMPORTANT]
477
+ > Exception handling in steps is in flux, expect API changes.
478
+
479
+ When performing the step, any exceptions raised from within the step will be stored in a local
480
+ variable to allow the Journey to be released as either `ready`, `finished` or `canceled`. The exception
481
+ will be raised from within an `ensure` block after the persistence of the Journey has been taken care of.
482
+
483
+ By default, an exception raised inside a step of a Journey will _pause_ that Journey. This is done for a number of reasons:
484
+
485
+ * An endlessly reattempting step can cause load on your infrastructure and will never stop retrying
486
+ * Since at the moment there is no configuration for backoff, such a step is likely to hit rate limits on the external resource it hits
487
+ * It is likely a condition that was not anticipated when the Journey was written, thus a blind reattempt is unwise.
488
+
489
+ While we may change this in future versions of `stepper_motor`, the current default is thus to `pause!` the Journey if an unhandled
490
+ exception occurs. You can, however, switch it to `reattempt!` or `cancel!` a Journey should a particular step raise. This is configured per step:
491
+
492
+ ```ruby
493
+ class Erasure < StepperMotor::Journey
494
+ step :initiate_deletion, on_exception: :reattempt! do
495
+ # ..Do the requisite work
496
+ end
497
+ end
498
+ ```
499
+
500
+ or, if you know that the correct action is to cancel the journey - specify it explicitly (even though it is the default at the moment)
501
+
502
+ ```ruby
503
+ class Erasure < StepperMotor::Journey
504
+ step :initiate_deletion, on_exception: :cancel! do
505
+ # ..Do the requisite work
506
+ end
507
+ end
508
+ ```
509
+
510
+ We recommend handling exceptions you care about explicitly inside your step definitions. This allows for
511
+ more fine-grained error matching and does not disrupt the step execution. If you want to register the
512
+ exceptions you `rescue` inside steps, make use of the `Rails.error.report` [method](https://guides.rubyonrails.org/error_reporting.html#manually-reporting-errors)
513
+
514
+ ```ruby
515
+ class Payment < StepperMotor::Journey
516
+ step def initiate_payment
517
+ payment = hero
518
+ client = PaymentProvider::Client.new
519
+ client.initiate_payment(idempotency_key: payment.id, amount: payment.amount_cents, recipient: payment.recipient.id)
520
+ rescue PaymentProvider::ConfigurationError => e
521
+ payment.failed!
522
+ Rails.error.report(e)
523
+ cancel! # Without reconfiguration the payment will never initiate
524
+ rescue PaymentProvider::RateLimitExceeded => e
525
+ reattempt! wait: e.retry_after
526
+ rescue PaymentProvider::InsufficientFunds => e
527
+ payment.sender.add_compliance_note("Halted payment due to insufficient funds")
528
+ pause!
529
+ rescue PaymentProvider::Timeout
530
+ reattempt! wait: rand(0.0..5.0) # Add some jitter
531
+ rescue PaymentProvider::AccountBlocked
532
+ paymend.failed!
533
+ cancel! # Do not even report the error - the account has been closed and will stay closed forever
534
+ end
535
+ end
536
+ ```
537
+
538
+