stepper_motor 0.1.4 → 0.1.6

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: 0bf607219c84d08a1a2f523a8bfdb2f65735ef947b9d18df6079f8b3154ffce5
4
- data.tar.gz: e75a98bbdc4e87eb2d7053cd045dcd97733fa3d748e5db7193c41f9d10042183
3
+ metadata.gz: a959b0f7b493cb88f9a89f1d84e7d5e9d9c968ac3f4025b31ed9d62e516f71df
4
+ data.tar.gz: 3ebfef286a7e80cad55478631f583e4d21f5ee153d05a2ef99ef4863bc6755c2
5
5
  SHA512:
6
- metadata.gz: 66dbb65657298caab318a6feae9ce01c402f61f56f95d26b12e82f803c71f8dc12dcca4c18475ab91782f1082cedcffe7a48ffef89ce9d14c7c4ee114afb8b2e
7
- data.tar.gz: 2dbcefac4833a846fc0abd0dda9cdfc1c4ef6212f6d7e94ee887548e57ed3d5aeb8bdb9ff0ac71803ec3b0db88b5df1ea4dd34c8b19c733d200ebd4ae4d9b2ed
6
+ metadata.gz: b43d2817735016bcaec60af188e405ccd67c5f86f09de4c43993b84a38a80e73f1a8c3be198a5c34d9e31b0f9e02b6397463a4307157dbba577f23ef87935787
7
+ data.tar.gz: 9403ad567b33918bfbe89da14ae9641fd2be5db621bdc78ecda18e84be12159a391eafe51c76b22d5711897d2ab17b3de6aafbe8751b9904e65aae66fb7e9b86
data/.standard.yml CHANGED
@@ -1,8 +1,4 @@
1
1
  ruby_version: 3.1
2
2
  ignore:
3
- - 'spec/**/*':
4
- - Lint/ConstantDefinitionInBlock
5
- - Style/GlobalVars
6
- - 'lib/zip_kit/rack_body.rb': # Erroneous detection
7
- - Lint/OrderedMagicComments
8
- - Layout/EmptyLineAfterMagicComment
3
+ - 'spec/app/db/**/*':
4
+ - Layout/TrailingWhitespace # Templated migrations have whitespace
data/Rakefile CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
-
6
- RSpec::Core::RakeTask.new(:spec)
7
-
8
5
  require "standard/rake"
9
6
 
7
+ task :format do
8
+ `bundle exec standardrb --fix`
9
+ `bundle exec magic_frozen_string_literal .`
10
+ end
11
+
12
+ RSpec::Core::RakeTask.new(:spec)
10
13
  task default: %i[spec standard]
@@ -0,0 +1,6 @@
1
+ class StepperMotorMigration002 < ActiveRecord::Migration[<%= migration_version %>]
2
+ def change
3
+ # An index is needed to recover stuck journeys
4
+ add_index :stepper_motor_journeys, [:updated_at], name: "stuck_journeys_index", where: "state = 'performing'"
5
+ end
6
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The cyclic scheduler is designed to be run regularly via a cron job. On every
2
4
  # cycle, it is going to look for Journeys which are going to come up for step execution
3
5
  # before the next cycle is supposed to run. Then it is going to enqueue jobs to perform
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The forward scheduler enqueues a job for every Journey that
2
4
  # gets sent to the `#schedule`. The job is then stored in the queue
3
5
  # and gets picked up by the ActiveJob worker normally. This is the simplest
@@ -13,6 +15,8 @@
13
15
  # this scheduler is not a good fit for you, and you will need to use the {CyclicScheduler} instead.
14
16
  class StepperMotor::ForwardScheduler
15
17
  def schedule(journey)
16
- StepperMotor::PerformStepJob.set(wait_until: journey.next_step_to_be_performed_at).perform_later(journey.to_global_id.to_s)
18
+ StepperMotor::PerformStepJobV2
19
+ .set(wait_until: journey.next_step_to_be_performed_at)
20
+ .perform_later(journey_id: journey.id, journey_class_name: journey.class.to_s)
17
21
  end
18
22
  end
@@ -52,6 +52,33 @@ module StepperMotor
52
52
  where(hero: hero)
53
53
  }
54
54
 
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
+
55
82
  after_create do |journey|
56
83
  journey.step_definitions.any? ? journey.set_next_step_and_enqueue(journey.step_definitions.first) : journey.finished!
57
84
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_job"
2
4
 
