stepper_motor 0.1.10 → 0.1.11

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: 649ad16d1e0831632a111b96f7435d66e2f601f6c301631252420876f6d2045a
4
- data.tar.gz: 6f09feace77d276184ad9e43e466c11aaf2b3df9fa295d11cf934d1ae719742f
3
+ metadata.gz: 619a3e8a9435e06f7fcf5b61ddc41d7ebb62450cb9fafccf89f8399818264ef0
4
+ data.tar.gz: 3c3009f6b5c48a8dcdcd92187dbadc21d92c37b86746b8426dba8b6b60cb007f
5
5
  SHA512:
6
- metadata.gz: 265c265164a0330f2de6f75012146dbe179147d7b808c2003b00aced6f9599709648384104fb8cd3a3f41d3211320e871dcc29b34f0faba07867296c8beda561
7
- data.tar.gz: 8275f9be10d01974d1aa9541d5d3784a056785c9e4412dbec74edbd95cfd4aa9fffad4d9fa08fc60f4621e75d81fc90af4e2893c8a944c7d4a6d399ab5e069d5
6
+ metadata.gz: 1911106735b7ef6d54e7b3149cfbf26a13a6a0ec8e69a45429e244216c32f46b63b114b8101b369a36c22cdae10682a8c0adf599f18efe9bcfd2a9ded05be3ab
7
+ data.tar.gz: e9996b145db211444300230ae742e8da61f8db21f3841a21c97a37779203767f4fc46535656c698c494001bbe024c6cd681599595df5a73fdaae101417ebde4c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.11] - 2025-06-08
4
+
5
+ - Add automatic cleanup of completed journeys after a configurable time period
6
+ - Add `HousekeepingJob` to run cleanup and recovery tasks
7
+ - Add ability to extend all StepperMotor jobs with custom configuration
8
+ - Merge V2/V1 job variants into single classes with backward compatibility
9
+ - Pin standardrb version to avoid Rubocop errors
10
+ - Improve documentation and test coverage
11
+
3
12
  ## [0.1.10] - 2025-05-28
4
13
 
5
14
  - Remove `algorithm: :concurrently` from migrations. If a user needs to conform with strong_migrations
@@ -9,8 +9,8 @@ module StepperMotor
9
9
  # Run it with `bin/rails g stepper_motor:install` in your console.
10
10
  class InstallGenerator < Rails::Generators::Base
11
11
  UUID_MESSAGE = <<~MSG
12
- If set, uuid type will be used for hero_id. Use this
13
- if most of your models use UUD as primary key"
12
+ If set, uuid type will be used for hero_id of the Journeys, as well as for the Journey IDs.
13
+ Use this if most of your models use UUD as primary key"
14
14
  MSG
15
15
 
16
16
  include ActiveRecord::Generators::Migration
@@ -31,9 +31,9 @@ module StepperMotor
31
31
  end
32
32
 
33
33
  def create_initializer
34
- create_file "config/initializers/stepper_motor.rb", <<~RUBY
35
- StepperMotor.scheduler = StepperMotor::ForwardScheduler.new
36
- RUBY
34
+ # Take the initializer code from the test dummy app, it is the best place really.
35
+ initializer_code_from_dummy_app = File.read(__dir__ + "/../../test/dummy/config/initializers/stepper_motor.rb")
36
+ create_file "config/initializers/stepper_motor.rb", initializer_code_from_dummy_app
37
37
  end
38
38
 
39
39
  private
@@ -0,0 +1,5 @@
1
+ # All StepperMotor job classes inherit from this one. It is available for
2
+ # extension from StepperMotor.extend_base_job_class so that you can set
3
+ # priority, include and prepend modules and so forth.
4
+ class StepperMotor::BaseJob < ActiveJob::Base
5
+ end
@@ -21,7 +21,7 @@
21
21
  #
22
22
  # The scheduler needs to be configured in your cron table.
23
23
  class StepperMotor::CyclicScheduler < StepperMotor::ForwardScheduler
24
- class RunSchedulingCycleJob < ActiveJob::Base
24
+ class RunSchedulingCycleJob < StepperMotor::BaseJob
25
25
  def perform
26
26
  scheduler = StepperMotor.scheduler
27
27
  return unless scheduler.is_a?(StepperMotor::CyclicScheduler)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The purpose of this job is to find journeys which have completed (finished or canceled) some
4
+ # time ago and to delete them. The time is configured in the initializer.
5
+ class StepperMotor::DeleteCompletedJourneysJob < StepperMotor::BaseJob
6
+ def perform(completed_for: StepperMotor.delete_completed_journeys_after, **)
7
+ return unless completed_for.present?
8
+
9
+ scope = StepperMotor::Journey.where(state: ["finished", "canceled"], updated_at: ..completed_for.ago)
10
+ scope.in_batches.each do |rel|
11
+ rel.delete_all
12
+ rescue => e
13
+ Rails.try(:error).try(:report, e)
14
+ end
15
+ end
16
+ end
@@ -19,7 +19,7 @@
19
19
  # scheduled to be performed). For good_job the {CyclicScheduler} is also likely to be a better option.
