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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcbfb106c1b6c9c74cef277a3bedec00c970d5264856ae92671b6f2302936eaa
4
- data.tar.gz: a351a974b2ed5af98f20c8e816e43aac19982539054c7db1e3b1290d2d6a2538
3
+ metadata.gz: 4b94d7f405351309bd1fbda83b352031795cbee87aad100d412d847e3d03543c
4
+ data.tar.gz: 59ecde85caacac0c7e65f6a0c527676bac4700b84fa39ce55443e81592023738
5
5
  SHA512:
6
- metadata.gz: 6a7f3190a99a62537b17ccfdfd76f1c4879a5b5c054395ffbd1d4ccaf7da469f96e2198954adbb2db79571b046b772ea37daf3865f27c23ea7d3a2b75f505699
7
- data.tar.gz: 8f999b8fb9e599d6cfac817a82cc4640902d9812a296f9643cd80d0632099f94140645f9ad55dcbd1a043e1c6f25df3084a0cc23ab88b24ac206dab2b991c302
6
+ metadata.gz: 5305f1f98eb8b8c630e45309280fae524ec458d15a75362e647a36e22fd29ca0fe1a7ecbbba186ed60f33b64b7b6b875e66878280e21a6a3196473652b82f098
7
+ data.tar.gz: 2e6db70cf8ad6b844c4d7a38eab6f087f785b1a112f446844e25d6d943bffd3f3b71da6bd7baa69c7249a7978a5538d2572235b3f90475b01af8be0d07074cfd
@@ -7,6 +7,7 @@ on:
7
7
 
8
8
  jobs:
9
9
  lint:
10
+ name: "Lint (standardrb)"
10
11
  runs-on: ubuntu-latest
11
12
  steps:
12
13
  - name: Checkout code
@@ -22,17 +23,45 @@ jobs:
22
23
  run: bundle exec standardrb
23
24
 
24
25
  test:
26
+ name: "Tests (${{ matrix.database.name }})"
25
27
  runs-on: ubuntu-latest
28
+ strategy:
29
+ matrix:
30
+ database:
31
+ - { name: 'PostgreSQL', url: 'postgresql://postgres:postgres@localhost:5432/stepper_motor_test' }
32
+ - { name: 'MySQL', url: 'mysql2://root:root@127.0.0.1:3306/stepper_motor_test?host=127.0.0.1' }
33
+ - { name: 'SQLite', url: 'sqlite3:db/test.sqlite3' }
34
+
35
+ services:
36
+ postgres:
37
+ image: postgres:14
38
+ env:
39
+ POSTGRES_USER: postgres
40
+ POSTGRES_PASSWORD: postgres
41
+ POSTGRES_DB: stepper_motor_test
42
+ ports:
43
+ - 5432:5432
44
+ options: >-
45
+ --health-cmd pg_isready
46
+ --health-interval 10s
47
+ --health-timeout 5s
48
+ --health-retries 5
49
+ mysql:
50
+ image: mysql:8.0
51
+ env:
52
+ MYSQL_ROOT_PASSWORD: root
53
+ MYSQL_DATABASE: stepper_motor_test
54
+ ports:
55
+ - 3306:3306
56
+ options: >-
57
+ --health-cmd "mysqladmin ping"
58
+ --health-interval 10s
59
+ --health-timeout 5s
60
+ --health-retries 5
26
61
 
27
- # services:
28
- # redis:
29
- # image: redis
30
- # ports:
31
- # - 6379:6379
32
- # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
33
62
  steps:
34
63
  - name: Install packages
35
- run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config google-chrome-stable
64
+ run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config
36
65
 
37
66
  - name: Checkout code
38
67
  uses: actions/checkout@v4
@@ -43,9 +72,22 @@ jobs:
43
72
  ruby-version: 3.2.2
44
73
  bundler-cache: true
45
74
 
