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
@@ -0,0 +1,26 @@
1
+ class StepperMotorMigration004 < ActiveRecord::Migration[<%= migration_version %>]
2
+ def up
3
+ quoted_false = connection.quote(false)
4
+ add_index :stepper_motor_journeys, [:type, :hero_id, :hero_type],
5
+ where: "allow_multiple = '#{quoted_false}' AND state IN ('ready', 'performing', 'paused')",
6
+ unique: true,
7
+ name: :idx_journeys_one_per_hero_with_paused
8
+
9
+ # Remove old indexes that only include 'ready' state
10
+ remove_index :stepper_motor_journeys, [:next_step_to_be_performed_at], where: "state = 'ready'"
11
+ remove_index :stepper_motor_journeys, [:type, :hero_id, :hero_type], name: :one_per_hero_index, where: "allow_multiple = '0' AND state IN ('ready', 'performing')"
12
+ end
13
+
14
+ def down
15
+ # Recreate old indexes
16
+ add_index :stepper_motor_journeys, [:next_step_to_be_performed_at], where: "state = 'ready'"
17
+ quoted_false = connection.quote(false)
18
+ add_index :stepper_motor_journeys, [:type, :hero_id, :hero_type],
19
+ where: "allow_multiple = '#{quoted_false}' AND state IN ('ready', 'performing')",
20
+ unique: true,
21
+ name: :one_per_hero_index
22
+
23
+ # Remove new indexes
24
+ remove_index :stepper_motor_journeys, name: :idx_journeys_one_per_hero_with_paused
25
+ end
26
+ end
@@ -6,17 +6,21 @@
6
6
  # option if your ActiveJob adapter supports far-ahead scheduling. Some adapters,
7
7
  # such as SQS, have limitations regarding the maximum delay after which a message
8
8
  # will become visible. For SQS, the limit is 900 seconds. If the job is further in the future,
9
- # it is likely going to fail to get enqueued. If you are working with a queue adapter
10
- # either:
9
+ # it is likely going to fail to get enqueued. If you are working with a queue adapter that:
11
10
  #
12
11
  # * Does not allow easy introspection of jobs in the future (like Redis-based queues)
13
12
  # * Limits the value of the `wait:` parameter
14
13
  #
15
- # this scheduler is not a good fit for you, and you will need to use the {CyclicScheduler} instead.
14
+ # this scheduler may not be a good fit for you, and you will need to use the {CyclicScheduler} instead.
15
+ # Note that this scheduler is also likely to populate your queue with a high number of "far out"
16
+ # jobs to be performed in the future. Different ActiveJob adapters are known to have varying
17
+ # performance depending on the number of jobs in the queue. For example, good_job is known to
18
+ # struggle a bit if the queue contains a large number of jobs (even if those jobs are not yet
19
+ # scheduled to be performed). For good_job the {CyclicScheduler} is also likely to be a better option.
16
20
  class StepperMotor::ForwardScheduler
17
21
  def schedule(journey)
18
22
  StepperMotor::PerformStepJobV2
19
23
  .set(wait_until: journey.next_step_to_be_performed_at)
20
- .perform_later(journey_id: journey.id, journey_class_name: journey.class.to_s)
24
+ .perform_later(journey_id: journey.id, journey_class_name: journey.class.to_s, idempotency_key: journey.idempotency_key)
21
25
  end