20
20
  class StepperMotor::ForwardScheduler
21
21
  def schedule(journey)
22
- StepperMotor::PerformStepJobV2
22
+ StepperMotor::PerformStepJob
23
23
  .set(wait_until: journey.next_step_to_be_performed_at)
24
24
  .perform_later(journey_id: journey.id, journey_class_name: journey.class.to_s, idempotency_key: journey.idempotency_key)
25
25
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StepperMotor::HousekeepingJob < StepperMotor::BaseJob
4
+ def perform(**)
5
+ StepperMotor::RecoverStuckJourneysJob.perform_later
6
+ StepperMotor::DeleteCompletedJourneysJob.perform_later
7
+ end
8
+ end
@@ -2,8 +2,18 @@
2
2
 
3
3
  require "active_job"
4
4
 
5
- class StepperMotor::PerformStepJob < ActiveJob::Base
6
- def perform(journey_gid)
5
+ class StepperMotor::PerformStepJob < StepperMotor::BaseJob
6
+ def perform(*posargs, **kwargs)
7
+ if posargs.length == 1 && kwargs.empty?
8
+ perform_via_journey_gid(*posargs)
9
+ else
10
+ perform_via_kwargs(**kwargs)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def perform_via_journey_gid(journey_gid)
7
17
  # Pass the GlobalID instead of the record itself, so that we can rescue the non-existing record
8
18
  # exception here as opposed to the job deserialization
9
19
  journey = begin
@@ -13,4 +23,17 @@ class StepperMotor::PerformStepJob < ActiveJob::Base
13
23
  end
14
24
  journey.perform_next_step!
15
25
  end
26
+
27
+ def perform_via_kwargs(journey_id:, journey_class_name:, idempotency_key: nil, **)
28
+ journey = begin
29
+ StepperMotor::Journey.find(journey_id)
30
+ rescue ActiveRecord::RecordNotFound
31
+ # The journey has been canceled and destroyed previously or elsewhere
32
+ return
33
+ end
34
+ journey.perform_next_step!(idempotency_key: idempotency_key)
35
+ end
16
36
  end
37
+
38
+ # Alias for the previous job name
39
+ StepperMotor::PerformStepJobV2 = StepperMotor::PerformStepJob
@@ -1,19 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_job"
4
-
5
3
  # The purpose of this job is to find journeys which have, for whatever reason, remained in the
6
4
  # `performing` state for far longer than the journey is supposed to. At the moment it assumes
7
5
  # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
8
6
  # cron table and perform it regularly.
9
- class StepperMotor::RecoverStuckJourneysJobV1 < ActiveJob::Base
7
+ class StepperMotor::RecoverStuckJourneysJob < StepperMotor::BaseJob
10
8
  DEFAULT_STUCK_FOR = 2.days
11
9
 
12
10
  def perform(stuck_for: DEFAULT_STUCK_FOR)
13
11
  StepperMotor::Journey.stuck(stuck_for.ago).find_each do |journey|
14
12
  journey.recover!
15
13
  rescue => e
16
- Rails&.error&.report(e)
14
+ Rails.try(:error).try(:report, e)
17
15
  end
18
16
  end
19
17
  end
18
+
19
+ # Alias for the previous job name
20
+ StepperMotor::RecoverStuckJourneysJobV1 = StepperMotor::RecoverStuckJourneysJob
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.10"
4
+ VERSION = "0.1.11"
5
5
  end
data/lib/stepper_motor.rb CHANGED
@@ -13,13 +13,25 @@ module StepperMotor
13
13
 
14
14
  autoload :Journey, File.dirname(__FILE__) + "/stepper_motor/journey.rb"
15
15
  autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
16
+
17
+ autoload :BaseJob, File.dirname(__FILE__) + "/stepper_motor/base_job.rb"
16
18
  autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
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"
19
+ autoload :PerformStepJobV2, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
20
+ autoload :HousekeepingJob, File.dirname(__FILE__) + "/stepper_motor/housekeeping_job.rb"
21
+ autoload :DeleteCompletedJourneysJob, File.dirname(__FILE__) + "/stepper_motor/delete_completed_journeys_job.rb"
22
+ autoload :RecoverStuckJourneysJob, File.dirname(__FILE__) + "/stepper_motor/recover_stuck_journeys_job.rb"
23
+ autoload :RecoverStuckJourneysJobV1, File.dirname(__FILE__) + "/stepper_motor/recover_stuck_journeys_job.rb"
24
+
19
25
  autoload :InstallGenerator, File.dirname(__FILE__) + "/generators/install_generator.rb"
20
26
  autoload :ForwardScheduler, File.dirname(__FILE__) + "/stepper_motor/forward_scheduler.rb"
