good_job 2.4.0 → 2.6.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +51 -20
  4. data/engine/app/assets/vendor/rails_ujs.js +747 -0
  5. data/engine/app/controllers/good_job/assets_controller.rb +4 -0
  6. data/engine/app/controllers/good_job/base_controller.rb +8 -0
  7. data/engine/app/controllers/good_job/cron_entries_controller.rb +19 -0
  8. data/engine/app/controllers/good_job/jobs_controller.rb +36 -0
  9. data/engine/app/filters/good_job/base_filter.rb +12 -7
  10. data/engine/app/filters/good_job/executions_filter.rb +1 -1
  11. data/engine/app/filters/good_job/jobs_filter.rb +4 -2
  12. data/engine/app/views/good_job/cron_entries/index.html.erb +51 -0
  13. data/engine/app/views/good_job/cron_entries/show.html.erb +4 -0
  14. data/engine/app/views/good_job/{shared/_executions_table.erb → executions/_table.erb} +1 -1
  15. data/engine/app/views/good_job/executions/index.html.erb +1 -1
  16. data/engine/app/views/good_job/{shared/_jobs_table.erb → jobs/_table.erb} +17 -5
  17. data/engine/app/views/good_job/jobs/index.html.erb +14 -1
  18. data/engine/app/views/good_job/jobs/show.html.erb +2 -2
  19. data/engine/app/views/good_job/shared/_filter.erb +9 -10
  20. data/engine/app/views/good_job/shared/icons/_arrow_clockwise.html.erb +5 -0
  21. data/engine/app/views/good_job/shared/icons/_play.html.erb +4 -0
  22. data/engine/app/views/good_job/shared/icons/_skip_forward.html.erb +4 -0
  23. data/engine/app/views/good_job/shared/icons/_stop.html.erb +4 -0
  24. data/engine/app/views/layouts/good_job/base.html.erb +3 -1
  25. data/engine/config/routes.rb +15 -2
  26. data/lib/generators/good_job/install_generator.rb +6 -0
  27. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +3 -1
  28. data/lib/generators/good_job/templates/update/migrations/{01_create_good_jobs.rb → 01_create_good_jobs.rb.erb} +1 -1
  29. data/lib/generators/good_job/templates/update/migrations/02_add_cron_at_to_good_jobs.rb.erb +14 -0
  30. data/lib/generators/good_job/templates/update/migrations/03_add_cron_key_cron_at_index_to_good_jobs.rb.erb +20 -0
  31. data/lib/generators/good_job/update_generator.rb +6 -0
  32. data/lib/good_job/active_job_extensions/concurrency.rb +3 -4
  33. data/lib/good_job/active_job_job.rb +245 -0
  34. data/lib/good_job/adapter.rb +4 -2
  35. data/lib/good_job/cli.rb +3 -1
  36. data/lib/good_job/configuration.rb +5 -1
  37. data/lib/good_job/cron_entry.rb +138 -0
  38. data/lib/good_job/cron_manager.rb +17 -31
  39. data/lib/good_job/current_thread.rb +38 -5
  40. data/lib/good_job/execution.rb +50 -25
  41. data/lib/good_job/lockable.rb +1 -1
  42. data/lib/good_job/log_subscriber.rb +3 -3
  43. data/lib/good_job/scheduler.rb +1 -0
  44. data/lib/good_job/version.rb +1 -1
  45. metadata +21 -12
  46. data/engine/app/controllers/good_job/cron_schedules_controller.rb +0 -9
  47. data/engine/app/models/good_job/active_job_job.rb +0 -127
  48. data/engine/app/views/good_job/cron_schedules/index.html.erb +0 -50
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+ module GoodJob
3
+ # ActiveRecord model that represents an +ActiveJob+ job.
4
+ # There is not a table in the database whose discrete rows represents "Jobs".
5
+ # The +good_jobs+ table is a table of individual {GoodJob::Execution}s that share the same +active_job_id+.
6
+ # A single row from the +good_jobs+ table of executions is fetched to represent an ActiveJobJob
7
+ # Parent class can be configured with +GoodJob.active_record_parent_class+.
8
+ # @!parse
9
+ # class ActiveJob < ActiveRecord::Base; end
10
+ class ActiveJobJob < Object.const_get(GoodJob.active_record_parent_class)
11
+ include GoodJob::Lockable
12
+
13
+ # Raised when an inappropriate action is applied to a Job based on its state.
14
+ ActionForStateMismatchError = Class.new(StandardError)
15
+ # Raised when an action requires GoodJob to be the ActiveJob Queue Adapter but GoodJob is not.
16
+ AdapterNotGoodJobError = Class.new(StandardError)
17
+ # Attached to a Job's Execution when the Job is discarded.
18
+ DiscardJobError = Class.new(StandardError)
19
+
20
+ self.table_name = 'good_jobs'
21
+ self.primary_key = 'active_job_id'
22
+ self.advisory_lockable_column = 'active_job_id'
23
+
24
+ has_many :executions, -> { order(created_at: :asc) }, class_name: 'GoodJob::Execution', foreign_key: 'active_job_id'
25
+
26
+ # Only the most-recent unretried execution represents a "Job"
27
+ default_scope { where(retried_good_job_id: nil) }
28
+
29
+ # Get Jobs with given class name
30
+ # @!method job_class
31
+ # @!scope class
32
+ # @param string [String]
33
+ # Execution class name
34
+ # @return [ActiveRecord::Relation]
35
+ scope :job_class, ->(job_class) { where("serialized_params->>'job_class' = ?", job_class) }
36
+
37
+ # First execution will run in the future
38
+ scope :scheduled, -> { where(finished_at: nil).where('COALESCE(scheduled_at, created_at) > ?', DateTime.current).where("(serialized_params->>'executions')::integer < 2") }
39
+ # Execution errored, will run in the future
40
+ scope :retried, -> { where(finished_at: nil).where('COALESCE(scheduled_at, created_at) > ?', DateTime.current).where("(serialized_params->>'executions')::integer > 1") }
41
+ # Immediate/Scheduled time to run has passed, waiting for an available thread run
42
+ scope :queued, -> { where(finished_at: nil).where('COALESCE(scheduled_at, created_at) <= ?', DateTime.current).joins_advisory_locks.where(pg_locks: { locktype: nil }) }
43
+ # Advisory locked and executing
44
+ scope :running, -> { where(finished_at: nil).joins_advisory_locks.where.not(pg_locks: { locktype: nil }) }
45
+ # Completed executing successfully
46
+ scope :finished, -> { where.not(finished_at: nil).where(error: nil) }
47
+ # Errored but will not be retried
48
+ scope :discarded, -> { where.not(finished_at: nil).where.not(error: nil) }
49
+
50
+ # Get Jobs in display order with optional keyset pagination.
51
+ # @!method display_all(after_scheduled_at: nil, after_id: nil)
52
+ # @!scope class
53
+ # @param after_scheduled_at [DateTime, String, nil]
54
+ # Display records scheduled after this time for keyset pagination
55
+ # @param after_id [Numeric, String, nil]
56
+ # Display records after this ID for keyset pagination
57
+ # @return [ActiveRecord::Relation]
58
+ scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
59
+ query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
60
+ if after_scheduled_at.present? && after_id.present?
61
+ 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)
62
+ elsif after_scheduled_at.present?
63
+ query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
64
+ end
65
+ query
66
+ end)
67
+
68
+ # The job's ActiveJob UUID
69
+ # @return [String]
70
+ def id
71
+ active_job_id
72
+ end
73
+
74
+ # The ActiveJob job class, as a string
75
+ # @return [String]
76
+ def job_class
77
+ serialized_params['job_class']
78
+ end
79
+
80
+ # The status of the Job, based on the state of its most recent execution.
81
+ # There are 3 buckets of non-overlapping statuses:
82
+ # 1. The job will be executed
83
+ # - queued: The job will execute immediately when an execution thread becomes available.
84
+ # - scheduled: The job is scheduled to execute in the future.
85
+ # - retried: The job previously errored on execution and will be re-executed in the future.
86
+ # 2. The job is being executed
87
+ # - running: the job is actively being executed by an execution thread
88
+ # 3. The job will not execute
89
+ # - finished: The job executed successfully
90
+ # - discarded: The job previously errored on execution and will not be re-executed in the future.
91
+ #
92
+ # @return [Symbol]
93
+ def status
94
+ execution = head_execution
95
+ if execution.finished_at.present?
96
+ if execution.error.present?
97
+ :discarded
98
+ else
99
+ :finished
100
+ end
101
+ elsif (execution.scheduled_at || execution.created_at) > DateTime.current
102
+ if execution.serialized_params.fetch('executions', 0) > 1
103
+ :retried
104
+ else
105
+ :scheduled
106
+ end
107
+ elsif running?
108
+ :running
109
+ else
110
+ :queued
111
+ end
112
+ end
113
+
114
+ # This job's most recent {Execution}
115
+ # @param reload [Booelan] whether to reload executions
116
+ # @return [Execution]
117
+ def head_execution(reload: false)
118
+ executions.reload if reload
119
+ executions.load # memoize the results
120
+ executions.last
121
+ end
122
+
123
+ # This job's initial/oldest {Execution}
124
+ # @return [Execution]
125
+ def tail_execution
126
+ executions.first
127
+ end
128
+
129
+ # The number of times this job has been executed, according to ActiveJob's serialized state.
130
+ # @return [Numeric]
131
+ def executions_count
132
+ aj_count = head_execution.serialized_params.fetch('executions', 0)
133
+ # The execution count within serialized_params is not updated
134
+ # once the underlying execution has been executed.
135
+ if status.in? [:discarded, :finished, :running]
136
+ aj_count + 1
137
+ else
138
+ aj_count
139
+ end
140
+ end
141
+
142
+ # The number of times this job has been executed, according to the number of GoodJob {Execution} records.
143
+ # @return [Numeric]
144
+ def preserved_executions_count
145
+ executions.size
146
+ end
147
+
148
+ # The most recent error message.
149
+ # If the job has been retried, the error will be fetched from the previous {Execution} record.
150
+ # @return [String]
151
+ def recent_error
152
+ head_execution.error || executions[-2]&.error
153
+ end
154
+
155
+ # Tests whether the job is being executed right now.
156
+ # @return [Boolean]
157
+ def running?
158
+ # Avoid N+1 Query: `.joins_advisory_locks.select('good_jobs.*', 'pg_locks.locktype AS locktype')`
159
+ if has_attribute?(:locktype)
160
+ self['locktype'].present?
161
+ else
162
+ advisory_locked?
163
+ end
164
+ end
165
+
166
+ # Retry a job that has errored and been discarded.
167
+ # This action will create a new job {Execution} record.
168
+ # @return [ActiveJob::Base]
169
+ def retry_job
170
+ with_advisory_lock do
171
+ execution = head_execution(reload: true)
172
+ active_job = execution.active_job
173
+
174
+ raise AdapterNotGoodJobError unless active_job.class.queue_adapter.is_a? GoodJob::Adapter
175
+ raise ActionForStateMismatchError unless status == :discarded
176
+
177
+ # Update the executions count because the previous execution will not have been preserved
178
+ # Do not update `exception_executions` because that comes from rescue_from's arguments
179
+ active_job.executions = (active_job.executions || 0) + 1
180
+
181
+ new_active_job = nil
182
+ GoodJob::CurrentThread.within do |current_thread|
183
+ current_thread.execution = execution
184
+
185
+ execution.class.transaction(joinable: false, requires_new: true) do
186
+ new_active_job = active_job.retry_job(wait: 0, error: error)
187
+ execution.save
188
+ end
189
+ end
190
+ new_active_job
191
+ end
192
+ end
193
+
194
+ # Discard a job so that it will not be executed further.
195
+ # This action will add a {DiscardJobError} to the job's {Execution} and mark it as finished.
196
+ # @return [void]
197
+ def discard_job(message)
198
+ with_advisory_lock do
199
+ raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]
200
+
201
+ execution = head_execution(reload: true)
202
+ active_job = execution.active_job
203
+
204
+ job_error = GoodJob::ActiveJobJob::DiscardJobError.new(message)
205
+
206
+ update_execution = proc do
207
+ execution.update(
208
+ finished_at: Time.current,
209
+ error: [job_error.class, GoodJob::Execution::ERROR_MESSAGE_SEPARATOR, job_error.message].join
210
+ )
211
+ end
212
+
213
+ if active_job.respond_to?(:instrument)
214
+ active_job.send :instrument, :discard, error: job_error, &update_execution
215
+ else
216
+ update_execution.call
217
+ end
218
+ end
219
+ end
220
+
221
+ # Reschedule a scheduled job so that it executes immediately (or later) by the next available execution thread.
222
+ # @param scheduled_at [DateTime, Time] When to reschedule the job
223
+ # @return [void]
224
+ def reschedule_job(scheduled_at = Time.current)
225
+ with_advisory_lock do
226
+ raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]
227
+
228
+ execution = head_execution(reload: true)
229
+ execution.update(scheduled_at: scheduled_at)
230
+ end
231
+ end
232
+
233
+ # Utility method to determine which execution record is used to represent this job
234
+ # @return [String]
235
+ def _execution_id
236
+ attributes['id']
237
+ end
238
+
239
+ # Utility method to test whether this job's underlying attributes represents its most recent execution.
240
+ # @return [Boolean]
241
+ def _head?
242
+ _execution_id == head_execution(reload: true).id
243
+ end
244
+ end
245
+ end
@@ -38,7 +38,7 @@ module GoodJob
38
38
  @notifier.recipients << [@scheduler, :create_thread]
