activejob 7.1.5.2 → 8.1.2.1
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 +86 -264
- data/README.md +8 -6
- data/lib/active_job/arguments.rb +51 -59
- data/lib/active_job/base.rb +5 -6
- data/lib/active_job/callbacks.rb +0 -3
- data/lib/active_job/configured_job.rb +5 -4
- data/lib/active_job/continuable.rb +102 -0
- data/lib/active_job/continuation/step.rb +83 -0
- data/lib/active_job/continuation/test_helper.rb +89 -0
- data/lib/active_job/continuation/validation.rb +50 -0
- data/lib/active_job/continuation.rb +332 -0
- data/lib/active_job/core.rb +25 -22
- data/lib/active_job/enqueue_after_transaction_commit.rb +38 -0
- data/lib/active_job/enqueuing.rb +46 -12
- data/lib/active_job/exceptions.rb +21 -17
- data/lib/active_job/execution_state.rb +11 -0
- data/lib/active_job/gem_version.rb +3 -3
- data/lib/active_job/instrumentation.rb +12 -12
- data/lib/active_job/log_subscriber.rb +65 -6
- data/lib/active_job/logging.rb +16 -2
- data/lib/active_job/queue_adapter.rb +6 -4
- data/lib/active_job/queue_adapters/abstract_adapter.rb +25 -0
- data/lib/active_job/queue_adapters/async_adapter.rb +7 -3
- data/lib/active_job/queue_adapters/backburner_adapter.rb +1 -1
- data/lib/active_job/queue_adapters/delayed_job_adapter.rb +1 -1
- data/lib/active_job/queue_adapters/inline_adapter.rb +1 -1
- data/lib/active_job/queue_adapters/queue_classic_adapter.rb +1 -1
- data/lib/active_job/queue_adapters/resque_adapter.rb +1 -1
- data/lib/active_job/queue_adapters/sidekiq_adapter.rb +20 -1
- data/lib/active_job/queue_adapters/sneakers_adapter.rb +1 -1
- data/lib/active_job/queue_adapters/test_adapter.rb +6 -2
- data/lib/active_job/queue_adapters.rb +1 -4
- data/lib/active_job/railtie.rb +25 -4
- data/lib/active_job/serializers/action_controller_parameters_serializer.rb +25 -0
- data/lib/active_job/serializers/big_decimal_serializer.rb +3 -4
- data/lib/active_job/serializers/date_serializer.rb +3 -4
- data/lib/active_job/serializers/date_time_serializer.rb +3 -4
- data/lib/active_job/serializers/duration_serializer.rb +5 -6
- data/lib/active_job/serializers/module_serializer.rb +3 -4
- data/lib/active_job/serializers/object_serializer.rb +13 -14
- data/lib/active_job/serializers/range_serializer.rb +9 -9
- data/lib/active_job/serializers/symbol_serializer.rb +4 -5
- data/lib/active_job/serializers/time_serializer.rb +3 -4
- data/lib/active_job/serializers/time_with_zone_serializer.rb +3 -4
- data/lib/active_job/serializers.rb +62 -18
- data/lib/active_job/structured_event_subscriber.rb +220 -0
- data/lib/active_job/test_helper.rb +28 -5
- data/lib/active_job.rb +5 -9
- metadata +18 -11
- data/lib/active_job/queue_adapters/sucker_punch_adapter.rb +0 -49
- data/lib/active_job/timezones.rb +0 -13
- data/lib/active_job/translation.rb +0 -13
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
# = Active Job Continuable
|
|
5
|
+
#
|
|
6
|
+
# The Continuable module provides the ability to track the progress of your
|
|
7
|
+
# jobs, and continue from where they left off if interrupted.
|
|
8
|
+
#
|
|
9
|
+
# Mix ActiveJob::Continuable into your job to enable continuations.
|
|
10
|
+
#
|
|
11
|
+
# See {ActiveJob::Continuation}[rdoc-ref:ActiveJob::Continuation] for usage.
|
|
12
|
+
#
|
|
13
|
+
module Continuable
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
class_attribute :max_resumptions, instance_writer: false
|
|
18
|
+
class_attribute :resume_options, instance_writer: false, default: { wait: 5.seconds }
|
|
19
|
+
class_attribute :resume_errors_after_advancing, instance_writer: false, default: true
|
|
20
|
+
|
|
21
|
+
around_perform :continue
|
|
22
|
+
|
|
23
|
+
def initialize(...)
|
|
24
|
+
super(...)
|
|
25
|
+
self.resumptions = 0
|
|
26
|
+
self.continuation = Continuation.new(self, {})
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The number of times the job has been resumed.
|
|
31
|
+
attr_accessor :resumptions
|
|
32
|
+
|
|
33
|
+
attr_accessor :continuation # :nodoc:
|
|
34
|
+
|
|
35
|
+
# Start a new continuation step
|
|
36
|
+
def step(step_name, start: nil, isolated: false, &block)
|
|
37
|
+
unless block_given?
|
|
38
|
+
step_method = method(step_name)
|
|
39
|
+
|
|
40
|
+
raise ArgumentError, "Step method '#{step_name}' must accept 0 or 1 arguments" if step_method.arity > 1
|
|
41
|
+
|
|
42
|
+
if step_method.parameters.any? { |type, name| type == :key || type == :keyreq }
|
|
43
|
+
raise ArgumentError, "Step method '#{step_name}' must not accept keyword arguments"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
block = step_method.arity == 0 ? -> (_) { step_method.call } : step_method
|
|
47
|
+
end
|
|
48
|
+
checkpoint! if continuation.advanced?
|
|
49
|
+
continuation.step(step_name, start: start, isolated: isolated, &block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def serialize # :nodoc:
|
|
53
|
+
super.merge("continuation" => continuation.to_h, "resumptions" => resumptions)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def deserialize(job_data) # :nodoc:
|
|
57
|
+
super
|
|
58
|
+
self.continuation = Continuation.new(self, job_data.fetch("continuation", {}))
|
|
59
|
+
self.resumptions = job_data.fetch("resumptions", 0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def checkpoint! # :nodoc:
|
|
63
|
+
interrupt!(reason: :stopping) if queue_adapter.stopping?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def interrupt!(reason:) # :nodoc:
|
|
67
|
+
instrument :interrupt, reason: reason, **continuation.instrumentation
|
|
68
|
+
raise Continuation::Interrupt, "Interrupted #{continuation.description} (#{reason})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
def continue(&block)
|
|
73
|
+
if continuation.started?
|
|
74
|
+
self.resumptions += 1
|
|
75
|
+
instrument :resume, **continuation.instrumentation
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
block.call
|
|
79
|
+
rescue Continuation::Interrupt => e
|
|
80
|
+
resume_job(e)
|
|
81
|
+
rescue Continuation::Error
|
|
82
|
+
raise
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
if resume_errors_after_advancing? && continuation.advanced?
|
|
85
|
+
resume_job(exception: e)
|
|
86
|
+
else
|
|
87
|
+
raise
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resume_job(exception) # :nodoc:
|
|
92
|
+
executions_for(exception)
|
|
93
|
+
if max_resumptions.nil? || resumptions < max_resumptions
|
|
94
|
+
retry_job(**self.resume_options)
|
|
95
|
+
else
|
|
96
|
+
raise Continuation::ResumeLimitError, "Job was resumed a maximum of #{max_resumptions} times"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
ActiveSupport.run_load_hooks(:active_job_continuable, Continuable)
|
|
102
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
class Continuation
|
|
5
|
+
# = Active Job Continuation Step
|
|
6
|
+
#
|
|
7
|
+
# Represents a step within a continuable job.
|
|
8
|
+
#
|
|
9
|
+
# When a step is completed, it is recorded in the job's continuation state.
|
|
10
|
+
# If the job is interrupted, it will be resumed from after the last completed step.
|
|
11
|
+
#
|
|
12
|
+
# Steps also have an optional cursor that can be used to track progress within the step.
|
|
13
|
+
# If a job is interrupted during a step, the cursor will be saved and passed back when
|
|
14
|
+
# the job is resumed.
|
|
15
|
+
#
|
|
16
|
+
# It is the responsibility of the code in the step to use the cursor correctly to resume
|
|
17
|
+
# from where it left off.
|
|
18
|
+
class Step
|
|
19
|
+
# The name of the step.
|
|
20
|
+
attr_reader :name
|
|
21
|
+
|
|
22
|
+
# The cursor for the step.
|
|
23
|
+
attr_reader :cursor
|
|
24
|
+
|
|
25
|
+
def initialize(name, cursor, job:, resumed:)
|
|
26
|
+
@name = name.to_sym
|
|
27
|
+
@initial_cursor = cursor
|
|
28
|
+
@cursor = cursor
|
|
29
|
+
@resumed = resumed
|
|
30
|
+
@job = job
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check if the job should be interrupted, and if so raise an Interrupt exception.
|
|
34
|
+
# The job will be requeued for retry.
|
|
35
|
+
def checkpoint!
|
|
36
|
+
job.checkpoint!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set the cursor and interrupt the job if necessary.
|
|
40
|
+
def set!(cursor)
|
|
41
|
+
@cursor = cursor
|
|
42
|
+
checkpoint!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Advance the cursor from the current or supplied value
|
|
46
|
+
#
|
|
47
|
+
# The cursor will be advanced by calling the +succ+ method on the cursor.
|
|
48
|
+
# An UnadvanceableCursorError error will be raised if the cursor does not implement +succ+.
|
|
49
|
+
def advance!(from: nil)
|
|
50
|
+
from = cursor if from.nil?
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
to = from.succ
|
|
54
|
+
rescue NoMethodError
|
|
55
|
+
raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement 'succ'"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
set! to
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Has this step been resumed from a previous job execution?
|
|
62
|
+
def resumed?
|
|
63
|
+
@resumed
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Has the cursor been advanced during this job execution?
|
|
67
|
+
def advanced?
|
|
68
|
+
initial_cursor != cursor
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_a
|
|
72
|
+
[ name.to_s, cursor ]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def description
|
|
76
|
+
"at '#{name}', cursor '#{cursor.inspect}'"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
attr_reader :initial_cursor, :job
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job/test_helper"
|
|
4
|
+
require "active_job/continuation"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
class Continuation
|
|
8
|
+
# Test helper for ActiveJob::Continuable jobs.
|
|
9
|
+
#
|
|
10
|
+
module TestHelper
|
|
11
|
+
include ::ActiveJob::TestHelper
|
|
12
|
+
|
|
13
|
+
# Interrupt a job during a step.
|
|
14
|
+
#
|
|
15
|
+
# class MyJob < ApplicationJob
|
|
16
|
+
# include ActiveJob::Continuable
|
|
17
|
+
#
|
|
18
|
+
# cattr_accessor :items, default: []
|
|
19
|
+
# def perform
|
|
20
|
+
# step :my_step, start: 1 do |step|
|
|
21
|
+
# (step.cursor..10).each do |i|
|
|
22
|
+
# items << i
|
|
23
|
+
# step.advance!
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# test "interrupt job during step" do
|
|
30
|
+
# MyJob.perform_later
|
|
31
|
+
# interrupt_job_during_step(MyJob, :my_step, cursor: 6) { perform_enqueued_jobs }
|
|
32
|
+
# assert_equal [1, 2, 3, 4, 5], MyJob.items
|
|
33
|
+
# perform_enqueued_jobs
|
|
34
|
+
# assert_equal [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], MyJob.items
|
|
35
|
+
# end
|
|
36
|
+
def interrupt_job_during_step(job, step, cursor: nil, &block)
|
|
37
|
+
require_active_job_test_adapter!("interrupt_job_during_step")
|
|
38
|
+
queue_adapter.with(stopping: ->() { during_step?(job, step, cursor: cursor) }, &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Interrupt a job after a step.
|
|
42
|
+
#
|
|
43
|
+
# Note that there's no checkpoint after the final step so it won't be interrupted.
|
|
44
|
+
#
|
|
45
|
+
# class MyJob < ApplicationJob
|
|
46
|
+
# include ActiveJob::Continuable
|
|
47
|
+
#
|
|
48
|
+
# cattr_accessor :items, default: []
|
|
49
|
+
#
|
|
50
|
+
# def perform
|
|
51
|
+
# step :step_one { items << 1 }
|
|
52
|
+
# step :step_two { items << 2 }
|
|
53
|
+
# step :step_three { items << 3 }
|
|
54
|
+
# step :step_four { items << 4 }
|
|
55
|
+
# end
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# test "interrupt job after step" do
|
|
59
|
+
# MyJob.perform_later
|
|
60
|
+
# interrupt_job_after_step(MyJob, :step_two) { perform_enqueued_jobs }
|
|
61
|
+
# assert_equal [1, 2], MyJob.items
|
|
62
|
+
# perform_enqueued_jobs
|
|
63
|
+
# assert_equal [1, 2, 3, 4], MyJob.items
|
|
64
|
+
# end
|
|
65
|
+
def interrupt_job_after_step(job, step, &block)
|
|
66
|
+
require_active_job_test_adapter!("interrupt_job_after_step")
|
|
67
|
+
queue_adapter.with(stopping: ->() { after_step?(job, step) }, &block)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
def continuation_for(klass)
|
|
72
|
+
job = ActiveSupport::ExecutionContext.to_h[:job]
|
|
73
|
+
job.send(:continuation)&.to_h if job && job.is_a?(klass)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def during_step?(job, step, cursor: nil)
|
|
77
|
+
if (continuation = continuation_for(job))
|
|
78
|
+
continuation["current"] == [ step.to_s, cursor ]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def after_step?(job, step)
|
|
83
|
+
if (continuation = continuation_for(job))
|
|
84
|
+
continuation["completed"].last == step.to_s && continuation["current"].nil?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
class Continuation
|
|
5
|
+
module Validation # :nodoc:
|
|
6
|
+
private
|
|
7
|
+
def validate_step!(name)
|
|
8
|
+
validate_step_symbol!(name)
|
|
9
|
+
validate_step_not_encountered!(name)
|
|
10
|
+
validate_step_not_nested!(name)
|
|
11
|
+
validate_step_resume_expected!(name)
|
|
12
|
+
validate_step_expected_order!(name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate_step_symbol!(name)
|
|
16
|
+
unless name.is_a?(Symbol)
|
|
17
|
+
raise_step_error! "Step '#{name}' must be a Symbol, found '#{name.class}'"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_step_not_encountered!(name)
|
|
22
|
+
if encountered.include?(name)
|
|
23
|
+
raise_step_error! "Step '#{name}' has already been encountered"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_step_not_nested!(name)
|
|
28
|
+
if running_step?
|
|
29
|
+
raise_step_error! "Step '#{name}' is nested inside step '#{current.name}'"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_step_resume_expected!(name)
|
|
34
|
+
if current && current.name != name && !completed?(name)
|
|
35
|
+
raise_step_error! "Step '#{name}' found, expected to resume from '#{current.name}'"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_step_expected_order!(name)
|
|
40
|
+
if completed.size > encountered.size && completed[encountered.size] != name
|
|
41
|
+
raise_step_error! "Step '#{name}' found, expected to see '#{completed[encountered.size]}'"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def raise_step_error!(message)
|
|
46
|
+
raise InvalidStepError, message
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/numeric/time"
|
|
4
|
+
require "active_job/continuable"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
# = Active Job \Continuation
|
|
8
|
+
#
|
|
9
|
+
# Continuations provide a mechanism for interrupting and resuming jobs. This allows
|
|
10
|
+
# long-running jobs to make progress across application restarts.
|
|
11
|
+
#
|
|
12
|
+
# Jobs should include the ActiveJob::Continuable module to enable continuations.
|
|
13
|
+
# \Continuable jobs are automatically retried when interrupted.
|
|
14
|
+
#
|
|
15
|
+
# Use the +step+ method to define the steps in your job. Steps can use an optional
|
|
16
|
+
# cursor to track progress in the step.
|
|
17
|
+
#
|
|
18
|
+
# Steps are executed as soon as they are encountered. If a job is interrupted, previously
|
|
19
|
+
# completed steps will be skipped. If a step is in progress, it will be resumed
|
|
20
|
+
# with the last recorded cursor.
|
|
21
|
+
#
|
|
22
|
+
# Code that is not part of a step will be executed on each job run.
|
|
23
|
+
#
|
|
24
|
+
# You can pass a block or a method name to the step method. The block will be called with
|
|
25
|
+
# the step object as an argument. Methods can either take no arguments or a single argument
|
|
26
|
+
# for the step object.
|
|
27
|
+
#
|
|
28
|
+
# class ProcessImportJob < ApplicationJob
|
|
29
|
+
# include ActiveJob::Continuable
|
|
30
|
+
#
|
|
31
|
+
# def perform(import_id)
|
|
32
|
+
# # This always runs, even if the job is resumed.
|
|
33
|
+
# @import = Import.find(import_id)
|
|
34
|
+
#
|
|
35
|
+
# step :validate do
|
|
36
|
+
# @import.validate!
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# step(:process_records) do |step|
|
|
40
|
+
# @import.records.find_each(start: step.cursor) do |record|
|
|
41
|
+
# record.process
|
|
42
|
+
# step.advance! from: record.id
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# step :reprocess_records
|
|
47
|
+
# step :finalize
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# def reprocess_records(step)
|
|
51
|
+
# @import.records.find_each(start: step.cursor) do |record|
|
|
52
|
+
# record.reprocess
|
|
53
|
+
# step.advance! from: record.id
|
|
54
|
+
# end
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# def finalize
|
|
58
|
+
# @import.finalize!
|
|
59
|
+
# end
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
# === Cursors
|
|
63
|
+
#
|
|
64
|
+
# Cursors are used to track progress within a step. The cursor can be any object that is
|
|
65
|
+
# serializable as an argument to +ActiveJob::Base.serialize+. It defaults to +nil+.
|
|
66
|
+
#
|
|
67
|
+
# When a step is resumed, the last cursor value is restored. The code in the step is responsible
|
|
68
|
+
# for using the cursor to continue from the right point.
|
|
69
|
+
#
|
|
70
|
+
# +set!+ sets the cursor to a specific value.
|
|
71
|
+
#
|
|
72
|
+
# step :iterate_items do |step|
|
|
73
|
+
# items[step.cursor..].each do |item|
|
|
74
|
+
# process(item)
|
|
75
|
+
# step.set! (step.cursor || 0) + 1
|
|
76
|
+
# end
|
|
77
|
+
# end
|
|
78
|
+
#
|
|
79
|
+
# An starting value for the cursor can be set when defining the step:
|
|
80
|
+
#
|
|
81
|
+
# step :iterate_items, start: 0 do |step|
|
|
82
|
+
# items[step.cursor..].each do |item|
|
|
83
|
+
# process(item)
|
|
84
|
+
# step.set! step.cursor + 1
|
|
85
|
+
# end
|
|
86
|
+
# end
|
|
87
|
+
#
|
|
88
|
+
# The cursor can be advanced with +advance!+. This calls +succ+ on the current cursor value.
|
|
89
|
+
# It raises an ActiveJob::Continuation::UnadvanceableCursorError if the cursor does not implement +succ+.
|
|
90
|
+
#
|
|
91
|
+
# step :iterate_items, start: 0 do |step|
|
|
92
|
+
# items[step.cursor..].each do |item|
|
|
93
|
+
# process(item)
|
|
94
|
+
# step.advance!
|
|
95
|
+
# end
|
|
96
|
+
# end
|
|
97
|
+
#
|
|
98
|
+
# You can optionally pass a +from+ argument to +advance!+. This is useful when iterating
|
|
99
|
+
# over a collection of records where IDs may not be contiguous.
|
|
100
|
+
#
|
|
101
|
+
# step :process_records do |step|
|
|
102
|
+
# import.records.find_each(start: step.cursor) do |record|
|
|
103
|
+
# record.process
|
|
104
|
+
# step.advance! from: record.id
|
|
105
|
+
# end
|
|
106
|
+
# end
|
|
107
|
+
#
|
|
108
|
+
# You can use an array to iterate over nested records:
|
|
109
|
+
#
|
|
110
|
+
# step :process_nested_records, start: [ 0, 0 ] do |step|
|
|
111
|
+
# Account.find_each(start: step.cursor[0]) do |account|
|
|
112
|
+
# account.records.find_each(start: step.cursor[1]) do |record|
|
|
113
|
+
# record.process
|
|
114
|
+
# step.set! [ account.id, record.id + 1 ]
|
|
115
|
+
# end
|
|
116
|
+
# step.set! [ account.id + 1, 0 ]
|
|
117
|
+
# end
|
|
118
|
+
# end
|
|
119
|
+
#
|
|
120
|
+
# Setting or advancing the cursor creates a checkpoint. You can also create a checkpoint
|
|
121
|
+
# manually by calling the +checkpoint!+ method on the step. This is useful if you want to
|
|
122
|
+
# allow interruptions, but don't need to update the cursor.
|
|
123
|
+
#
|
|
124
|
+
# step :destroy_records do |step|
|
|
125
|
+
# import.records.find_each do |record|
|
|
126
|
+
# record.destroy!
|
|
127
|
+
# step.checkpoint!
|
|
128
|
+
# end
|
|
129
|
+
# end
|
|
130
|
+
#
|
|
131
|
+
# === Checkpoints
|
|
132
|
+
#
|
|
133
|
+
# A checkpoint is where a job can be interrupted. At a checkpoint the job will call
|
|
134
|
+
# +queue_adapter.stopping?+. If it returns true, the job will raise an
|
|
135
|
+
# ActiveJob::Continuation::Interrupt exception.
|
|
136
|
+
#
|
|
137
|
+
# There is an automatic checkpoint before the start of each step except for the first for
|
|
138
|
+
# each job execution. Within a step one is created when calling +set!+, +advance!+ or +checkpoint!+.
|
|
139
|
+
#
|
|
140
|
+
# Jobs are not automatically interrupted when the queue adapter is marked as stopping - they
|
|
141
|
+
# will continue to run either until the next checkpoint, or when the process is stopped.
|
|
142
|
+
#
|
|
143
|
+
# This is to allow jobs to be interrupted at a safe point, but it also means that the jobs
|
|
144
|
+
# should checkpoint more frequently than the shutdown timeout to ensure a graceful restart.
|
|
145
|
+
#
|
|
146
|
+
# When interrupted, the job will automatically retry with the progress serialized
|
|
147
|
+
# in the job data under the +continuation+ key.
|
|
148
|
+
#
|
|
149
|
+
# The serialized progress contains:
|
|
150
|
+
# - a list of the completed steps
|
|
151
|
+
# - the current step and its cursor value (if one is in progress)
|
|
152
|
+
#
|
|
153
|
+
# === Isolated Steps
|
|
154
|
+
#
|
|
155
|
+
# Steps run sequentially in a single job execution, unless the job is interrupted.
|
|
156
|
+
#
|
|
157
|
+
# You can specify that a step should always run in its own execution by passing the +isolated: true+ option.
|
|
158
|
+
#
|
|
159
|
+
# This is useful for long-running steps where it may not be possible to checkpoint within
|
|
160
|
+
# the job grace period - it ensures that progress is serialized back into the job data before
|
|
161
|
+
# the step starts.
|
|
162
|
+
#
|
|
163
|
+
# step :quick_step1
|
|
164
|
+
# step :slow_step, isolated: true
|
|
165
|
+
# step :quick_step2
|
|
166
|
+
# step :quick_step3
|
|
167
|
+
#
|
|
168
|
+
# === Errors
|
|
169
|
+
#
|
|
170
|
+
# If a job raises an error and is not retried via Active Job, it will be passed back to the underlying
|
|
171
|
+
# queue backend and any progress in this execution will be lost.
|
|
172
|
+
#
|
|
173
|
+
# To mitigate this, the job will be automatically retried if it raises an error after it has made progress.
|
|
174
|
+
# Making progress is defined as having completed a step or advanced the cursor within the current step.
|
|
175
|
+
#
|
|
176
|
+
# === Configuration
|
|
177
|
+
#
|
|
178
|
+
# Continuable jobs have several configuration options:
|
|
179
|
+
# * <tt>:max_resumptions</tt> - The maximum number of times a job can be resumed. Defaults to +nil+ which means
|
|
180
|
+
# unlimited resumptions.
|
|
181
|
+
# * <tt>:resume_options</tt> - Options to pass to +retry_job+ when resuming the job.
|
|
182
|
+
# Defaults to <tt>{ wait: 5.seconds }</tt>.
|
|
183
|
+
# See {ActiveJob::Exceptions#retry_job}[rdoc-ref:ActiveJob::Exceptions#retry_job] for available options.
|
|
184
|
+
# * <tt>:resume_errors_after_advancing</tt> - Whether to resume errors after advancing the continuation.
|
|
185
|
+
# Defaults to +true+.
|
|
186
|
+
class Continuation
|
|
187
|
+
extend ActiveSupport::Autoload
|
|
188
|
+
|
|
189
|
+
autoload :Validation
|
|
190
|
+
|
|
191
|
+
# Raised when a job is interrupted, allowing Active Job to requeue it.
|
|
192
|
+
# This inherits from +Exception+ rather than +StandardError+, so it's not
|
|
193
|
+
# caught by normal exception handling.
|
|
194
|
+
class Interrupt < Exception; end
|
|
195
|
+
|
|
196
|
+
# Base class for all Continuation errors.
|
|
197
|
+
class Error < StandardError; end
|
|
198
|
+
|
|
199
|
+
# Raised when a step is invalid.
|
|
200
|
+
class InvalidStepError < Error; end
|
|
201
|
+
|
|
202
|
+
# Raised when there is an error with a checkpoint, such as open database transactions.
|
|
203
|
+
class CheckpointError < Error; end
|
|
204
|
+
|
|
205
|
+
# Raised when attempting to advance a cursor that doesn't implement `succ`.
|
|
206
|
+
class UnadvanceableCursorError < Error; end
|
|
207
|
+
|
|
208
|
+
# Raised when a job has reached its limit of the number of resumes.
|
|
209
|
+
# The limit is defined by the +max_resumes+ class attribute.
|
|
210
|
+
class ResumeLimitError < Error; end
|
|
211
|
+
|
|
212
|
+
include Validation
|
|
213
|
+
|
|
214
|
+
def initialize(job, serialized_progress) # :nodoc:
|
|
215
|
+
@job = job
|
|
216
|
+
@completed = serialized_progress.fetch("completed", []).map(&:to_sym)
|
|
217
|
+
@current = new_step(*serialized_progress["current"], resumed: true) if serialized_progress.key?("current")
|
|
218
|
+
@encountered = []
|
|
219
|
+
@advanced = false
|
|
220
|
+
@running_step = false
|
|
221
|
+
@isolating = false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def step(name, **options, &block) # :nodoc:
|
|
225
|
+
validate_step!(name)
|
|
226
|
+
encountered << name
|
|
227
|
+
|
|
228
|
+
if completed?(name)
|
|
229
|
+
skip_step(name)
|
|
230
|
+
else
|
|
231
|
+
run_step(name, **options, &block)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def to_h # :nodoc:
|
|
236
|
+
{
|
|
237
|
+
"completed" => completed.map(&:to_s),
|
|
238
|
+
"current" => current&.to_a,
|
|
239
|
+
}.compact
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def description # :nodoc:
|
|
243
|
+
if current
|
|
244
|
+
current.description
|
|
245
|
+
elsif completed.any?
|
|
246
|
+
"after '#{completed.last}'"
|
|
247
|
+
else
|
|
248
|
+
"not started"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def started?
|
|
253
|
+
completed.any? || current.present?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def advanced?
|
|
257
|
+
@advanced
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def instrumentation
|
|
261
|
+
{ description: description,
|
|
262
|
+
completed_steps: completed,
|
|
263
|
+
current_step: current }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
attr_reader :job, :encountered, :completed, :current
|
|
268
|
+
|
|
269
|
+
def running_step?
|
|
270
|
+
@running_step
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def isolating?
|
|
274
|
+
@isolating
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def completed?(name)
|
|
278
|
+
completed.include?(name)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def new_step(*args, **options)
|
|
282
|
+
Step.new(*args, job: job, **options)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def skip_step(name)
|
|
286
|
+
instrument :step_skipped, step: name
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def run_step(name, start:, isolated:, &block)
|
|
290
|
+
@isolating ||= isolated
|
|
291
|
+
|
|
292
|
+
if isolating? && advanced?
|
|
293
|
+
job.interrupt!(reason: :isolating)
|
|
294
|
+
else
|
|
295
|
+
run_step_inline(name, start: start, &block)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def run_step_inline(name, start:, **options, &block)
|
|
300
|
+
@running_step = true
|
|
301
|
+
@current ||= new_step(name, start, resumed: false)
|
|
302
|
+
|
|
303
|
+
instrumenting_step(current) do
|
|
304
|
+
block.call(current)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
@completed << current.name
|
|
308
|
+
@current = nil
|
|
309
|
+
@advanced = true
|
|
310
|
+
ensure
|
|
311
|
+
@running_step = false
|
|
312
|
+
@advanced ||= current&.advanced?
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def instrumenting_step(step, &block)
|
|
316
|
+
instrument :step, step: step, interrupted: false do |payload|
|
|
317
|
+
instrument :step_started, step: step
|
|
318
|
+
|
|
319
|
+
block.call
|
|
320
|
+
rescue Interrupt
|
|
321
|
+
payload[:interrupted] = true
|
|
322
|
+
raise
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def instrument(...)
|
|
327
|
+
job.instrument(...)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
require "active_job/continuation/step"
|