22
26
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StepperMotor::Journey::FlowControl
4
+ # Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
5
+ # to rejoin the platform, and are just about to send them an email - but they have already joined. You
6
+ # can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
7
+ #
8
+ # Calling `cancel!` within a step will abort the execution of the current step.
9
+ #
10
+ # @return void
11
+ def cancel!
12
+ canceled!
13
+ throw :abort_step if @current_step_definition
14
+ end
15
+
16
+ # 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
17
+ # (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
18
+ # Reattempting will resume the step from the beginning, so the step should be idempotent.
19
+ #
20
+ # `reattempt!` may only be called within a step.
21
+ #
22
+ # @return void
23
+ def reattempt!(wait: nil)
24
+ raise "reattempt! can only be called within a step" unless @current_step_definition
25
+ # The default `wait` is the one for the step definition
26
+ @reattempt_after = wait || @current_step_definition.wait || 0
27
+ throw :abort_step if @current_step_definition
28
+ end
29
+
30
+ # 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
31
+ # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
32
+ #
33
+ # * The hero of the journey is in a compliance procedure, and their Journeys should not continue
34
+ # * The external resource a Journey will be calling is not available
35
+ # * There is a bug in the Journey implementation and you need some time to get it fixed without canceling or recreating existing Journeys
36
+ #
37
+ # Calling `pause!` within a step will abort the execution of the current step.
38
+ #
39
+ # @return void
40
+ def pause!
41
+ paused!
42
+ throw :abort_step if @current_step_definition
43
+ end
44
+
45
+ # Is used to resume a paused Journey. It places the Journey into the `ready` state and schedules the job to perform that step.
46
+ #
47
+ # Calling `resume!` is only permitted outside of a step
48
+ #
49
+ # @return void
50
+ def resume!
51
+ raise "resume! can only be used outside of a step" if @current_step_definition
52
+ with_lock do
53
+ raise "The #{self.class} to resume must be in the `paused' state, but was in #{state.inspect}" unless paused?
54
+ update!(state: "ready", idempotency_key: SecureRandom.base36(16))
55
+ schedule!
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StepperMotor::Journey::Recovery
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ # Allows querying for Journeys which are stuck in "performing" state since a certain
8
+ # timestamp. These Journeys have likely been stuck because the worker that was performing
9
+ # the step has crashed or was forcibly restarted.
10
+ scope :stuck, ->(since) {
11
+ where(updated_at: ..since).performing
12
+ }
13
+
14
+ # Sets the behavior when a Journey gets stuck in "performing" state. The default us "reattempt" -
15
+ # it is going to try to restart the step the Journey got stuck on
16
+ class_attribute :when_stuck, default: :reattempt, instance_accessor: false, instance_reader: true
17
+ end
18
+
19
+ def recover!
20
+ case when_stuck
21
+ when :reattempt
22
+ with_lock do
23
+ return unless performing?
24
+ ready!
25
+ schedule!
26
+ end
27
+ else
28
+ with_lock do
29
+ return unless performing?
30
+ canceled!
31
+ end
32
+ end
33
+ end
34
+ end
@@ -14,17 +14,17 @@ module StepperMotor
14
14
  # ReinviteMailer.with(recipient: hero).deliver_later
15
15
  # end
16
16
  #
17
- # step, wait: 3.days do
17
+ # step wait: 3.days do
18
18
  # cancel! if hero.active?
19
19
  # ReinviteMailer.with(recipient: hero).deliver_later
20
20
  # end
21
21
  #
22
- # step, wait: 3.days do
22
+ # step wait: 3.days do
23
23
  # cancel! if hero.active?
24
24
  # ReinviteMailer.with(recipient: hero).deliver_later
25
25
  # end
26
26
  #
27
- # step, wait: 3.days do
27
+ # step wait: 3.days do
28
28
  # cancel! if hero.active?
29
29
  # hero.close_account!
30
30
  # end
@@ -36,14 +36,20 @@ module StepperMotor
36
36
  #
37
37
  # To stop the journey forcibly, delete it from your database - or call `cancel!` within any of the steps.
38
38
  class Journey < ActiveRecord::Base
39
+ require_relative "journey/flow_control"
40
+ include StepperMotor::Journey::FlowControl
41
+
42
+ require_relative "journey/recovery"
43
+ include StepperMotor::Journey::Recovery
44
+
39
45
  self.table_name = "stepper_motor_journeys"
40
46
 
41
- # @return [Array] the step definitions defined so far
47
+ # @return [Array<StepperMotor::Step>] the step definitions defined so far
42
48
  class_attribute :step_definitions, default: []
43
49
 
44
50
  belongs_to :hero, polymorphic: true, optional: true
45
51
 
46
- STATES = %w[ready performing canceled finished]
52
+ STATES = %w[ready paused performing canceled finished]
47
53
  enum :state, STATES.zip(STATES).to_h, default: "ready"
48
54
 
49
55
  # Allows querying for journeys for this specific hero. This uses a scope for convenience as the hero
@@ -52,40 +58,29 @@ module StepperMotor
52
58
  where(hero: hero)
53
59
  }
54
60
 