39
39
  @poller.recipients << [@scheduler, :create_thread]
40
40
 
41
- @cron_manager = GoodJob::CronManager.new(@configuration.cron, start_on_initialize: Rails.application.initialized?) if @configuration.enable_cron?
41
+ @cron_manager = GoodJob::CronManager.new(@configuration.cron_entries, start_on_initialize: Rails.application.initialized?) if @configuration.enable_cron?
42
42
  end
43
43
  end
44
44
 
@@ -64,10 +64,12 @@ module GoodJob
64
64
 
65
65
  if execute_inline?
66
66
  begin
67
- execution.perform
67
+ result = execution.perform
68
68
  ensure
69
69
  execution.advisory_unlock
70
70
  end
71
+
72
+ raise result.unhandled_error if result.unhandled_error
71
73
  else
72
74
  job_state = { queue_name: execution.queue_name }
73
75
  job_state[:scheduled_at] = execution.scheduled_at if execution.scheduled_at
data/lib/good_job/cli.rb CHANGED
@@ -91,7 +91,9 @@ module GoodJob
91
91
  scheduler = GoodJob::Scheduler.from_configuration(configuration, warm_cache_on_initialize: true)
92
92
  notifier.recipients << [scheduler, :create_thread]
93
93
  poller.recipients << [scheduler, :create_thread]
