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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -27
  3. data/README.md +8 -6
  4. data/lib/active_job/arguments.rb +46 -47
  5. data/lib/active_job/base.rb +4 -6
  6. data/lib/active_job/configured_job.rb +5 -4
  7. data/lib/active_job/continuable.rb +102 -0
  8. data/lib/active_job/continuation/step.rb +83 -0
  9. data/lib/active_job/continuation/test_helper.rb +89 -0
  10. data/lib/active_job/continuation/validation.rb +50 -0
  11. data/lib/active_job/continuation.rb +332 -0
  12. data/lib/active_job/core.rb +12 -2
  13. data/lib/active_job/enqueue_after_transaction_commit.rb +1 -26
  14. data/lib/active_job/enqueuing.rb +8 -4
  15. data/lib/active_job/exceptions.rb +16 -6
  16. data/lib/active_job/execution_state.rb +11 -0
  17. data/lib/active_job/gem_version.rb +3 -3
  18. data/lib/active_job/instrumentation.rb +12 -12
  19. data/lib/active_job/log_subscriber.rb +61 -6
  20. data/lib/active_job/queue_adapters/abstract_adapter.rb +6 -0
  21. data/lib/active_job/queue_adapters/async_adapter.rb +5 -1
  22. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +19 -0
  23. data/lib/active_job/queue_adapters/test_adapter.rb +5 -1
  24. data/lib/active_job/railtie.rb +9 -19
  25. data/lib/active_job/serializers/action_controller_parameters_serializer.rb +25 -0
  26. data/lib/active_job/serializers/big_decimal_serializer.rb +3 -4
  27. data/lib/active_job/serializers/date_serializer.rb +3 -4
  28. data/lib/active_job/serializers/date_time_serializer.rb +3 -4
  29. data/lib/active_job/serializers/duration_serializer.rb +5 -6
  30. data/lib/active_job/serializers/module_serializer.rb +3 -4
  31. data/lib/active_job/serializers/object_serializer.rb +11 -13
  32. data/lib/active_job/serializers/range_serializer.rb +9 -9
  33. data/lib/active_job/serializers/symbol_serializer.rb +4 -5
  34. data/lib/active_job/serializers/time_serializer.rb +3 -4
  35. data/lib/active_job/serializers/time_with_zone_serializer.rb +3 -4
  36. data/lib/active_job/serializers.rb +46 -15
  37. data/lib/active_job.rb +2 -0
  38. metadata +13 -9
  39. data/lib/active_job/queue_adapters/sucker_punch_adapter.rb +0 -56
  40. data/lib/active_job/timezones.rb +0 -13
  41. data/lib/active_job/translation.rb +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c02e749103c9735fd2df2554980ae11eb1e79fe023f034528f6481f7d7b3bbd
4
- data.tar.gz: 4a8b3a0585aa1523ffd7404640ee7562f72ce85c1865c71da599b5c07136bcb6
3
+ metadata.gz: c9a034077d88450ca67c9f9921d2b9678d506724adeb387742be2be30b30b038
4
+ data.tar.gz: 6efdff593dfb0f7d0773a6a8419c084919346d1dc15c44619e58f5009886ad3b
5
5
  SHA512:
6
- metadata.gz: 9a6c0e84aca264905887fce990ba85938abefd87c97f03eabbc821c114b515f0d2eb5b91c30fa5c83b6feafa60977dedd44e9fac11f22494574f4f37d04ebba6
7
- data.tar.gz: 03ae4188df409f83db411a67353cfcca270f39642a989ebd044d352ed6207e681f018fee9124aa812022c0e3be114ac749d5db6737f9756d763abd6a08efaa52
6
+ metadata.gz: 7a6be4dfbb96daba9f49b695a5aa568420a446cb303bf3a9ba6083a1d3d4c3bb7663fb428107a2f0b3f954a2ba4b4c5781dad015f4d56b97201096d75ab4bf35
7
+ data.tar.gz: 4b01822ccd36668f128a6754b8ad9ea06d7874b2f3584c7fe44e54948df92552c6e93609ab7f2a688a20c23116078b0a84711ac1c851308657e438f0892ceb32
data/CHANGELOG.md CHANGED
@@ -1,60 +1,93 @@
1
- ## Rails 8.0.2.1 (August 13, 2025) ##
1
+ ## Rails 8.1.0.beta1 (September 04, 2025) ##
2
2
 
