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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -264
  3. data/README.md +8 -6
  4. data/lib/active_job/arguments.rb +51 -59
  5. data/lib/active_job/base.rb +5 -6
  6. data/lib/active_job/callbacks.rb +0 -3
  7. data/lib/active_job/configured_job.rb +5 -4
  8. data/lib/active_job/continuable.rb +102 -0
  9. data/lib/active_job/continuation/step.rb +83 -0
  10. data/lib/active_job/continuation/test_helper.rb +89 -0
  11. data/lib/active_job/continuation/validation.rb +50 -0
  12. data/lib/active_job/continuation.rb +332 -0
  13. data/lib/active_job/core.rb +25 -22
  14. data/lib/active_job/enqueue_after_transaction_commit.rb +38 -0
  15. data/lib/active_job/enqueuing.rb +46 -12
  16. data/lib/active_job/exceptions.rb +21 -17
  17. data/lib/active_job/execution_state.rb +11 -0
  18. data/lib/active_job/gem_version.rb +3 -3
  19. data/lib/active_job/instrumentation.rb +12 -12
  20. data/lib/active_job/log_subscriber.rb +65 -6
  21. data/lib/active_job/logging.rb +16 -2
  22. data/lib/active_job/queue_adapter.rb +6 -4
  23. data/lib/active_job/queue_adapters/abstract_adapter.rb +25 -0
  24. data/lib/active_job/queue_adapters/async_adapter.rb +7 -3
  25. data/lib/active_job/queue_adapters/backburner_adapter.rb +1 -1
  26. data/lib/active_job/queue_adapters/delayed_job_adapter.rb +1 -1
  27. data/lib/active_job/queue_adapters/inline_adapter.rb +1 -1
  28. data/lib/active_job/queue_adapters/queue_classic_adapter.rb +1 -1
  29. data/lib/active_job/queue_adapters/resque_adapter.rb +1 -1
  30. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +20 -1
  31. data/lib/active_job/queue_adapters/sneakers_adapter.rb +1 -1
  32. data/lib/active_job/queue_adapters/test_adapter.rb +6 -2
  33. data/lib/active_job/queue_adapters.rb +1 -4
  34. data/lib/active_job/railtie.rb +25 -4
  35. data/lib/active_job/serializers/action_controller_parameters_serializer.rb +25 -0
  36. data/lib/active_job/serializers/big_decimal_serializer.rb +3 -4
  37. data/lib/active_job/serializers/date_serializer.rb +3 -4
  38. data/lib/active_job/serializers/date_time_serializer.rb +3 -4
  39. data/lib/active_job/serializers/duration_serializer.rb +5 -6
  40. data/lib/active_job/serializers/module_serializer.rb +3 -4
  41. data/lib/active_job/serializers/object_serializer.rb +13 -14
  42. data/lib/active_job/serializers/range_serializer.rb +9 -9
  43. data/lib/active_job/serializers/symbol_serializer.rb +4 -5
  44. data/lib/active_job/serializers/time_serializer.rb +3 -4
  45. data/lib/active_job/serializers/time_with_zone_serializer.rb +3 -4
  46. data/lib/active_job/serializers.rb +62 -18
  47. data/lib/active_job/structured_event_subscriber.rb +220 -0
  48. data/lib/active_job/test_helper.rb +28 -5
  49. data/lib/active_job.rb +5 -9
  50. metadata +18 -11
  51. data/lib/active_job/queue_adapters/sucker_punch_adapter.rb +0 -49
  52. data/lib/active_job/timezones.rb +0 -13
  53. 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"