stepper_motor 0.1.12 → 0.1.16

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.
@@ -30,14 +30,24 @@ module StepperMotor
30
30
  #
31
31
  # _@param_ `wait` — the amount of time to wait before entering the step
32
32
  #
33
- # _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising.
33
+ # _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:pause!` - pauses the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:skip!` - skips the current step and proceeds to the next step, or finishes the journey if it's the last step.
34
+ #
35
+ # _@param_ `skip_if` — condition to check before performing the step. If a boolean is provided, it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
34
36
  def initialize: (
35
37
  name: (String | Symbol),
36
38
  seq: untyped,
37
- on_exception: Symbol,
38
- ?wait: (Numeric | ActiveSupport::Duration)
39
+ ?on_exception: Symbol,
40
+ ?wait: (Numeric | ActiveSupport::Duration),
41
+ ?skip_if: (TrueClass | FalseClass | NilClass | Symbol | Proc)
39
42
  ) -> void
40
43
 
44
+ # Checks if the step should be skipped based on the skip_if condition
45
+ #
46
+ # _@param_ `journey` — the journey to check the condition for
47
+ #
48
+ # _@return_ — true if the step should be skipped, false otherwise
49
+ def should_skip?: (StepperMotor::Journey journey) -> bool
50
+
41
51
  # Performs the step on the passed Journey, wrapping the step with the required context.
42
52
  #
43
53
  # _@param_ `journey` — the journey to perform the step in. If a `step_block` is passed in, it is going to be executed in the context of the journey using `instance_exec`. If only the name of the step has been provided, an accordingly named public method on the journey will be called
@@ -117,6 +127,10 @@ module StepperMotor
117
127
  #
118
128
  # _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
119
129
  #
130
+ # _@param_ `skip_if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be skipped if the condition returns a truthy value.
131
+ #
132
+ # _@param_ `if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
133
+ #
120
134
  # _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
121
135
  #
122
136
  # _@return_ — the step definition that has been created
@@ -124,8 +138,7 @@ module StepperMotor
124
138
  ?String? name,
125
139
  ?wait: (Float | untyped | ActiveSupport::Duration)?,
126
140
  ?after: (Float | untyped | ActiveSupport::Duration)?,
127
- ?on_exception: Symbol,
128
- **untyped additional_step_definition_options
141
+ **::Hash[untyped, untyped] additional_step_definition_options
129
142
  ) -> StepperMotor::Step
130
143
 
131
144
  # sord warn - "StepperMotor::Step?" does not appear to be a type
@@ -205,6 +218,19 @@ module StepperMotor
205
218
  # _@return_ — void
206
219
  def reattempt!: (?wait: untyped) -> untyped
207
220
 
221
+ # Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
222
+ # conditionally skip a step based on some business logic without canceling the entire journey. For example,
223
+ # you might want to skip a reminder email step if the user has already taken the required action.
224
+ #
225
+ # If there are more steps after the current step, `skip!` will schedule the next step to be performed.
226
+ # If the current step is the last step in the journey, `skip!` will finish the journey.
227
+ #
228
+ # `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
229
+ # When called outside of a step, it will skip the next scheduled step and proceed to the following step.
230
+ #
231
+ # _@return_ — void
232
+ def skip!: () -> untyped
233
+
208
234
  # 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
209
235
  # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
210
236
  #
@@ -251,6 +277,19 @@ module StepperMotor
251
277
  # _@return_ — void
252
278
  def reattempt!: (?wait: untyped) -> untyped
253
279
 
280
+ # Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
281
+ # conditionally skip a step based on some business logic without canceling the entire journey. For example,
282
+ # you might want to skip a reminder email step if the user has already taken the required action.
283
+ #
284
+ # If there are more steps after the current step, `skip!` will schedule the next step to be performed.
285
+ # If the current step is the last step in the journey, `skip!` will finish the journey.
286
+ #
287
+ # `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
288
+ # When called outside of a step, it will skip the next scheduled step and proceed to the following step.
289
+ #
290
+ # _@return_ — void
291
+ def skip!: () -> untyped
292
+
254
293
  # 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