3
5
  class StepperMotor::PerformStepJob < ActiveJob::Base
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ class StepperMotor::PerformStepJobV2 < ActiveJob::Base
6
+ def perform(journey_id:, journey_class_name:, **)
7
+ journey = StepperMotor::Journey.find(journey_id)
8
+ journey.perform_next_step!
9
+ rescue ActiveRecord::RecordNotFound
10
+ # The journey has been canceled and destroyed previously or elsewhere
11
+ end
12
+ end
@@ -1,12 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
1
5
  # The purpose of this job is to find journeys which have, for whatever reason, remained in the
2
6
  # `performing` state for far longer than the journey is supposed to. At the moment it assumes
3
7
  # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
4
8
  # cron table and perform it regularly.
5
- class StepperMotor::ReapHungJourneysJob < ActiveJob::Base
6
- def perform
7
- StepperMotor::Journey.where("state = 'performing' AND updated_at < ?", 1.hour.ago).find_each do |hung_journey|
8
- hung_journey.update!(state: "ready")
9
- StepperMotor.scheduler.schedule(hung_journey)
9
+ class StepperMotor::RecoverStuckJourneysJobV1 < ActiveJob::Base
10
+ def perform(stuck_for: 2.days)
11
+ StepperMotor::Journey.stuck(stuck_for.ago).find_each do |journey|
12
+ journey.recover!
13
+ rescue => e
14
+ Rails&.error&.report(e)
10
15
  end
11
16
  end
12
17
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Describes a step in a journey. These objects get stored inside the `step_definitions`
2
4
  # array of the Journey subclass. When the step gets performed, the block passed to the
3
5
  # constructor will be instance_exec'd with the Journey model being the context
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StepperMotor::TestHelper
2
4
  # Allows running a given Journey to completion, skipping across the waiting periods.
3
5
  # This is useful to evaluate all side effects of a Journey. The helper will ensure
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.4"
4
+ VERSION = "0.1.6"
5
5
  end
data/lib/stepper_motor.rb CHANGED
@@ -13,6 +13,8 @@ module StepperMotor
13
13
  autoload :Journey, File.dirname(__FILE__) + "/stepper_motor/journey.rb"
14
14
  autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
15
15
  autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
16
+ autoload :PerformStepJobV2, File.dirname(__FILE__) + "/stepper_motor/perform_step_job_v2.rb"
17
+ autoload :RecoverStuckJourneysJobV1, File.dirname(__FILE__) + "/stepper_motor/recover_stuck_journeys_job_v1.rb"
16
18
  autoload :InstallGenerator, File.dirname(__FILE__) + "/generators/install_generator.rb"
17
19
  autoload :ForwardScheduler, File.dirname(__FILE__) + "/stepper_motor/forward_scheduler.rb"
18
20
  autoload :CyclicScheduler, File.dirname(__FILE__) + "/stepper_motor/cyclic_scheduler.rb"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SideEffects
2
4
  module SpecHelper
3
5
  def self.included(into)
data/spec/spec_helper.rb CHANGED
@@ -56,7 +56,7 @@ end
56
56
  module JourneyDefinitionHelper
57
57
  def create_journey_subclass(&blk)
58
58
  # https://stackoverflow.com/questions/4113479/dynamic-class-definition-with-a-class-name
59
- random_component = Random.hex(8)
59
+ random_component = Random.hex(2)
60
60
  random_name = "JourneySubclass#{random_component}"
61
61
  klass = Class.new(StepperMotor::Journey, &blk)
62
62
  Object.const_set(random_name, klass)
@@ -85,5 +85,6 @@ RSpec.configure do |config|
85
85
  StepperMotorRailtieTestHelpers.establish_test_connection
86
86
  StepperMotorRailtieTestHelpers.run_generator
87
87
  StepperMotorRailtieTestHelpers.run_migrations
88
+ ActiveJob::Base.logger.level = Logger::FATAL
88
89
  end
89
90
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  RSpec.describe "StepperMotor::CyclicScheduler" do
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  RSpec.describe "StepperMotor::ForwardScheduler" do
@@ -30,7 +32,7 @@ RSpec.describe "StepperMotor::ForwardScheduler" do
30
32
  expect(enqueued_jobs.size).to eq(1)
31
33
  job = enqueued_jobs.first
32
34
 
33
- expect(job["job_class"]).to eq("StepperMotor::PerformStepJob")
35
+ expect(job["job_class"]).to eq("StepperMotor::PerformStepJobV2")
34
36
  expect(job["scheduled_at"]).not_to be_nil
35
37
 
36
38
  scheduled_at = Time.parse(job["scheduled_at"])
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  RSpec.describe "StepperMotor::InstallGenerator" do
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  # rubocop:disable Lint/ConstantDefinitionInBlock
@@ -26,8 +28,6 @@ RSpec.describe "StepperMotor::Journey" do
26
28
  end
27
29
 
28
30
  it "allows a journey consisting of multiple named steps to be defined and performed to completion" do