21
27
  autoload :CyclicScheduler, File.dirname(__FILE__) + "/stepper_motor/cyclic_scheduler.rb"
22
28
  autoload :TestHelper, File.dirname(__FILE__) + "/stepper_motor/test_helper.rb"
23
29
 
24
30
  mattr_accessor :scheduler, default: ForwardScheduler.new
31
+ mattr_accessor :delete_completed_journeys_after, default: 30.days
32
+
33
+ # Extends the BaseJob of the library with any additional options
34
+ def self.extend_base_job(&blk)
35
+ BaseJob.class_eval(&blk)
36
+ end
25
37
  end
@@ -3,6 +3,6 @@
3
3
  namespace :stepper_motor do
4
4
  desc "Recover all journeys hanging in the 'performing' state"
5
5
  task :recovery do
6
- StepperMotor::RecoverStuckJourneysJobV1.perform_now
6
+ StepperMotor::RecoverStuckJourneysJob.perform_now
7
7
  end
8
8
  end
@@ -2,7 +2,14 @@
2
2
  # StepperMotor is a module for building multi-step flows where steps are sequential and only
3
3
  # ever progress forward. The building block of StepperMotor is StepperMotor::Journey
4
4
  module StepperMotor
5
- VERSION = T.let("0.1.10", T.untyped)
5
+ VERSION = T.let("0.1.11", T.untyped)
6
+ PerformStepJobV2 = T.let(StepperMotor::PerformStepJob, T.untyped)
7
+ RecoverStuckJourneysJobV1 = T.let(StepperMotor::RecoverStuckJourneysJob, T.untyped)
8
+
9
+ # sord omit - no YARD return type given, using untyped
10
+ # Extends the BaseJob of the library with any additional options
11
+ sig { params(blk: T.untyped).returns(T.untyped) }
12
+ def self.extend_base_job(&blk); end
6
13
 
7
14
  class Error < StandardError
8
15
  end
@@ -304,6 +311,12 @@ module StepperMotor
304
311
  class Railtie < Rails::Railtie
305
312
  end
306
313
 
314
+ # All StepperMotor job classes inherit from this one. It is available for
315
+ # extension from StepperMotor.extend_base_job_class so that you can set
316
+ # priority, include and prepend modules and so forth.
317
+ class BaseJob < ActiveJob::Base
318
+ end
319
+
307
320
  module TestHelper
308
321
  # Allows running a given Journey to completion, skipping across the waiting periods.
309
322
  # This is useful to evaluate all side effects of a Journey. The helper will ensure
@@ -334,8 +347,8 @@ module StepperMotor
334
347
  class InstallGenerator < Rails::Generators::Base
335
348
  include ActiveRecord::Generators::Migration
336
349
  UUID_MESSAGE = T.let(<<~MSG, T.untyped)
337
- If set, uuid type will be used for hero_id. Use this
338
- if most of your models use UUD as primary key"
350
+ If set, uuid type will be used for hero_id of the Journeys, as well as for the Journey IDs.
351
+ Use this if most of your models use UUD as primary key"
339
352
  MSG
340
353
 
341
354
  # sord omit - no YARD return type given, using untyped
@@ -397,18 +410,37 @@ MSG
397
410
  sig { params(journey: T.untyped).returns(T.untyped) }
398
411
  def schedule(journey); end
399
412
 
400
- class RunSchedulingCycleJob < ActiveJob::Base
413
+ class RunSchedulingCycleJob < StepperMotor::BaseJob
401
414
  # sord omit - no YARD return type given, using untyped
402
415
  sig { returns(T.untyped) }
403
416
  def perform; end
404
417
  end
405
418
  end
406
419
 
407
- class PerformStepJob < ActiveJob::Base
420
+ class HousekeepingJob < StepperMotor::BaseJob
421
+ # sord omit - no YARD return type given, using untyped
422
+ sig { returns(T.untyped) }
423
+ def perform; end
424
+ end
425
+
426
+ class PerformStepJob < StepperMotor::BaseJob
427
+ # sord omit - no YARD type given for "*posargs", using untyped
428
+ # sord omit - no YARD type given for "**kwargs", using untyped
429
+ # sord omit - no YARD return type given, using untyped
430
+ sig { params(posargs: T.untyped, kwargs: T.untyped).returns(T.untyped) }
431
+ def perform(*posargs, **kwargs); end
432
+
408
433
  # sord omit - no YARD type given for "journey_gid", using untyped
409
434
  # sord omit - no YARD return type given, using untyped
410
435
  sig { params(journey_gid: T.untyped).returns(T.untyped) }
411
- def perform(journey_gid); end
436
+ def perform_via_journey_gid(journey_gid); end
437
+
438
+ # sord omit - no YARD type given for "journey_id:", using untyped
439
+ # sord omit - no YARD type given for "journey_class_name:", using untyped
440
+ # sord omit - no YARD type given for "idempotency_key:", using untyped
441
+ # sord omit - no YARD return type given, using untyped
442
+ sig { params(journey_id: T.untyped, journey_class_name: T.untyped, idempotency_key: T.untyped).returns(T.untyped) }
443
+ def perform_via_kwargs(journey_id:, journey_class_name:, idempotency_key: nil); end
412
444
  end
