stepper_motor 0.1.6 → 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 (97) 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/railtie.rb +1 -1
  18. data/lib/stepper_motor/recover_stuck_journeys_job_v1.rb +3 -1
  19. data/lib/stepper_motor/step.rb +70 -5
  20. data/lib/stepper_motor/version.rb +1 -1
  21. data/lib/stepper_motor.rb +1 -2
  22. data/lib/tasks/stepper_motor_tasks.rake +8 -0
  23. data/manual/MANUAL.md +538 -0
  24. data/rbi/stepper_motor.rbi +459 -0
  25. data/sig/stepper_motor.rbs +406 -3
  26. data/stepper_motor.gemspec +49 -0
  27. data/test/dummy/Rakefile +8 -0
  28. data/test/dummy/app/assets/stylesheets/application.css +1 -0
  29. data/test/dummy/app/controllers/application_controller.rb +6 -0
  30. data/test/dummy/app/helpers/application_helper.rb +4 -0
  31. data/test/dummy/app/jobs/application_job.rb +9 -0
  32. data/test/dummy/app/mailers/application_mailer.rb +6 -0
  33. data/test/dummy/app/models/application_record.rb +5 -0
  34. data/test/dummy/app/views/layouts/application.html.erb +27 -0
  35. data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  36. data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  37. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  38. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  39. data/test/dummy/bin/dev +2 -0
  40. data/test/dummy/bin/rails +4 -0
  41. data/test/dummy/bin/rake +4 -0
  42. data/test/dummy/bin/setup +34 -0
  43. data/test/dummy/config/application.rb +28 -0
  44. data/test/dummy/config/boot.rb +7 -0
  45. data/test/dummy/config/cable.yml +10 -0
  46. data/test/dummy/config/database.yml +32 -0
  47. data/test/dummy/config/environment.rb +7 -0
  48. data/test/dummy/config/environments/development.rb +71 -0
  49. data/test/dummy/config/environments/production.rb +91 -0
  50. data/test/dummy/config/environments/test.rb +55 -0
  51. data/test/dummy/config/initializers/content_security_policy.rb +27 -0
  52. data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  53. data/test/dummy/config/initializers/inflections.rb +18 -0
  54. data/test/dummy/config/initializers/stepper_motor.rb +3 -0
  55. data/test/dummy/config/locales/en.yml +31 -0
  56. data/test/dummy/config/puma.rb +40 -0
  57. data/test/dummy/config/routes.rb +16 -0
  58. data/test/dummy/config/storage.yml +34 -0
  59. data/test/dummy/config.ru +8 -0
  60. data/test/dummy/db/migrate/20250520094921_stepper_motor_migration_001.rb +38 -0
  61. data/test/dummy/db/migrate/20250520094922_stepper_motor_migration_002.rb +8 -0
  62. data/test/dummy/db/migrate/20250522212312_stepper_motor_migration_003.rb +7 -0
  63. data/test/dummy/db/migrate/20250525110812_stepper_motor_migration_004.rb +28 -0
  64. data/test/dummy/db/schema.rb +37 -0
  65. data/test/dummy/public/400.html +114 -0
  66. data/test/dummy/public/404.html +114 -0
  67. data/test/dummy/public/406-unsupported-browser.html +114 -0
  68. data/test/dummy/public/422.html +114 -0
  69. data/test/dummy/public/500.html +114 -0
  70. data/test/dummy/public/icon.png +0 -0
  71. data/test/dummy/public/icon.svg +3 -0
  72. data/test/side_effects_helper.rb +67 -0
  73. data/test/stepper_motor/cyclic_scheduler_test.rb +77 -0
  74. data/{spec/stepper_motor/forward_scheduler_spec.rb → test/stepper_motor/forward_scheduler_test.rb} +9 -10
  75. data/test/stepper_motor/journey/exception_handling_test.rb +89 -0
  76. data/test/stepper_motor/journey/flow_control_test.rb +78 -0
  77. data/test/stepper_motor/journey/idempotency_test.rb +65 -0
  78. data/test/stepper_motor/journey/step_definition_test.rb +187 -0
  79. data/test/stepper_motor/journey/uniqueness_test.rb +48 -0
  80. data/test/stepper_motor/journey_test.rb +352 -0
  81. data/{spec/stepper_motor/recover_stuck_journeys_job_spec.rb → test/stepper_motor/recover_stuck_journeys_job_test.rb} +14 -14
  82. data/{spec/stepper_motor/recovery_spec.rb → test/stepper_motor/recovery_test.rb} +27 -27
  83. data/test/stepper_motor/test_helper_test.rb +44 -0
  84. data/test/stepper_motor_test.rb +9 -0
  85. data/test/test_helper.rb +46 -0
  86. metadata +120 -24
  87. data/.rspec +0 -3
  88. data/.ruby-version +0 -1
  89. data/.standard.yml +0 -4
  90. data/.yardopts +0 -1
  91. data/spec/helpers/side_effects.rb +0 -85
  92. data/spec/spec_helper.rb +0 -90
  93. data/spec/stepper_motor/cyclic_scheduler_spec.rb +0 -68
  94. data/spec/stepper_motor/generator_spec.rb +0 -16
  95. data/spec/stepper_motor/journey_spec.rb +0 -401
  96. data/spec/stepper_motor/test_helper_spec.rb +0 -48
  97. data/spec/stepper_motor_spec.rb +0 -7
