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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +12 -0
- data/.github/workflows/ci.yml +51 -0
- data/CHANGELOG.md +77 -2
- data/Gemfile +11 -0
- data/README.md +13 -374
- data/Rakefile +21 -3
- data/bin/test +5 -0
- data/lib/generators/install_generator.rb +6 -1
- data/lib/generators/stepper_motor_migration_003.rb.erb +6 -0
- data/lib/generators/stepper_motor_migration_004.rb.erb +26 -0
- data/lib/stepper_motor/forward_scheduler.rb +8 -4
- data/lib/stepper_motor/journey/flow_control.rb +58 -0
- data/lib/stepper_motor/journey/recovery.rb +34 -0
- data/lib/stepper_motor/journey.rb +85 -84
- data/lib/stepper_motor/perform_step_job_v2.rb +2 -2
- data/lib/stepper_motor/recover_stuck_journeys_job_v1.rb +3 -1
- data/lib/stepper_motor/step.rb +70 -5
- data/lib/stepper_motor/version.rb +1 -1
- data/lib/stepper_motor.rb +0 -1
- data/lib/tasks/stepper_motor_tasks.rake +8 -0
- data/manual/MANUAL.md +538 -0
- data/rbi/stepper_motor.rbi +459 -0
- data/sig/stepper_motor.rbs +406 -3
- data/stepper_motor.gemspec +49 -0
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/assets/stylesheets/application.css +1 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/helpers/application_helper.rb +4 -0
- data/test/dummy/app/jobs/application_job.rb +9 -0
- data/test/dummy/app/mailers/application_mailer.rb +6 -0
- data/test/dummy/app/models/application_record.rb +5 -0
- data/test/dummy/app/views/layouts/application.html.erb +27 -0
- data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/dev +2 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +34 -0
- data/test/dummy/config/application.rb +28 -0
- data/test/dummy/config/boot.rb +7 -0
- data/test/dummy/config/cable.yml +10 -0
- data/test/dummy/config/database.yml +32 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/environments/development.rb +71 -0
- data/test/dummy/config/environments/production.rb +91 -0
- data/test/dummy/config/environments/test.rb +55 -0
- data/test/dummy/config/initializers/content_security_policy.rb +27 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
- data/test/dummy/config/initializers/inflections.rb +18 -0
- data/test/dummy/config/initializers/stepper_motor.rb +3 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/puma.rb +40 -0
- data/test/dummy/config/routes.rb +16 -0
- data/test/dummy/config/storage.yml +34 -0
- data/test/dummy/config.ru +8 -0
- data/test/dummy/db/migrate/20250520094921_stepper_motor_migration_001.rb +38 -0
- data/test/dummy/db/migrate/20250520094922_stepper_motor_migration_002.rb +8 -0
- data/test/dummy/db/migrate/20250522212312_stepper_motor_migration_003.rb +7 -0
- data/test/dummy/db/migrate/20250525110812_stepper_motor_migration_004.rb +28 -0
- data/test/dummy/db/schema.rb +37 -0
- data/test/dummy/public/400.html +114 -0
- data/test/dummy/public/404.html +114 -0
- data/test/dummy/public/406-unsupported-browser.html +114 -0
- data/test/dummy/public/422.html +114 -0
- data/test/dummy/public/500.html +114 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/side_effects_helper.rb +67 -0
- data/test/stepper_motor/cyclic_scheduler_test.rb +77 -0
- data/{spec/stepper_motor/forward_scheduler_spec.rb → test/stepper_motor/forward_scheduler_test.rb} +9 -10
- data/test/stepper_motor/journey/exception_handling_test.rb +89 -0
- data/test/stepper_motor/journey/flow_control_test.rb +78 -0
- data/test/stepper_motor/journey/idempotency_test.rb +65 -0
- data/test/stepper_motor/journey/step_definition_test.rb +187 -0
- data/test/stepper_motor/journey/uniqueness_test.rb +48 -0
- data/test/stepper_motor/journey_test.rb +352 -0
- data/{spec/stepper_motor/recover_stuck_journeys_job_spec.rb → test/stepper_motor/recover_stuck_journeys_job_test.rb} +14 -14
- data/{spec/stepper_motor/recovery_spec.rb → test/stepper_motor/recovery_test.rb} +27 -27
- data/test/stepper_motor/test_helper_test.rb +44 -0
- data/test/stepper_motor_test.rb +9 -0
- data/test/test_helper.rb +46 -0
- metadata +120 -24
- data/.rspec +0 -3
- data/.ruby-version +0 -1
- data/.standard.yml +0 -4
- data/.yardopts +0 -1
- data/spec/helpers/side_effects.rb +0 -85
- data/spec/spec_helper.rb +0 -90
- data/spec/stepper_motor/cyclic_scheduler_spec.rb +0 -68
- data/spec/stepper_motor/generator_spec.rb +0 -16
- data/spec/stepper_motor/journey_spec.rb +0 -401
- data/spec/stepper_motor/test_helper_spec.rb +0 -48
- 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
|
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
|
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
|
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
|
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
|
-
|
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
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
-
|
209
|
-
|
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
|
-
|
224
|
-
|
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
|
228
|
-
logger.info { "has been
|
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
|
-
|
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
|
-
|
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
|
267
|
-
|
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
|
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
|
-
|
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
|
data/lib/stepper_motor/step.rb
CHANGED
@@ -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
|
-
|
8
|
-
|
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
|
-
#
|
16
|
-
|
17
|
-
|
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
|
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
|