413
445
 
414
446
  # The forward scheduler enqueues a job for every Journey that
@@ -435,20 +467,11 @@ MSG
435
467
  def schedule(journey); end
436
468
  end
437
469
 
438
- class PerformStepJobV2 < ActiveJob::Base
439
- # sord omit - no YARD type given for "journey_id:", using untyped
440
- # sord omit - no YARD type given for "journey_class_name:", using untyped
441
- # sord omit - no YARD type given for "idempotency_key:", using untyped
442
- # sord omit - no YARD return type given, using untyped
443
- sig { params(journey_id: T.untyped, journey_class_name: T.untyped, idempotency_key: T.untyped).returns(T.untyped) }
444
- def perform(journey_id:, journey_class_name:, idempotency_key: nil); end
445
- end
446
-
447
470
  # The purpose of this job is to find journeys which have, for whatever reason, remained in the
448
471
  # `performing` state for far longer than the journey is supposed to. At the moment it assumes
449
472
  # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
450
473
  # cron table and perform it regularly.
451
- class RecoverStuckJourneysJobV1 < ActiveJob::Base
474
+ class RecoverStuckJourneysJob < StepperMotor::BaseJob
452
475
  DEFAULT_STUCK_FOR = T.let(2.days, T.untyped)
453
476
 
454
477
  # sord omit - no YARD type given for "stuck_for:", using untyped
@@ -456,4 +479,13 @@ MSG
456
479
  sig { params(stuck_for: T.untyped).returns(T.untyped) }
457
480
  def perform(stuck_for: DEFAULT_STUCK_FOR); end
458
481
  end
482
+
483
+ # The purpose of this job is to find journeys which have completed (finished or canceled) some
484
+ # time ago and to delete them. The time is configured in the initializer.
485
+ class DeleteCompletedJourneysJob < StepperMotor::BaseJob
486
+ # sord omit - no YARD type given for "completed_for:", using untyped
487
+ # sord omit - no YARD return type given, using untyped
488
+ sig { params(completed_for: T.untyped).returns(T.untyped) }
489
+ def perform(completed_for: StepperMotor.delete_completed_journeys_after); end
490
+ end
459
491
  end
@@ -2,6 +2,12 @@
2
2
  # ever progress forward. The building block of StepperMotor is StepperMotor::Journey
3
3
  module StepperMotor
4
4
  VERSION: untyped
5
+ PerformStepJobV2: untyped
6
+ RecoverStuckJourneysJobV1: untyped
7
+
8
+ # sord omit - no YARD return type given, using untyped
9
+ # Extends the BaseJob of the library with any additional options
10
+ def self.extend_base_job: () -> untyped
5
11
 
6
12
  class Error < StandardError
7
13
  end
@@ -269,6 +275,12 @@ module StepperMotor
269
275
  class Railtie < Rails::Railtie
270
276
  end
271
277
 
278
+ # All StepperMotor job classes inherit from this one. It is available for
279
+ # extension from StepperMotor.extend_base_job_class so that you can set
280
+ # priority, include and prepend modules and so forth.
281
+ class BaseJob < ActiveJob::Base
282
+ end
283
+
272
284
  module TestHelper
273
285
  # Allows running a given Journey to completion, skipping across the waiting periods.
274
286
  # This is useful to evaluate all side effects of a Journey. The helper will ensure
@@ -350,16 +362,32 @@ module StepperMotor
350
362
  # sord omit - no YARD return type given, using untyped
351
363
  def schedule: (untyped journey) -> untyped
352
364
 
353
- class RunSchedulingCycleJob < ActiveJob::Base
365
+ class RunSchedulingCycleJob < StepperMotor::BaseJob
354
366
  # sord omit - no YARD return type given, using untyped
355
367
  def perform: () -> untyped
356
368
  end
357
369
  end
358
370
 
359
- class PerformStepJob < ActiveJob::Base
371
+ class HousekeepingJob < StepperMotor::BaseJob
372
+ # sord omit - no YARD return type given, using untyped
373
+ def perform: () -> untyped
374
+ end
375
+
376
+ class PerformStepJob < StepperMotor::BaseJob
377
+ # sord omit - no YARD type given for "*posargs", using untyped
378
+ # sord omit - no YARD type given for "**kwargs", using untyped
379
+ # sord omit - no YARD return type given, using untyped
380
+ def perform: (*untyped posargs, **untyped kwargs) -> untyped
381
+
360
382
  # sord omit - no YARD type given for "journey_gid", using untyped
361
383
  # sord omit - no YARD return type given, using untyped
