activejob 8.0.3 → 8.1.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -31
  3. data/README.md +7 -5
  4. data/lib/active_job/arguments.rb +47 -44
  5. data/lib/active_job/base.rb +3 -4
  6. data/lib/active_job/configured_job.rb +6 -1
  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 +11 -1
  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 +17 -8
  16. data/lib/active_job/execution_state.rb +11 -0
  17. data/lib/active_job/gem_version.rb +2 -2
  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 -14
  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 +54 -15
  37. data/lib/active_job/structured_event_subscriber.rb +220 -0
  38. data/lib/active_job.rb +3 -0
  39. metadata +14 -9
  40. data/lib/active_job/queue_adapters/sucker_punch_adapter.rb +0 -56
  41. data/lib/active_job/timezones.rb +0 -13
  42. data/lib/active_job/translation.rb +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd83b074127046f406525380f3021fae2cd3ddb36ce9ae8ef44768e63a315b39
4
- data.tar.gz: 6df6eb7459e16314d5819aac1966f9ecf596ed17c5590d4229b92de444f70d4c
3
+ metadata.gz: bc270930c258d16e62b3f01ee4ed3591e91b680ddea4c43ddba1870206bda41f
4
+ data.tar.gz: 6bc1d66fb46babd36a3959754021ddbc6d115fbeef7e78d025b4880e48c63db1
5
5
  SHA512:
6
- metadata.gz: d59029dbd69e9b803af9319161888de3569574b6105e0550bda39f0632b7686f97fbac1c0f4694c00b7e9d76bc17f70570a88e7262ae1c5a6864d810fbce02e8
7
- data.tar.gz: c7ac883680b1cbe1f7da6b6aa9243ae33dbed2c296ebdaba279349af6f87fe6b9fbf148923f849e578deb76fe4804d401c632bbaac2860f0cbd6bbe458fc90c4
6
+ metadata.gz: aed3444d15f38a03c9df4321d898958c8c4611c427dd4ad6ada5d14804db2dced96aa035d98e3d777c0280d1c7c0433a1888cb930ff55692882eb1dbecfb729e
7
+ data.tar.gz: 47396127fb20f53dc5ec782619913145fa911128b04ab3e8f80dc52d4b3071634653f49c6271f86a1fe1ccf58595d1dd6a7fc0453d37eb2a3a9a250fa601ded8
data/CHANGELOG.md CHANGED
@@ -1,66 +1,109 @@
1
- ## Rails 8.0.3 (September 22, 2025) ##
1
+ ## Rails 8.1.0 (October 22, 2025) ##
2
2
 
3
- * Include the actual Active Job locale when serializing rather than I18n locale.
3
+ * Add structured events for Active Job:
4
+ - `active_job.enqueued`
5
+ - `active_job.bulk_enqueued`
6
+ - `active_job.started`
7
+ - `active_job.completed`
8
+ - `active_job.retry_scheduled`
9
+ - `active_job.retry_stopped`
10
+ - `active_job.discarded`
11
+ - `active_job.interrupt`
12
+ - `active_job.resume`
13
+ - `active_job.step_skipped`
14
+ - `active_job.step_started`
15
+ - `active_job.step`
4
16
 
5
- *Adrien S*
17
+ *Adrianna Chang*
6
18
 
7
- * Fix `retry_job` instrumentation when using `:test` adapter for Active Job.
8
-
9
- *fatkodima*
19
+ * Deprecate built-in `sidekiq` adapter.
10
20
 
21
+ If you're using this adapter, upgrade to `sidekiq` 7.3.3 or later to use the `sidekiq` gem's adapter.
11
22
 
12
- ## Rails 8.0.2.1 (August 13, 2025) ##
23
+ *fatkodima*
13
24
 
14
- * No changes.
25
+ * Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem.
15
26
 
27
+ *Rafael Mendonça França*
16
28
 
17
- ## Rails 8.0.2 (March 12, 2025) ##
29
+ * Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`.
18
30
 
19
- * No changes.
31
+ *Rafael Mendonça França*
20
32
 
33
+ * Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`.
21
34
 
22
- ## Rails 8.0.1 (December 13, 2024) ##
35
+ *Rafael Mendonça França*
23
36
 