255
294
  # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
256
295
  #
@@ -37,6 +37,8 @@ Gem::Specification.new do |spec|
37
37
  spec.add_development_dependency "minitest"
38
38
  spec.add_development_dependency "rails", "~> 7.0"
39
39
  spec.add_development_dependency "sqlite3"
40
+ spec.add_development_dependency "mysql2"
41
+ spec.add_development_dependency "pg"
40
42
  spec.add_development_dependency "rake", "~> 13.0"
41
43
  spec.add_development_dependency "standard", "~> 1.50.0", "< 2.0"
42
44
  spec.add_development_dependency "magic_frozen_string_literal"
@@ -0,0 +1,14 @@
1
+ default: &default
2
+ adapter: mysql2
3
+ database: stepper_motor_dummy_<%= Rails.env %>
4
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
5
+ username: root
6
+ password: constabulary
7
+ timeout: 5000
8
+
9
+ development:
10
+ <<: *default
11
+ test:
12
+ <<: *default
13
+ production:
14
+ <<: *default
@@ -0,0 +1,14 @@
1
+ default: &default
2
+ adapter: postgres
3
+ database: stepper_motor_dummy_<%= Rails.env %>
4
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
5
+ username: root
6
+ password: constabulary
7
+ timeout: 5000
8
+
9
+ development:
10
+ <<: *default
11
+ test:
12
+ <<: *default
13
+ production:
14
+ <<: *default
@@ -0,0 +1,32 @@
1
+ # SQLite. Versions 3.8.0 and up are supported.
2
+ # gem install sqlite3
3
+ #
4
+ # Ensure the SQLite 3 gem is defined in your Gemfile
5
+ # gem "sqlite3"
6
+ #
7
+ default: &default
8
+ adapter: sqlite3
9
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
10
+ timeout: 5000
11
+
12
+ development:
13
+ <<: *default
14
+ database: storage/development.sqlite3
15
+
16
+ # Warning: The database defined as "test" will be erased and
17
+ # re-generated from your development database when you run "rake".
18
+ # Do not set this db to the same as development or production.
19
+ test:
20
+ <<: *default
21
+ database: storage/test.sqlite3
22
+
23
+
24
+ # SQLite3 write its data on the local filesystem, as such it requires
25
+ # persistent disks. If you are deploying to a managed service, you should
26
+ # make sure it provides disk persistence, as many don't.
27
+ #
28
+ # Similarly, if you deploy your application as a Docker container, you must
29
+ # ensure the database is located in a persisted volume.
30
+ production:
31
+ <<: *default
32
+ # database: path/to/persistent/storage/production.sqlite3
@@ -9,7 +9,7 @@
9
9
  #
10
10
  # and add its cycle job into your recurring jobs table. For example, for solid_queue:
11
11
  #
12
- # stepper_motor_houseleeping:
12
+ # run_stepper_motor_scheduling_cycle:
13
13
  # schedule: "*/30 * * * *" # Every 30 minutes
14
14
  # class: "StepperMotor::CyclicScheduler::RunSchedulingCycleJob"
15
15
  #