94
- cron_manager = GoodJob::CronManager.new(configuration.cron, start_on_initialize: true) if configuration.enable_cron?
94
+
95
+ cron_manager = GoodJob::CronManager.new(configuration.cron_entries, start_on_initialize: true) if configuration.enable_cron?
96
+
95
97
  @stop_good_job_executable = false
96
98
  %w[INT TERM].each do |signal|
97
99
  trap(signal) { @stop_good_job_executable = true }
@@ -157,7 +157,7 @@ module GoodJob
157
157
  alias enable_cron? enable_cron
158
158
 
159
159
  def cron
160
- env_cron = JSON.parse(ENV['GOOD_JOB_CRON']) if ENV['GOOD_JOB_CRON'].present?
160
+ env_cron = JSON.parse(ENV['GOOD_JOB_CRON'], symbolize_names: true) if ENV['GOOD_JOB_CRON'].present?
161
161
 
162
162
  options[:cron] ||
163
163
  rails_config[:cron] ||
@@ -165,6 +165,10 @@ module GoodJob
165
165
  {}
166
166
  end
167
167
 
168
+ def cron_entries
169
+ cron.map { |cron_key, params| GoodJob::CronEntry.new(params.merge(key: cron_key)) }
170
+ end
171
+
168
172
  # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