@@ -0,0 +1,459 @@
1
+ # typed: strong
2
+ # StepperMotor is a module for building multi-step flows where steps are sequential and only
3
+ # ever progress forward. The building block of StepperMotor is StepperMotor::Journey
4
+ module StepperMotor
5
+ VERSION = T.let("0.1.8", T.untyped)
6
+
7
+ class Error < StandardError
8
+ end
9
+
10
+ class JourneyNotPersisted < StepperMotor::Error
11
+ end
12
+
13
+ class StepConfigurationError < ArgumentError
14
+ end
15
+
16
+ # Describes a step in a journey. These objects get stored inside the `step_definitions`
17
+ # array of the Journey subclass. When the step gets performed, the block passed to the
18
+ # constructor will be instance_exec'd with the Journey model being the context
19
+ class Step
20
+ # sord omit - no YARD type given for "seq:", using untyped
21
+ # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
22
+ # Creates a new step definition
23
+ #
24
+ # _@param_ `name` — the name of the Step
25
+ #
26
+ # _@param_ `wait` — the amount of time to wait before entering the step
27
+ #
28
+ # _@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.
29
+ sig do
30
+ params(
31
+ name: T.any(String, Symbol),
32
+ seq: T.untyped,
33
+ on_exception: Symbol,
34
+ wait: T.any(Numeric, ActiveSupport::Duration),
35
+ step_block: T.untyped
36
+ ).void
37
+ end
38
+ def initialize(name:, seq:, on_exception:, wait: 0, &step_block); end
39
+
40
+ # Performs the step on the passed Journey, wrapping the step with the required context.
41
+ #
42
+ # _@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
43
+ #
44
+ # _@return_ — void
45
+ sig { params(journey: StepperMotor::Journey).returns(T.untyped) }
46
+ def perform_in_context_of(journey); end
47
+
48
+ # _@return_ — the name of the step or method to call on the Journey
49
+ sig { returns(String) }
50
+ attr_reader :name
51
+
52
+ # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
53
+ # _@return_ — how long to wait before performing the step
54
+ sig { returns(T.any(Numeric, ActiveSupport::Duration)) }
55
+ attr_reader :wait
56
+
57
+ # sord omit - no YARD type given for :seq, using untyped
58
+ sig { returns(T.untyped) }
59
+ attr_reader :seq
60
+
61
+ class MissingDefinition < NoMethodError
62
+ end
63
+ end
64
+
65
+ # A Journey is the main building block of StepperMotor. You create a journey to guide a particular model
66
+ # ("hero") through a sequence of steps. Any of your model can be the hero and have multiple Journeys. To create
67
+ # your own Journey, subclass the `StepperMotor::Journey` class and define your steps. For example, a drip mail
68
+ # campaign can look like this:
69
+ #
70
+ #
71
+ # class ResubscribeCampaign < StepperMotor::Journey
72
+ # step do
73
+ # ReinviteMailer.with(recipient: hero).deliver_later
74
+ # end
75
+ #
76
+ # step wait: 3.days do
77
+ # cancel! if hero.active?
78
+ # ReinviteMailer.with(recipient: hero).deliver_later
79
+ # end
80
+ #
81
+ # step wait: 3.days do
82
+ # cancel! if hero.active?
83
+ # ReinviteMailer.with(recipient: hero).deliver_later
84
+ # end
85
+ #
86
+ # step wait: 3.days do
87
+ # cancel! if hero.active?
88
+ # hero.close_account!
89
+ # end
90
+ # end
91
+ #
92
+ # Creating a record for the Journey (just using `create!`) will instantly send your hero on their way:
93
+ #
94
+ # ResubscribeCampaign.create!(hero: current_account)
95
+ #
96
+ # To stop the journey forcibly, delete it from your database - or call `cancel!` within any of the steps.
97
+ class Journey < ActiveRecord::Base
98
+ include StepperMotor::Journey::FlowControl
99
+ include StepperMotor::Journey::Recovery
100
+ STATES = T.let(%w[ready paused performing canceled finished], T.untyped)
101
+
102
+ # sord omit - no YARD return type given, using untyped
103
+ # Alias for the class attribute, for brevity
104
+ #
105
+ # _@see_ `Journey.step_definitions`
106
+ sig { returns(T.untyped) }
107
+ def step_definitions; end
108
+
109
+ # sord duck - #to_f looks like a duck type, replacing with untyped
110
+ # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
111
+ # sord duck - #to_f looks like a duck type, replacing with untyped
112
+ # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
113
+ # Defines a step in the journey.
114
+ # Steps are stacked top to bottom and get performed in sequence.
115
+ #
116
+ # _@param_ `name` — the name of the step. If none is provided, a name will be automatically generated based on the position of the step in the list of `step_definitions`. The name can also be used to call a method on the `Journey` instead of calling the provided block.
117
+ #
118
+ # _@param_ `wait` — the amount of time this step should wait before getting performed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time, and the `next_step_to_be_performed_at` attribute will be set to the current time plus the wait duration. Mutually exclusive with `after:`
119
+ #
120
+ # _@param_ `after` — the amount of time this step should wait before getting performed including all the previous waits. This allows you to set the wait time based on the time after the journey started, as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at` attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition. Mutually exclusive with `wait:`
121
+ #
122
+ # _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
123
+ #
124
+ # _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
125
+ #
126
+ # _@return_ — the step definition that has been created
127
+ sig do
128
+ params(
129
+ name: T.nilable(String),
130
+ wait: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
131
+ after: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
132
+ on_exception: Symbol,
133
+ additional_step_definition_options: T.untyped,
134
+ blk: T.untyped
135
+ ).returns(StepperMotor::Step)
136
+ end
137
+ def self.step(name = nil, wait: nil, after: nil, on_exception: :pause!, **additional_step_definition_options, &blk); end
138
+
139
+ # sord warn - "StepperMotor::Step?" does not appear to be a type
140
+ # Returns the `Step` object for a named step. This is used when performing a step, but can also
141
+ # be useful in other contexts.
142
+ #
143
+ # _@param_ `by_step_name` — the name of the step to find
144
+ sig { params(by_step_name: T.any(Symbol, String)).returns(SORD_ERROR_StepperMotorStep) }
145
+ def self.lookup_step_definition(by_step_name); end
146
+
147
+ # sord omit - no YARD type given for "by_step_name", using untyped
148
+ # sord omit - no YARD return type given, using untyped
149
+ # Alias for the class method, for brevity
150
+ #
151
+ # _@see_ `Journey.lookup_step_definition`
152
+ sig { params(by_step_name: T.untyped).returns(T.untyped) }
153
+ def lookup_step_definition(by_step_name); end
154
+
155
+ # Performs the next step in the journey. Will check whether any other process has performed the step already
156
+ # and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
157
+ #
158
+ # After setting the state, it will determine the next step to perform, and perform it. Depending on the outcome of
159
+ # the step another `PerformStepJob` may get enqueued. If the journey ends here, the journey record will set its state
160
+ # to 'finished'.
161
+ #
162
+ # _@param_ `idempotency_key` — If provided, the step will only be performed if the idempotency key matches the current idempotency key. This ensures that the only the triggering job that was scheduled for this step can trigger the step and not any other.
163
+ sig { params(idempotency_key: T.nilable(String)).void }
164
+ def perform_next_step!(idempotency_key: nil); end
165
+
166
+ # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
167
+ sig { returns(ActiveSupport::Duration) }
168
+ def time_remaining_until_final_step; end
169
+
170
+ # sord omit - no YARD type given for "next_step_definition", using untyped
171
+ # sord omit - no YARD type given for "wait:", using untyped
172
+ # sord omit - no YARD return type given, using untyped
173
+ sig { params(next_step_definition: T.untyped, wait: T.untyped).returns(T.untyped) }
174
+ def set_next_step_and_enqueue(next_step_definition, wait: nil); end
175
+
176
+ # sord omit - no YARD return type given, using untyped
177
+ sig { returns(T.untyped) }
178
+ def logger; end
179
+
180
+ # sord omit - no YARD type given for "step_name", using untyped
181
+ # sord omit - no YARD return type given, using untyped
182
+ sig { params(step_name: T.untyped).returns(T.untyped) }
183
+ def after_locking_for_step(step_name); end
184
+
185
+ # sord omit - no YARD type given for "step_name", using untyped
186
+ # sord omit - no YARD type given for "exception", using untyped
187
+ # sord omit - no YARD return type given, using untyped
188
+ sig { params(step_name: T.untyped, exception: T.untyped).returns(T.untyped) }
189
+ def after_performing_step_with_exception(step_name, exception); end
190
+
191
+ # sord omit - no YARD type given for "step_name", using untyped
192
+ # sord omit - no YARD return type given, using untyped
193
+ sig { params(step_name: T.untyped).returns(T.untyped) }
194
+ def before_step_starts(step_name); end
195
+
196
+ # sord omit - no YARD type given for "step_name", using untyped
197
+ # sord omit - no YARD return type given, using untyped
198
+ sig { params(step_name: T.untyped).returns(T.untyped) }
199
+ def after_performing_step_without_exception(step_name); end
200
+
201
+ # sord omit - no YARD return type given, using untyped
202
+ sig { returns(T.untyped) }
203
+ def schedule!; end
204
+
205
+ # sord omit - no YARD return type given, using untyped
206
+ sig { returns(T.untyped) }
207
+ def recover!; end
208
+
209
+ # Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
210
+ # to rejoin the platform, and are just about to send them an email - but they have already joined. You
211
+ # can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
212
+ #
213
+ # Calling `cancel!` within a step will abort the execution of the current step.
214
+ #
215
+ # _@return_ — void
216
+ sig { returns(T.untyped) }
217
+ def cancel!; end
218
+
219
+ # sord omit - no YARD type given for "wait:", using untyped
220
+ # Inside a step it is possible to ask StepperMotor to retry to start the step at a later point in time. Maybe now is an inconvenient moment
221
+ # (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
222
+ # Reattempting will resume the step from the beginning, so the step should be idempotent.
223
+ #
224
+ # `reattempt!` may only be called within a step.
225
+ #
226
+ # _@return_ — void
227
+ sig { params(wait: T.untyped).returns(T.untyped) }
228
+ def reattempt!(wait: nil); end
229
+
230
+ # Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
231
+ # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
232
+ #
233
+ # * The hero of the journey is in a compliance procedure, and their Journeys should not continue
234
+ # * The external resource a Journey will be calling is not available
235
+ # * There is a bug in the Journey implementation and you need some time to get it fixed without canceling or recreating existing Journeys
236
+ #
237
+ # Calling `pause!` within a step will abort the execution of the current step.
238
+ #
239
+ # _@return_ — void
240
+ sig { returns(T.untyped) }
241
+ def pause!; end
242
+
243
+ # Is used to resume a paused Journey. It places the Journey into the `ready` state and schedules the job to perform that step.
244
+ #
245
+ # Calling `resume!` is only permitted outside of a step
246
+ #
247
+ # _@return_ — void
248
+ sig { returns(T.untyped) }
249
+ def resume!; end
250
+
251
+ module Recovery
252
+ extend ActiveSupport::Concern
253
+
254
+ # sord omit - no YARD return type given, using untyped
255
+ sig { returns(T.untyped) }
256
+ def recover!; end
257
+ end
258
+
259
+ module FlowControl
260
+ # Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
261
+ # to rejoin the platform, and are just about to send them an email - but they have already joined. You
262
+ # can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
263
+ #
264
+ # Calling `cancel!` within a step will abort the execution of the current step.
265
+ #
266
+ # _@return_ — void
267
+ sig { returns(T.untyped) }
268
+ def cancel!; end
269
+
270
+ # sord omit - no YARD type given for "wait:", using untyped
271
+ # Inside a step it is possible to ask StepperMotor to retry to start the step at a later point in time. Maybe now is an inconvenient moment
272
+ # (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
273
+ # Reattempting will resume the step from the beginning, so the step should be idempotent.
274
+ #
275
+ # `reattempt!` may only be called within a step.
276
+ #
277
+ # _@return_ — void
278
+ sig { params(wait: T.untyped).returns(T.untyped) }
279
+ def reattempt!(wait: nil); end
280
+
281
+ # Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
282
+ # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
283
+ #
284
+ # * The hero of the journey is in a compliance procedure, and their Journeys should not continue
285
+ # * The external resource a Journey will be calling is not available
286
+ # * There is a bug in the Journey implementation and you need some time to get it fixed without canceling or recreating existing Journeys
287
+ #
288
+ # Calling `pause!` within a step will abort the execution of the current step.
289
+ #
290
+ # _@return_ — void
291
+ sig { returns(T.untyped) }
292
+ def pause!; end
293
+
294
+ # Is used to resume a paused Journey. It places the Journey into the `ready` state and schedules the job to perform that step.
295
+ #
296
+ # Calling `resume!` is only permitted outside of a step
297
+ #
298
+ # _@return_ — void
299
+ sig { returns(T.untyped) }
300
+ def resume!; end
301
+ end
302
+ end
303
+
304
+ class Railtie < Rails::Railtie
305
+ end
306
+
307
+ module TestHelper
308
+ # Allows running a given Journey to completion, skipping across the waiting periods.
309
+ # This is useful to evaluate all side effects of a Journey. The helper will ensure
310
+ # that the number of steps performed is equal to the number of steps defined - this way
311
+ # it will not enter into an endless loop. If, after completing all the steps, the journey
312
+ # has neither canceled nor finished, an exception will be raised.
313
+ #
314
+ # _@param_ `journey` — the journey to speedrun
315
+ #
316
+ # _@return_ — void
317
+ sig { params(journey: StepperMotor::Journey).returns(T.untyped) }
318
+ def speedrun_journey(journey); end
319
+
320
+ # Performs the named step of the journey without waiting for the time to perform the step.
321
+ #
322
+ # _@param_ `journey` — the journey to speedrun
323
+ #
324
+ # _@param_ `step_name` — the name of the step to run
325
+ #
326
+ # _@return_ — void
327
+ sig { params(journey: StepperMotor::Journey, step_name: Symbol).returns(T.untyped) }
328
+ def immediately_perform_single_step(journey, step_name); end
329
+ end
330
+
331
+ # The generator is used to install StepperMotor. It adds an example Journey, a configing
332
+ # initializer and the migration that creates tables.
333
+ # Run it with `bin/rails g stepper_motor:install` in your console.
334
+ class InstallGenerator < Rails::Generators::Base
335
+ include ActiveRecord::Generators::Migration
336
+ UUID_MESSAGE = T.let(<<~MSG, T.untyped)
337
+ If set, uuid type will be used for hero_id. Use this
338
+ if most of your models use UUD as primary key"
339
+ MSG
340
+
341
+ # sord omit - no YARD return type given, using untyped
342
+ # Generates monolithic migration file that contains all database changes.
343
+ sig { returns(T.untyped) }
344
+ def create_migration_file; end
345
+
346
+ # sord omit - no YARD return type given, using untyped
347
+ sig { returns(T.untyped) }
348
+ def create_initializer; end
349
+
350
+ sig { returns(T::Boolean) }
351
+ def uuid_fk?; end
352
+
353
+ # sord omit - no YARD return type given, using untyped
354
+ sig { returns(T.untyped) }
355
+ def migration_version; end
356
+ end
357
+
358
+ # The cyclic scheduler is designed to be run regularly via a cron job. On every
359
+ # cycle, it is going to look for Journeys which are going to come up for step execution
360
+ # before the next cycle is supposed to run. Then it is going to enqueue jobs to perform
361
+ # steps on those journeys. Since the scheduler gets run at a discrete interval, but we
362
+ # still them to be processed on time, if we only picked up the journeys which have the
363
+ # step execution time set to now or earlier, we will always have delays. This is why
364
+ # this scheduler enqueues jobs for journeys whose time to run is between now and the
365
+ # next cycle.
366
+ #
367
+ # Once the job gets created, it then gets enqueued and gets picked up by the ActiveJob
368
+ # worker normally. If you are using SQS, which has a limit of 900 seconds for the `wait:`
369
+ # value, you need to run the scheduler at least (!) every 900 seconds, and preferably
370
+ # more frequently (for example, once every 5 minutes). This scheduler is also going to be
371
+ # more gentle with ActiveJob adapters that may get slower with large queue depths, such as
372
+ # good_job. This scheduler is a good fit if you are using an ActiveJob adapter which:
373
+ #
374
+ # * Does not allow easy introspection of jobs in the future (like Redis-based queues)
375
+ # * Limits the value of the `wait:` parameter
376
+ #
377
+ # The scheduler needs to be configured in your cron table.
378
+ class CyclicScheduler < StepperMotor::ForwardScheduler
379
+ # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
380
+ # Creates a new scheduler. The scheduler needs to know how frequently it is going to be running -
381
+ # you define that frequency when you configure your cron job that calls `run_scheduling_cycle`. Journeys which
382
+ # have to perform their steps between the runs of the cycles will generate jobs. The more frequent the scheduling
383
+ # cycle, the fewer jobs are going to be created per cycle.
384
+ #
385
+ # _@param_ `cycle_duration` — how frequently the scheduler runs
386
+ sig { params(cycle_duration: ActiveSupport::Duration).void }
387
+ def initialize(cycle_duration:); end
388
+
389
+ # Run a scheduling cycle. This should be called from your ActiveJob that runs on a regular Cron cadence. Ideally you
390
+ # would call the instance of the scheduler configured for the whole StepperMotor (so that the `cycle_duration` gets
391
+ # correctly applied, as it is necessary to pick the journeys to step). Normally, you would do this:
392
+ sig { void }
393
+ def run_scheduling_cycle; end
394
+
395
+ # sord omit - no YARD type given for "journey", using untyped
396
+ # sord omit - no YARD return type given, using untyped
397
+ sig { params(journey: T.untyped).returns(T.untyped) }
398
+ def schedule(journey); end
399
+
400
+ class RunSchedulingCycleJob < ActiveJob::Base
401
+ # sord omit - no YARD return type given, using untyped
402
+ sig { returns(T.untyped) }
403
+ def perform; end
404
+ end
405
+ end
406
+
407
+ class PerformStepJob < ActiveJob::Base
408
+ # sord omit - no YARD type given for "journey_gid", using untyped
409
+ # sord omit - no YARD return type given, using untyped
410
+ sig { params(journey_gid: T.untyped).returns(T.untyped) }
411
+ def perform(journey_gid); end
412
+ end
413
+
414
+ # The forward scheduler enqueues a job for every Journey that
415
+ # gets sent to the `#schedule`. The job is then stored in the queue
416
+ # and gets picked up by the ActiveJob worker normally. This is the simplest
417
+ # option if your ActiveJob adapter supports far-ahead scheduling. Some adapters,
418
+ # such as SQS, have limitations regarding the maximum delay after which a message
419
+ # will become visible. For SQS, the limit is 900 seconds. If the job is further in the future,
420
+ # it is likely going to fail to get enqueued. If you are working with a queue adapter that:
421
+ #
422
+ # * Does not allow easy introspection of jobs in the future (like Redis-based queues)
423
+ # * Limits the value of the `wait:` parameter
424
+ #
425
+ # this scheduler may not be a good fit for you, and you will need to use the {CyclicScheduler} instead.
426
+ # Note that this scheduler is also likely to populate your queue with a high number of "far out"
427
+ # jobs to be performed in the future. Different ActiveJob adapters are known to have varying
428
+ # performance depending on the number of jobs in the queue. For example, good_job is known to
429
+ # struggle a bit if the queue contains a large number of jobs (even if those jobs are not yet
430
+ # scheduled to be performed). For good_job the {CyclicScheduler} is also likely to be a better option.
431
+ class ForwardScheduler
432
+ # sord omit - no YARD type given for "journey", using untyped
433
+ # sord omit - no YARD return type given, using untyped
434
+ sig { params(journey: T.untyped).returns(T.untyped) }
435
+ def schedule(journey); end
436
+ end
437
+
438
+ class PerformStepJobV2 < ActiveJob::Base
439
+ # sord omit - no YARD type given for "journey_id:", using untyped
440
+ # sord omit - no YARD type given for "journey_class_name:", using untyped
441
+ # sord omit - no YARD type given for "idempotency_key:", using untyped
442
+ # sord omit - no YARD return type given, using untyped
443
+ sig { params(journey_id: T.untyped, journey_class_name: T.untyped, idempotency_key: T.untyped).returns(T.untyped) }
444
+ def perform(journey_id:, journey_class_name:, idempotency_key: nil); end
445
+ end
446
+
447
+ # The purpose of this job is to find journeys which have, for whatever reason, remained in the
448
+ # `performing` state for far longer than the journey is supposed to. At the moment it assumes
449
+ # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
450
+ # cron table and perform it regularly.
451
+ class RecoverStuckJourneysJobV1 < ActiveJob::Base
452
+ DEFAULT_STUCK_FOR = T.let(2.days, T.untyped)
453
+
454
+ # sord omit - no YARD type given for "stuck_for:", using untyped
455
+ # sord omit - no YARD return type given, using untyped
456
+ sig { params(stuck_for: T.untyped).returns(T.untyped) }
457
+ def perform(stuck_for: DEFAULT_STUCK_FOR); end
458
+ end
459
+ end