75
+ - name: Remove existing schema.rb
76
+ run: rm -f test/dummy/db/schema.rb
77
+
78
+ - name: Setup database
79
+ env:
80
+ RAILS_ENV: test
81
+ DATABASE_URL: ${{ matrix.database.url }}
82
+ run: |
83
+ cd test/dummy
84
+ bundle exec rails db:create
85
+ bundle exec rails db:migrate
86
+ cd ../..
87
+
46
88
  - name: Run tests
47
89
  env:
48
90
  RAILS_ENV: test
49
- # REDIS_URL: redis://localhost:6379/0
91
+ DATABASE_URL: ${{ matrix.database.url }}
50
92
  run: bin/test
51
93
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.16] - 2025-06-20
6
+
7
+ - Add `skip!` flow control method to skip the current (or next) step and move on to the subsequent step, or finish the journey.
8
+ - Rename `if:` parameter to `skip_if:` for better clarity. The `if:` parameter is still supported for brevity.
9
+
10
+ ## [0.1.15] - 2025-06-20
11
+
12
+ - Add `if:` condition allowing steps to be skipped. The `if:` can be a boolean, a callable or a symbol for a method name. The method should be on the Journey.
13
+
14
+ ## [0.1.14] - 2025-06-10
15
+
16
+ - Since MySQL does not support partial indexes, use a generated column for the uniqueness checks. Other indexes
17
+ which are set up as partial indexes just become full indexes - that will make them larger on MySQL but the functionality
18
+ is going to be the same. This is a tradeoff but it seems sensible for the time being.
19
+
3
20
  ## [0.1.12] - 2025-06-08
4
21
 
5
22
  - Ensure base job extension gets done via the reloader, so that app classes are available
@@ -0,0 +1,58 @@
1
+ class StepperMotorMigration005 < ActiveRecord::Migration[<%= migration_version %>]
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
@@ -27,6 +27,55 @@ module StepperMotor::Journey::FlowControl
27
27
  throw :abort_step if @current_step_definition
28
28
  end
29
29
 
30
+ # Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
31
+ # conditionally skip a step based on some business logic without canceling the entire journey. For example,
32
+ # you might want to skip a reminder email step if the user has already taken the required action.
33
+ #
34
+ # If there are more steps after the current step, `skip!` will schedule the next step to be performed.
35
+ # If the current step is the last step in the journey, `skip!` will finish the journey.
36
+ #
37
+ # `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
38
+ # When called outside of a step, it will skip the next scheduled step and proceed to the following step.
39
+ #
40
+ # @return void
41
+ def skip!
42
+ if @current_step_definition
43
+ # Called within a step - set flag to skip current step
44
+ @skip_current_step = true
45
+ throw :abort_step if @current_step_definition
46
+ else
47
+ # Called outside of a step - skip next scheduled step
48
+ with_lock do
49
+ raise "skip! can only be used on journeys in the `ready` state, but was in #{state.inspect}" unless ready?
50
+
51
+ current_step_name = next_step_name
52
+ current_step_definition = lookup_step_definition(current_step_name)
53
+
54
+ unless current_step_definition
55
+ logger.warn { "no step definition found for #{current_step_name} - finishing journey" }
56
+ finished!
57
+ update!(previous_step_name: current_step_name, next_step_name: nil)
58
+ return
59
+ end
60
+
61
+ current_step_seq = current_step_definition.seq
62
+ next_step_definition = step_definitions[current_step_seq + 1]
63
+
64
+ if next_step_definition
65
+ # There are more steps after this one - schedule the next step
66
+ logger.info { "skipping scheduled step #{current_step_name}, will continue to #{next_step_definition.name}" }
67
+ set_next_step_and_enqueue(next_step_definition)
68
+ ready!
69
+ else
70
+ # This is the last step - finish the journey
71
+ logger.info { "skipping scheduled step #{current_step_name}, finishing journey" }
72
+ finished!
73
+ update!(previous_step_name: current_step_name, next_step_name: nil)
74
+ end
75
+ end
76
+ end
77
+ end
78
+
30
79
  # 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
80
  # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
32
81
  #
@@ -72,15 +72,41 @@ module StepperMotor
72
72
  # When the journey gets scheduled, the triggering job is going to be delayed by this amount of time, and the