362
- def perform: (untyped journey_gid) -> untyped
384
+ def perform_via_journey_gid: (untyped journey_gid) -> untyped
385
+
386
+ # sord omit - no YARD type given for "journey_id:", using untyped
387
+ # sord omit - no YARD type given for "journey_class_name:", using untyped
388
+ # sord omit - no YARD type given for "idempotency_key:", using untyped
389
+ # sord omit - no YARD return type given, using untyped
390
+ def perform_via_kwargs: (journey_id: untyped, journey_class_name: untyped, ?idempotency_key: untyped) -> untyped
363
391
  end
364
392
 
365
393
  # The forward scheduler enqueues a job for every Journey that
@@ -385,23 +413,23 @@ module StepperMotor
385
413
  def schedule: (untyped journey) -> untyped
386
414
  end
387
415
 
388
- class PerformStepJobV2 < ActiveJob::Base
389
- # sord omit - no YARD type given for "journey_id:", using untyped
390
- # sord omit - no YARD type given for "journey_class_name:", using untyped
391
- # sord omit - no YARD type given for "idempotency_key:", using untyped
392
- # sord omit - no YARD return type given, using untyped
393
- def perform: (journey_id: untyped, journey_class_name: untyped, ?idempotency_key: untyped) -> untyped
394
- end
395
-
396
416
  # The purpose of this job is to find journeys which have, for whatever reason, remained in the
397
417
  # `performing` state for far longer than the journey is supposed to. At the moment it assumes
398
418
  # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
399
419
  # cron table and perform it regularly.
400
- class RecoverStuckJourneysJobV1 < ActiveJob::Base
420
+ class RecoverStuckJourneysJob < StepperMotor::BaseJob
401
421
  DEFAULT_STUCK_FOR: untyped
402
422
 
403
423
  # sord omit - no YARD type given for "stuck_for:", using untyped
404
424
  # sord omit - no YARD return type given, using untyped
405
425
  def perform: (?stuck_for: untyped) -> untyped
406
426
  end
427
+
428
+ # The purpose of this job is to find journeys which have completed (finished or canceled) some
429
+ # time ago and to delete them. The time is configured in the initializer.
430
+ class DeleteCompletedJourneysJob < StepperMotor::BaseJob
431
+ # sord omit - no YARD type given for "completed_for:", using untyped
432
+ # sord omit - no YARD return type given, using untyped
433
+ def perform: (?completed_for: untyped) -> untyped
434
+ end
407
435
  end
@@ -38,7 +38,7 @@ Gem::Specification.new do |spec|
38
38
  spec.add_development_dependency "rails", "~> 7.0"
39
39
  spec.add_development_dependency "sqlite3"
40
40
  spec.add_development_dependency "rake", "~> 13.0"
41
- spec.add_development_dependency "standard"
41
+ spec.add_development_dependency "standard", "~> 1.50.0", "< 2.0"
42
42
  spec.add_development_dependency "magic_frozen_string_literal"
43
43
  spec.add_development_dependency "yard"
44
44
  spec.add_development_dependency "redcarpet" # needed for the yard gem to enable Github Flavored Markdown
@@ -1 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sets the scheduler. The ForwardScheduler will enqueue jobs for performing steps
4
+ # regardless of how far in the future a step needs to be taken. The CyclicScheduler
5
+ # will only enqueue jobs for steps which are to be performed soon. If you want to use
6
+ # the CyclicScheduler, you will need to configure it for the proper interval duration:
7
+ #
8
+ # StepperMotor.scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 30.minutes)
9
+ #
10
+ # and add its cycle job into your recurring jobs table. For example, for solid_queue:
11
+ #
12
+ # stepper_motor_houseleeping:
13
+ # schedule: "*/30 * * * *" # Every 30 minutes
14
+ # class: "StepperMotor::CyclicScheduler::RunSchedulingCycleJob"
15
+ #
16
+ # The cadence of the cyclic scheduler and the cadence of your cron job should be equal.
17
+ #
18
+ # If your queue is not susceptible to performance degradation with large numbers of
19
+ # "far future" jobs and allows scheduling "far ahead" - you can use the `ForwardScheduler`
20
+ # which is the default.
1
21
  StepperMotor.scheduler = StepperMotor::ForwardScheduler.new