55
- # Allows querying for Journeys which are stuck in "performing" state since a certain
56
- # timestamp. These Journeys have likely been stuck because the worker that was performing
57
- # the step has crashed or was forcibly restarted.
58
- scope :stuck, ->(since) {
59
- where(updated_at: ..since).performing
60
- }
61
-
62
- # Sets the behavior when a Journey gets stuck in "performing" state. The default us "reattempt" -
63
- # it is going to try to restart the step the Journey got stuck on
64
- class_attribute :when_stuck, default: :reattempt, instance_accessor: false, instance_reader: true
65
-
66
- def recover!
67
- case when_stuck
68
- when :reattempt
69
- with_lock do
70
- return unless performing?
71
- ready!
72
- schedule!
73
- end
74
- else
75
- with_lock do
76
- return unless performing?
77
- canceled!
78
- end
79
- end
80
- end
81
-
82
61
  after_create do |journey|
83
62
  journey.step_definitions.any? ? journey.set_next_step_and_enqueue(journey.step_definitions.first) : journey.finished!
84
63
  end
85
64
 
86
65
  # Defines a step in the journey.
87
66
  # Steps are stacked top to bottom and get performed in sequence.
88
- def self.step(name = nil, wait: nil, after: nil, &blk)
67
+ #
68
+ # @param name[String,nil] the name of the step. If none is provided, a name will be automatically generated based
69
+ # on the position of the step in the list of `step_definitions`. The name can also be used to call a method
70
+ # on the `Journey` instead of calling the provided block.
71
+ # @param wait[Float,#to_f,ActiveSupport::Duration] the amount of time this step should wait before getting performed.
72
+ # When the journey gets scheduled, the triggering job is going to be delayed by this amount of time, and the
73
+ # `next_step_to_be_performed_at` attribute will be set to the current time plus the wait duration. Mutually exclusive with `after:`
74
+ # @param after[Float,#to_f,ActiveSupport::Duration] the amount of time this step should wait before getting performed
75
+ # including all the previous waits. This allows you to set the wait time based on the time after the journey started, as opposed
76
+ # to when the previous step has completed. When the journey gets scheduled, the triggering job is going to be delayed by this
77
+ # amount of time _minus the `wait` values of the preceding steps, and the
78
+ # `next_step_to_be_performed_at` attribute will be set to the current time. The `after` value gets converted into the `wait`
79
+ # value and passed to the step definition. Mutually exclusive with `wait:`
80
+ # @param on_exception[Symbol] See {StepperMotor::Step#on_exception}
81
+ # @param additional_step_definition_options Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
82
+ # @return [StepperMotor::Step] the step definition that has been created
83
+ def self.step(name = nil, wait: nil, after: nil, on_exception: :pause!, **additional_step_definition_options, &blk)
89
84
  wait = if wait && after
90
85
  raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
91
86
  elsif !wait && !after
@@ -97,6 +92,16 @@ module StepperMotor
97
92
  wait
98
93
  end
99
94
  raise StepConfigurationError, "wait: cannot be negative, but computed was #{wait}s" if wait.negative?
95
+
96
+ if name.blank? && blk.blank?
97
+ raise StepConfigurationError, <<~MSG
98
+ Step #{step_definitions.length + 1} of #{self} has no explicit name,
99
+ and no block with step definition has been provided. Without a name the step
100
+ must be defined with a block to execute. If you want an instance method to be
101
+ executed as a step, pass the name of the method as the name of the step.
102
+ MSG
103
+ end
104
+
100
105
  name ||= "step_%d" % (step_definitions.length + 1)
101
106
  name = name.to_s
102
107
 
@@ -104,12 +109,12 @@ module StepperMotor
104
109
  raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
105
110
 
106
111
  # Create the step definition
107
- step_definition = StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, &blk)
108
-
109
- # As per Rails docs: you need to be aware when using class_attribute with mutable structures
110
- # as Array or Hash. In such cases, you don’t want to do changes in place. Instead use setters.
111
- # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
112
- self.step_definitions = step_definitions + [step_definition]
112
+ StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, on_exception:, **additional_step_definition_options, &blk).tap do |step_definition|
113
+ # As per Rails docs: you need to be aware when using class_attribute with mutable structures
114
+ # as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
115
+ # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
116
+ self.step_definitions = step_definitions + [step_definition]
117
+ end
113
118
  end
114
119
 
115
120
  # Returns the `Step` object for a named step. This is used when performing a step, but can also
@@ -135,27 +140,6 @@ module StepperMotor
135
140
  self.class.lookup_step_definition(by_step_name)