73
73
  # `next_step_to_be_performed_at` attribute will be set to the current time plus the wait duration. Mutually exclusive with `after:`
74
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:`
75
+ # including all the previous waits. This allows you to set the wait time based on the time after the journey started,
76
+ # as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to
77
+ # be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at`
78
+ # attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition.
79
+ # Mutually exclusive with `wait:`
80
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.
81
+ # @param skip_if[TrueClass,FalseClass,Symbol,Proc] condition to check before performing the step. If a symbol is provided,
82
+ # it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
83
+ # The step will be skipped if the condition returns a truthy value.
84
+ # @param if[TrueClass,FalseClass,Symbol,Proc] condition to check before performing the step. If a symbol is provided,
85
+ # it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
86
+ # The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
87
+ # @param additional_step_definition_options[Hash] Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
82
88
  # @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
+ def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk)
90
+ # Handle the if: alias for backward compatibility
91
+ if additional_step_definition_options.key?(:if) && additional_step_definition_options.key?(:skip_if)
92
+ raise StepConfigurationError, "Either skip_if: or if: can be specified, but not both"
93
+ end
94
+ if additional_step_definition_options.key?(:if)
95
+ if_condition = additional_step_definition_options.delete(:if)
96
+ # Convert if: to skip_if: by negating either the actual value or the return value of the callable
97
+ # if: truthy means perform, skip_if: truthy means "skip"
98
+ additional_step_definition_options[:skip_if] = case if_condition
99
+ when true, false, nil
100
+ !if_condition
101
+ when Symbol
102
+ # For symbols, we need to create a proc that negates the result
103
+ -> { !send(if_condition) }
104
+ else
105
+ # For callables, we need to create a proc that negates the result
106
+ -> { !instance_exec(&if_condition) }
107
+ end
108
+ end
109
+
84
110
  wait = if wait && after
85
111
  raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
86
112
  elsif !wait && !after
@@ -97,8 +123,8 @@ module StepperMotor
97
123
  raise StepConfigurationError, <<~MSG
98
124
  Step #{step_definitions.length + 1} of #{self} has no explicit name,
99
125
  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.
126
+ must be defined with a block to execute. If you want an instance method of
127
+ this Journey to be used as the step, pass the name of the method as the name of the step.
102
128
  MSG
103
129
  end
104
130
 
@@ -109,7 +135,7 @@ module StepperMotor
109
135
  raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
110
136
 
111
137
  # Create the 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|
138
+ StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, **additional_step_definition_options, &blk).tap do |step_definition|
113
139
  # As per Rails docs: you need to be aware when using class_attribute with mutable structures
114
140
  # as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
115
141
  # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
@@ -229,6 +255,22 @@ module StepperMotor
229
255
  logger.info { "will reattempt #{current_step_name} in #{@reattempt_after} seconds" }
230
256
  set_next_step_and_enqueue(@current_step_definition, wait: @reattempt_after)
231
257
  ready!
258
+ elsif @skip_current_step
259
+ # The step asked to be skipped
260
+ current_step_seq = @current_step_definition.seq
261
+ next_step_definition = step_definitions[current_step_seq + 1]
262
+
263
+ if next_step_definition
264
+ # There are more steps after this one - schedule the next step
265
+ logger.info { "skipping current step #{current_step_name}, will continue to #{next_step_definition.name}" }
266
+ set_next_step_and_enqueue(next_step_definition)
267
+ ready!
268
+ else
269
+ # This is the last step - finish the journey
270
+ logger.info { "skipping current step #{current_step_name}, finishing journey" }
271
+ finished!
272
+ update!(previous_step_name: current_step_name, next_step_name: nil)
273
+ end
232
274
  elsif finished?
233
275
  logger.info { "was marked finished inside the step" }
234
276
  update!(previous_step_name: current_step_name, next_step_name: nil)
@@ -247,6 +289,7 @@ module StepperMotor
247
289
  # and not via background jobs (which reload the model). This should actually be solved