@@ -0,0 +1,58 @@
1
+ class StepperMotorMigration005 < ActiveRecord::Migration[7.2]
2
+ def up
3
+ unless mysql?
4
+ say "Skipping migration as it is only used with MySQL (mysql2 or trilogy)"
5
+ return
6
+ end
7
+
8
+ # Add generated column that combines the state, type, hero_id, and hero_type
9
+ # The column will be NULL if any of the components is NULL
10
+ execute <<-SQL
11
+ ALTER TABLE stepper_motor_journeys
12
+ ADD COLUMN journey_uniq_col_generated VARCHAR(255) GENERATED ALWAYS AS (
13
+ CASE
14
+ WHEN state IN ('ready', 'performing', 'paused')
15
+ AND allow_multiple = 0
16
+ AND type IS NOT NULL
17
+ AND hero_id IS NOT NULL
18
+ AND hero_type IS NOT NULL
19
+ THEN CONCAT(type, ':', hero_id, ':', hero_type)
20
+ ELSE NULL
21
+ END
22
+ ) STORED
23
+ SQL
24
+
25
+ # Add unique index on the generated column with MySQL-specific name
26
+ add_index :stepper_motor_journeys, :journey_uniq_col_generated,
27
+ unique: true,
28
+ name: :idx_journeys_one_per_hero_mysql_generated
29
+
30
+ # Remove old indexes that include 'ready', 'performing', and 'paused' states
31
+ remove_index :stepper_motor_journeys, name: :idx_journeys_one_per_hero_with_paused
32
+ end
33
+
34
+ def down
35
+ unless mysql?
36
+ say "Skipping migration as it is only used with MySQL (mysql2 or trilogy)"
37
+ return
38
+ end
39
+
40
+ # Remove the generated column and its index
41
+ remove_index :stepper_motor_journeys, name: :idx_journeys_one_per_hero_mysql_generated
42
+ remove_column :stepper_motor_journeys, :journey_uniq_col_generated
43
+
44
+ # Recreate old indexes
45
+ quoted_false = connection.quote(false)
46
+ add_index :stepper_motor_journeys, [:type, :hero_id, :hero_type],
47
+ where: "allow_multiple = '#{quoted_false}' AND state IN ('ready', 'performing', 'paused')",
48
+ unique: true,
49
+ name: :idx_journeys_one_per_hero_with_paused
50
+ end
51
+
52
+ private
53
+
54
+ def mysql?
55
+ adapter = connection.adapter_name.downcase
56
+ adapter == "mysql2" || adapter == "trilogy"
57
+ end
58
+ end
@@ -10,8 +10,8 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema[7.2].define(version: 2025_05_28_141038) do
14
- create_table "stepper_motor_journeys", force: :cascade do |t|
13
+ ActiveRecord::Schema[7.2].define(version: 2025_06_09_221201) do
14
+ create_table "stepper_motor_journeys", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
15
15
  t.string "type", null: false
16
16
  t.string "state", default: "ready"
17
17
  t.string "hero_type"
@@ -25,12 +25,13 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_28_141038) do
25
25
  t.datetime "created_at", null: false
26
26
  t.datetime "updated_at", null: false
27
27
  t.string "idempotency_key"
28
+ t.virtual "journey_uniq_col_generated", type: :string, as: "case when `state` in ('ready','performing','paused') and `allow_multiple` = 0 and `type` is not null and `hero_id` is not null and `hero_type` is not null then concat(`type`,':',`hero_id`,':',`hero_type`) else NULL end", stored: true
28
29
  t.index ["hero_type", "hero_id"], name: "index_stepper_motor_journeys_on_hero_type_and_hero_id"
29
- t.index ["next_step_to_be_performed_at"], name: "index_stepper_motor_journeys_on_next_step_to_be_performed_at", where: "state = 'ready' /*application='Dummy'*/"
30
- t.index ["type", "hero_id", "hero_type"], name: "idx_journeys_one_per_hero_with_paused", unique: true, where: "allow_multiple = '0' AND state IN ('ready', 'performing', 'paused') /*application='Dummy'*/"
30
+ t.index ["journey_uniq_col_generated"], name: "idx_journeys_one_per_hero_mysql_generated", unique: true
31
+ t.index ["next_step_to_be_performed_at"], name: "index_stepper_motor_journeys_on_next_step_to_be_performed_at"
31
32
  t.index ["type", "hero_type", "hero_id"], name: "index_stepper_motor_journeys_on_type_and_hero_type_and_hero_id"
32
33
  t.index ["type"], name: "index_stepper_motor_journeys_on_type"