22
+
23
+ # Sets the amount of time after which completed (finished and canceled)
24
+ # Journeys are going to be deleted from the database. If you want to keep
25
+ # them in the database indefinitely, set this parameter to `nil`.
26
+ # To perform the actual cleanups, add the `StepperMotor::HousekeepingJob` to your
27
+ # recurring jobs table. For example, for solid_queue:
28
+ #
29
+ # stepper_motor_housekeeping:
30
+ # schedule: "*/30 * * * *" # Every 30 minutes
31
+ # class: "StepperMotor::HousekeepingJob"
32
+ StepperMotor.delete_completed_journeys_after = 30.days
33
+
34
+ # Extends the base StepperMotor ActiveJob with any calls you would use to customise a
35
+ # job in your codebase. At the minimum, we recommend setting all StepperMotor job priorities
36
+ # to "high" - according to the priority denomination you are using.
37
+ # StepperMotor.extend_base_job do
38
+ # priority :high
39
+ # discard_on ActiveRecord::NotFound
40
+ # end
@@ -0,0 +1,96 @@
1
+ require "test_helper"
2
+
3
+ class CompletedJourneysCleanupTest < ActiveSupport::TestCase
4
+ include SideEffects::TestHelper
5
+
6
+ test "defines a variable on StepperMotor and sets it to a default value" do
7
+ assert StepperMotor.delete_completed_journeys_after
8
+ assert_equal 30.days, StepperMotor.delete_completed_journeys_after
9
+ end
10
+
11
+ test "cleans up finished journeys" do
12
+ previous_setting = StepperMotor.delete_completed_journeys_after
13
+
14
+ journey_class = create_journey_subclass do
15
+ step wait: 20.minutes do
16
+ SideEffects.touch! :a_step
17
+ end
18
+ end
19
+ journey = journey_class.create!
20
+ another_journey = journey_class.create!
21
+
22
+ assert_no_changes "StepperMotor::Journey.count" do
23
+ StepperMotor::DeleteCompletedJourneysJob.new.perform
24
+ end
25
+
26
+ travel_to Time.current + 20.minutes + 1.second
27
+ journey.perform_next_step!
28
+
29
+ assert SideEffects.produced?(:a_step)
30
+ assert journey.finished?
31
+
32
+ assert_no_changes "StepperMotor::Journey.count" do
33
+ StepperMotor::DeleteCompletedJourneysJob.new.perform
34
+ end
35
+
36
+ StepperMotor.delete_completed_journeys_after = 7.minutes
37
+ travel_to Time.current + 7.minutes + 1.second
38
+
39
+ assert_changes "StepperMotor::Journey.count", -1 do
40
+ StepperMotor::DeleteCompletedJourneysJob.new.perform
41
+ end
42
+
43
+ assert_raises(ActiveRecord::RecordNotFound) { journey.reload }
44
+ assert_nothing_raised { another_journey.reload }
45
+ ensure
46
+ StepperMotor.delete_completed_journeys_after = previous_setting
47
+ end
48
+
49
+ test "cleans up canceled journeys" do
50
+ previous_setting = StepperMotor.delete_completed_journeys_after
51
+
52
+ journey_class = create_journey_subclass do
53
+ step wait: 20.minutes do
54
+ # noop
55
+ end
56
+ end
57
+ journey = journey_class.create!
58
+ journey.cancel!
59
+
60
+ assert_no_changes "StepperMotor::Journey.count" do
61
+ StepperMotor::DeleteCompletedJourneysJob.new.perform
62
+ end
63
+
64
+ StepperMotor.delete_completed_journeys_after = 7.minutes
65
+ travel_to Time.current + 7.minutes + 1.second
66
+
67
+ assert_changes "StepperMotor::Journey.count", -1 do
68
+ StepperMotor::DeleteCompletedJourneysJob.new.perform
69
+ end
70
+
71
+ assert_raises(ActiveRecord::RecordNotFound) { journey.reload }
72
+ ensure
73
+ StepperMotor.delete_completed_journeys_after = previous_setting
74
+ end
75
+
76
+ test "does not delete any journeys if the setting is set to nil" do
77
+ previous_setting = StepperMotor.delete_completed_journeys_after
78
+ StepperMotor.delete_completed_journeys_after = nil
79
+
80
+ journey_class = create_journey_subclass do
81
+ step wait: 20.minutes do
82
+ # noop
83
+ end
84
+ end
85
+ journey = journey_class.create!
86
+ journey.cancel!
87
+
88
+ travel_to Time.current + 365.days
89
+ assert_no_changes "StepperMotor::Journey.count" do
90
+ StepperMotor::DeleteCompletedJourneysJob.new.perform
91
+ end
92
+ assert_nothing_raised { journey.reload }
93
+ ensure
94
+ StepperMotor.delete_completed_journeys_after = previous_setting
95
+ end
96
+ end
@@ -0,0 +1,8 @@
1
+ require "test_helper"
2
+
3
+ class ConfigurationTest < ActiveSupport::TestCase
4
+ test "allows extending the base job" do
5
+ retrieved_name = StepperMotor.extend_base_job { name }
6
+ assert_equal "StepperMotor::BaseJob", retrieved_name
7
+ end
8
+ end
@@ -39,7 +39,7 @@ class CyclicSchedulerTest < ActiveSupport::TestCase
39
39
  scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 40.minutes)
40
40
  StepperMotor.scheduler = scheduler
41
41
 
42
- assert_enqueued_jobs 1, only: StepperMotor::PerformStepJobV2 do
42
+ assert_enqueued_jobs 1, only: StepperMotor::PerformStepJob do
43
43
  far_future_journey_class.create!
44
44
  end
45
45
  end