248
290
  # using some object that contains the state of the action later, but for now - the dirty approach is fine.
249
291
  @reattempt_after = nil
292
+ @skip_current_step = nil
250
293
  @current_step_definition = nil
251
294
  # Re-raise the exception, now that we have persisted the Journey according to the recovery policy
252
295
  if ex_rescued_at_perform
@@ -24,12 +24,39 @@ class StepperMotor::Step
24
24
  # The possible values are:
25
25
  # * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising.
26
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)
27
+ # * `:pause!` - pauses the Journey and re-raises the exception. The Journey will be persisted before re-raising.
28
+ # * `:skip!` - skips the current step and proceeds to the next step, or finishes the journey if it's the last step.
29
+ # @param skip_if[TrueClass,FalseClass,NilClass,Symbol,Proc] condition to check before performing the step. If a boolean is provided,
30
+ # it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided,
31
+ # it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
32
+ # The step will only be performed if the condition returns a truthy value.
33
+ def initialize(name:, seq:, on_exception: :pause!, wait: 0, skip_if: false, &step_block)
28
34
  @step_block = step_block
29
35
  @name = name.to_s
30
36
  @wait = wait
31
37
  @seq = seq
32
38
  @on_exception = on_exception # TODO: Validate?
39
+ @skip_if_condition = skip_if
40
+
41
+ # Validate the skip_if condition
42
+ if ![true, false, nil].include?(@skip_if_condition) && !@skip_if_condition.is_a?(Symbol) && !@skip_if_condition.respond_to?(:call)
43
+ raise ArgumentError, "skip_if: condition must be a boolean, nil, Symbol or a callable object, but was a #{@skip_if_condition.inspect}"
44
+ end
45
+ end
46
+
47
+ # Checks if the step should be skipped based on the skip_if condition
48
+ #
49
+ # @param journey[StepperMotor::Journey] the journey to check the condition for
50
+ # @return [Boolean] true if the step should be skipped, false otherwise
51
+ def should_skip?(journey)
52
+ case @skip_if_condition
53
+ when true, false, nil
54
+ !!@skip_if_condition
55
+ when Symbol
56
+ journey.send(@skip_if_condition) # Allow private methods
57
+ else
58
+ journey.instance_exec(&@skip_if_condition)
59
+ end
33
60
  end
34
61
 
35
62
  # Performs the step on the passed Journey, wrapping the step with the required context.
@@ -40,6 +67,12 @@ class StepperMotor::Step
40
67
  # journey will be called
41
68
  # @return void
42
69
  def perform_in_context_of(journey)
70
+ # Return early should the `skip_if` condition be truthy
71
+ if should_skip?(journey)
72
+ journey.logger.info { "skipping as skip_if: condition was truthy" }
73
+ return
74
+ end
75
+
43
76
  # This is a tricky bit.
44
77
  #
45
78
  # reattempt!, cancel! (and potentially - future flow control methods) all use `throw` to
@@ -61,21 +94,18 @@ class StepperMotor::Step
61
94
  end
62
95
  end
63
96
  rescue MissingDefinition
64
- # This journey won't succeed with any number of reattempts, cancel it. Cancellation also will throw.
97
+ # This journey won't succeed with any number of reattempts, pause it.
65
98
  catch(:abort_step) { journey.pause! }
66
99
  raise
67
100
  rescue => e
68
101
  # Act according to the set policy. The basic 2 for the moment are :reattempt! and :cancel!,
69
102
  # and can be applied by just calling the methods on the passed journey
70
103
  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! }
104
+ when :reattempt!, :cancel!, :pause!, :skip!
105
+ catch(:abort_step) { journey.public_send(@on_exception) }
77
106
  else
78
107
  # Leave the journey hanging in the "performing" state
108
+ journey.logger.warn { "unusual on_exception: value (#{@on_exception.inspect}) - the journey will be left hanging in 'performing' state and will be collected as hung" }
79
109
  end
80
110
 
81
111
  # Re-raise the exception so that the Rails error handling can register it
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.12"
4
+ VERSION = "0.1.16"
5
5
  end