good_job 2.1.0 → 2.2.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -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/active_jobs_controller.rb +3 -2
  7. data/engine/app/controllers/good_job/cron_schedules_controller.rb +10 -0
  8. data/engine/app/controllers/good_job/dashboards_controller.rb +17 -16
  9. data/engine/app/controllers/good_job/executions_controller.rb +10 -0
  10. data/engine/app/views/good_job/active_jobs/show.html.erb +3 -1
  11. data/engine/app/views/good_job/cron_schedules/index.html.erb +28 -0
  12. data/engine/app/views/good_job/dashboards/index.html.erb +5 -5
  13. data/engine/app/views/good_job/shared/_executions_table.erb +56 -0
  14. data/engine/app/views/layouts/good_job/base.html.erb +9 -3
  15. data/engine/config/routes.rb +2 -1
  16. data/lib/good_job/active_job_extensions/concurrency.rb +6 -6
  17. data/lib/good_job/adapter.rb +8 -8
  18. data/lib/good_job/cron_manager.rb +3 -3
  19. data/lib/good_job/{current_execution.rb → current_thread.rb} +8 -8
  20. data/lib/good_job/execution.rb +308 -0
  21. data/lib/good_job/job.rb +6 -294
  22. data/lib/good_job/job_performer.rb +2 -2
  23. data/lib/good_job/log_subscriber.rb +4 -4
  24. data/lib/good_job/notifier.rb +3 -3
  25. data/lib/good_job/railtie.rb +2 -2
  26. data/lib/good_job/scheduler.rb +2 -2
  27. data/lib/good_job/version.rb +1 -1
  28. data/lib/good_job.rb +2 -2
  29. metadata +8 -5
  30. data/engine/app/controllers/good_job/jobs_controller.rb +0 -10
  31. data/engine/app/views/good_job/shared/_jobs_table.erb +0 -48
@@ -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
data/lib/good_job/job.rb CHANGED
@@ -1,299 +1,11 @@
1
1
  # frozen_string_literal: true