136
141
  end
137
142
 
138
- # Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
139
- # to rejoin the platform, and are just about to send them an email - but they have already joined. You
140
- # can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
141
- #
142
- # Calling `cancel!` will abort the execution of the current step.
143
- def cancel!
144
- canceled!
145
- throw :abort_step
146
- end
147
-
148
- # 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
149
- # (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
150
- # Reattempting will resume the step from the beginning, so the step should be idempotent.
151
- #
152
- # Calling `reattempt!` will abort the execution of the current step.
153
- def reattempt!(wait: nil)
154
- # The default `wait` is the one for the step definition
155
- @reattempt_after = wait || @current_step_definition.wait || 0
156
- throw :abort_step
157
- end
158
-
159
143
  # Performs the next step in the journey. Will check whether any other process has performed the step already
160
144
  # and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
161
145
  #
@@ -163,13 +147,18 @@ module StepperMotor
163
147
  # the step another `PerformStepJob` may get enqueued. If the journey ends here, the journey record will set its state
164
148
  # to 'finished'.
165
149
  #
150
+ # @param idempotency_key [String, nil] If provided, the step will only be performed if the idempotency key matches the current idempotency key.
151
+ # This ensures that the only the triggering job that was scheduled for this step can trigger the step and not any other.
166
152
  # @return [void]
167
- def perform_next_step!
153
+ def perform_next_step!(idempotency_key: nil)
168
154
  # Make sure we can't start running the same step of the same journey twice
169
155
  next_step_name_before_locking = next_step_name
170
156
  with_lock do
171
157
  # Make sure no other worker has snatched this journey and made steps instead of us
172
158
  return unless ready? && next_step_name == next_step_name_before_locking
159
+ # Check idempotency key if both are present
160
+ return if idempotency_key && idempotency_key != self.idempotency_key
161
+
173
162
  performing!
174
163
  after_locking_for_step(next_step_name)
175
164
  end
@@ -205,8 +194,13 @@ module StepperMotor
205
194
  increment!(:steps_entered)
206
195
  logger.debug { "entering step #{current_step_name}" }
207
196
 
208
- catch(:abort_step) do
209
- instance_exec(&@current_step_definition)
197
+ # The flow control for reattempt! and cancel! happens inside perform_in_context_of
198
+ ex_rescued_at_perform = nil
199
+ begin
200
+ @current_step_definition.perform_in_context_of(self)
201
+ rescue => e
202
+ ex_rescued_at_perform = e
203
+ logger.debug { "#{e} raised during #{@current_step_definition.name}, will be re-raised after" }
210
204
  end
211
205
 
212
206
  # By the end of the step the Journey must either be untouched or saved
@@ -220,17 +214,20 @@ module StepperMotor
220
214
  MSG
221
215
  end
222
216
 
223
- increment!(:steps_completed)
224
- logger.debug { "completed #{current_step_name} without exceptions" }
217
+ if ex_rescued_at_perform
218
+ logger.warn { "performed #{current_step_name}, #{ex_rescued_at_perform} was raised" }
219
+ else
220
+ increment!(:steps_completed)
221
+ logger.debug { "performed #{current_step_name} without exceptions" }
222
+ end
225
223
 
226
- if canceled?
227
- # The step aborted the journey, nothing to do
228
- logger.info { "has been canceled inside #{current_step_name}" }
224
+ if paused? || canceled?
225
+ # The step made arrangements regarding how we shoudl continue, nothing to do
226
+ logger.info { "has been #{state} inside #{current_step_name}" }
229
227
  elsif @reattempt_after
230
228
  # The step asked the actions to be attempted at a later time
231
229
  logger.info { "will reattempt #{current_step_name} in #{@reattempt_after} seconds" }
232
- update!(previous_step_name: current_step_name, next_step_name: current_step_name, next_step_to_be_performed_at: Time.current + @reattempt_after)
233
- schedule!
230
+ set_next_step_and_enqueue(@current_step_definition, wait: @reattempt_after)
234
231
  ready!
235
232
  elsif finished?
236
233
  logger.info { "was marked finished inside the step" }
@@ -240,18 +237,24 @@ module StepperMotor
240
237
  set_next_step_and_enqueue(next_step_definition)
241
238
  ready!
242
239
  else
243
- # The hero's journey is complete
244
- logger.info { "journey completed" }
240
+ logger.info { "has finished" } # The hero's journey is complete
245
241
  finished!