@@ -54,7 +54,7 @@ class CyclicSchedulerTest < ActiveSupport::TestCase
54
54
  end
55
55
  journey.update!(next_step_to_be_performed_at: 10.minutes.ago)
56
56
 
57
- assert_enqueued_jobs 1, only: StepperMotor::PerformStepJobV2 do
57
+ assert_enqueued_jobs 1, only: StepperMotor::PerformStepJob do
58
58
  scheduler.run_scheduling_cycle
59
59
  end
60
60
  end
@@ -31,7 +31,7 @@ class ForwardSchedulerTest < ActiveSupport::TestCase
31
31
  assert_equal 1, enqueued_jobs.size
32
32
  job = enqueued_jobs.first
33
33
 
34
- assert_equal "StepperMotor::PerformStepJobV2", job["job_class"]
34
+ assert_equal "StepperMotor::PerformStepJob", job["job_class"]
35
35
  assert_not_nil job["scheduled_at"]
36
36
 
37
37
  scheduled_at = Time.parse(job["scheduled_at"])
@@ -0,0 +1,13 @@
1
+ require "test_helper"
2
+
3
+ class HousekeepingJobTest < ActiveSupport::TestCase
4
+ include ActiveJob::TestHelper
5
+
6
+ test "runs without exceptions and enqueues the two actual jobs" do
7
+ assert_nothing_raised do
8
+ StepperMotor::HousekeepingJob.perform_now
9
+ end
10
+
11
+ assert_enqueued_jobs 2
12
+ end
13
+ end
@@ -0,0 +1,60 @@
1
+ require "test_helper"
2
+
3
+ class PerformStepJobTest < ActiveSupport::TestCase
4
+ include SideEffects::TestHelper
5
+
6
+ test "exposes the V2 variant in a constant to allow old jobs to be unserialized" do
7
+ assert defined?(StepperMotor::PerformStepJobV2)
8
+ assert_equal StepperMotor::PerformStepJob, StepperMotor::PerformStepJobV2
9
+ end
10
+
11
+ test "allows perform() with a GlobalID as argument" do
12
+ journey = create_journey_subclass do
13
+ step do
14
+ # noop
15
+ end
16
+ end.create!
17
+
18
+ assert_nothing_raised { StepperMotor::PerformStepJob.new.perform(journey.to_global_id) }
19
+ assert journey.reload.finished?
20
+ end
21
+
22
+ test "allows perform() with the journey class name and ID" do
23
+ journey = create_journey_subclass do
24
+ step do
25
+ # noop
26
+ end
27
+ end.create!
28
+
29
+ assert_nothing_raised do
30
+ StepperMotor::PerformStepJob.new.perform(journey_id: journey.id, journey_class_name: journey.class.name)
31
+ end
32
+ assert journey.reload.finished?
33
+ end
34
+
35
+ test "allows perform() with the journey class name, ID and idempotency key" do
36
+ journey = create_journey_subclass do
37
+ step do
38
+ # noop
39
+ end
40
+ end.create!
41
+
42
+ assert_nothing_raised do
43
+ StepperMotor::PerformStepJob.new.perform(journey_id: journey.id, journey_class_name: journey.class.name, idempotency_key: journey.idempotency_key)
44
+ end
45
+ assert journey.reload.finished?
46
+ end
47
+
48
+ test "skips without exceptions if the idempotency key is incorrect" do
49
+ journey = create_journey_subclass do
50
+ step do
51
+ # noop
52
+ end
53
+ end.create!
54
+
55
+ assert_nothing_raised do
56
+ StepperMotor::PerformStepJob.new.perform(journey_id: journey.id, journey_class_name: journey.class.name, idempotency_key: "wrong")
57
+ end
58
+ assert journey.reload.ready?
59
+ end
60
+ end
@@ -7,6 +7,11 @@ class RecoverStuckJourneysJobTest < ActiveSupport::TestCase
7
7
  StepperMotor::Journey.delete_all
8
8
  end
9
9
 
10
+ test "still has the previous job class name available to allow older jobs to be unserialized" do
11
+ assert defined?(StepperMotor::RecoverStuckJourneysJobV1)
12
+ assert_equal StepperMotor::RecoverStuckJourneysJob, StepperMotor::RecoverStuckJourneysJobV1
13
+ end
14
+
10
15
  test "handles recovery from a background job" do
11
16
  stuck_journey_class1 = create_journey_subclass do
12
17
  self.when_stuck = :cancel
@@ -52,12 +57,12 @@ class RecoverStuckJourneysJobTest < ActiveSupport::TestCase
52
57
  assert journey_to_cancel.reload.performing?
53
58
  assert journey_to_reattempt.reload.performing?
54
59
 
55
- StepperMotor::RecoverStuckJourneysJobV1.perform_now(stuck_for: 2.days)
60
+ StepperMotor::RecoverStuckJourneysJob.perform_now(stuck_for: 2.days)
56
61
  assert journey_to_cancel.reload.performing?