169
173
  # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
170
174
  # @return [Integer]
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+ require "concurrent/hash"
3
+ require "concurrent/scheduled_task"
4
+ require "fugit"
5
+
6
+ module GoodJob # :nodoc:
7
+ #
8
+ # A CronEntry represents a single scheduled item's properties.
9
+ #
10
+ class CronEntry
11
+ include ActiveModel::Model
12
+
13
+ attr_reader :params
14
+
15
+ def self.all(configuration: nil)
16
+ configuration ||= GoodJob::Configuration.new({})
17
+ configuration.cron_entries
18
+ end
19
+
20
+ def self.find(key, configuration: nil)
21
+ all(configuration: configuration).find { |entry| entry.key == key.to_sym }.tap do |cron_entry|
22
+ raise ActiveRecord::RecordNotFound unless cron_entry
23
+ end
24
+ end
25
+
26
+ def initialize(params = {})
27
+ @params = params
28
+
29
+ raise ArgumentError, "Invalid cron format: '#{cron}'" unless fugit.instance_of?(Fugit::Cron)
30
+ end
31
+
32
+ def key
33
+ params.fetch(:key)
34
+ end
35
+
36
+ alias id key
37
+ alias to_param key
38
+
39
+ def job_class
40
+ params.fetch(:class)
41
+ end
42
+
43
+ def cron
44
+ params.fetch(:cron)
45
+ end
46
+
47
+ def set
48
+ params[:set]
49
+ end
50
+
51
+ def args
52
+ params[:args]
53
+ end
54
+
55
+ def description
56
+ params[:description]
57
+ end
58
+
59
+ def next_at
60
+ fugit.next_time.to_t
61
+ end
62
+
63
+ def schedule
64
+ fugit.original
65
+ end
66
+
67
+ def fugit
68
+ @_fugit ||= Fugit.parse(cron)
69
+ end
70
+
71
+ def jobs
72
+ GoodJob::ActiveJobJob.where(cron_key: key)
73
+ end
74
+
75
+ def last_at
76
+ return if last_job.blank?
77
+
78
+ if GoodJob::ActiveJobJob.column_names.include?('cron_at')
79
+ (last_job.cron_at || last_job.created_at).localtime
80
+ else
81
+ last_job.created_at
82
+ end
83
+ end
84
+
85
+ def enqueue(cron_at = nil)
86
+ GoodJob::CurrentThread.within do |current_thread|
87
+ current_thread.cron_key = key
88
+ current_thread.cron_at = cron_at
89
+
90
+ job_class.constantize.set(set_value).perform_later(*args_value)
91
+ end
92
+ rescue ActiveRecord::RecordNotUnique
93
+ false
94
+ end
95
+
96
+ def last_job
97
+ if GoodJob::ActiveJobJob.column_names.include?('cron_at')
98
+ jobs.order("cron_at DESC NULLS LAST").first
99
+ else
100
+ jobs.order(created_at: :asc).last
101
+ end
102
+ end
103
+
104
+ def display_properties
105
+ {
106
+ key: key,
107
+ class: job_class,
108
+ cron: schedule,
109
+ set: display_property(set),
110
+ args: display_property(args),
111
+ description: display_property(description),
112
+ }
113
+ end
114
+
115
+ private
116
+
117
+ def set_value
118
+ value = set || {}
119
+ value.respond_to?(:call) ? value.call : value
120
+ end
121
+
122
+ def args_value
123
+ value = args || []
124
+ value.respond_to?(:call) ? value.call : value
125
+ end
126
+
127
+ def display_property(value)
128
+ case value
129
+ when NilClass
130
+ "None"
131
+ when Proc
132
+ "Lambda/Callable"
133
+ else
134
+ value
135
+ end
136
+ end
137
+ end
138
+ end
@@ -11,7 +11,7 @@ module GoodJob # :nodoc:
11
11
  # @!attribute [r] instances