246
242
  update!(previous_step_name: current_step_name, next_step_name: nil)
247
243
  end
248
244
  ensure
249
245
  # The instance variables must not be present if `perform_next_step!` gets called
250
246
  # on this same object again. This will be the case if the steps are performed inline
251
- # and not via background jobs (which reload the model)
247
+ # and not via background jobs (which reload the model). This should actually be solved
248
+ # using some object that contains the state of the action later, but for now - the dirty approach is fine.
252
249
  @reattempt_after = nil
253
250
  @current_step_definition = nil
254
- after_step_completes(current_step_name) if current_step_name
251
+ # Re-raise the exception, now that we have persisted the Journey according to the recovery policy
252
+ if ex_rescued_at_perform
253
+ after_performing_step_with_exception(current_step_name, ex_rescued_at_perform) if current_step_name
254
+ raise ex_rescued_at_perform
255
+ elsif current_step_name
256
+ after_performing_step_without_exception(current_step_name)
257
+ end
255
258
  end
256
259
 
257
260
  # @return [ActiveSupport::Duration]
@@ -262,9 +265,10 @@ module StepperMotor
262
265
  seconds_remaining.seconds # Convert to ActiveSupport::Duration
263
266
  end
264
267
 
265
- def set_next_step_and_enqueue(next_step_definition)
266
- wait = next_step_definition.wait
267
- update!(previous_step_name: next_step_name, next_step_name: next_step_definition.name, next_step_to_be_performed_at: Time.current + wait)
268
+ def set_next_step_and_enqueue(next_step_definition, wait: nil)
269
+ wait ||= next_step_definition.wait
270
+ next_idempotency_key = SecureRandom.base36(16)
271
+ update!(previous_step_name: next_step_name, next_step_name: next_step_definition.name, next_step_to_be_performed_at: Time.current + wait, idempotency_key: next_idempotency_key)
268
272
  schedule!
269
273
  end
270
274
 
@@ -282,20 +286,17 @@ module StepperMotor
282
286
  def after_locking_for_step(step_name)
283
287
  end
284
288
 
289
+ def after_performing_step_with_exception(step_name, exception)
290
+ end
291
+
285
292
  def before_step_starts(step_name)
286
293
  end
287
294
 
288
- def after_step_completes(step_name)
295
+ def after_performing_step_without_exception(step_name)
289
296
  end
290
297
 
291
298
  def schedule!
292
299
  StepperMotor.scheduler.schedule(self)
293
300
  end
294
-
295
- def to_global_id
296
- # This gets included into ActiveModel during Rails bootstrap,
297
- # for now do this manually
298
- GlobalID.create(self, app: "stepper-motor")
299
- end
300
301
  end
301
302
  end
@@ -3,9 +3,9 @@
3
3
  require "active_job"
4
4
 
5
5
  class StepperMotor::PerformStepJobV2 < ActiveJob::Base
6
- def perform(journey_id:, journey_class_name:, **)
6
+ def perform(journey_id:, journey_class_name:, idempotency_key: nil, **)
7
7
  journey = StepperMotor::Journey.find(journey_id)
8
- journey.perform_next_step!
8
+ journey.perform_next_step!(idempotency_key: idempotency_key)
9
9
  rescue ActiveRecord::RecordNotFound
10
10
  # The journey has been canceled and destroyed previously or elsewhere
11
11
  end
@@ -7,7 +7,9 @@ require "active_job"
7
7
  # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
8
8
  # cron table and perform it regularly.
9
9
  class StepperMotor::RecoverStuckJourneysJobV1 < ActiveJob::Base
10
- def perform(stuck_for: 2.days)
10
+ DEFAULT_STUCK_FOR = 2.days
11
+
12
+ def perform(stuck_for: DEFAULT_STUCK_FOR)
11
13
  StepperMotor::Journey.stuck(stuck_for.ago).find_each do |journey|
12
14
  journey.recover!
13
15
  rescue => e
@@ -4,16 +4,81 @@
4
4
  # array of the Journey subclass. When the step gets performed, the block passed to the
5
5
  # constructor will be instance_exec'd with the Journey model being the context
6
6
  class StepperMotor::Step