29
- step_names = [:step1, :step2, :step3]
30
-
31
31
  multi_step_journey_class = create_journey_subclass do
32
32
  [:step1, :step2, :step3].each do |step_name|
33
33
  step step_name do
@@ -103,7 +103,7 @@ RSpec.describe "StepperMotor::Journey" do
103
103
  end
104
104
 
105
105
  it "allows a journey where steps are delayed in time using wait:" do
106
- timely_journey_class = carrier_journey_class = create_journey_subclass do
106
+ timely_journey_class = create_journey_subclass do
107
107
  step wait: 10.hours do
108
108
  SideEffects.touch! "after_10_hours.txt"
109
109
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../spec_helper"
4
+
5
+ RSpec.describe "RecoveryStuckJourneysJobV1" do
6
+ before do
7
+ StepperMotor::Journey.delete_all
8
+ end
9
+
10
+ it "handles recovery from a background job" do
11
+ stuck_journey_class1 = create_journey_subclass do
12
+ self.when_stuck = :cancel
13
+
14
+ step :first do
15
+ end
16
+
17
+ step :second, wait: 4.days do
18
+ Fiber.yield # Simulate the journey hanging
19
+ end
20
+ end
21
+
22
+ stuck_journey_class2 = create_journey_subclass do
23
+ self.when_stuck = :reattempt
24
+
25
+ step :first do
26
+ end
27
+
28
+ step :second, wait: 4.days do
29
+ Fiber.yield # Simulate the journey hanging
30
+ end
31
+ end
32
+
33
+ freeze_time
34
+
35
+ journey_to_cancel = stuck_journey_class1.create!
36
+ journey_to_reattempt = stuck_journey_class2.create!
37
+
38
+ journey_to_cancel.perform_next_step!
39
+ journey_to_reattempt.perform_next_step!
40
+
41
+ travel_to Time.now + 5.days
42
+
43
+ # Get both stuck
44
+ Fiber.new do
45
+ journey_to_cancel.perform_next_step!
46
+ end.resume
47
+
48
+ Fiber.new do
49
+ journey_to_reattempt.perform_next_step!
50
+ end.resume
51
+
52
+ expect(journey_to_cancel.reload).to be_performing
53
+ expect(journey_to_reattempt.reload).to be_performing
54
+
55
+ StepperMotor::RecoverStuckJourneysJobV1.perform_now(stuck_for: 2.days)
56
+ expect(journey_to_cancel.reload).to be_performing
57
+ expect(journey_to_reattempt.reload).to be_performing
58
+
59
+ travel_to Time.now + 2.days + 1.second
60
+ StepperMotor::RecoverStuckJourneysJobV1.perform_now(stuck_for: 2.days)
61
+
62
+ expect(journey_to_cancel.reload).to be_canceled
63
+ expect(journey_to_reattempt.reload).to be_ready
64
+ end
65
+
66
+ it "does not raise when the class of the journey is no longer present" do
67
+ stuck_journey_class1 = create_journey_subclass do
68
+ self.when_stuck = :cancel
69
+
70
+ step :first do
71
+ Fiber.yield # Simulate the journey hanging
72
+ end
73
+ end
74
+
75
+ freeze_time
76
+
77
+ journey_to_cancel = stuck_journey_class1.create!
78
+ Fiber.new do
79
+ journey_to_cancel.perform_next_step!
80
+ end.resume
81
+ expect(journey_to_cancel.reload).to be_performing
82
+
83
+ journey_to_cancel.class.update_all(type: "UnknownJourneySubclass")
84
+
85
+ expect {
86
+ StepperMotor::RecoverStuckJourneysJobV1.perform_now(stuck_for: 2.days)
87
+ }.not_to raise_error
88
+ end
89
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../spec_helper"
4
+
5
+ RSpec.describe "Recovery of stuck journeys" do
6
+ before do
7
+ StepperMotor::Journey.delete_all
8
+ end
9
+
10
+ it "recovers a journey by reattempting it" do
11
+ stuck_journey_class = create_journey_subclass do
12
+ step :first do
13
+ end
14
+
15
+ step :second, wait: 4.days do
16
+ Fiber.yield # Simulate the journey hanging
17
+ end
18
+
19
+ step :third, wait: 4.days do
20
+ end
21
+ end
22
+
23
+ freeze_time
24
+ journey = stuck_journey_class.create!
25
+
26
+ journey.perform_next_step!
27
+ expect(journey.next_step_name).to eq("second")
28
+
29
+ travel_to Time.now + 5.days
30
+
31
+ expect(stuck_journey_class.when_stuck).to eq(:reattempt)
32
+ expect(journey.when_stuck).to eq(:reattempt)
33
+
34
+ # Hang the journey in "performing"
35
+ stuck_fiber = Fiber.new do
36
+ journey.perform_next_step!
37
+ end
38
+ stuck_fiber.resume
39
+
40
+ expect(journey).to be_persisted
41
+ expect(journey).to be_performing
42
+ expect(journey.updated_at).to eq(Time.now)
43
+
44
+ expect(StepperMotor::Journey.stuck(1.days.ago)).not_to include(journey)
45
+
46
+ travel_to Time.now + 2.days
47
+ expect(StepperMotor::Journey.stuck(2.days.ago)).to include(journey)
48
+
49
+ perform_at_before_recovery = journey.next_step_to_be_performed_at
50
+ expect {
51
+ journey.reload.recover!
52
+ }.not_to raise_error
53
+
54
+ journey.reload
55
+ expect(journey.next_step_to_be_performed_at).to eq(perform_at_before_recovery)
56
+ expect(journey.next_step_name).to eq("second")
57
+ end
58
+
59
+ it "recovers a journey by canceling it" do
60
+ stuck_journey_class = create_journey_subclass do
61
+ self.when_stuck = :cancel
62
+
63
+ step :first do
64
+ end
65
+
66
+ step :second, wait: 4.days do
67
+ Fiber.yield # Simulate the journey hanging
68
+ end
69
+
70
+ step :third, wait: 4.days do
71
+ end
72
+ end
73
+
74
+ freeze_time
75
+ journey = stuck_journey_class.create!
76
+
77
+ journey.perform_next_step!
78
+ expect(journey.next_step_name).to eq("second")
79
+
80
+ travel_to Time.now + 5.days
81
+
82
+ expect(stuck_journey_class.when_stuck).to eq(:cancel)
83
+ expect(journey.when_stuck).to eq(:cancel)
84
+
85
+ # Hang the journey in "performing"
86
+ stuck_fiber = Fiber.new do
87
+ journey.perform_next_step!
88
+ end
89
+ stuck_fiber.resume
90
+
91
+ expect(journey).to be_persisted
92
+ expect(journey).to be_performing
93
+ expect(journey.updated_at).to eq(Time.now)
94
+
95
+ travel_to Time.now + 2.days
96
+ expect(StepperMotor::Journey.stuck(2.days.ago)).to include(journey)
97
+
98
+ expect {
99
+ journey.reload.recover!
100
+ }.not_to raise_error
101
+
102
+ journey.reload
103
+ expect(journey).to be_canceled
104
+ end
105
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../spec_helper"
2
4
 