3
- * No changes.
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
- ## Rails 8.0.2 (March 12, 2025) ##
7
+ *fatkodima*
7
8
 
8
- * No changes.
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
- ## Rails 8.0.2 (March 12, 2025) ##
13
+ * Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`.
12
14
 
13
- * No changes.
15
+ *Rafael Mendonça França*
14
16
 
17
+ * Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`.
15
18
 
16
- ## Rails 8.0.1 (December 13, 2024) ##
19
+ *Rafael Mendonça França*
17
20
 
18
- * Avoid crashing in Active Job logger when logging enqueueing errors
21
+ * `ActiveJob::Serializers::ObjectSerializers#klass` method is now public.
19
22
 
20
- `ActiveJob.perform_all_later` could fail with a `TypeError` when all
21
- provided jobs failed to be enqueueed.
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
- *Efstathios Stivaros*
26
+ *Jean Boussier*
24
27
 
28
+ * Allow jobs to the interrupted and resumed with Continuations
25
29
 
26
- ## Rails 8.0.0.1 (December 10, 2024) ##
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
- * No changes.
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
- ## Rails 8.0.0 (November 07, 2024) ##
41
+ # block format
42
+ step :initialize do
43
+ @import.initialize
44
+ end
32
45
 
33
- * No changes.
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
- ## Rails 8.0.0.rc2 (October 30, 2024) ##
57
+ private
58
+ def finalize
59
+ @import.finalize
60
+ end
61
+ end
62
+ end
63
+ ```
37
64
 
38
- * No changes.
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
- ## Rails 8.0.0.rc1 (October 19, 2024) ##
70
+ *Will Roever*
42
71
 
43
- * Remove deprecated `config.active_job.use_big_decimal_serializer`.
72
+ * Add `report:` option to `ActiveJob::Base#retry_on` and `#discard_on`
44
73
 
45
- *Rafael Mendonça França*
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
- ## Rails 8.0.0.beta1 (September 26, 2024) ##
79
+ * Accept a block for `ActiveJob::ConfiguredJob#perform_later`.
49
80
 
50
- * Deprecate `sucker_punch` as an adapter option.
81
+ This was inconsistent with a regular `ActiveJob::Base#perform_later`.
51
82
 
52
- If you're using this adapter, change to `adapter: async` for the same functionality.
83
+ *fatkodima*
53
84
 
54
- *Dino Maric, zzak*
85
+ * Raise a more specific error during deserialization when a previously serialized job class is now unknown.
55
86
 
56
- * Use `RAILS_MAX_THREADS` in `ActiveJob::AsyncAdapter`. If it is not set, use 5 as default.
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
- *heka1024*
91
+ *Earlopain*
59
92
 
60
- Please check [7-2-stable](https://github.com/rails/rails/blob/7-2-stable/activejob/CHANGELOG.md) for previous changes.
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. We
93
- encourage library authors to provide an ActiveJob adapter as part of
94
- their gem, or as a stand-alone gem. For discussion about this see the
95
- following PRs: [23311](https://github.com/rails/rails/issues/23311#issuecomment-176275718),
96
- [21406](https://github.com/rails/rails/pull/21406#issuecomment-138813484), and [#32285](https://github.com/rails/rails/pull/32285).
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 rails-core mailing list here:
131
+ Feature requests should be discussed on the rubyonrails-core forum here:
130
132
 
131
133
  * https://discuss.rubyonrails.org/c/rubyonrails-core
@@ -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(arguments)
35
- arguments.map { |argument| serialize_argument(argument) }
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)] = serialize_argument(value)
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 *RESERVED_KEYS
159
+ when RESERVED_KEYS
163
160
  raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}")
164
- when String, Symbol
165
- key.to_s
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] = serialize_argument(true)
172
+ result[WITH_INDIFFERENT_ACCESS_KEY] = true
174
173
  result
175
174
  end
176
175
 
@@ -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/timezones"
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 Timezones
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(...).enqueue @options
16
- end
15
+ job = @job_class.new(...)
16
+ enqueue_result = job.enqueue(@options)
17
+
18
+ yield job if block_given?
17
19
 
18
- def perform_all_later(multi_args)
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