good_job 2.1.0 → 2.4.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +32 -0
  4. data/engine/app/assets/vendor/bootstrap/bootstrap.bundle.min.js +2 -2
  5. data/engine/app/assets/vendor/bootstrap/bootstrap.min.css +2 -2
  6. data/engine/app/controllers/good_job/cron_schedules_controller.rb +9 -0
  7. data/engine/app/controllers/good_job/executions_controller.rb +14 -0
  8. data/engine/app/controllers/good_job/jobs_controller.rb +8 -4
  9. data/engine/app/filters/good_job/base_filter.rb +101 -0
  10. data/engine/app/filters/good_job/executions_filter.rb +40 -0
  11. data/engine/app/filters/good_job/jobs_filter.rb +46 -0
  12. data/engine/app/helpers/good_job/application_helper.rb +4 -0
  13. data/engine/app/models/good_job/active_job_job.rb +127 -0
  14. data/engine/app/views/good_job/cron_schedules/index.html.erb +50 -0
  15. data/engine/app/views/good_job/executions/index.html.erb +21 -0
  16. data/engine/app/views/good_job/jobs/index.html.erb +7 -0
  17. data/engine/app/views/good_job/jobs/show.html.erb +3 -0
  18. data/engine/app/views/good_job/shared/_executions_table.erb +56 -0
  19. data/engine/app/views/good_job/shared/_filter.erb +52 -0
  20. data/engine/app/views/good_job/shared/_jobs_table.erb +19 -11
  21. data/engine/app/views/layouts/good_job/base.html.erb +13 -4
  22. data/engine/config/routes.rb +4 -3
  23. data/lib/good_job/active_job_extensions/concurrency.rb +6 -6
  24. data/lib/good_job/adapter.rb +10 -10
  25. data/lib/good_job/cron_manager.rb +3 -3
  26. data/lib/good_job/{current_execution.rb → current_thread.rb} +8 -8
  27. data/lib/good_job/execution.rb +308 -0
  28. data/lib/good_job/job.rb +6 -294
  29. data/lib/good_job/job_performer.rb +2 -2
  30. data/lib/good_job/log_subscriber.rb +4 -4
  31. data/lib/good_job/notifier.rb +3 -3
  32. data/lib/good_job/railtie.rb +2 -2
  33. data/lib/good_job/scheduler.rb +3 -3
  34. data/lib/good_job/version.rb +1 -1
  35. data/lib/good_job.rb +2 -2
  36. metadata +16 -7
  37. data/engine/app/controllers/good_job/active_jobs_controller.rb +0 -9
  38. data/engine/app/controllers/good_job/dashboards_controller.rb +0 -106
  39. data/engine/app/views/good_job/active_jobs/show.html.erb +0 -1
  40. data/engine/app/views/good_job/dashboards/index.html.erb +0 -54
@@ -18,7 +18,7 @@ module GoodJob
18
18
  next(block.call) unless job.class.queue_adapter.is_a?(GoodJob::Adapter)
19
19
 
20
20
  # Always allow jobs to be retried because the current job's execution will complete momentarily
21
- next(block.call) if CurrentExecution.active_job_id == job.job_id
21
+ next(block.call) if CurrentThread.active_job_id == job.job_id
22
22
 
23
23
  enqueue_limit = job.class.good_job_concurrency_config[:enqueue_limit]
24
24
  total_limit = job.class.good_job_concurrency_config[:total_limit]
@@ -30,12 +30,12 @@ module GoodJob
30
30
  key = job.good_job_concurrency_key
31
31
  next(block.call) if key.blank?
32
32
 
33
- GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
33
+ GoodJob::Execution.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
34
34
  enqueue_concurrency = if enqueue_limit
35
35
  # TODO: Why is `unscoped` necessary? Nested scope is bleeding into subsequent query?