7
- attr_reader :name, :wait, :seq
8
- def initialize(name:, seq:, wait: 0, &step_block)
7
+ class MissingDefinition < NoMethodError
8
+ end
9
+
10
+ # @return [String] the name of the step or method to call on the Journey
11
+ attr_reader :name
12
+
13
+ # @return [Numeric,ActiveSupport::Duration] how long to wait before performing the step
14
+ attr_reader :wait
15
+
16
+ # @private
17
+ attr_reader :seq
18
+
19
+ # Creates a new step definition
20
+ #
21
+ # @param name[String,Symbol] the name of the Step
22
+ # @param wait[Numeric,ActiveSupport::Duration] the amount of time to wait before entering the step
23
+ # @param on_exception[Symbol] the action to take if an exception occurs when performing the step.
24
+ # The possible values are:
25
+ # * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising.
26
+ # * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising.
27
+ def initialize(name:, seq:, on_exception:, wait: 0, &step_block)
9
28
  @step_block = step_block
10
29
  @name = name.to_s
11
30
  @wait = wait
12
31
  @seq = seq
32
+ @on_exception = on_exception # TODO: Validate?
13
33
  end
14
34
 
15
- # Makes the Step object itself callable
16
- def to_proc
17
- @step_block
35
+ # Performs the step on the passed Journey, wrapping the step with the required context.
36
+ #
37
+ # @param journey[StepperMotor::Journey] the journey to perform the step in. If a `step_block`
38
+ # is passed in, it is going to be executed in the context of the journey using `instance_exec`.
39
+ # If only the name of the step has been provided, an accordingly named public method on the
40
+ # journey will be called
41
+ # @return void
42
+ def perform_in_context_of(journey)
43
+ # This is a tricky bit.
44
+ #
45
+ # reattempt!, cancel! (and potentially - future flow control methods) all use `throw` to
46
+ # immediately hop out of the perform block. They all use the same symbol thrown - :abort_step.
47
+ # Nothing after `reattempt!` and `cancel!` in the same scope will run because of that `throw` -
48
+ # not even the `rescue` clauses, so we need to catch here instead of the `perform_next_step!`
49
+ # method. This way, if the step raises an exception, we can still let Journey flow control methods
50
+ # be used, but we can capture the exception. Moreover: we need to be able to _call_ those methods from
51
+ # within the rescue() clauses. So:
52
+ catch(:abort_step) do
53
+ if @step_block
54
+ journey.instance_exec(&@step_block)
55
+ elsif journey.respond_to?(name)
56
+ journey.public_send(name) # TODO: context/params?
57
+ else
58
+ raise MissingDefinition.new(<<~MSG, name, _args = nil, _private = false, receiver: journey)
59
+ No block or method to use for step `#{name}' on #{journey.class}
60
+ MSG
61
+ end
62
+ end
63
+ rescue MissingDefinition
64
+ # This journey won't succeed with any number of reattempts, cancel it. Cancellation also will throw.
65
+ catch(:abort_step) { journey.pause! }
66
+ raise
67
+ rescue => e
68
+ # Act according to the set policy. The basic 2 for the moment are :reattempt! and :cancel!,
69
+ # and can be applied by just calling the methods on the passed journey
70
+ case @on_exception
71
+ when :reattempt!
72
+ catch(:abort_step) { journey.reattempt! }
73
+ when :cancel!
74
+ catch(:abort_step) { journey.cancel! }
75
+ when :pause!
76
+ catch(:abort_step) { journey.pause! }
77
+ else
78
+ # Leave the journey hanging in the "performing" state
79
+ end
80
+
81
+ # Re-raise the exception so that the Rails error handling can register it
82
+ raise e
18
83
  end
19
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.7"
4
+ VERSION = "0.1.8"
5
5
  end
data/lib/stepper_motor.rb CHANGED
@@ -21,6 +21,5 @@ module StepperMotor
21
21
  autoload :CyclicScheduler, File.dirname(__FILE__) + "/stepper_motor/cyclic_scheduler.rb"
22
22
  autoload :TestHelper, File.dirname(__FILE__) + "/stepper_motor/test_helper.rb"
23
23
 
24
-
25
24
  mattr_accessor :scheduler, default: ForwardScheduler.new
26
25
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :stepper_motor do
4
+ desc "Recover all journeys hanging in the 'performing' state"
5
+ task :recovery do
6
+ StepperMotor::RecoverStuckJourneysJobV1.perform_now
7
+ end
8
+ end