stepper_motor 0.1.12 → 0.1.15
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/workflows/ci.yml +50 -8
- data/CHANGELOG.md +10 -0
- data/lib/generators/stepper_motor_migration_005.rb.erb +58 -0
- data/lib/stepper_motor/journey.rb +11 -8
- data/lib/stepper_motor/step.rb +36 -8
- data/lib/stepper_motor/version.rb +1 -1
- data/manual/MANUAL.md +337 -86
- data/rbi/stepper_motor.rbi +18 -4
- data/sig/stepper_motor.rbs +15 -2
- data/stepper_motor.gemspec +2 -0
- data/test/dummy/config/database.mysql2.yml +14 -0
- data/test/dummy/config/database.postgres.yml +14 -0
- data/test/dummy/config/database.sqlite3.yml +32 -0
- data/test/dummy/config/initializers/stepper_motor.rb +1 -1
- data/test/dummy/db/migrate/20250609221201_stepper_motor_migration_005.rb +58 -0
- data/test/dummy/db/schema.rb +7 -6
- data/test/stepper_motor/journey/if_condition_test.rb +286 -0
- data/test/stepper_motor/journey/step_definition_test.rb +1 -0
- metadata +37 -6
data/manual/MANUAL.md
CHANGED
@@ -230,6 +230,322 @@ end
|
|
230
230
|
|
231
231
|
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.
|
232
232
|
|
233
|
+
### Polling with anonymous steps
|
234
|
+
|
235
|
+
Here's an example of a Journey that polls an external API for a status update, using anonymous steps with increasing intervals:
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
class OrderStatusPollingJourney < StepperMotor::Journey
|
239
|
+
alias_method :order, :hero
|
240
|
+
|
241
|
+
# Initial check
|
242
|
+
step do
|
243
|
+
check_status!
|
244
|
+
end
|
245
|
+
|
246
|
+
# Check after 30 seconds
|
247
|
+
step(wait: 30.seconds) do
|
248
|
+
check_status!
|
249
|
+
end
|
250
|
+
|
251
|
+
# Check every minute for 5 minutes
|
252
|
+
5.times do
|
253
|
+
step(wait: 1.minute) do
|
254
|
+
check_status!
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Check every 5 minutes for 30 minutes
|
259
|
+
6.times do
|
260
|
+
step(wait: 5.minutes) do
|
261
|
+
check_status!
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
# Final check after 1 hour
|
266
|
+
step(wait: 1.hour) do
|
267
|
+
check_status!
|
268
|
+
end
|
269
|
+
|
270
|
+
private
|
271
|
+
|
272
|
+
def check_status!
|
273
|
+
status = ExternalOrderAPI.fetch_status(order.external_id)
|
274
|
+
|
275
|
+
case status
|
276
|
+
when :completed
|
277
|
+
order.complete!
|
278
|
+
finished!
|
279
|
+
when :failed
|
280
|
+
order.fail!
|
281
|
+
cancel!
|
282
|
+
when :processing
|
283
|
+
# Do nothing, will continue to next step
|
284
|
+
else
|
285
|
+
# Unexpected status, pause for manual investigation
|
286
|
+
pause!
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
290
|
+
```
|
291
|
+
|
292
|
+
## Defining steps
|
293
|
+
|
294
|
+
Steps are key building blocks in stepper_motor and can be defined in a number of ways. Rules of thumb:
|
295
|
+
|
296
|
+
* When steps are recalled to be performed, they get recalled _by name._
|
297
|
+
* When preparing for the next step or skipping to the next step, _the next step from the current in order of definition_ is going to be used.
|
298
|
+
* Step names within a Journey subclass must be unique.
|
299
|
+
|
300
|
+
stepper_motor will help you follow these rules, so don't worry too much about them just yet.
|
301
|
+
|
302
|
+
The step definition is some form of callable block, which is then going to run in the context of this Journey instance (or the Journey subclass' instance)
|
303
|
+
|
304
|
+
> [!TIP]
|
305
|
+
> Regardless whether the step definition is a method or a block - the step will be `instance_exec`-ed in the context of the Journey instance.
|
306
|
+
> Inside the step code you have access to all the methods of the Journey, including instance variables.
|
307
|
+
|
308
|
+
### Anonymous steps
|
309
|
+
|
310
|
+
The simplest is using anonymous steps and blocks (stepper_motor will generate you step names):
|
311
|
+
|
312
|
+
```ruby
|
313
|
+
class EraseAccountJourney < StepperMotor::Journey
|
314
|
+
step { hero.datapoints.in_batches.each(&:delete_all) }
|
315
|
+
step { hero.update!(email: "#{Digest::SHA1.hexdigest(hero.email)}@example.com") }
|
316
|
+
end
|
317
|
+
```
|
318
|
+
|
319
|
+
Anonymous steps have a very good use for polling, when you want to define the repeats of the step programmatically. For example:
|
320
|
+
|
321
|
+
```ruby
|
322
|
+
class PollStatusJourney < StepperMotor::Journey
|
323
|
+
alias_method :payment, :hero
|
324
|
+
|
325
|
+
step { verify_status! }
|
326
|
+
|
327
|
+
# Then after 5 minutes
|
328
|
+
step(wait: 5.minutes) { verify_status! }
|
329
|
+
|
330
|
+
# Check every 2 hours after
|
331
|
+
12.times do
|
332
|
+
step(wait: 2.hours) { verify_status! }
|
333
|
+
end
|
334
|
+
|
335
|
+
# Check once a day after that
|
336
|
+
7.times do
|
337
|
+
step(wait: 1.day) { verify_status! }
|
338
|
+
end
|
339
|
+
|
340
|
+
step :terminate do
|
341
|
+
payment.failed!
|
342
|
+
end
|
343
|
+
|
344
|
+
def verify_status!
|
345
|
+
status = payment.current_status
|
346
|
+
finished! if status.complete?
|
347
|
+
end
|
348
|
+
end
|
349
|
+
```
|
350
|
+
|
351
|
+
### Named steps
|
352
|
+
|
353
|
+
You can give the steps names. Step names must be unique and stepper_motor will raise an exception if you reuse a step name:
|
354
|
+
|
355
|
+
```ruby
|
356
|
+
class EraseAccountJourney < StepperMotor::Journey
|
357
|
+
step :delete_datapoints { hero.datapoints.in_batches.each(&:delete_all) }
|
358
|
+
step :pseudonymize_email { hero.update!(email: "#{Digest::SHA1.hexdigest(hero.email)}@example.com") }
|
359
|
+
end
|
360
|
+
```
|
361
|
+
|
362
|
+
The name of the step is what stepper_motor is going to persist to resume the Journey later. Naming your steps is useful because you then can then update your code and insert steps between the 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:
|
363
|
+
|
364
|
+
```ruby
|
365
|
+
step :one do
|
366
|
+
# perform some action
|
367
|
+
end
|
368
|
+
|
369
|
+
step :two do
|
370
|
+
# perform some other action
|
371
|
+
end
|
372
|
+
```
|
373
|
+
|
374
|
+
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:
|
375
|
+
|
376
|
+
```ruby
|
377
|
+
step :one do
|
378
|
+
# perform some action
|
379
|
+
end
|
380
|
+
|
381
|
+
step :one_bis do
|
382
|
+
# some compliance action
|
383
|
+
end
|
384
|
+
|
385
|
+
step :two do
|
386
|
+
# perform some other action
|
387
|
+
end
|
388
|
+
```
|
389
|
+
|
390
|
+
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.
|
391
|
+
|
392
|
+
### Using instance methods as steps
|
393
|
+
|
394
|
+
You can use instance methods of your Journey as steps by passing their name as a symbol to the `step` method:
|
395
|
+
|
396
|
+
```ruby
|
397
|
+
class Erasure < StepperMotor::Journey
|
398
|
+
step :erase_attachments
|
399
|
+
step :erase_emails
|
400
|
+
|
401
|
+
def erase_attachments
|
402
|
+
hero.uploaded_attachments.find_each(&:destroy)
|
403
|
+
end
|
404
|
+
|
405
|
+
def erase_emails
|
406
|
+
while hero.emails.count > 0
|
407
|
+
hero.emails.limit(5000).delete_all
|
408
|
+
end
|
409
|
+
end
|
410
|
+
end
|
411
|
+
```
|
412
|
+
|
413
|
+
Since a method definition in Ruby returns a Symbol, you can use the return value of the `def` expression
|
414
|
+
to define a `step` immediately:
|
415
|
+
|
416
|
+
```ruby
|
417
|
+
class Erasure < StepperMotor::Journey
|
418
|
+
step def erase_attachments
|
419
|
+
hero.uploaded_attachments.find_each(&:destroy)
|
420
|
+
end
|
421
|
+
|
422
|
+
step def erase_emails
|
423
|
+
while hero.emails.count > 0
|
424
|
+
hero.emails.limit(5000).delete_all
|
425
|
+
end
|
426
|
+
end
|
427
|
+
end
|
428
|
+
```
|
429
|
+
|
430
|
+
### Conditional steps with `if:`
|
431
|
+
|
432
|
+
You can make steps conditional by using the `if:` parameter. This allows you to skip steps based on runtime conditions. The `if:` parameter accepts:
|
433
|
+
|
434
|
+
* A symbol (method name) that returns a boolean.
|
435
|
+
* A callable (lambda or proc) that returns a boolean. It will be `instance_exec`d in the context of the Journey.
|
436
|
+
* A literal boolean value (`true` or `false`) or a `nil` (treated as `false`)
|
437
|
+
|
438
|
+
When a step's condition evaluates to `false`, the step is skipped and the journey continues to the next step. If there are no more steps, the journey finishes.
|
439
|
+
|
440
|
+
#### Using method names as conditions
|
441
|
+
|
442
|
+
```ruby
|
443
|
+
class UserOnboardingJourney < StepperMotor::Journey
|
444
|
+
step :send_welcome_email, if: :should_send_welcome? do
|
445
|
+
WelcomeMailer.welcome(hero).deliver_later
|
446
|
+
end
|
447
|
+
|
448
|
+
step :send_premium_offer, if: :is_premium_user? do
|
449
|
+
PremiumOfferMailer.exclusive_offer(hero).deliver_later
|
450
|
+
end
|
451
|
+
|
452
|
+
step :complete_onboarding do
|
453
|
+
hero.update!(onboarding_completed_at: Time.current)
|
454
|
+
end
|
455
|
+
|
456
|
+
private
|
457
|
+
|
458
|
+
def should_send_welcome?
|
459
|
+
hero.email.present? && !hero.welcome_email_sent?
|
460
|
+
end
|
461
|
+
|
462
|
+
def is_premium_user?
|
463
|
+
hero.subscription&.premium?
|
464
|
+
end
|
465
|
+
end
|
466
|
+
```
|
467
|
+
|
468
|
+
#### Using callable conditions
|
469
|
+
|
470
|
+
You can use lambdas or procs for more dynamic conditions. They will be `instance_exec`d in the context of the Journey.:
|
471
|
+
|
472
|
+
```ruby
|
473
|
+
class OrderProcessingJourney < StepperMotor::Journey
|
474
|
+
step :send_confirmation, if: -> { hero.email.present? } do
|
475
|
+
OrderConfirmationMailer.confirm(hero).deliver_later
|
476
|
+
end
|
477
|
+
|
478
|
+
step :process_payment
|
479
|
+
PaymentProcessor.charge(hero)
|
480
|
+
end
|
481
|
+
end
|
482
|
+
```
|
483
|
+
|
484
|
+
#### Skipping steps with literal conditions
|
485
|
+
|
486
|
+
You can use literal boolean values to conditionally include or exclude steps:
|
487
|
+
|
488
|
+
```ruby
|
489
|
+
class FeatureFlagJourney < StepperMotor::Journey
|
490
|
+
step :new_feature_step, if: Rails.application.config.new_feature_enabled do
|
491
|
+
NewFeatureService.process(hero)
|
492
|
+
end
|
493
|
+
|
494
|
+
step :legacy_step, if: ENV["PERFORM_LEGACY_STEP"] do # This step will never execute
|
495
|
+
LegacyService.process(hero)
|
496
|
+
end
|
497
|
+
|
498
|
+
step :always_execute do
|
499
|
+
# This step always runs
|
500
|
+
end
|
501
|
+
end
|
502
|
+
```
|
503
|
+
|
504
|
+
When a step is skipped due to a false condition, the journey seamlessly continues to the next step without any interruption - or finishes if that step was the last one.
|
505
|
+
|
506
|
+
#### Accessing Journey state in conditions
|
507
|
+
|
508
|
+
It is possible to store instance variables on the `Journey` instance, but they do not persist between steps. This is very important to remember:
|
509
|
+
|
510
|
+
> [!WARNING]
|
511
|
+
> Because conditions are `instance_exec`d they can access instance variables of the `Journey`. It will also break majestically, because
|
512
|
+
> the `Journey` is getting persisted and then loaded from the database on a different matchine, and it is always consumed fresh.
|
513
|
+
> This means that the volatile state such as instance variables is not going to be available between steps. Always assume that
|
514
|
+
> the `Journey` you are inside of does not have any instance variables set by previous steps and has just been freshly loaded from the database.
|
515
|
+
|
516
|
+
|
517
|
+
### Waiting for the start of the step
|
518
|
+
|
519
|
+
You configure how long a step should wait before starting using the `wait:` parameter. The `wait:` can be arbirarily long - but must be finite:
|
520
|
+
|
521
|
+
```ruby
|
522
|
+
class DraconianPasswordResetJourney < StepperMotor::Journey
|
523
|
+
step :ask_for_password_reset, wait: 6.months do
|
524
|
+
PasswordExpiredMailer.expired(hero).deliver_later
|
525
|
+
reattempt!
|
526
|
+
emd
|
527
|
+
end
|
528
|
+
```
|
529
|
+
|
530
|
+
The `wait:` parameter defines the amount of time computed **from the moment the Journey gets created or the previous step is completed.**
|
531
|
+
|
532
|
+
## Journey states
|
533
|
+
|
534
|
+
The Journeys are managed using a state machine, which stepper_motor completely coordinates for you. The states are as follows:
|
535
|
+
|
536
|
+
```mermaid
|
537
|
+
stateDiagram-v2
|
538
|
+
[*] --> ready: create
|
539
|
+
ready --> performing: perform_next_step!
|
540
|
+
performing --> ready: reattempt!
|
541
|
+
performing --> finished: step completes
|
542
|
+
performing --> canceled: cancel!
|
543
|
+
performing --> paused: pause! or exception
|
544
|
+
paused --> ready: resume!
|
545
|
+
finished --> [*]
|
546
|
+
canceled --> [*]
|
547
|
+
```
|
548
|
+
|
233
549
|
## Flow control within steps
|
234
550
|
|
235
551
|
Inside a step, you currently can use the following flow control methods:
|
@@ -250,7 +566,6 @@ Inside a step, you currently can use the following flow control methods:
|
|
250
566
|
|
251
567
|
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.
|
252
568
|
|
253
|
-
|
254
569
|
## Transactional semantics within steps
|
255
570
|
|
256
571
|
Getting the transactional semantics _right_ with a system like stepper_motor is crucial. We strike a decent balance between reliability/durability and performance, namely:
|
@@ -273,16 +588,12 @@ We recommend using a "co-committing" ActiveJob adapter with stepper_motor (an ad
|
|
273
588
|
* [good_job](https://github.com/bensheldon/good_job)
|
274
589
|
* [solid_queue](https://github.com/rails/solid_queue) - with the same database used for the queue as the one used for Journeys
|
275
590
|
|
276
|
-
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
|
591
|
+
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. It has negative consequences for a system such as stepper_motor - where durability is paramount. Having good defaults is great, but removing a crucial affordance that a DB-based job queue provides is user-hostile.
|
277
592
|
|
278
593
|
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.
|
594
|
+
Note that at the moment - if your ActiveJob adapter enqueues after commit - it is possible for your app to crash _between_ the Journey committing and your job enqueueing.
|
279
595
|
|
280
|
-
|
281
|
-
|
282
|
-
This is done for the following reasons:
|
283
|
-
|
284
|
-
* 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
|
285
|
-
* 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.
|
596
|
+
We may address this in the future using a transactional outbox.
|
286
597
|
|
287
598
|
## Saving side-effects of steps
|
288
599
|
|
@@ -386,102 +697,40 @@ end
|
|
386
697
|
|
387
698
|
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.
|
388
699
|
|
389
|
-
##
|
390
|
-
|
391
|
-
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:
|
392
|
-
|
393
|
-
```ruby
|
394
|
-
step :one do
|
395
|
-
# perform some action
|
396
|
-
end
|
397
|
-
|
398
|
-
step :two do
|
399
|
-
# perform some other action
|
400
|
-
end
|
401
|
-
```
|
402
|
-
|
403
|
-
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:
|
404
|
-
|
405
|
-
```ruby
|
406
|
-
step :one do
|
407
|
-
# perform some action
|
408
|
-
end
|
409
|
-
|
410
|
-
step :one_bis do
|
411
|
-
# some compliance action
|
412
|
-
end
|
413
|
-
|
414
|
-
step :two do
|
415
|
-
# perform some other action
|
416
|
-
end
|
417
|
-
```
|
700
|
+
## Exception handling inside steps
|
418
701
|
|
419
|
-
|
702
|
+
> [!IMPORTANT]
|
703
|
+
> Exception handling in steps is in flux, expect API changes.
|
420
704
|
|
421
|
-
|
705
|
+
By default, an exception raised inside a step of a Journey will _pause_ that Journey at that step. This is done for a number of reasons:
|
422
706
|
|
423
|
-
*
|
424
|
-
*
|
707
|
+
* An endlessly reattempting step can cause load on your infrastructure and will never stop retrying
|
708
|
+
* 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
|
709
|
+
* It is likely a condition that was not anticipated when the Journey was written, thus a blind reattempt is unwise.
|
425
710
|
|
426
|
-
|
711
|
+
While we may change this in future versions of `stepper_motor`, the current default is thus to `pause!` the Journey if an unhandled exception occurs.
|
427
712
|
|
428
|
-
|
713
|
+
Should that happen, you can query for the paused Journeys in your Rails console:
|
429
714
|
|
430
715
|
```ruby
|
431
|
-
|
432
|
-
step :erase_attachments
|
433
|
-
step :erase_emails
|
434
|
-
|
435
|
-
def erase_attachments
|
436
|
-
hero.uploaded_attachments.find_each(&:destroy)
|
437
|
-
end
|
438
|
-
|
439
|
-
def erase_emails
|
440
|
-
while hero.emails.count > 0
|
441
|
-
hero.emails.limit(5000).delete_all
|
442
|
-
end
|
443
|
-
end
|
444
|
-
end
|
716
|
+
my-app(dev)> StepperMotor::Journey.paused
|
445
717
|
```
|
446
718
|
|
447
|
-
|
448
|
-
to define a `step` immediately:
|
719
|
+
If a journey has been paused, you can resume it manually from the Rails console once you have investigated and rectified the error. You do so by calling `resume!` on it:
|
449
720
|
|
450
721
|
```ruby
|
451
|
-
|
452
|
-
|
453
|
-
hero.uploaded_attachments.find_each(&:destroy)
|
454
|
-
end
|
455
|
-
|
456
|
-
step def erase_emails
|
457
|
-
while hero.emails.count > 0
|
458
|
-
hero.emails.limit(5000).delete_all
|
459
|
-
end
|
460
|
-
end
|
461
|
-
end
|
722
|
+
my-app(dev)> journey = StepperMotor::Journey.paused.first!
|
723
|
+
my-app(dev)> paused_journey.resume!
|
462
724
|
```
|
463
|
-
## Exception handling inside steps
|
464
|
-
|
465
|
-
> [!IMPORTANT]
|
466
|
-
> Exception handling in steps is in flux, expect API changes.
|
467
|
-
|
468
|
-
When performing the step, any exceptions raised from within the step will be stored in a local
|
469
|
-
variable to allow the Journey to be released as either `ready`, `finished` or `canceled`. The exception
|
470
|
-
will be raised from within an `ensure` block after the persistence of the Journey has been taken care of.
|
471
|
-
|
472
|
-
By default, an exception raised inside a step of a Journey will _pause_ that Journey. This is done for a number of reasons:
|
473
725
|
|
474
|
-
|
475
|
-
* 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
|
476
|
-
* It is likely a condition that was not anticipated when the Journey was written, thus a blind reattempt is unwise.
|
726
|
+
This action is deliberately manual. You can also set up a job which automatically resumes paused Journeys, but we do not recommend that as things usually fail for a reason.
|
477
727
|
|
478
|
-
|
479
|
-
exception occurs. You can, however, switch it to `reattempt!` or `cancel!` a Journey should a particular step raise. This is configured per step:
|
728
|
+
If you can make a decision to cancel or reattempt the journey - that's possible as well. This is configured per step:
|
480
729
|
|
481
730
|
```ruby
|
482
731
|
class Erasure < StepperMotor::Journey
|
483
732
|
step :initiate_deletion, on_exception: :reattempt! do
|
484
|
-
# ..Do the requisite work
|
733
|
+
# ..Do the requisite work with aggressive retries
|
485
734
|
end
|
486
735
|
end
|
487
736
|
```
|
@@ -524,4 +773,6 @@ class Payment < StepperMotor::Journey
|
|
524
773
|
end
|
525
774
|
```
|
526
775
|
|
776
|
+
It can be helpful to understand how exception handling is done by stepper_motor internally: when performing the step, any exceptions raised from within the step will be stored in a local variable to allow the Journey to be released as `ready`, `finished`, `canceled` or `paused`.
|
527
777
|
|
778
|
+
That exception will is then going to be raised from within an `ensure` block, but only after the persistence of the Journey has been taken care of. This way the Journey has way more chance to reach a stable persisted state where it can be recovered (provided the database accepts the write, of course).
|
data/rbi/stepper_motor.rbi
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# StepperMotor is a module for building multi-step flows where steps are sequential and only
|
3
3
|
# ever progress forward. The building block of StepperMotor is StepperMotor::Journey
|
4
4
|
module StepperMotor
|
5
|
-
VERSION = T.let("0.1.
|
5
|
+
VERSION = T.let("0.1.15", T.untyped)
|
6
6
|
PerformStepJobV2 = T.let(StepperMotor::PerformStepJob, T.untyped)
|
7
7
|
RecoverStuckJourneysJobV1 = T.let(StepperMotor::RecoverStuckJourneysJob, T.untyped)
|
8
8
|
|
@@ -33,16 +33,27 @@ module StepperMotor
|
|
33
33
|
# _@param_ `wait` — the amount of time to wait before entering the step
|
34
34
|
#
|
35
35
|
# _@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.
|
36
|
+
#
|
37
|
+
# _@param_ `if` — condition to check before performing the step. If a boolean is provided, it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
|
36
38
|
sig do
|
37
39
|
params(
|
38
40
|
name: T.any(String, Symbol),
|
39
41
|
seq: T.untyped,
|
40
42
|
on_exception: Symbol,
|
41
43
|
wait: T.any(Numeric, ActiveSupport::Duration),
|
44
|
+
if: T.any(TrueClass, FalseClass, NilClass, Symbol, Proc),
|
42
45
|
step_block: T.untyped
|
43
46
|
).void
|
44
47
|
end
|
45
|
-
def initialize(name:, seq:, on_exception:, wait: 0, &step_block); end
|
48
|
+
def initialize(name:, seq:, on_exception:, wait: 0, if: true, &step_block); end
|
49
|
+
|
50
|
+
# Checks if the step should be performed based on the if condition
|
51
|
+
#
|
52
|
+
# _@param_ `journey` — the journey to check the condition for
|
53
|
+
#
|
54
|
+
# _@return_ — true if the step should be performed, false otherwise
|
55
|
+
sig { params(journey: StepperMotor::Journey).returns(T::Boolean) }
|
56
|
+
def should_perform?(journey); end
|
46
57
|
|
47
58
|
# Performs the step on the passed Journey, wrapping the step with the required context.
|
48
59
|
#
|
@@ -128,6 +139,8 @@ module StepperMotor
|
|
128
139
|
#
|
129
140
|
# _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
|
130
141
|
#
|
142
|
+
# _@param_ `if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
|
143
|
+
#
|
131
144
|
# _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
132
145
|
#
|
133
146
|
# _@return_ — the step definition that has been created
|
@@ -137,11 +150,12 @@ module StepperMotor
|
|
137
150
|
wait: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
|
138
151
|
after: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
|
139
152
|
on_exception: Symbol,
|
140
|
-
|
153
|
+
if: T.any(TrueClass, FalseClass, Symbol, Proc),
|
154
|
+
additional_step_definition_options: T::Hash[T.untyped, T.untyped],
|
141
155
|
blk: T.untyped
|
142
156
|
).returns(StepperMotor::Step)
|
143
157
|
end
|
144
|
-
def self.step(name = nil, wait: nil, after: nil, on_exception: :pause!, **additional_step_definition_options, &blk); end
|
158
|
+
def self.step(name = nil, wait: nil, after: nil, on_exception: :pause!, if: true, **additional_step_definition_options, &blk); end
|
145
159
|
|
146
160
|
# sord warn - "StepperMotor::Step?" does not appear to be a type
|
147
161
|
# Returns the `Step` object for a named step. This is used when performing a step, but can also
|
data/sig/stepper_motor.rbs
CHANGED
@@ -31,13 +31,23 @@ module StepperMotor
|
|
31
31
|
# _@param_ `wait` — the amount of time to wait before entering the step
|
32
32
|
#
|
33
33
|
# _@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.
|
34
|
+
#
|
35
|
+
# _@param_ `if` — condition to check before performing the step. If a boolean is provided, it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
|
34
36
|
def initialize: (
|
35
37
|
name: (String | Symbol),
|
36
38
|
seq: untyped,
|
37
39
|
on_exception: Symbol,
|
38
|
-
?wait: (Numeric | ActiveSupport::Duration)
|
40
|
+
?wait: (Numeric | ActiveSupport::Duration),
|
41
|
+
?if: (TrueClass | FalseClass | NilClass | Symbol | Proc)
|
39
42
|
) -> void
|
40
43
|
|
44
|
+
# Checks if the step should be performed based on the if condition
|
45
|
+
#
|
46
|
+
# _@param_ `journey` — the journey to check the condition for
|
47
|
+
#
|
48
|
+
# _@return_ — true if the step should be performed, false otherwise
|
49
|
+
def should_perform?: (StepperMotor::Journey journey) -> bool
|
50
|
+
|
41
51
|
# Performs the step on the passed Journey, wrapping the step with the required context.
|
42
52
|
#
|
43
53
|
# _@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
|
@@ -117,6 +127,8 @@ module StepperMotor
|
|
117
127
|
#
|
118
128
|
# _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
|
119
129
|
#
|
130
|
+
# _@param_ `if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
|
131
|
+
#
|
120
132
|
# _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
121
133
|
#
|
122
134
|
# _@return_ — the step definition that has been created
|
@@ -125,7 +137,8 @@ module StepperMotor
|
|
125
137
|
?wait: (Float | untyped | ActiveSupport::Duration)?,
|
126
138
|
?after: (Float | untyped | ActiveSupport::Duration)?,
|
127
139
|
?on_exception: Symbol,
|
128
|
-
|
140
|
+
?if: (TrueClass | FalseClass | Symbol | Proc),
|
141
|
+
**::Hash[untyped, untyped] additional_step_definition_options
|
129
142
|
) -> StepperMotor::Step
|
130
143
|
|
131
144
|
# sord warn - "StepperMotor::Step?" does not appear to be a type
|
data/stepper_motor.gemspec
CHANGED
@@ -37,6 +37,8 @@ Gem::Specification.new do |spec|
|
|
37
37
|
spec.add_development_dependency "minitest"
|
38
38
|
spec.add_development_dependency "rails", "~> 7.0"
|
39
39
|
spec.add_development_dependency "sqlite3"
|
40
|
+
spec.add_development_dependency "mysql2"
|
41
|
+
spec.add_development_dependency "pg"
|
40
42
|
spec.add_development_dependency "rake", "~> 13.0"
|
41
43
|
spec.add_development_dependency "standard", "~> 1.50.0", "< 2.0"
|
42
44
|
spec.add_development_dependency "magic_frozen_string_literal"
|
@@ -0,0 +1,14 @@
|
|
1
|
+
default: &default
|
2
|
+
adapter: mysql2
|
3
|
+
database: stepper_motor_dummy_<%= Rails.env %>
|
4
|
+
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
5
|
+
username: root
|
6
|
+
password: constabulary
|
7
|
+
timeout: 5000
|
8
|
+
|
9
|
+
development:
|
10
|
+
<<: *default
|
11
|
+
test:
|
12
|
+
<<: *default
|
13
|
+
production:
|
14
|
+
<<: *default
|
@@ -0,0 +1,14 @@
|
|
1
|
+
default: &default
|
2
|
+
adapter: postgres
|
3
|
+
database: stepper_motor_dummy_<%= Rails.env %>
|
4
|
+
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
5
|
+
username: root
|
6
|
+
password: constabulary
|
7
|
+
timeout: 5000
|
8
|
+
|
9
|
+
development:
|
10
|
+
<<: *default
|
11
|
+
test:
|
12
|
+
<<: *default
|
13
|
+
production:
|
14
|
+
<<: *default
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# SQLite. Versions 3.8.0 and up are supported.
|
2
|
+
# gem install sqlite3
|
3
|
+
#
|
4
|
+
# Ensure the SQLite 3 gem is defined in your Gemfile
|
5
|
+
# gem "sqlite3"
|
6
|
+
#
|
7
|
+
default: &default
|
8
|
+
adapter: sqlite3
|
9
|
+
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
10
|
+
timeout: 5000
|
11
|
+
|
12
|
+
development:
|
13
|
+
<<: *default
|
14
|
+
database: storage/development.sqlite3
|
15
|
+
|
16
|
+
# Warning: The database defined as "test" will be erased and
|
17
|
+
# re-generated from your development database when you run "rake".
|
18
|
+
# Do not set this db to the same as development or production.
|
19
|
+
test:
|
20
|
+
<<: *default
|
21
|
+
database: storage/test.sqlite3
|
22
|
+
|
23
|
+
|
24
|
+
# SQLite3 write its data on the local filesystem, as such it requires
|
25
|
+
# persistent disks. If you are deploying to a managed service, you should
|
26
|
+
# make sure it provides disk persistence, as many don't.
|
27
|
+
#
|
28
|
+
# Similarly, if you deploy your application as a Docker container, you must
|
29
|
+
# ensure the database is located in a persisted volume.
|
30
|
+
production:
|
31
|
+
<<: *default
|
32
|
+
# database: path/to/persistent/storage/production.sqlite3
|
@@ -9,7 +9,7 @@
|
|
9
9
|
#
|
10
10
|
# and add its cycle job into your recurring jobs table. For example, for solid_queue:
|
11
11
|
#
|
12
|
-
#
|
12
|
+
# run_stepper_motor_scheduling_cycle:
|
13
13
|
# schedule: "*/30 * * * *" # Every 30 minutes
|
14
14
|
# class: "StepperMotor::CyclicScheduler::RunSchedulingCycleJob"
|
15
15
|
#
|