3
5
  RSpec.describe "StepperMotor::TestHelper" do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stepper_motor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-11 00:00:00.000000000 Z
11
+ date: 2025-05-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -136,6 +136,20 @@ dependencies:
136
136
  - - '='
137
137
  - !ruby/object:Gem::Version
138
138
  version: 1.28.5
139
+ - !ruby/object:Gem::Dependency
140
+ name: magic_frozen_string_literal
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
139
153
  - !ruby/object:Gem::Dependency
140
154
  name: yard
141
155
  requirement: !ruby/object:Gem::Requirement
@@ -183,13 +197,15 @@ files:
183
197
  - bin/setup
184
198
  - lib/generators/install_generator.rb
185
199
  - lib/generators/stepper_motor_migration_001.rb.erb
200
+ - lib/generators/stepper_motor_migration_002.rb.erb
186
201
  - lib/stepper_motor.rb
187
202
  - lib/stepper_motor/cyclic_scheduler.rb
188
203
  - lib/stepper_motor/forward_scheduler.rb
189
204
  - lib/stepper_motor/journey.rb
190
205
  - lib/stepper_motor/perform_step_job.rb
206
+ - lib/stepper_motor/perform_step_job_v2.rb
191
207
  - lib/stepper_motor/railtie.rb
192
- - lib/stepper_motor/reap_hung_journeys_job.rb
208
+ - lib/stepper_motor/recover_stuck_journeys_job_v1.rb
193
209
  - lib/stepper_motor/step.rb
194
210
  - lib/stepper_motor/test_helper.rb
195
211
  - lib/stepper_motor/version.rb
@@ -200,6 +216,8 @@ files:
200
216
  - spec/stepper_motor/forward_scheduler_spec.rb
201
217
  - spec/stepper_motor/generator_spec.rb
202
218
  - spec/stepper_motor/journey_spec.rb
219
+ - spec/stepper_motor/recover_stuck_journeys_job_spec.rb
220
+ - spec/stepper_motor/recovery_spec.rb
203
221
  - spec/stepper_motor/test_helper_spec.rb
204
222
  - spec/stepper_motor_spec.rb
205
223
  homepage: https://steppermotor.dev