stepper_motor 0.1.12 → 0.1.15
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/workflows/ci.yml +50 -8
- data/CHANGELOG.md +10 -0
- data/lib/generators/stepper_motor_migration_005.rb.erb +58 -0
- data/lib/stepper_motor/journey.rb +11 -8
- data/lib/stepper_motor/step.rb +36 -8
- data/lib/stepper_motor/version.rb +1 -1
- data/manual/MANUAL.md +337 -86
- data/rbi/stepper_motor.rbi +18 -4
- data/sig/stepper_motor.rbs +15 -2
- data/stepper_motor.gemspec +2 -0
- data/test/dummy/config/database.mysql2.yml +14 -0
- data/test/dummy/config/database.postgres.yml +14 -0
- data/test/dummy/config/database.sqlite3.yml +32 -0
- data/test/dummy/config/initializers/stepper_motor.rb +1 -1
- data/test/dummy/db/migrate/20250609221201_stepper_motor_migration_005.rb +58 -0
- data/test/dummy/db/schema.rb +7 -6
- data/test/stepper_motor/journey/if_condition_test.rb +286 -0
- data/test/stepper_motor/journey/step_definition_test.rb +1 -0
- metadata +37 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c62f05b7bc2c994345697f0d403fc6faeaf491c3efa883bfdfe9c3dc7595d52a
|
4
|
+
data.tar.gz: 6fbf7c140444e906ef178af631023022994707c4cf282d954e681c5f6fe07bc1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f95cd7b0eebf80306a14d04f5ce3cd229e0b4e5ee4aa7fc5f12d902ee027b2d97294ecce7bc0a15e56e8490ea9f4724becc6f817dc6bcc9f10667767292c6b85
|
7
|
+
data.tar.gz: e44253c904ab4cf471216352cdc0ec85790532ecb66d9d401d24a9ee85673c42af6d2aa4301be67824344bd0f1864fc34121713db6499f841765a3e6a3ea602b
|
data/.github/workflows/ci.yml
CHANGED
@@ -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
|
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
|
-
|
91
|
+
DATABASE_URL: ${{ matrix.database.url }}
|
50
92
|
run: bin/test
|
51
93
|
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## [0.1.15] - 2025-06-20
|
4
|
+
|
5
|
+
- 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.
|
6
|
+
|
7
|
+
## [0.1.14] - 2025-06-10
|
8
|
+
|
9
|
+
- Since MySQL does not support partial indexes, use a generated column for the uniqueness checks. Other indexes
|
10
|
+
which are set up as partial indexes just become full indexes - that will make them larger on MySQL but the functionality
|
11
|
+
is going to be the same. This is a tradeoff but it seems sensible for the time being.
|
12
|
+
|
3
13
|
## [0.1.12] - 2025-06-08
|
4
14
|
|
5
15
|
- 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
|
@@ -72,15 +72,18 @@ 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,
|
76
|
-
# to when the previous step has completed. When the journey gets scheduled, the triggering job is going to
|
77
|
-
# amount of time _minus the `wait` values of the preceding steps, and the
|
78
|
-
#
|
79
|
-
#
|
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
|
81
|
+
# @param 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 only be performed if the condition returns a truthy value.
|
84
|
+
# @param additional_step_definition_options[Hash] Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
82
85
|
# @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)
|
86
|
+
def self.step(name = nil, wait: nil, after: nil, on_exception: :pause!, if: true, **additional_step_definition_options, &blk)
|
84
87
|
wait = if wait && after
|
85
88
|
raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
|
86
89
|
elsif !wait && !after
|
@@ -109,7 +112,7 @@ module StepperMotor
|
|
109
112
|
raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
|
110
113
|
|
111
114
|
# 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|
|
115
|
+
StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, on_exception:, if: binding.local_variable_get(:if), **additional_step_definition_options, &blk).tap do |step_definition|
|
113
116
|
# As per Rails docs: you need to be aware when using class_attribute with mutable structures
|
114
117
|
# as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
|
115
118
|
# See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
|
data/lib/stepper_motor/step.rb
CHANGED
@@ -24,12 +24,37 @@ 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
|
-
|
27
|
+
# @param if[TrueClass,FalseClass,NilClass,Symbol,Proc] condition to check before performing the step. If a boolean is provided,
|
28
|
+
# it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided,
|
29
|
+
# it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
|
30
|
+
# The step will only be performed if the condition returns a truthy value.
|
31
|
+
def initialize(name:, seq:, on_exception:, wait: 0, if: true, &step_block)
|
28
32
|
@step_block = step_block
|
29
33
|
@name = name.to_s
|
30
34
|
@wait = wait
|
31
35
|
@seq = seq
|
32
36
|
@on_exception = on_exception # TODO: Validate?
|
37
|
+
@if_condition = binding.local_variable_get(:if) # Done this way because `if` is a reserved keyword
|
38
|
+
|
39
|
+
# Validate the if condition
|
40
|
+
if ![true, false, nil].include?(@if_condition) && !@if_condition.is_a?(Symbol) && !@if_condition.respond_to?(:call)
|
41
|
+
raise ArgumentError, "if: condition must be a boolean, nil, Symbol or a callable object, but was a #{@if_condition.inspect}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Checks if the step should be performed based on the if condition
|
46
|
+
#
|
47
|
+
# @param journey[StepperMotor::Journey] the journey to check the condition for
|
48
|
+
# @return [Boolean] true if the step should be performed, false otherwise
|
49
|
+
def should_perform?(journey)
|
50
|
+
case @if_condition
|
51
|
+
when true, false, nil
|
52
|
+
!!@if_condition
|
53
|
+
when Symbol
|
54
|
+
journey.send(@if_condition) # Allow private methods
|
55
|
+
else
|
56
|
+
journey.instance_exec(&@if_condition)
|
57
|
+
end
|
33
58
|
end
|
34
59
|
|
35
60
|
# Performs the step on the passed Journey, wrapping the step with the required context.
|
@@ -40,6 +65,12 @@ class StepperMotor::Step
|
|
40
65
|
# journey will be called
|
41
66
|
# @return void
|
42
67
|
def perform_in_context_of(journey)
|
68
|
+
# Return early should the `if` condition be false
|
69
|
+
if !should_perform?(journey)
|
70
|
+
journey.logger.info { "skipping as if: condition was falsey or returned false" }
|
71
|
+
return
|
72
|
+
end
|
73
|
+
|
43
74
|
# This is a tricky bit.
|
44
75
|
#
|
45
76
|
# reattempt!, cancel! (and potentially - future flow control methods) all use `throw` to
|
@@ -61,21 +92,18 @@ class StepperMotor::Step
|
|
61
92
|
end
|
62
93
|
end
|
63
94
|
rescue MissingDefinition
|
64
|
-
# This journey won't succeed with any number of reattempts,
|
95
|
+
# This journey won't succeed with any number of reattempts, pause it.
|
65
96
|
catch(:abort_step) { journey.pause! }
|
66
97
|
raise
|
67
98
|
rescue => e
|
68
99
|
# Act according to the set policy. The basic 2 for the moment are :reattempt! and :cancel!,
|
69
100
|
# and can be applied by just calling the methods on the passed journey
|
70
101
|
case @on_exception
|
71
|
-
when :reattempt!
|
72
|
-
catch(:abort_step) { journey.
|
73
|
-
when :cancel!
|
74
|
-
catch(:abort_step) { journey.cancel! }
|
75
|
-
when :pause!
|
76
|
-
catch(:abort_step) { journey.pause! }
|
102
|
+
when :reattempt!, :cancel!, :pause!
|
103
|
+
catch(:abort_step) { journey.public_send(@on_exception) }
|
77
104
|
else
|
78
105
|
# Leave the journey hanging in the "performing" state
|
106
|
+
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
107
|
end
|
80
108
|
|
81
109
|
# Re-raise the exception so that the Rails error handling can register it
|