activejob 8.0.2.1 → 8.1.0.beta1
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 +60 -27
- data/README.md +8 -6
- data/lib/active_job/arguments.rb +46 -47
- data/lib/active_job/base.rb +4 -6
- 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 +12 -2
- data/lib/active_job/enqueue_after_transaction_commit.rb +1 -26
- data/lib/active_job/enqueuing.rb +8 -4
- data/lib/active_job/exceptions.rb +16 -6
- 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 +61 -6
- data/lib/active_job/queue_adapters/abstract_adapter.rb +6 -0
- data/lib/active_job/queue_adapters/async_adapter.rb +5 -1
- data/lib/active_job/queue_adapters/sidekiq_adapter.rb +19 -0
- data/lib/active_job/queue_adapters/test_adapter.rb +5 -1
- data/lib/active_job/railtie.rb +9 -19
- 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 +11 -13
- 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 +46 -15
- data/lib/active_job.rb +2 -0
- metadata +13 -9
- data/lib/active_job/queue_adapters/sucker_punch_adapter.rb +0 -56
- data/lib/active_job/timezones.rb +0 -13
- data/lib/active_job/translation.rb +0 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c9a034077d88450ca67c9f9921d2b9678d506724adeb387742be2be30b30b038
|
4
|
+
data.tar.gz: 6efdff593dfb0f7d0773a6a8419c084919346d1dc15c44619e58f5009886ad3b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7a6be4dfbb96daba9f49b695a5aa568420a446cb303bf3a9ba6083a1d3d4c3bb7663fb428107a2f0b3f954a2ba4b4c5781dad015f4d56b97201096d75ab4bf35
|
7
|
+
data.tar.gz: 4b01822ccd36668f128a6754b8ad9ea06d7874b2f3584c7fe44e54948df92552c6e93609ab7f2a688a20c23116078b0a84711ac1c851308657e438f0892ceb32
|
data/CHANGELOG.md
CHANGED
@@ -1,60 +1,93 @@
|
|
1
|
-
## Rails 8.0.
|
1
|
+
## Rails 8.1.0.beta1 (September 04, 2025) ##
|
2
2
|
|
3
|
-
*
|
3
|
+
* Deprecate built-in `sidekiq` adapter.
|
4
4
|
|
5
|
+
If you're using this adapter, upgrade to `sidekiq` 7.3.3 or later to use the `sidekiq` gem's adapter.
|
5
6
|
|
6
|
-
|
7
|
+
*fatkodima*
|
7
8
|
|
8
|
-
*
|
9
|
+
* Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem.
|
9
10
|
|
11
|
+
*Rafael Mendonça França*
|
10
12
|
|
11
|
-
|
13
|
+
* Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`.
|
12
14
|
|
13
|
-
*
|
15
|
+
*Rafael Mendonça França*
|
14
16
|
|
17
|
+
* Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`.
|
15
18
|
|
16
|
-
|
19
|
+
*Rafael Mendonça França*
|
17
20
|
|
18
|
-
*
|
21
|
+
* `ActiveJob::Serializers::ObjectSerializers#klass` method is now public.
|
19
22
|
|
20
|
-
|
21
|
-
|
23
|
+
Custom Active Job serializers must have a public `#klass` method too.
|
24
|
+
The returned class will be index allowing for faster serialization.
|
22
25
|
|
23
|
-
*
|
26
|
+
*Jean Boussier*
|
24
27
|
|
28
|
+
* Allow jobs to the interrupted and resumed with Continuations
|
25
29
|
|
26
|
-
|
30
|
+
A job can use Continuations by including the `ActiveJob::Continuable`
|
31
|
+
concern. Continuations split jobs into steps. When the queuing system
|
32
|
+
is shutting down jobs can be interrupted and their progress saved.
|
27
33
|
|
28
|
-
|
34
|
+
```ruby
|
35
|
+
class ProcessImportJob
|
36
|
+
include ActiveJob::Continuable
|
29
37
|
|
38
|
+
def perform(import_id)
|
39
|
+
@import = Import.find(import_id)
|
30
40
|
|
31
|
-
|
41
|
+
# block format
|
42
|
+
step :initialize do
|
43
|
+
@import.initialize
|
44
|
+
end
|
32
45
|
|
33
|
-
|
46
|
+
# step with cursor, the cursor is saved when the job is interrupted
|
47
|
+
step :process do |step|
|
48
|
+
@import.records.find_each(start: step.cursor) do |record|
|
49
|
+
record.process
|
50
|
+
step.advance! from: record.id
|
51
|
+
end
|
52
|
+
end
|
34
53
|
|
54
|
+
# method format
|
55
|
+
step :finalize
|
35
56
|
|
36
|
-
|
57
|
+
private
|
58
|
+
def finalize
|
59
|
+
@import.finalize
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
```
|
37
64
|
|
38
|
-
*
|
65
|
+
*Donal McBreen*
|
39
66
|
|
67
|
+
* Defer invocation of ActiveJob enqueue callbacks until after commit when
|
68
|
+
`enqueue_after_transaction_commit` is enabled.
|
40
69
|
|
41
|
-
|
70
|
+
*Will Roever*
|
42
71
|
|
43
|
-
*
|
72
|
+
* Add `report:` option to `ActiveJob::Base#retry_on` and `#discard_on`
|
44
73
|
|
45
|
-
|
74
|
+
When the `report:` option is passed, errors will be reported to the error reporter
|
75
|
+
before being retried / discarded.
|
46
76
|
|
77
|
+
*Andrew Novoselac*
|
47
78
|
|
48
|
-
|
79
|
+
* Accept a block for `ActiveJob::ConfiguredJob#perform_later`.
|
49
80
|
|
50
|
-
|
81
|
+
This was inconsistent with a regular `ActiveJob::Base#perform_later`.
|
51
82
|
|
52
|
-
|
83
|
+
*fatkodima*
|
53
84
|
|
54
|
-
|
85
|
+
* Raise a more specific error during deserialization when a previously serialized job class is now unknown.
|
55
86
|
|
56
|
-
|
87
|
+
`ActiveJob::UnknownJobClassError` will be raised instead of a more generic
|
88
|
+
`NameError` to make it easily possible for adapters to tell if the `NameError`
|
89
|
+
was raised during job execution or deserialization.
|
57
90
|
|
58
|
-
*
|
91
|
+
*Earlopain*
|
59
92
|
|
60
|
-
Please check [
|
93
|
+
Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activejob/CHANGELOG.md) for previous changes.
|
data/README.md
CHANGED
@@ -89,11 +89,13 @@ Active Job has built-in adapters for multiple queuing backends (Sidekiq,
|
|
89
89
|
Resque, Delayed Job and others). To get an up-to-date list of the adapters
|
90
90
|
see the API Documentation for [ActiveJob::QueueAdapters](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters.html).
|
91
91
|
|
92
|
-
**Please note:** We are not accepting pull requests for new adapters
|
93
|
-
|
94
|
-
their gem, or as a stand-alone gem.
|
95
|
-
|
96
|
-
|
92
|
+
**Please note:** We are not accepting pull requests for new adapters, and we are
|
93
|
+
actively extracting the current adapters. We encourage library authors to provide
|
94
|
+
an Active Job adapter as part of their gem, or as a stand-alone gem.
|
95
|
+
|
96
|
+
## Continuations
|
97
|
+
|
98
|
+
Continuations allow jobs to be interrupted and resumed. See more at ActiveJob::Continuation.
|
97
99
|
|
98
100
|
|
99
101
|
## Download and installation
|
@@ -126,6 +128,6 @@ Bug reports for the Ruby on \Rails project can be filed here:
|
|
126
128
|
|
127
129
|
* https://github.com/rails/rails/issues
|
128
130
|
|
129
|
-
Feature requests should be discussed on the
|
131
|
+
Feature requests should be discussed on the rubyonrails-core forum here:
|
130
132
|
|
131
133
|
* https://discuss.rubyonrails.org/c/rubyonrails-core
|
data/lib/active_job/arguments.rb
CHANGED
@@ -31,8 +31,44 @@ module ActiveJob
|
|
31
31
|
# serialized without mutation are returned as-is. Arrays/Hashes are
|
32
32
|
# serialized element by element. All other types are serialized using
|
33
33
|
# GlobalID.
|
34
|
-
def serialize(
|
35
|
-
|
34
|
+
def serialize(argument)
|
35
|
+
case argument
|
36
|
+
when nil, true, false, Integer, Float # Types that can hardly be subclassed
|
37
|
+
argument
|
38
|
+
when String
|
39
|
+
if argument.class == String
|
40
|
+
argument
|
41
|
+
else
|
42
|
+
begin
|
43
|
+
Serializers.serialize(argument)
|
44
|
+
rescue SerializationError
|
45
|
+
argument
|
46
|
+
end
|
47
|
+
end
|
48
|
+
when Symbol
|
49
|
+
{ OBJECT_SERIALIZER_KEY => "ActiveJob::Serializers::SymbolSerializer", "value" => argument.name }
|
50
|
+
when GlobalID::Identification
|
51
|
+
convert_to_global_id_hash(argument)
|
52
|
+
when Array
|
53
|
+
argument.map { |arg| serialize(arg) }
|
54
|
+
when ActiveSupport::HashWithIndifferentAccess
|
55
|
+
serialize_indifferent_hash(argument)
|
56
|
+
when Hash
|
57
|
+
symbol_keys = argument.keys
|
58
|
+
symbol_keys.select! { |k| k.is_a?(Symbol) }
|
59
|
+
symbol_keys.map!(&:name)
|
60
|
+
|
61
|
+
aj_hash_key = if Hash.ruby2_keywords_hash?(argument)
|
62
|
+
RUBY2_KEYWORDS_KEY
|
63
|
+
else
|
64
|
+
SYMBOL_KEYS_KEY
|
65
|
+
end
|
66
|
+
result = serialize_hash(argument)
|
67
|
+
result[aj_hash_key] = symbol_keys
|
68
|
+
result
|
69
|
+
else
|
70
|
+
Serializers.serialize(argument)
|
71
|
+
end
|
36
72
|
end
|
37
73
|
|
38
74
|
# Deserializes a set of arguments. Intrinsic types that can safely be
|
@@ -64,49 +100,10 @@ module ActiveJob
|
|
64
100
|
RUBY2_KEYWORDS_KEY, RUBY2_KEYWORDS_KEY.to_sym,
|
65
101
|
OBJECT_SERIALIZER_KEY, OBJECT_SERIALIZER_KEY.to_sym,
|
66
102
|
WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym,
|
67
|
-
]
|
103
|
+
].to_set
|
68
104
|
private_constant :RESERVED_KEYS, :GLOBALID_KEY,
|
69
105
|
:SYMBOL_KEYS_KEY, :RUBY2_KEYWORDS_KEY, :WITH_INDIFFERENT_ACCESS_KEY
|
70
106
|
|
71
|
-
def serialize_argument(argument)
|
72
|
-
case argument
|
73
|
-
when nil, true, false, Integer, Float # Types that can hardly be subclassed
|
74
|
-
argument
|
75
|
-
when String
|
76
|
-
if argument.class == String
|
77
|
-
argument
|
78
|
-
else
|
79
|
-
begin
|
80
|
-
Serializers.serialize(argument)
|
81
|
-
rescue SerializationError
|
82
|
-
argument
|
83
|
-
end
|
84
|
-
end
|
85
|
-
when GlobalID::Identification
|
86
|
-
convert_to_global_id_hash(argument)
|
87
|
-
when Array
|
88
|
-
argument.map { |arg| serialize_argument(arg) }
|
89
|
-
when ActiveSupport::HashWithIndifferentAccess
|
90
|
-
serialize_indifferent_hash(argument)
|
91
|
-
when Hash
|
92
|
-
symbol_keys = argument.each_key.grep(Symbol).map!(&:to_s)
|
93
|
-
aj_hash_key = if Hash.ruby2_keywords_hash?(argument)
|
94
|
-
RUBY2_KEYWORDS_KEY
|
95
|
-
else
|
96
|
-
SYMBOL_KEYS_KEY
|
97
|
-
end
|
98
|
-
result = serialize_hash(argument)
|
99
|
-
result[aj_hash_key] = symbol_keys
|
100
|
-
result
|
101
|
-
else
|
102
|
-
if argument.respond_to?(:permitted?) && argument.respond_to?(:to_h)
|
103
|
-
serialize_indifferent_hash(argument.to_h)
|
104
|
-
else
|
105
|
-
Serializers.serialize(argument)
|
106
|
-
end
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
107
|
def deserialize_argument(argument)
|
111
108
|
case argument
|
112
109
|
when nil, true, false, String, Integer, Float
|
@@ -140,7 +137,7 @@ module ActiveJob
|
|
140
137
|
|
141
138
|
def serialize_hash(argument)
|
142
139
|
argument.each_with_object({}) do |(key, value), hash|
|
143
|
-
hash[serialize_hash_key(key)] =
|
140
|
+
hash[serialize_hash_key(key)] = serialize(value)
|
144
141
|
end
|
145
142
|
end
|
146
143
|
|
@@ -159,10 +156,12 @@ module ActiveJob
|
|
159
156
|
|
160
157
|
def serialize_hash_key(key)
|
161
158
|
case key
|
162
|
-
when
|
159
|
+
when RESERVED_KEYS
|
163
160
|
raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}")
|
164
|
-
when String
|
165
|
-
key
|
161
|
+
when String
|
162
|
+
key
|
163
|
+
when Symbol
|
164
|
+
key.name
|
166
165
|
else
|
167
166
|
raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}")
|
168
167
|
end
|
@@ -170,7 +169,7 @@ module ActiveJob
|
|
170
169
|
|
171
170
|
def serialize_indifferent_hash(indifferent_hash)
|
172
171
|
result = serialize_hash(indifferent_hash)
|
173
|
-
result[WITH_INDIFFERENT_ACCESS_KEY] =
|
172
|
+
result[WITH_INDIFFERENT_ACCESS_KEY] = true
|
174
173
|
result
|
175
174
|
end
|
176
175
|
|
data/lib/active_job/base.rb
CHANGED
@@ -11,8 +11,7 @@ require "active_job/exceptions"
|
|
11
11
|
require "active_job/log_subscriber"
|
12
12
|
require "active_job/logging"
|
13
13
|
require "active_job/instrumentation"
|
14
|
-
require "active_job/
|
15
|
-
require "active_job/translation"
|
14
|
+
require "active_job/execution_state"
|
16
15
|
|
17
16
|
module ActiveJob # :nodoc:
|
18
17
|
# = Active Job \Base
|
@@ -40,7 +39,7 @@ module ActiveJob # :nodoc:
|
|
40
39
|
# end
|
41
40
|
#
|
42
41
|
# Records that are passed in are serialized/deserialized using Global
|
43
|
-
# ID. More information can be found in Arguments.
|
42
|
+
# ID. More information can be found in ActiveJob::Arguments.
|
44
43
|
#
|
45
44
|
# To enqueue a job to be performed as soon as the queuing system is free:
|
46
45
|
#
|
@@ -50,7 +49,7 @@ module ActiveJob # :nodoc:
|
|
50
49
|
#
|
51
50
|
# ProcessPhotoJob.set(wait_until: Date.tomorrow.noon).perform_later(photo)
|
52
51
|
#
|
53
|
-
# More information can be found in ActiveJob::Core::ClassMethods#set
|
52
|
+
# More information can be found in ActiveJob::Core::ClassMethods#set.
|
54
53
|
#
|
55
54
|
# A job can also be processed immediately without sending to the queue:
|
56
55
|
#
|
@@ -71,8 +70,7 @@ module ActiveJob # :nodoc:
|
|
71
70
|
include Exceptions
|
72
71
|
include Instrumentation
|
73
72
|
include Logging
|
74
|
-
include
|
75
|
-
include Translation
|
73
|
+
include ExecutionState
|
76
74
|
|
77
75
|
ActiveSupport.run_load_hooks(:active_job, self)
|
78
76
|
end
|
@@ -12,11 +12,12 @@ module ActiveJob
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def perform_later(...)
|
15
|
-
@job_class.new(...)
|
16
|
-
|
15
|
+
job = @job_class.new(...)
|
16
|
+
enqueue_result = job.enqueue(@options)
|
17
|
+
|
18
|
+
yield job if block_given?
|
17
19
|
|
18
|
-
|
19
|
-
@job_class.perform_all_later(multi_args, options: @options)
|
20
|
+
enqueue_result
|
20
21
|
end
|
21
22
|
end
|
22
23
|
end
|
@@ -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
|