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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcbfb106c1b6c9c74cef277a3bedec00c970d5264856ae92671b6f2302936eaa
4
- data.tar.gz: a351a974b2ed5af98f20c8e816e43aac19982539054c7db1e3b1290d2d6a2538
3
+ metadata.gz: c62f05b7bc2c994345697f0d403fc6faeaf491c3efa883bfdfe9c3dc7595d52a
4
+ data.tar.gz: 6fbf7c140444e906ef178af631023022994707c4cf282d954e681c5f6fe07bc1
5
5
  SHA512:
6
- metadata.gz: 6a7f3190a99a62537b17ccfdfd76f1c4879a5b5c054395ffbd1d4ccaf7da469f96e2198954adbb2db79571b046b772ea37daf3865f27c23ea7d3a2b75f505699
7
- data.tar.gz: 8f999b8fb9e599d6cfac817a82cc4640902d9812a296f9643cd80d0632099f94140645f9ad55dcbd1a043e1c6f25df3084a0cc23ab88b24ac206dab2b991c302
6
+ metadata.gz: f95cd7b0eebf80306a14d04f5ce3cd229e0b4e5ee4aa7fc5f12d902ee027b2d97294ecce7bc0a15e56e8490ea9f4724becc6f817dc6bcc9f10667767292c6b85
7
+ data.tar.gz: e44253c904ab4cf471216352cdc0ec85790532ecb66d9d401d24a9ee85673c42af6d2aa4301be67824344bd0f1864fc34121713db6499f841765a3e6a3ea602b
@@ -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,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, 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 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
@@ -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
- def initialize(name:, seq:, on_exception:, wait: 0, &step_block)
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, cancel it. Cancellation also will throw.
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.reattempt! }
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
@@ -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.15"
5
5
  end