36
- GoodJob::Job.unscoped.where(concurrency_key: key).unfinished.advisory_unlocked.count
36
+ GoodJob::Execution.unscoped.where(concurrency_key: key).unfinished.advisory_unlocked.count
37
37
  else
38
- GoodJob::Job.unscoped.where(concurrency_key: key).unfinished.count
38
+ GoodJob::Execution.unscoped.where(concurrency_key: key).unfinished.count
39
39
  end
40
40
 
41
41
  # The job has not yet been enqueued, so check if adding it will go over the limit
@@ -62,8 +62,8 @@ module GoodJob
62
62
  key = job.good_job_concurrency_key
63
63
  next if key.blank?
64
64
 
65
- GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
66
- allowed_active_job_ids = GoodJob::Job.unscoped.where(concurrency_key: key).advisory_locked.order(Arel.sql("COALESCE(performed_at, scheduled_at, created_at) ASC")).limit(perform_limit).pluck(:active_job_id)
65
+ GoodJob::Execution.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
66
+ allowed_active_job_ids = GoodJob::Execution.unscoped.where(concurrency_key: key).advisory_locked.order(Arel.sql("COALESCE(performed_at, scheduled_at, created_at) ASC")).limit(perform_limit).pluck(:active_job_id)
67
67
  # The current job has already been locked and will appear in the previous query
68
68
  raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError unless allowed_active_job_ids.include? job.job_id
69
69
  end
@@ -45,7 +45,7 @@ module GoodJob
45
45
  # Enqueues the ActiveJob job to be performed.
46
46
  # For use by Rails; you should generally not call this directly.
47
47
  # @param active_job [ActiveJob::Base] the job to be enqueued from +#perform_later+
48
- # @return [GoodJob::Job]
48
+ # @return [GoodJob::Execution]
49
49
  def enqueue(active_job)
50
50
  enqueue_at(active_job, nil)
51
51
  end
@@ -54,9 +54,9 @@ module GoodJob
54
54
  # For use by Rails; you should generally not call this directly.
55
55
  # @param active_job [ActiveJob::Base] the job to be enqueued from +#perform_later+
56
56
  # @param timestamp [Integer, nil] the epoch time to perform the job
57
- # @return [GoodJob::Job]
57
+ # @return [GoodJob::Execution]
58
58
  def enqueue_at(active_job, timestamp)
