stepper_motor 0.1.10 → 0.1.12
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/CHANGELOG.md +13 -0
- data/lib/generators/install_generator.rb +5 -5
- data/lib/stepper_motor/base_job.rb +5 -0
- data/lib/stepper_motor/cyclic_scheduler.rb +1 -1
- data/lib/stepper_motor/delete_completed_journeys_job.rb +16 -0
- data/lib/stepper_motor/forward_scheduler.rb +1 -1
- data/lib/stepper_motor/housekeeping_job.rb +8 -0
- data/lib/stepper_motor/perform_step_job.rb +25 -2
- data/lib/stepper_motor/{recover_stuck_journeys_job_v1.rb → recover_stuck_journeys_job.rb} +5 -4
- data/lib/stepper_motor/version.rb +1 -1
- data/lib/stepper_motor.rb +16 -2
- data/lib/tasks/stepper_motor_tasks.rake +1 -1
- data/rbi/stepper_motor.rbi +48 -16
- data/sig/stepper_motor.rbs +40 -12
- data/stepper_motor.gemspec +1 -1
- data/test/dummy/config/initializers/stepper_motor.rb +39 -0
- data/test/stepper_motor/completed_journeys_cleanup_test.rb +96 -0
- data/test/stepper_motor/configuration_test.rb +17 -0
- data/test/stepper_motor/cyclic_scheduler_test.rb +2 -2
- data/test/stepper_motor/forward_scheduler_test.rb +1 -1
- data/test/stepper_motor/housekeeping_job_test.rb +13 -0
- data/test/stepper_motor/perform_step_job_test.rb +60 -0
- data/test/stepper_motor/recover_stuck_journeys_job_test.rb +8 -3
- metadata +20 -8
- data/lib/stepper_motor/perform_step_job_v2.rb +0 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dcbfb106c1b6c9c74cef277a3bedec00c970d5264856ae92671b6f2302936eaa
|
4
|
+
data.tar.gz: a351a974b2ed5af98f20c8e816e43aac19982539054c7db1e3b1290d2d6a2538
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6a7f3190a99a62537b17ccfdfd76f1c4879a5b5c054395ffbd1d4ccaf7da469f96e2198954adbb2db79571b046b772ea37daf3865f27c23ea7d3a2b75f505699
|
7
|
+
data.tar.gz: 8f999b8fb9e599d6cfac817a82cc4640902d9812a296f9643cd80d0632099f94140645f9ad55dcbd1a043e1c6f25df3084a0cc23ab88b24ac206dab2b991c302
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,18 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## [0.1.12] - 2025-06-08
|
4
|
+
|
5
|
+
- Ensure base job extension gets done via the reloader, so that app classes are available
|
6
|
+
|
7
|
+
## [0.1.11] - 2025-06-08
|
8
|
+
|
9
|
+
- Add automatic cleanup of completed journeys after a configurable time period
|
10
|
+
- Add `HousekeepingJob` to run cleanup and recovery tasks
|
11
|
+
- Add ability to extend all StepperMotor jobs with custom configuration
|
12
|
+
- Merge V2/V1 job variants into single classes with backward compatibility
|
13
|
+
- Pin standardrb version to avoid Rubocop errors
|
14
|
+
- Improve documentation and test coverage
|
15
|
+
|
3
16
|
## [0.1.10] - 2025-05-28
|
4
17
|
|
5
18
|
- 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
|
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
|
-
|
35
|
-
|
36
|
-
|
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
|
@@ -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 <
|
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::
|
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
|
@@ -2,8 +2,18 @@
|
|
2
2
|
|
3
3
|
require "active_job"
|
4
4
|
|
5
|
-
class StepperMotor::PerformStepJob <
|
6
|
-
def perform(
|
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::
|
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
|
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
|
data/lib/stepper_motor.rb
CHANGED
@@ -13,13 +13,27 @@ 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/
|
18
|
-
autoload :
|
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
|
+
ActiveSupport::Reloader.to_prepare do
|
36
|
+
BaseJob.class_eval(&blk)
|
37
|
+
end
|
38
|
+
end
|
25
39
|
end
|
data/rbi/stepper_motor.rbi
CHANGED
@@ -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.
|
5
|
+
VERSION = T.let("0.1.12", 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
|
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 <
|
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
|
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
|
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
|
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
|
data/sig/stepper_motor.rbs
CHANGED
@@ -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 <
|
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
|
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
|
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
|
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
|
data/stepper_motor.gemspec
CHANGED
@@ -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
|
+
# queue_with_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,17 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class ConfigurationTest < ActiveSupport::TestCase
|
4
|
+
module TestExtension
|
5
|
+
end
|
6
|
+
|
7
|
+
test "allows extending the base job" do
|
8
|
+
ActiveSupport::Reloader.reload!
|
9
|
+
|
10
|
+
refute StepperMotor::BaseJob.ancestors.include?(TestExtension)
|
11
|
+
|
12
|
+
StepperMotor.extend_base_job { include TestExtension }
|
13
|
+
ActiveSupport::Reloader.reload!
|
14
|
+
|
15
|
+
assert StepperMotor::BaseJob.ancestors.include?(TestExtension)
|
16
|
+
end
|
17
|
+
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::
|
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::
|
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::
|
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::
|
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::
|
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::
|
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.
|
4
|
+
version: 0.1.12
|
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-
|
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:
|
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:
|
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/
|
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
|