24
- * Avoid crashing in Active Job logger when logging enqueueing errors
37
+ * `ActiveJob::Serializers::ObjectSerializers#klass` method is now public.
25
38
 
26
- `ActiveJob.perform_all_later` could fail with a `TypeError` when all
27
- provided jobs failed to be enqueueed.
39
+ Custom Active Job serializers must have a public `#klass` method too.
40
+ The returned class will be index allowing for faster serialization.
28
41
 
29
- *Efstathios Stivaros*
42
+ *Jean Boussier*
30
43
 
44
+ * Allow jobs to the interrupted and resumed with Continuations
31
45
 
32
- ## Rails 8.0.0.1 (December 10, 2024) ##
46
+ A job can use Continuations by including the `ActiveJob::Continuable`
47
+ concern. Continuations split jobs into steps. When the queuing system
48
+ is shutting down jobs can be interrupted and their progress saved.
33
49
 
34
- * No changes.
50
+ ```ruby
51
+ class ProcessImportJob
52
+ include ActiveJob::Continuable
35
53
 
54
+ def perform(import_id)
55
+ @import = Import.find(import_id)
36
56
 
37
- ## Rails 8.0.0 (November 07, 2024) ##
57
+ # block format
58
+ step :initialize do
59
+ @import.initialize
60
+ end
38
61
 
39
- * No changes.
62
+ # step with cursor, the cursor is saved when the job is interrupted
63
+ step :process do |step|
64
+ @import.records.find_each(start: step.cursor) do |record|
65
+ record.process
66
+ step.advance! from: record.id
67
+ end
68
+ end
40
69
 
70
+ # method format
71
+ step :finalize
41
72
 
42
- ## Rails 8.0.0.rc2 (October 30, 2024) ##
73
+ private
74
+ def finalize
75
+ @import.finalize
76
+ end
77
+ end
78
+ end
79
+ ```
43
80
 
44
- * No changes.
81
+ *Donal McBreen*
45
82
 
83
+ * Defer invocation of ActiveJob enqueue callbacks until after commit when
84
+ `enqueue_after_transaction_commit` is enabled.
46
85
 
47
- ## Rails 8.0.0.rc1 (October 19, 2024) ##
86
+ *Will Roever*
48
87
 
49
- * Remove deprecated `config.active_job.use_big_decimal_serializer`.
88
+ * Add `report:` option to `ActiveJob::Base#retry_on` and `#discard_on`
50
89
 
51
- *Rafael Mendonça França*
90
+ When the `report:` option is passed, errors will be reported to the error reporter
91
+ before being retried / discarded.
52
92
 
93
+ *Andrew Novoselac*
53
94
 
54
- ## Rails 8.0.0.beta1 (September 26, 2024) ##
95
+ * Accept a block for `ActiveJob::ConfiguredJob#perform_later`.
55
96
 
56
- * Deprecate `sucker_punch` as an adapter option.
97
+ This was inconsistent with a regular `ActiveJob::Base#perform_later`.
57
98
 
58
- If you're using this adapter, change to `adapter: async` for the same functionality.
99
+ *fatkodima*
59
100
 
60
- *Dino Maric, zzak*
101
+ * Raise a more specific error during deserialization when a previously serialized job class is now unknown.
61
102
 
62
- * Use `RAILS_MAX_THREADS` in `ActiveJob::AsyncAdapter`. If it is not set, use 5 as default.
103
+ `ActiveJob::UnknownJobClassError` will be raised instead of a more generic
104
+ `NameError` to make it easily possible for adapters to tell if the `NameError`
105
+ was raised during job execution or deserialization.
63
106
 
64
- *heka1024*
107
+ *Earlopain*
65
108
 