12
12
  # @!scope class
13
13
  # List of all instantiated CronManagers in the current process.
14
- # @return [Array<GoodJob::CronManagers>, nil]
14
+ # @return [Array<GoodJob::CronManager>, nil]
15
15
  cattr_reader :instances, default: [], instance_reader: false
16
16
 
17
17
  # Task observer for cron task
@@ -26,13 +26,13 @@ module GoodJob # :nodoc:
26
26
 
27
27
  # Execution configuration to be scheduled
28
28
  # @return [Hash]
29
- attr_reader :schedules
29
+ attr_reader :cron_entries
30
30
 
31
- # @param schedules [Hash]
31
+ # @param cron_entries [Array<CronEntry>]
32
32
  # @param start_on_initialize [Boolean]
33
- def initialize(schedules = {}, start_on_initialize: false)
33
+ def initialize(cron_entries = [], start_on_initialize: false)
34
34
  @running = false
35
- @schedules = schedules
35
+ @cron_entries = cron_entries
36
36
  @tasks = Concurrent::Hash.new
37
37
 
38
38
  self.class.instances << self
@@ -42,9 +42,11 @@ module GoodJob # :nodoc:
42
42
 
43
43
  # Schedule tasks that will enqueue jobs based on their schedule
44
44
  def start
45
- ActiveSupport::Notifications.instrument("cron_manager_start.good_job", cron_jobs: @schedules) do
45
+ ActiveSupport::Notifications.instrument("cron_manager_start.good_job", cron_entries: cron_entries) do
46
46
  @running = true
47
- schedules.each_key { |cron_key| create_task(cron_key) }
47
+ cron_entries.each do |cron_entry|
48
+ create_task(cron_entry)
49
+ end
48
50
  end
49
51
  end