33
- t.index ["updated_at"], name: "index_stepper_motor_journeys_on_updated_at", where: "state = 'canceled' OR state = 'finished' /*application='Dummy'*/"
34
- t.index ["updated_at"], name: "stuck_journeys_index", where: "state = 'performing' /*application='Dummy'*/"
34
+ t.index ["updated_at"], name: "index_stepper_motor_journeys_on_updated_at"
35
+ t.index ["updated_at"], name: "stuck_journeys_index"
35
36
  end
36
37
  end
@@ -3,6 +3,8 @@
3
3
  require "test_helper"
4
4
 
5
5
  class ExceptionHandlingTest < ActiveSupport::TestCase
6
+ include SideEffects::TestHelper
7
+
6
8
  # See below.
7
9
  self.use_transactional_tests = false
8
10
 
@@ -46,6 +48,61 @@ class ExceptionHandlingTest < ActiveSupport::TestCase
46
48
  assert faulty_journey.canceled?
47
49
  end
48
50
 
51
+ test "with :skip!, skips the failing step and continues to next step" do
52
+ faulty_journey_class = create_journey_subclass do
53
+ step on_exception: :skip! do
54
+ raise CustomEx, "Something went wrong"
55
+ end
56
+
57
+ step do
58
+ SideEffects.touch! "second step"
59
+ end
60
+ end
61
+
62
+ faulty_journey = faulty_journey_class.create!
63
+ assert faulty_journey.ready?
64
+
65
+ assert_raises(CustomEx) { faulty_journey.perform_next_step! }
66
+
67
+ assert faulty_journey.persisted?
68
+ refute faulty_journey.changed?
69
+ assert faulty_journey.ready?
70
+
71
+ # The second step should now be scheduled
72
+ assert_produced_side_effects("second step") do
73
+ faulty_journey.perform_next_step!
74
+ end
75
+ assert faulty_journey.finished?
76
+ end
77
+
78
+ test "with :skip! on last step, skips the failing step and finishes the journey" do
79
+ faulty_journey_class = create_journey_subclass do
80
+ step do
81
+ SideEffects.touch! "first step"
82
+ end
83
+
84
+ step on_exception: :skip! do
85
+ raise CustomEx, "Something went wrong"
86
+ end
87
+ end
88
+
89
+ faulty_journey = faulty_journey_class.create!
90
+ assert faulty_journey.ready?
91
+
92
+ # Perform first step
93
+ assert_produced_side_effects("first step") do
94
+ faulty_journey.perform_next_step!
95
+ end
96
+ assert faulty_journey.ready?
97
+
98
+ # The second step should be skipped due to exception
99
+ assert_raises(CustomEx) { faulty_journey.perform_next_step! }
100
+
101
+ assert faulty_journey.persisted?
102
+ refute faulty_journey.changed?
103
+ assert faulty_journey.finished?
104
+ end
105
+
49
106
  test "pauses the journey by default at the failig step" do
50
107
  faulty_journey_class = create_journey_subclass do
51
108
  step do
@@ -75,4 +75,168 @@ class FlowControlTest < ActiveSupport::TestCase
75
75
 
76
76
  assert_no_side_effects { journey.perform_next_step! }
77
77
  end