57
62
  assert journey_to_reattempt.reload.performing?
58
63
 
59
64
  travel_to Time.now + 2.days + 1.second
60
- StepperMotor::RecoverStuckJourneysJobV1.perform_now(stuck_for: 2.days)
65
+ StepperMotor::RecoverStuckJourneysJob.perform_now(stuck_for: 2.days)
61
66
 
62
67
  assert journey_to_cancel.reload.canceled?
63
68
  assert journey_to_reattempt.reload.ready?
@@ -83,7 +88,7 @@ class RecoverStuckJourneysJobTest < ActiveSupport::TestCase
83
88
  journey_to_cancel.class.update_all(type: "UnknownJourneySubclass")
84
89
 
85
90
  assert_nothing_raised do
86
- StepperMotor::RecoverStuckJourneysJobV1.perform_now(stuck_for: 2.days)
91
+ StepperMotor::RecoverStuckJourneysJob.perform_now(stuck_for: 2.days)
87
92
  end
88
93
  end
89
94
  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.10
4
+ version: 0.1.11
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-05-28 00:00:00.000000000 Z
11
+ date: 2025-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -112,16 +112,22 @@ dependencies:
112
112
  name: standard
113
113
  requirement: !ruby/object:Gem::Requirement
114
114
  requirements:
115
- - - ">="
115
+ - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: '0'
117
+ version: 1.50.0
118
+ - - "<"
119
+ - !ruby/object:Gem::Version
120
+ version: '2.0'
118
121
  type: :development
119
122
  prerelease: false
120
123
  version_requirements: !ruby/object:Gem::Requirement
121
124
  requirements:
122
- - - ">="
125
+ - - "~>"
123
126
  - !ruby/object:Gem::Version
124
- version: '0'
127
+ version: 1.50.0
128
+ - - "<"
129
+ - !ruby/object:Gem::Version
130
+ version: '2.0'
125
131
  - !ruby/object:Gem::Dependency
126
132
  name: magic_frozen_string_literal
127
133
  requirement: !ruby/object:Gem::Requirement
@@ -201,15 +207,17 @@ files:
201
207
  - lib/generators/stepper_motor_migration_003.rb.erb
202
208
  - lib/generators/stepper_motor_migration_004.rb.erb
203
209
  - lib/stepper_motor.rb
210
+ - lib/stepper_motor/base_job.rb
204
211
  - lib/stepper_motor/cyclic_scheduler.rb
212
+ - lib/stepper_motor/delete_completed_journeys_job.rb
205
213
  - lib/stepper_motor/forward_scheduler.rb
214
+ - lib/stepper_motor/housekeeping_job.rb
206
215
  - lib/stepper_motor/journey.rb
207
216
  - lib/stepper_motor/journey/flow_control.rb
208
217
  - lib/stepper_motor/journey/recovery.rb
209
218
  - lib/stepper_motor/perform_step_job.rb
210
- - lib/stepper_motor/perform_step_job_v2.rb
211
219
  - lib/stepper_motor/railtie.rb
212
- - lib/stepper_motor/recover_stuck_journeys_job_v1.rb
220
+ - lib/stepper_motor/recover_stuck_journeys_job.rb
213
221
  - lib/stepper_motor/step.rb
214
222
  - lib/stepper_motor/test_helper.rb
215
223
  - lib/stepper_motor/version.rb
@@ -264,14 +272,18 @@ files:
264
272
  - test/dummy/public/icon.png
265
273
  - test/dummy/public/icon.svg
266
274
  - test/side_effects_helper.rb
275
+ - test/stepper_motor/completed_journeys_cleanup_test.rb
276
+ - test/stepper_motor/configuration_test.rb
267
277
  - test/stepper_motor/cyclic_scheduler_test.rb
268
278
  - test/stepper_motor/forward_scheduler_test.rb
279
+ - test/stepper_motor/housekeeping_job_test.rb
269
280
  - test/stepper_motor/journey/exception_handling_test.rb
270
281
  - test/stepper_motor/journey/flow_control_test.rb
271
282
  - test/stepper_motor/journey/idempotency_test.rb
272
283
  - test/stepper_motor/journey/step_definition_test.rb
273
284
  - test/stepper_motor/journey/uniqueness_test.rb
274
285
  - test/stepper_motor/journey_test.rb
286
+ - test/stepper_motor/perform_step_job_test.rb
275
287
  - test/stepper_motor/recover_stuck_journeys_job_test.rb
276
288
  - test/stepper_motor/recovery_test.rb
277
289
  - test/stepper_motor/test_helper_test.rb
@@ -1,12 +0,0 @@
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:, idempotency_key: nil, **)
7
- journey = StepperMotor::Journey.find(journey_id)
8
- journey.perform_next_step!(idempotency_key: idempotency_key)
9
- rescue ActiveRecord::RecordNotFound
10
- # The journey has been canceled and destroyed previously or elsewhere
11
- end
12
- end