2
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 Job < ActiveRecord::Base; end
7
- class Job < Object.const_get(GoodJob.active_record_parent_class)
8
- include Lockable
9
-
10
- # Raised if something attempts to execute a previously completed Job 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::Job.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 class name
54
- # @!method with_job_class
55
- # @!scope class
56
- # @param string [String]
57
- # Job class name
58
- # @return [ActiveRecord::Relation]
59
- scope :with_job_class, ->(job_class) { where("serialized_params->>'job_class' = ?", job_class) }
60
-
61
- # Get Jobs that have not yet been completed.
62
- # @!method unfinished
63
- # @!scope class
64
- # @return [ActiveRecord::Relation]
65
- scope :unfinished, -> { where(finished_at: nil) }
66
-
67
- # Get Jobs that are not scheduled for a later time than now (i.e. jobs that
68
- # are not scheduled or scheduled for earlier than the current time).
69
- # @!method only_scheduled
70
- # @!scope class
71
- # @return [ActiveRecord::Relation]
72
- scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(Time.current)).or(where(scheduled_at: nil)) }
73
-
74
- # Order jobs by priority (highest priority first).
75
- # @!method priority_ordered
76
- # @!scope class
77
- # @return [ActiveRecord::Relation]
78
- scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
79
-
80
- # Order jobs by scheduled (unscheduled or soonest first).
81
- # @!method schedule_ordered
82
- # @!scope class
83
- # @return [ActiveRecord::Relation]
84
- scope :schedule_ordered, -> { order(Arel.sql('COALESCE(scheduled_at, created_at) ASC')) }
85
-
86
- # Get Jobs were completed before the given timestamp. If no timestamp is
87
- # provided, get all jobs that have been completed. By default, GoodJob
88
- # deletes jobs after they are completed and this will find no jobs.
89
- # However, if you have changed {GoodJob.preserve_job_records}, this may
90
- # find completed Jobs.
91
- # @!method finished(timestamp = nil)
92
- # @!scope class
93
- # @param timestamp (Float)
94
- # Get jobs that finished before this time (in epoch time).
95
- # @return [ActiveRecord::Relation]
96
- scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
97
-
98
- # Get Jobs that started but not finished yet.
99
- # @!method running
100
- # @!scope class
101
- # @return [ActiveRecord::Relation]
102
- scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
103
-
104
- # Get Jobs that do not have subsequent retries
105
- # @!method running
106
- # @!scope class
107
- # @return [ActiveRecord::Relation]
108
- scope :head, -> { where(retried_good_job_id: nil) }
109
-
110
- # Get Jobs have errored that will not be retried further
111
- # @!method running
112
- # @!scope class
113
- # @return [ActiveRecord::Relation]
114
- scope :dead, -> { head.where.not(error: nil) }
115
-
116
- # Get Jobs on queues that match the given queue string.
117
- # @!method queue_string(string)
118
- # @!scope class
119
- # @param string [String]
120
- # A string expression describing what queues to select. See
121
- # {Job.queue_parser} or
122
- # {file:README.md#optimize-queues-threads-and-processes} for more details
123
- # on the format of the string. Note this only handles individual
124
- # semicolon-separated segments of that string format.
125
- # @return [ActiveRecord::Relation]
126
- scope :queue_string, (lambda do |string|
127
- parsed = queue_parser(string)
128
-
129
- if parsed[:all]
130
- all
131
- elsif parsed[:exclude]
132
- where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
133
- elsif parsed[:include]
134
- where(queue_name: parsed[:include])
135
- end
136
- end)
137
-
138
- # Get Jobs in display order with optional keyset pagination.
139
- # @!method display_all(after_scheduled_at: nil, after_id: nil)
140
- # @!scope class
141
- # @param after_scheduled_at [DateTime, String, nil]
142
- # Display records scheduled after this time for keyset pagination
143
- # @param after_id [Numeric, String, nil]
144
- # Display records after this ID for keyset pagination
145
- # @return [ActiveRecord::Relation]
146
- scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
147
- query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
148
- if after_scheduled_at.present? && after_id.present?
149
- 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)
150
- elsif after_scheduled_at.present?
151
- query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
152
- end
153
- query
154
- end)
155
-
156
- # Finds the next eligible Job, acquire an advisory lock related to it, and
157
- # executes the job.
158
- # @return [ExecutionResult, nil]
159
- # If a job was executed, returns an array with the {Job} record, the
160
- # return value for the job's +#perform+ method, and the exception the job
161
- # raised, if any (if the job raised, then the second array entry will be
162
- # +nil+). If there were no jobs to execute, returns +nil+.
163
- def self.perform_with_advisory_lock
164
- unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock(unlock_session: true) do |good_jobs|
165
- good_job = good_jobs.first
166
- break if good_job.blank?
167
- break :unlocked unless good_job&.executable?
168
-
169
- begin
170
- good_job.with_advisory_lock(key: "good_jobs-#{good_job.active_job_id}") do
171
- good_job.perform
172
- end
173
- rescue RecordAlreadyAdvisoryLockedError => e
174
- ExecutionResult.new(value: nil, handled_error: e)
175
- end
176
- end
177
- end
178
-
179
- # Fetches the scheduled execution time of the next eligible Job(s).
180
- # @param after [DateTime]
181
- # @param limit [Integer]
182
- # @param now_limit [Integer, nil]
183
- # @return [Array<DateTime>]
184
- def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
185
- query = advisory_unlocked.unfinished.schedule_ordered
186
-
187
- after ||= Time.current
188
- after_query = query.where('scheduled_at > ?', after).or query.where(scheduled_at: nil).where('created_at > ?', after)
189
- after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
190
-
191
- if now_limit&.positive?
192
- now_query = query.where('scheduled_at < ?', Time.current).or query.where(scheduled_at: nil)
193
- now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
194
- end
195
-
196
- Array(now_at) + after_at
197
- end
198
-
199
- # Places an ActiveJob job on a queue by creating a new {Job} record.
200
- # @param active_job [ActiveJob::Base]
201
- # The job to enqueue.
202
- # @param scheduled_at [Float]
203
- # Epoch timestamp when the job should be executed.
204
- # @param create_with_advisory_lock [Boolean]
205
- # Whether to establish a lock on the {Job} record after it is created.
206
- # @return [Job]
207
- # The new {Job} instance representing the queued ActiveJob job.
208
- def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
209
- 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|
210
- good_job_args = {
211
- active_job_id: active_job.job_id,
212
- queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
213
- priority: active_job.priority || DEFAULT_PRIORITY,
214
- serialized_params: active_job.serialize,
215
- scheduled_at: scheduled_at,
216
- create_with_advisory_lock: create_with_advisory_lock,
217
- }
218
-
219
- good_job_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
220
-
221
- if CurrentExecution.cron_key
222
- good_job_args[:cron_key] = CurrentExecution.cron_key
223
- elsif CurrentExecution.active_job_id == active_job.job_id
224
- good_job_args[:cron_key] = CurrentExecution.good_job.cron_key
225
- end
226
-
227
- good_job = GoodJob::Job.new(**good_job_args)
228
-
229
- instrument_payload[:good_job] = good_job
230
-
231
- good_job.save!
232
- active_job.provider_job_id = good_job.id
233
-
234
- CurrentExecution.good_job.retried_good_job_id = good_job.id if CurrentExecution.good_job && CurrentExecution.good_job.active_job_id == active_job.job_id
235
-
236
- good_job
237
- end
238
- end
239
-
240
- # Execute the ActiveJob job this {Job} represents.
241
- # @return [ExecutionResult]
242
- # An array of the return value of the job's +#perform+ method and the
243
- # exception raised by the job, if any. If the job completed successfully,
244
- # the second array entry (the exception) will be +nil+ and vice versa.
245
- def perform
246
- raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
247
-
248
- self.performed_at = Time.current
249
- save! if GoodJob.preserve_job_records
250
-
251
- result = execute
252
-
253
- job_error = result.handled_error || result.unhandled_error
254
- self.error = "#{job_error.class}: #{job_error.message}" if job_error
255
-
256
- if result.unhandled_error && GoodJob.retry_on_unhandled_error
257
- save!
258
- elsif GoodJob.preserve_job_records == true || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
259
- self.finished_at = Time.current
260
- save!
261
- else
262
- destroy!
263
- end
264
-
265
- result
266
- end
267
-
268
- # Tests whether this job is safe to be executed by this thread.
269
- # @return [Boolean]
270
- def executable?
271
- self.class.unscoped.unfinished.owns_advisory_locked.exists?(id: id)
272
- end
273
-
274
- private
275
-
276
- # @return [ExecutionResult]
277
- def execute
278
- GoodJob::CurrentExecution.reset
279
- GoodJob::CurrentExecution.good_job = self
280
-
281
- job_data = serialized_params.deep_dup
282
- job_data["provider_job_id"] = id
283
-
284
- ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
285
- value = ActiveJob::Base.execute(job_data)
286
-
287
- if value.is_a?(Exception)
288
- handled_error = value
289
- value = nil
290
- end
291
- handled_error ||= GoodJob::CurrentExecution.error_on_retry || GoodJob::CurrentExecution.error_on_discard
292
-
293
- ExecutionResult.new(value: value, handled_error: handled_error)
294
- rescue StandardError => e
295
- ExecutionResult.new(value: nil, unhandled_error: e)
296
- end
3
+ # @deprecated Use {GoodJob::Execution} instead.
4
+ class Job < Execution
5
+ after_initialize do |_job|
6
+ ActiveSupport::Deprecation.warn(
7
+ "The `GoodJob::Job` class name is deprecated. Replace with `GoodJob::Execution`."
8
+ )
297
9
  end
298
10
  end
299
11
  end