78
+
79
+ test "can skip a step and continue to next step" do
80
+ skipping_journey = create_journey_subclass do
81
+ step do
82
+ SideEffects.touch! "before skipping"
83
+ skip!
84
+ SideEffects.touch! "after skipping"
85
+ end
86
+
87
+ step do
88
+ SideEffects.touch! "second step"
89
+ end
90
+ end
91
+
92
+ journey = skipping_journey.create!
93
+ assert journey.ready?
94
+
95
+ # First step should be skipped
96
+ assert_produced_side_effects("before skipping") do
97
+ assert_did_not_produce_side_effects("after skipping") do
98
+ journey.perform_next_step!
99
+ end
100
+ end
101
+ assert journey.ready?
102
+
103
+ # Second step should be performed
104
+ assert_produced_side_effects("second step") do
105
+ journey.perform_next_step!
106
+ end
107
+ assert journey.finished?
108
+ end
109
+
110
+ test "can skip the last step and finish the journey" do
111
+ skipping_journey = create_journey_subclass do
112
+ step do
113
+ SideEffects.touch! "first step"
114
+ end
115
+
116
+ step do
117
+ SideEffects.touch! "before skipping last"
118
+ skip!
119
+ SideEffects.touch! "after skipping last"
120
+ end
121
+ end
122
+
123
+ journey = skipping_journey.create!
124
+ assert journey.ready?
125
+
126
+ # First step should be performed normally
127
+ assert_produced_side_effects("first step") do
128
+ journey.perform_next_step!
129
+ end
130
+ assert journey.ready?
131
+
132
+ # Last step should be skipped and journey should finish
133
+ assert_produced_side_effects("before skipping last") do
134
+ assert_did_not_produce_side_effects("after skipping last") do
135
+ journey.perform_next_step!
136
+ end
137
+ end
138
+ assert journey.finished?
139
+ end
140
+
141
+ test "skip! can be called outside of a step for ready journeys" do
142
+ skipping_journey = create_journey_subclass do
143
+ step do
144
+ SideEffects.touch! "first step"
145
+ end
146
+
147
+ step do
148
+ SideEffects.touch! "second step"
149
+ end
150
+ end
151
+
152
+ journey = skipping_journey.create!
153
+ assert journey.ready?
154
+
155
+ # Skip the first step from outside
156
+ journey.skip!
157
+ assert journey.ready?
158
+
159
+ # The second step should now be scheduled
160
+ assert_produced_side_effects("second step") do
161
+ journey.perform_next_step!
162
+ end
163
+ assert journey.finished?
164
+ end
165
+
166
+ test "skip! outside of step raises error for non-ready journeys" do
167
+ skipping_journey = create_journey_subclass do
168
+ step do
169
+ SideEffects.touch! "step completed"
170
+ end
171
+ end
172
+
173
+ journey = skipping_journey.create!
174
+ journey.pause!
175
+ assert journey.paused?
176
+
177
+ assert_raises(RuntimeError, "skip! can only be used on journeys in the `ready` state") do
178
+ journey.skip!
179
+ end
180
+ end
181
+
182
+ test "skip! outside of step can finish journey when skipping last step" do
183
+ skipping_journey = create_journey_subclass do
184
+ step do
185
+ SideEffects.touch! "first step"
186
+ end
187
+
188
+ step do
189
+ SideEffects.touch! "last step"
190
+ end
191
+ end
192
+
193
+ journey = skipping_journey.create!
194
+ assert journey.ready?
195
+
196
+ # Perform first step
197
+ assert_produced_side_effects("first step") do
198
+ journey.perform_next_step!
199
+ end
200
+ assert journey.ready?
201
+
202
+ # Skip the last step from outside
203
+ journey.skip!
204
+ assert journey.finished?
205
+ end
206
+
207
+ test "skip! outside of step handles missing step definitions gracefully" do
208
+ skipping_journey = create_journey_subclass do
209
+ step do
210
+ SideEffects.touch! "step completed"
211
+ end
212
+ end
213
+
214
+ journey = skipping_journey.create!
215
+ assert journey.ready?
216
+
217
+ # Manually set a non-existent next step
218
+ journey.update!(next_step_name: "non_existent_step")
219
+
220
+ # Skip should handle this gracefully and finish the journey
221
+ journey.skip!
222
+ assert journey.finished?
223
+ end
224
+
225
+ test "skip! aborts the current step execution" do
226
+ skipping_journey = create_journey_subclass do
227
+ step do
228
+ SideEffects.touch! "before skip"
229
+ skip!
230
+ SideEffects.touch! "after skip"
231
+ end
232
+ end
233
+
234
+ journey = skipping_journey.create!
235
+
236
+ assert_produced_side_effects("before skip") do
237
+ assert_did_not_produce_side_effects("after skip") do
238
+ journey.perform_next_step!
239
+ end
240
+ end
241
+ end
78
242
  end