stepper_motor 0.1.5 → 0.1.7

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: 7846dbc38d7f5d10b018f8e052e33f2347598e59bcf04dfcc4b49c3bcd247fd6
4
- data.tar.gz: 947b3810bff07dd61b72c86319d83f1ef93341986541a8b8ff9bfd8fe0230461
3
+ metadata.gz: dfb7061ca792fe914c363ae586ba322df95147dc832c39852af36809f0013e73
4
+ data.tar.gz: cbc8583f27710cc4d800dd7d380b527c1382c021cfed1430c2eb10afa2c4b591
5
5
  SHA512:
6
- metadata.gz: eab053515d0672cdd23065e0c7c13276cd6c972d07df6340755052cc8412e447d5d37ad31a34fac9c0ddb587c73b050a0571bab7a521544963c2a2149f1c069d
7
- data.tar.gz: d4041d89eb0cb2abf39721ff426ea99f33548576cd510fd8cbf5b5723f1f920075dbda0b692323afc89715eb266957d0d21907219c0a06aab7848237d1db1ddf
6
+ metadata.gz: 99e9981596319a6c18856536b0f9889b2eb5cf2c69b1306bb762a7ea9af3d95b746882c4af7f20f39c45068c81fe1134f157b773121edef016163e2831bec754
7
+ data.tar.gz: 54c83aa39398e84bf34640301f1258aef437f4e1dfd6a3e2b08b0f70ab8e76bc59b9aa8f032681185c8635528d4be9f67c247be468f98c718bcadd2524c6c3e3
@@ -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
@@ -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
@@ -7,7 +7,7 @@ module StepperMotor
7
7
  end
8
8
 
9
9
  generators do
10
- require "generators/install_generator"
10
+ require_relative "../generators/install_generator"
11
11
  end
12
12
 
13
13
  # The `to_prepare` block which is executed once in production
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_job"
4
+
3
5
  # The purpose of this job is to find journeys which have, for whatever reason, remained in the
4
6
  # `performing` state for far longer than the journey is supposed to. At the moment it assumes
5
7
  # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
6
8
  # cron table and perform it regularly.
7
- class StepperMotor::ReapHungJourneysJob < ActiveJob::Base
8
- def perform
9
- StepperMotor::Journey.where("state = 'performing' AND updated_at < ?", 1.hour.ago).find_each do |hung_journey|
10
- hung_journey.update!(state: "ready")
11
- 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)
12
15
  end
13
16
  end
14
17
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.5"
4
+ VERSION = "0.1.7"
5
5
  end
data/lib/stepper_motor.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "stepper_motor/version"
4
+ require_relative "stepper_motor/railtie" if defined?(Rails::Railtie)
4
5
  require "active_support"
5
6
 
6
7
  module StepperMotor
@@ -14,12 +15,12 @@ module StepperMotor
14
15
  autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
15
16
  autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
16
17
  autoload :PerformStepJobV2, File.dirname(__FILE__) + "/stepper_motor/perform_step_job_v2.rb"
18
+ autoload :RecoverStuckJourneysJobV1, File.dirname(__FILE__) + "/stepper_motor/recover_stuck_journeys_job_v1.rb"
17
19
  autoload :InstallGenerator, File.dirname(__FILE__) + "/generators/install_generator.rb"
18
20
  autoload :ForwardScheduler, File.dirname(__FILE__) + "/stepper_motor/forward_scheduler.rb"
19
21
  autoload :CyclicScheduler, File.dirname(__FILE__) + "/stepper_motor/cyclic_scheduler.rb"
20
22
  autoload :TestHelper, File.dirname(__FILE__) + "/stepper_motor/test_helper.rb"
21
23
 
22
- require_relative "stepper_motor/railtie" if defined?(Rails::Railtie)
23
24
 
24
25
  mattr_accessor :scheduler, default: ForwardScheduler.new
25
26
  end
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)
@@ -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
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.5
4
+ version: 0.1.7
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-17 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
@@ -197,6 +197,7 @@ files:
197
197
  - bin/setup
198
198
  - lib/generators/install_generator.rb
199
199
  - lib/generators/stepper_motor_migration_001.rb.erb
200
+ - lib/generators/stepper_motor_migration_002.rb.erb
200
201
  - lib/stepper_motor.rb
201
202
  - lib/stepper_motor/cyclic_scheduler.rb
202
203
  - lib/stepper_motor/forward_scheduler.rb
@@ -204,7 +205,7 @@ files:
204
205
  - lib/stepper_motor/perform_step_job.rb
205
206
  - lib/stepper_motor/perform_step_job_v2.rb
206
207
  - lib/stepper_motor/railtie.rb
207
- - lib/stepper_motor/reap_hung_journeys_job.rb
208
+ - lib/stepper_motor/recover_stuck_journeys_job_v1.rb
208
209
  - lib/stepper_motor/step.rb
209
210
  - lib/stepper_motor/test_helper.rb
210
211
  - lib/stepper_motor/version.rb
@@ -215,6 +216,8 @@ files:
215
216
  - spec/stepper_motor/forward_scheduler_spec.rb
216
217
  - spec/stepper_motor/generator_spec.rb
217
218
  - spec/stepper_motor/journey_spec.rb
219
+ - spec/stepper_motor/recover_stuck_journeys_job_spec.rb
220
+ - spec/stepper_motor/recovery_spec.rb
218
221
  - spec/stepper_motor/test_helper_spec.rb
219
222
  - spec/stepper_motor_spec.rb
220
223
  homepage: https://steppermotor.dev