59
- good_job = GoodJob::Job.enqueue(
59
+ execution = GoodJob::Execution.enqueue(
60
60
  active_job,
61
61
  scheduled_at: timestamp ? Time.zone.at(timestamp) : nil,
62
62
  create_with_advisory_lock: execute_inline?
@@ -64,19 +64,19 @@ module GoodJob
64
64
 
65
65
  if execute_inline?
66
66
  begin
67
- good_job.perform
67
+ execution.perform
68
68
  ensure
69
- good_job.advisory_unlock
69
+ execution.advisory_unlock
70
70
  end
71
71
  else
72
- job_state = { queue_name: good_job.queue_name }
73
- job_state[:scheduled_at] = good_job.scheduled_at if good_job.scheduled_at
72
+ job_state = { queue_name: execution.queue_name }
73
+ job_state[:scheduled_at] = execution.scheduled_at if execution.scheduled_at
74
74
 
75
75
  executed_locally = execute_async? && @scheduler.create_thread(job_state)
76
76
  Notifier.notify(job_state) unless executed_locally
77
77
  end
78
78
 
79
- good_job
79
+ execution
80
80
  end
81
81
 
82
82
  # Shut down the thread pool executors.
@@ -101,14 +101,14 @@ module GoodJob
101
101
  # @return [Boolean]
102
102
  def execute_async?
103
103
  @configuration.execution_mode == :async_all ||
104
- @configuration.execution_mode.in?([:async, :async_server]) && in_server_process?
104
+ (@configuration.execution_mode.in?([:async, :async_server]) && in_server_process?)
105
105
  end
106
106
 
107
107
  # Whether in +:external+ execution mode.
108
108
  # @return [Boolean]
109
109
  def execute_externally?
110
110
  @configuration.execution_mode == :external ||
111
- @configuration.execution_mode.in?([:async, :async_server]) && !in_server_process?
111
+ (@configuration.execution_mode.in?([:async, :async_server]) && !in_server_process?)
112
112
  end
113
113
 
114
114
  # Whether in +:inline+ execution mode.
@@ -24,7 +24,7 @@ module GoodJob # :nodoc:
24
24
  GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
25
25
  end
26
26
 
27
- # Job configuration to be scheduled
27
+ # Execution configuration to be scheduled
28
28
  # @return [Hash]
29
29
  attr_reader :schedules
30
30
 
@@ -90,8 +90,8 @@ module GoodJob # :nodoc:
90
90
  # Re-schedule the next cron task before executing the current task
91
91
  thr_scheduler.create_task(thr_cron_key)
92
92
 
93
- CurrentExecution.reset
94
- CurrentExecution.cron_key = thr_cron_key
93
+ CurrentThread.reset
94
+ CurrentThread.cron_key = thr_cron_key
95
95
 
96
96
  Rails.application.executor.wrap do
97
97
  schedule = thr_scheduler.schedules.fetch(thr_cron_key).with_indifferent_access
@@ -4,7 +4,7 @@ require 'active_support/core_ext/module/attribute_accessors_per_thread'
4
4
  module GoodJob
5
5
  # Thread-local attributes for passing values from Instrumentation.
6
6
  # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
7
- module CurrentExecution
7
+ module CurrentThread
8
8
  # @!attribute [rw] cron_key
9
9
  # @!scope class
10
10
  # Cron Key
@@ -23,24 +23,24 @@ module GoodJob
23
23
  # @return [Exception, nil]
24
24
  thread_mattr_accessor :error_on_retry
25
25
 
26
- # @!attribute [rw] good_job
26
+ # @!attribute [rw] executions
27
27
  # @!scope class
28
- # Cron Key
29
- # @return [GoodJob::Job, nil]
30
- thread_mattr_accessor :good_job
28
+ # Execution
29
+ # @return [GoodJob::Execution, nil]
30
+ thread_mattr_accessor :execution
31
31
 
32
32
  # Resets attributes
33
33
  # @return [void]
34
34
  def self.reset
35
35
  self.cron_key = nil
36
- self.good_job = nil
36
+ self.execution = nil
37
37
  self.error_on_discard = nil
38
38
  self.error_on_retry = nil
39
39
  end
40
40
 
41
- # @return [String] UUID of the currently executing GoodJob::Job
41
+ # @return [String] UUID of the currently executing GoodJob::Execution
42
42
  def self.active_job_id
43
- good_job&.active_job_id
43
+ execution&.active_job_id
44
44
  end
45
45
 
46
46
  # @return [Integer] Current process ID
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+ module GoodJob
3
+ # ActiveRecord model that represents an +ActiveJob+ job.
4
+ # Parent class can be configured with +GoodJob.active_record_parent_class+.
5
+ # @!parse
6
+ # class Execution < ActiveRecord::Base; end
7
+ class Execution < Object.const_get(GoodJob.active_record_parent_class)
8
+ include Lockable
9
+
10
+ # Raised if something attempts to execute a previously completed Execution again.
11
+ PreviouslyPerformedError = Class.new(StandardError)
12
+
13
+ # ActiveJob jobs without a +queue_name+ attribute are placed on this queue.
14
+ DEFAULT_QUEUE_NAME = 'default'
15
+ # ActiveJob jobs without a +priority+ attribute are given this priority.
16
+ DEFAULT_PRIORITY = 0
17
+
18
+ self.table_name = 'good_jobs'
19
+ self.advisory_lockable_column = 'active_job_id'
20
+
21
+ # Parse a string representing a group of queues into a more readable data
22
+ # structure.
23
+ # @param string [String] Queue string
24
+ # @return [Hash]
25
+ # How to match a given queue. It can have the following keys and values:
26
+ # - +{ all: true }+ indicates that all queues match.
27
+ # - +{ exclude: Array<String> }+ indicates the listed queue names should
28
+ # not match.
29
+ # - +{ include: Array<String> }+ indicates the listed queue names should
30
+ # match.
31
+ # @example
32
+ # GoodJob::Execution.queue_parser('-queue1,queue2')
33
+ # => { exclude: [ 'queue1', 'queue2' ] }
34
+ def self.queue_parser(string)
35
+ string = string.presence || '*'
36
+
37
+ if string.first == '-'
38
+ exclude_queues = true
39
+ string = string[1..-1]
40
+ end
41
+
42
+ queues = string.split(',').map(&:strip)
43
+
44
+ if queues.include?('*')
45
+ { all: true }
46
+ elsif exclude_queues
47
+ { exclude: queues }
48
+ else
49
+ { include: queues }
50
+ end
51
+ end
52
+
53
+ # Get Jobs with given ActiveJob ID
54
+ # @!method active_job_id
55
+ # @!scope class
56
+ # @param active_job_id [String]
57
+ # ActiveJob ID
58
+ # @return [ActiveRecord::Relation]
59
+ scope :active_job_id, ->(active_job_id) { where(active_job_id: active_job_id) }
60
+
61
+ # Get Jobs with given class name
62
+ # @!method job_class
63
+ # @!scope class
64
+ # @param string [String]
65
+ # Execution class name
66
+ # @return [ActiveRecord::Relation]
67
+ scope :job_class, ->(job_class) { where("serialized_params->>'job_class' = ?", job_class) }
68
+
69
+ # Get Jobs that have not yet been completed.
70
+ # @!method unfinished
71
+ # @!scope class
72
+ # @return [ActiveRecord::Relation]
73
+ scope :unfinished, -> { where(finished_at: nil) }
74
+
75
+ # Get Jobs that are not scheduled for a later time than now (i.e. jobs that
76
+ # are not scheduled or scheduled for earlier than the current time).
77
+ # @!method only_scheduled
78
+ # @!scope class
79
+ # @return [ActiveRecord::Relation]
80
+ scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(Time.current)).or(where(scheduled_at: nil)) }
81
+
82
+ # Order jobs by priority (highest priority first).
83
+ # @!method priority_ordered
84
+ # @!scope class
85
+ # @return [ActiveRecord::Relation]
86
+ scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
87
+
88
+ # Order jobs by scheduled (unscheduled or soonest first).
89
+ # @!method schedule_ordered
90
+ # @!scope class
91
+ # @return [ActiveRecord::Relation]
92
+ scope :schedule_ordered, -> { order(Arel.sql('COALESCE(scheduled_at, created_at) ASC')) }
93
+
94
+ # Get Jobs were completed before the given timestamp. If no timestamp is
95
+ # provided, get all jobs that have been completed. By default, GoodJob
96
+ # deletes jobs after they are completed and this will find no jobs.
97
+ # However, if you have changed {GoodJob.preserve_job_records}, this may
98
+ # find completed Jobs.
99
+ # @!method finished(timestamp = nil)
100
+ # @!scope class
101
+ # @param timestamp (Float)
102
+ # Get jobs that finished before this time (in epoch time).
103
+ # @return [ActiveRecord::Relation]
104
+ scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
105
+
106
+ # Get Jobs that started but not finished yet.
107
+ # @!method running
108
+ # @!scope class
109
+ # @return [ActiveRecord::Relation]
110
+ scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
111
+
112
+ # Get Jobs that do not have subsequent retries
113
+ # @!method running
114
+ # @!scope class
115
+ # @return [ActiveRecord::Relation]
116
+ scope :head, -> { where(retried_good_job_id: nil) }
117
+
118
+ # Get Jobs have errored that will not be retried further
119
+ # @!method running
120
+ # @!scope class
121
+ # @return [ActiveRecord::Relation]
122
+ scope :dead, -> { head.where.not(error: nil) }
123
+
124
+ # Get Jobs on queues that match the given queue string.
125
+ # @!method queue_string(string)
126
+ # @!scope class
127
+ # @param string [String]
128
+ # A string expression describing what queues to select. See
129
+ # {Execution.queue_parser} or
130
+ # {file:README.md#optimize-queues-threads-and-processes} for more details
131
+ # on the format of the string. Note this only handles individual
132
+ # semicolon-separated segments of that string format.
133
+ # @return [ActiveRecord::Relation]
134
+ scope :queue_string, (lambda do |string|
135
+ parsed = queue_parser(string)
136
+
137
+ if parsed[:all]
138
+ all
139
+ elsif parsed[:exclude]
140
+ where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
141
+ elsif parsed[:include]
142
+ where(queue_name: parsed[:include])
143
+ end
144
+ end)
145
+
146
+ # Get Jobs in display order with optional keyset pagination.
147
+ # @!method display_all(after_scheduled_at: nil, after_id: nil)
148
+ # @!scope class
149
+ # @param after_scheduled_at [DateTime, String, nil]
150
+ # Display records scheduled after this time for keyset pagination
151
+ # @param after_id [Numeric, String, nil]
152
+ # Display records after this ID for keyset pagination
153
+ # @return [ActiveRecord::Relation]
154
+ scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
155
+ query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
156
+ if after_scheduled_at.present? && after_id.present?
157
+ query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at), id) < (:after_scheduled_at, :after_id)'), after_scheduled_at: after_scheduled_at, after_id: after_id)
158
+ elsif after_scheduled_at.present?
159
+ query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
160
+ end
161
+ query
162
+ end)
163
+
164
+ # Finds the next eligible Execution, acquire an advisory lock related to it, and
165
+ # executes the job.
166
+ # @return [ExecutionResult, nil]
167
+ # If a job was executed, returns an array with the {Execution} record, the
168
+ # return value for the job's +#perform+ method, and the exception the job
169
+ # raised, if any (if the job raised, then the second array entry will be
170
+ # +nil+). If there were no jobs to execute, returns +nil+.
171
+ def self.perform_with_advisory_lock
172
+ unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock(unlock_session: true) do |executions|
173
+ execution = executions.first
174
+ break if execution.blank?
175
+ break :unlocked unless execution&.executable?
176
+
177
+ begin
178
+ execution.with_advisory_lock(key: "good_jobs-#{execution.active_job_id}") do
179
+ execution.perform
180
+ end
181
+ rescue RecordAlreadyAdvisoryLockedError => e
182
+ ExecutionResult.new(value: nil, handled_error: e)
183
+ end
184
+ end
185
+ end
186
+
187
+ # Fetches the scheduled execution time of the next eligible Execution(s).
188
+ # @param after [DateTime]
189
+ # @param limit [Integer]
190
+ # @param now_limit [Integer, nil]
191
+ # @return [Array<DateTime>]
192
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
193
+ query = advisory_unlocked.unfinished.schedule_ordered
194
+
195
+ after ||= Time.current
196
+ after_query = query.where('scheduled_at > ?', after).or query.where(scheduled_at: nil).where('created_at > ?', after)
197
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
198
+
199
+ if now_limit&.positive?
200
+ now_query = query.where('scheduled_at < ?', Time.current).or query.where(scheduled_at: nil)
201
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
202
+ end
203
+
204
+ Array(now_at) + after_at
205
+ end
206
+
207
+ # Places an ActiveJob job on a queue by creating a new {Execution} record.
208
+ # @param active_job [ActiveJob::Base]
209
+ # The job to enqueue.
210
+ # @param scheduled_at [Float]
211
+ # Epoch timestamp when the job should be executed.
212
+ # @param create_with_advisory_lock [Boolean]
213
+ # Whether to establish a lock on the {Execution} record after it is created.
214
+ # @return [Execution]
215
+ # The new {Execution} instance representing the queued ActiveJob job.
216
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
217
+ ActiveSupport::Notifications.instrument("enqueue_job.good_job", { active_job: active_job, scheduled_at: scheduled_at, create_with_advisory_lock: create_with_advisory_lock }) do |instrument_payload|
218
+ execution_args = {
219
+ active_job_id: active_job.job_id,
220
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
221
+ priority: active_job.priority || DEFAULT_PRIORITY,
222
+ serialized_params: active_job.serialize,
223
+ scheduled_at: scheduled_at,
224
+ create_with_advisory_lock: create_with_advisory_lock,
225
+ }
226
+
227
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
228
+
229
+ if CurrentThread.cron_key
230
+ execution_args[:cron_key] = CurrentThread.cron_key
231
+ elsif CurrentThread.active_job_id == active_job.job_id
232
+ execution_args[:cron_key] = CurrentThread.execution.cron_key
233
+ end
234
+
235
+ execution = GoodJob::Execution.new(**execution_args)
236
+
237
+ instrument_payload[:execution] = execution
238
+
239
+ execution.save!
240
+ active_job.provider_job_id = execution.id
241
+
242
+ CurrentThread.execution.retried_good_job_id = execution.id if CurrentThread.execution && CurrentThread.execution.active_job_id == active_job.job_id
243
+
244
+ execution
245
+ end
246
+ end
247
+
248
+ # Execute the ActiveJob job this {Execution} represents.
249
+ # @return [ExecutionResult]
250
+ # An array of the return value of the job's +#perform+ method and the
251
+ # exception raised by the job, if any. If the job completed successfully,
252
+ # the second array entry (the exception) will be +nil+ and vice versa.
253
+ def perform
254
+ raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
255
+
256
+ self.performed_at = Time.current
257
+ save! if GoodJob.preserve_job_records
258
+
259
+ result = execute
260
+
261
+ job_error = result.handled_error || result.unhandled_error
262
+ self.error = "#{job_error.class}: #{job_error.message}" if job_error
263
+
264
+ if result.unhandled_error && GoodJob.retry_on_unhandled_error
265
+ save!
266
+ elsif GoodJob.preserve_job_records == true || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
267
+ self.finished_at = Time.current
268
+ save!
269
+ else
270
+ destroy!
271
+ end
272
+
273
+ result
274
+ end
275
+
276
+ # Tests whether this job is safe to be executed by this thread.
277
+ # @return [Boolean]
278
+ def executable?
279
+ self.class.unscoped.unfinished.owns_advisory_locked.exists?(id: id)
280
+ end
281
+
282
+ private
283
+
284
+ # @return [ExecutionResult]
285
+ def execute
286
+ GoodJob::CurrentThread.reset
287
+ GoodJob::CurrentThread.execution = self
288
+
289
+ job_data = serialized_params.deep_dup
290
+ job_data["provider_job_id"] = id
291
+
292
+ # DEPRECATION: Remove deprecated `good_job:` parameter in GoodJob v3
293
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, execution: self, process_id: GoodJob::CurrentThread.process_id, thread_name: GoodJob::CurrentThread.thread_name }) do
294
+ value = ActiveJob::Base.execute(job_data)
295
+
296
+ if value.is_a?(Exception)
297
+ handled_error = value
298
+ value = nil
299
+ end
300
+ handled_error ||= GoodJob::CurrentThread.error_on_retry || GoodJob::CurrentThread.error_on_discard
301
+
302
+ ExecutionResult.new(value: value, handled_error: handled_error)
303
+ rescue StandardError => e
304
+ ExecutionResult.new(value: nil, unhandled_error: e)
305
+ end
306
+ end
307
+ end
308
+ end