50
52
 
@@ -78,36 +80,20 @@ module GoodJob # :nodoc:
78
80
  end
79
81
 
80
82
  # Enqueues a scheduled task
81
- # @param cron_key [Symbol, String] the key within the schedule to use
82
- def create_task(cron_key)
83
- schedule = @schedules[cron_key]
84
- return false if schedule.blank?
85
-
86
- fugit = Fugit::Cron.parse(schedule.fetch(:cron))
87
- delay = [(fugit.next_time - Time.current).to_f, 0].max
88
-
89
- future = Concurrent::ScheduledTask.new(delay, args: [self, cron_key]) do |thr_scheduler, thr_cron_key|
83
+ # @param cron_entry [CronEntry] the CronEntry object to schedule
84
+ def create_task(cron_entry)
85
+ cron_at = cron_entry.next_at
86
+ delay = [(cron_at - Time.current).to_f, 0].max
87
+ future = Concurrent::ScheduledTask.new(delay, args: [self, cron_entry, cron_at]) do |thr_scheduler, thr_cron_entry, thr_cron_at|
90
88
  # Re-schedule the next cron task before executing the current task
91
- thr_scheduler.create_task(thr_cron_key)
92
-
93
- CurrentThread.reset
94
- CurrentThread.cron_key = thr_cron_key
89
+ thr_scheduler.create_task(thr_cron_entry)
95
90
 
96
91
  Rails.application.executor.wrap do
97
- schedule = thr_scheduler.schedules.fetch(thr_cron_key).with_indifferent_access
98
- job_class = schedule.fetch(:class).constantize
99
-
100
- job_set_value = schedule.fetch(:set, {})
101
- job_set = job_set_value.respond_to?(:call) ? job_set_value.call : job_set_value
102
-
103
- job_args_value = schedule.fetch(:args, [])
104
- job_args = job_args_value.respond_to?(:call) ? job_args_value.call : job_args_value
105
-
106
- job_class.set(job_set).perform_later(*job_args)
92
+ cron_entry.enqueue(thr_cron_at)
107
93
  end
108
94
  end
109
95
 
110
- @tasks[cron_key] = future
96
+ @tasks[cron_entry.key] = future
111
97
  future.add_observer(self.class, :task_observer)
112
98
  future.execute
113
99
  end
@@ -5,6 +5,21 @@ module GoodJob
5
5
  # Thread-local attributes for passing values from Instrumentation.
6
6
  # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
7
7
  module CurrentThread
8
+ # Resettable accessors for thread-local values.
9
+ ACCESSORS = %i[
10
+ cron_at
11
+ cron_key
12
+ error_on_discard
13
+ error_on_retry
14
+ execution
15
+ ].freeze
16
+
17
+ # @!attribute [rw] cron_at
18
+ # @!scope class
19
+ # Cron At
20
+ # @return [DateTime, nil]
21
+ thread_mattr_accessor :cron_at
22
+
8
23
  # @!attribute [rw] cron_key
9
24
  # @!scope class
10
25
  # Cron Key
@@ -30,12 +45,20 @@ module GoodJob
30
45
  thread_mattr_accessor :execution
31
46
 
32
47
  # Resets attributes
48
+ # @param [Hash] values to assign
33
49
  # @return [void]
34
- def self.reset
35
- self.cron_key = nil
36
- self.execution = nil
37
- self.error_on_discard = nil
38
- self.error_on_retry = nil
50
+ def self.reset(values = {})
51
+ ACCESSORS.each do |accessor|
52
+ send("#{accessor}=", values[accessor])
53
+ end
54
+ end
55
+
56
+ # Exports values to hash
57
+ # @return [Hash]
58
+ def self.to_h
59
+ ACCESSORS.each_with_object({}) do |accessor, hash|
60
+ hash[accessor] = send(accessor)
61
+ end
39
62
  end
40
63
 
41
64
  # @return [String] UUID of the currently executing GoodJob::Execution
@@ -52,5 +75,15 @@ module GoodJob
52
75
  def self.thread_name
53
76
  (Thread.current.name || Thread.current.object_id).to_s
54
77
  end
78
+
79
+ # Wrap the yielded block with CurrentThread values and reset after the block
80
+ # @yield [self]
81
+ # @return [void]
82
+ def self.within
83
+ original_values = to_h
84
+ yield(self)
85
+ ensure
86
+ reset(original_values)
87
+ end
55
88
  end
56
89
  end