66
- Please check [7-2-stable](https://github.com/rails/rails/blob/7-2-stable/activejob/CHANGELOG.md) for previous changes.
109
+ 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
@@ -35,6 +35,46 @@ module ActiveJob
35
35
  arguments.map { |argument| serialize_argument(argument) }
36
36
  end
37
37
 
38
+ def serialize_argument(argument) # :nodoc:
39
+ case argument
40
+ when nil, true, false, Integer, Float # Types that can hardly be subclassed
41
+ argument
42
+ when String
43
+ if argument.class == String
44
+ argument
45
+ else
46
+ begin
47
+ Serializers.serialize(argument)
48
+ rescue SerializationError
49
+ argument
50
+ end
51
+ end
52
+ when Symbol
53
+ { OBJECT_SERIALIZER_KEY => "ActiveJob::Serializers::SymbolSerializer", "value" => argument.name }
54
+ when GlobalID::Identification
55
+ convert_to_global_id_hash(argument)
56
+ when Array
57
+ argument.map { |arg| serialize_argument(arg) }
58
+ when ActiveSupport::HashWithIndifferentAccess
59
+ serialize_indifferent_hash(argument)
60
+ when Hash
61
+ symbol_keys = argument.keys
62
+ symbol_keys.select! { |k| k.is_a?(Symbol) }
63
+ symbol_keys.map!(&:name)
64
+
65
+ aj_hash_key = if Hash.ruby2_keywords_hash?(argument)
66
+ RUBY2_KEYWORDS_KEY
67
+ else
68
+ SYMBOL_KEYS_KEY
69
+ end
70
+ result = serialize_hash(argument)
71
+ result[aj_hash_key] = symbol_keys
72
+ result
73
+ else
74
+ Serializers.serialize(argument)
75
+ end
76
+ end
77
+
38
78
  # Deserializes a set of arguments. Intrinsic types that can safely be
39
79
  # deserialized without mutation are returned as-is. Arrays/Hashes are
40
80
  # deserialized element by element. All other types are deserialized using
@@ -64,49 +104,10 @@ module ActiveJob
64
104
  RUBY2_KEYWORDS_KEY, RUBY2_KEYWORDS_KEY.to_sym,
65
105
  OBJECT_SERIALIZER_KEY, OBJECT_SERIALIZER_KEY.to_sym,
66
106
  WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym,
67
- ]
107
+ ].to_set
68
108
  private_constant :RESERVED_KEYS, :GLOBALID_KEY,
69
109
  :SYMBOL_KEYS_KEY, :RUBY2_KEYWORDS_KEY, :WITH_INDIFFERENT_ACCESS_KEY
70
110
 
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
111
  def deserialize_argument(argument)
111
112
  case argument
112
113
  when nil, true, false, String, Integer, Float
@@ -159,10 +160,12 @@ module ActiveJob
159
160
 
160
161
  def serialize_hash_key(key)
161
162
  case key
162
- when *RESERVED_KEYS
163
+ when RESERVED_KEYS
163
164
  raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}")
164
- when String, Symbol
165
- key.to_s
165
+ when String
166
+ key
167
+ when Symbol
168
+ key.name
166
169
  else
167
170
  raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}")
168
171
  end
@@ -170,7 +173,7 @@ module ActiveJob
170
173
 
171
174
  def serialize_indifferent_hash(indifferent_hash)
172
175
  result = serialize_hash(indifferent_hash)
173
- result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true)
176
+ result[WITH_INDIFFERENT_ACCESS_KEY] = true
174
177
  result
175
178
  end
176
179
 
@@ -9,10 +9,10 @@ require "active_job/execution"
9
9
  require "active_job/callbacks"
10
10
  require "active_job/exceptions"
11
11
  require "active_job/log_subscriber"
12
+ require "active_job/structured_event_subscriber"
12
13
  require "active_job/logging"
13
14
  require "active_job/instrumentation"
14
- require "active_job/timezones"
15
- require "active_job/translation"
15
+ require "active_job/execution_state"
16
16
 
17
17
  module ActiveJob # :nodoc:
18
18
  # = Active Job \Base
@@ -71,8 +71,7 @@ module ActiveJob # :nodoc:
71
71
  include Exceptions
72
72
  include Instrumentation
73
73
  include Logging
74
- include Timezones
75
- include Translation
74
+ include ExecutionState
76
75
 
77
76
  ActiveSupport.run_load_hooks(:active_job, self)
78
77
  end
@@ -12,7 +12,12 @@ module ActiveJob
12
12
  end
13
13
 
14
14
  def perform_later(...)
15
- @job_class.new(...).enqueue @options
15
+ job = @job_class.new(...)
16
+ enqueue_result = job.enqueue(@options)
17
+
18
+ yield job if block_given?
19
+
20
+ enqueue_result
16
21
  end
17
22
  end
18
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