good_job 3.30.0 → 4.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +22 -0
  4. data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
  5. data/app/controllers/good_job/cron_entries_controller.rb +0 -8
  6. data/app/controllers/good_job/metrics_controller.rb +1 -1
  7. data/app/controllers/good_job/performance_controller.rb +19 -0
  8. data/app/filters/good_job/base_filter.rb +1 -1
  9. data/app/helpers/good_job/application_helper.rb +1 -0
  10. data/app/models/concerns/good_job/error_events.rb +2 -2
  11. data/app/models/concerns/good_job/filterable.rb +1 -1
  12. data/app/models/good_job/base_execution.rb +544 -50
  13. data/app/models/good_job/cron_entry.rb +0 -2
  14. data/app/models/good_job/discrete_execution.rb +1 -26
  15. data/app/models/good_job/execution.rb +3 -609
  16. data/app/models/good_job/job.rb +37 -116
  17. data/app/models/good_job/process.rb +7 -20
  18. data/app/views/good_job/batches/index.html.erb +11 -15
  19. data/app/views/good_job/jobs/_executions.erb +1 -1
  20. data/app/views/good_job/jobs/_table.erb +1 -1
  21. data/app/views/good_job/jobs/show.html.erb +1 -8
  22. data/app/views/good_job/performance/index.html.erb +43 -0
  23. data/app/views/good_job/shared/_navbar.erb +5 -0
  24. data/config/locales/de.yml +10 -2
  25. data/config/locales/en.yml +10 -2
  26. data/config/locales/es.yml +10 -2
  27. data/config/locales/fr.yml +10 -2
  28. data/config/locales/it.yml +10 -2
  29. data/config/locales/ja.yml +10 -2
  30. data/config/locales/ko.yml +10 -2
  31. data/config/locales/nl.yml +10 -2
  32. data/config/locales/pt-BR.yml +10 -2
  33. data/config/locales/ru.yml +10 -2
  34. data/config/locales/tr.yml +10 -2
  35. data/config/locales/uk.yml +10 -2
  36. data/config/routes.rb +2 -0
  37. data/lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb +65 -3
  38. data/lib/good_job/active_job_extensions/batches.rb +1 -1
  39. data/lib/good_job/active_job_extensions/concurrency.rb +10 -10
  40. data/lib/good_job/adapter.rb +13 -24
  41. data/lib/good_job/configuration.rb +5 -1
  42. data/lib/good_job/current_thread.rb +6 -6
  43. data/lib/good_job/job_performer.rb +2 -2
  44. data/lib/good_job/log_subscriber.rb +2 -10
  45. data/lib/good_job/notifier.rb +3 -3
  46. data/lib/good_job/version.rb +1 -1
  47. data/lib/good_job.rb +22 -21
  48. metadata +18 -30
  49. data/lib/generators/good_job/templates/update/migrations/02_create_good_job_settings.rb.erb +0 -20
  50. data/lib/generators/good_job/templates/update/migrations/03_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb.erb +0 -19
  51. data/lib/generators/good_job/templates/update/migrations/04_create_good_job_batches.rb.erb +0 -35
  52. data/lib/generators/good_job/templates/update/migrations/05_create_good_job_executions.rb.erb +0 -33
  53. data/lib/generators/good_job/templates/update/migrations/06_create_good_jobs_error_event.rb.erb +0 -16
  54. data/lib/generators/good_job/templates/update/migrations/07_recreate_good_job_cron_indexes_with_conditional.rb.erb +0 -45
  55. data/lib/generators/good_job/templates/update/migrations/08_create_good_job_labels.rb.erb +0 -15
  56. data/lib/generators/good_job/templates/update/migrations/09_create_good_job_labels_index.rb.erb +0 -22
  57. data/lib/generators/good_job/templates/update/migrations/10_remove_good_job_active_id_index.rb.erb +0 -21
  58. data/lib/generators/good_job/templates/update/migrations/11_create_index_good_job_jobs_for_candidate_lookup.rb.erb +0 -19
  59. data/lib/generators/good_job/templates/update/migrations/12_create_good_job_execution_error_backtrace.rb.erb +0 -15
  60. data/lib/generators/good_job/templates/update/migrations/13_create_good_job_process_lock_ids.rb.erb +0 -17
  61. data/lib/generators/good_job/templates/update/migrations/14_create_good_job_process_lock_indexes.rb.erb +0 -37
  62. data/lib/generators/good_job/templates/update/migrations/15_create_good_job_execution_duration.rb.erb +0 -15
@@ -4,20 +4,198 @@ module GoodJob
4
4
  # Active Record model to share behavior between {Job} and {Execution} models
5
5
  # which both read out of the same table.
6
6
  class BaseExecution < BaseRecord
7
+ self.abstract_class = true
8
+
7
9
  include AdvisoryLockable
8
10
  include ErrorEvents
9
11
  include Filterable
10
12
  include Reportable
11
13
 
12
- self.table_name = 'good_jobs'
14
+ # Raised if something attempts to execute a previously completed Execution again.
15
+ PreviouslyPerformedError = Class.new(StandardError)
16
+
17
+ # String separating Error Class from Error Message
18
+ ERROR_MESSAGE_SEPARATOR = ": "
19
+
20
+ # ActiveJob jobs without a +queue_name+ attribute are placed on this queue.
21
+ DEFAULT_QUEUE_NAME = 'default'
22
+ # ActiveJob jobs without a +priority+ attribute are given this priority.
23
+ DEFAULT_PRIORITY = 0
24
+
25
+ self.advisory_lockable_column = 'active_job_id'
26
+ self.implicit_order_column = 'created_at'
27
+
28
+ define_model_callbacks :perform
29
+ define_model_callbacks :perform_unlocked, only: :after
30
+
31
+ set_callback :perform, :around, :reset_batch_values
32
+ set_callback :perform_unlocked, :after, :continue_discard_or_finish_batch
33
+
34
+ # Parse a string representing a group of queues into a more readable data
35
+ # structure.
36
+ # @param string [String] Queue string
37
+ # @return [Hash]
38
+ # How to match a given queue. It can have the following keys and values:
39
+ # - +{ all: true }+ indicates that all queues match.
40
+ # - +{ exclude: Array<String> }+ indicates the listed queue names should
41
+ # not match.
42
+ # - +{ include: Array<String> }+ indicates the listed queue names should
43
+ # match.
44
+ # - +{ include: Array<String>, ordered_queues: true }+ indicates the listed
45
+ # queue names should match, and dequeue should respect queue order.
46
+ # @example
47
+ # GoodJob::Execution.queue_parser('-queue1,queue2')
48
+ # => { exclude: [ 'queue1', 'queue2' ] }
49
+ def self.queue_parser(string)
50
+ string = string.strip.presence || '*'
51
+
52
+ case string.first
53
+ when '-'
54
+ exclude_queues = true
55
+ string = string[1..]
56
+ when '+'
57
+ ordered_queues = true
58
+ string = string[1..]
59
+ end
60
+
61
+ queues = string.split(',').map(&:strip)
62
+
63
+ if queues.include?('*')
64
+ { all: true }
65
+ elsif exclude_queues
66
+ { exclude: queues }
67
+ elsif ordered_queues
68
+ {
69
+ include: queues,
70
+ ordered_queues: true,
71
+ }
72
+ else
73
+ { include: queues }
74
+ end
75
+ end
13
76
 
14
77
  # With a given class name
15
78
  # @!method job_class(name)
16
79
  # @!scope class
17
- # @param name [String] Execution class name
80
+ # @param name [String] Job class name
18
81
  # @return [ActiveRecord::Relation]
19
82
  scope :job_class, ->(name) { where(params_job_class.eq(name)) }
20
83
 
84
+ # Get jobs with given ActiveJob ID
85
+ # @!method active_job_id(active_job_id)
86
+ # @!scope class
87
+ # @param active_job_id [String]
88
+ # ActiveJob ID
89
+ # @return [ActiveRecord::Relation]
90
+ scope :active_job_id, ->(active_job_id) { where(active_job_id: active_job_id) }
91
+
92
+ # Get jobs that have not yet finished (succeeded or discarded).
93
+ # @!method unfinished
94
+ # @!scope class
95
+ # @return [ActiveRecord::Relation]
96
+ scope :unfinished, -> { where(finished_at: nil) }
97
+
98
+ # Get jobs that are not scheduled for a later time than now (i.e. jobs that
99
+ # are not scheduled or scheduled for earlier than the current time).
100
+ # @!method only_scheduled
101
+ # @!scope class
102
+ # @return [ActiveRecord::Relation]
103
+ scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(bind_value('scheduled_at', DateTime.current, ActiveRecord::Type::DateTime))).or(where(scheduled_at: nil)) }
104
+
105
+ # Order jobs by priority (highest priority first).
106
+ # @!method priority_ordered
107
+ # @!scope class
108
+ # @return [ActiveRecord::Relation]
109
+ scope :priority_ordered, (lambda do
110
+ if GoodJob.configuration.smaller_number_is_higher_priority
111
+ order('priority ASC NULLS LAST')
112
+ else
113
+ order('priority DESC NULLS LAST')
114
+ end
115
+ end)
116
+
117
+ # Order jobs by created_at, for first-in first-out
118
+ # @!method creation_ordered
119
+ # @!scope class
120
+ # @return [ActiveRecord:Relation]
121
+ scope :creation_ordered, -> { order(created_at: :asc) }
122
+
123
+ # Order jobs for de-queueing
124
+ # @!method dequeueing_ordered(parsed_queues)
125
+ # @!scope class
126
+ # @param parsed_queues [Hash]
127
+ # optional output of .queue_parser, parsed queues, will be used for
128
+ # ordered queues.
129
+ # @return [ActiveRecord::Relation]
130
+ scope :dequeueing_ordered, (lambda do |parsed_queues|
131
+ relation = self
132
+ relation = relation.queue_ordered(parsed_queues[:include]) if parsed_queues && parsed_queues[:ordered_queues] && parsed_queues[:include]
133
+ relation = relation.priority_ordered.creation_ordered
134
+
135
+ relation
136
+ end)
137
+
138
+ # Order jobs in order of queues in array param
139
+ # @!method queue_ordered(queues)
140
+ # @!scope class
141
+ # @param queues [Array<string] ordered names of queues
142
+ # @return [ActiveRecord::Relation]
143
+ scope :queue_ordered, (lambda do |queues|
144
+ clauses = queues.map.with_index do |queue_name, index|
145
+ "WHEN queue_name = '#{queue_name}' THEN #{index}"
146
+ end
147
+
148
+ order(
149
+ Arel.sql("(CASE #{clauses.join(' ')} ELSE #{queues.length} END)")
150
+ )
151
+ end)
152
+
153
+ # Order jobs by scheduled or created (oldest first).
154
+ # @!method schedule_ordered
155
+ # @!scope class
156
+ # @return [ActiveRecord::Relation]
157
+ scope :schedule_ordered, -> { order(coalesce_scheduled_at_created_at.asc) }
158
+
159
+ # Get completed jobs before the given timestamp. If no timestamp is
160
+ # provided, get *all* completed jobs. By default, GoodJob
161
+ # destroys jobs after they're completed, meaning this returns no jobs.
162
+ # However, if you have changed {GoodJob.preserve_job_records}, this may
163
+ # find completed Jobs.
164
+ # @!method finished(timestamp = nil)
165
+ # @!scope class
166
+ # @param timestamp (Float)
167
+ # Get jobs that finished before this time (in epoch time).
168
+ # @return [ActiveRecord::Relation]
169
+ scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(bind_value('finished_at', timestamp, ActiveRecord::Type::DateTime))) : where.not(finished_at: nil) }
170
+
171
+ # Get Jobs that started but not finished yet.
172
+ # @!method running
173
+ # @!scope class
174
+ # @return [ActiveRecord::Relation]
175
+ scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
176
+
177
+ # Get Jobs on queues that match the given queue string.
178
+ # @!method queue_string(string)
179
+ # @!scope class
180
+ # @param string [String]
181
+ # A string expression describing what queues to select. See
182
+ # {Job.queue_parser} or
183
+ # {file:README.md#optimize-queues-threads-and-processes} for more details
184
+ # on the format of the string. Note this only handles individual
185
+ # semicolon-separated segments of that string format.
186
+ # @return [ActiveRecord::Relation]
187
+ scope :queue_string, (lambda do |string|
188
+ parsed = queue_parser(string)
189
+
190
+ if parsed[:all]
191
+ all
192
+ elsif parsed[:exclude]
193
+ where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
194
+ elsif parsed[:include]
195
+ where(queue_name: parsed[:include])
196
+ end
197
+ end)
198
+
21
199
  class << self
22
200
  def json_string(json, attr)
23
201
  Arel::Nodes::Grouping.new(Arel::Nodes::InfixOperation.new('->>', json, Arel::Nodes.build_quoted(attr)))
@@ -40,92 +218,408 @@ module GoodJob
40
218
  end
41
219
 
42
220
  def discrete_support?
43
- GoodJob::DiscreteExecution.migrated?
221
+ true
44
222
  end
223
+ end
45
224
 
46
- def error_event_migrated?
47
- return true if columns_hash["error_event"].present?
225
+ def discrete?
226
+ is_discrete?
227
+ end
48
228
 
49
- migration_pending_warning!
50
- false
229
+ # Build an ActiveJob instance and deserialize the arguments, using `#active_job_data`.
230
+ #
231
+ # @param ignore_deserialization_errors [Boolean]
232
+ # Whether to ignore ActiveJob::DeserializationError and NameError when deserializing the arguments.
233
+ # This is most useful if you aren't planning to use the arguments directly.
234
+ def active_job(ignore_deserialization_errors: false)
235
+ ActiveJob::Base.deserialize(active_job_data).tap do |aj|
236
+ aj.send(:deserialize_arguments_if_needed)
237
+ rescue ActiveJob::DeserializationError
238
+ raise unless ignore_deserialization_errors
51
239
  end
240
+ rescue NameError
241
+ raise unless ignore_deserialization_errors
242
+ end
243
+
244
+ def self.build_for_enqueue(active_job, scheduled_at: nil)
245
+ new(**enqueue_args(active_job, scheduled_at: scheduled_at))
246
+ end
247
+
248
+ # Construct arguments for GoodJob::Execution from an ActiveJob instance.
249
+ def self.enqueue_args(active_job, scheduled_at: nil)
250
+ execution_args = {
251
+ id: active_job.job_id,
252
+ active_job_id: active_job.job_id,
253
+ job_class: active_job.class.name,
254
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
255
+ priority: active_job.priority || DEFAULT_PRIORITY,
256
+ serialized_params: active_job.serialize,
257
+ created_at: Time.current,
258
+ }
52
259
 
53
- def cron_indices_migrated?
54
- return true if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond)
260
+ execution_args[:scheduled_at] = if scheduled_at
261
+ scheduled_at
262
+ elsif active_job.scheduled_at
263
+ Time.zone.at(active_job.scheduled_at)
264
+ else
265
+ execution_args[:created_at]
266
+ end
55
267
 
56
- migration_pending_warning!
57
- false
268
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
269
+
270
+ if active_job.respond_to?(:good_job_labels) && active_job.good_job_labels.any?
271
+ labels = active_job.good_job_labels.dup
272
+ labels.map! { |label| label.to_s.strip.presence }
273
+ labels.tap(&:compact!).tap(&:uniq!)
274
+ execution_args[:labels] = labels
58
275
  end
59
276
 
60
- def labels_migrated?
61
- return true if columns_hash["labels"].present?
277
+ reenqueued_current_job = CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
278
+ current_job = CurrentThread.job
62
279
 
63
- migration_pending_warning!
64
- false
280
+ if reenqueued_current_job
281
+ execution_args[:batch_id] = current_job.batch_id
282
+ execution_args[:batch_callback_id] = current_job.batch_callback_id
283
+ execution_args[:cron_key] = current_job.cron_key
284
+ else
285
+ execution_args[:batch_id] = GoodJob::Batch.current_batch_id
286
+ execution_args[:batch_callback_id] = GoodJob::Batch.current_batch_callback_id
287
+ execution_args[:cron_key] = CurrentThread.cron_key
288
+ execution_args[:cron_at] = CurrentThread.cron_at
65
289
  end
66
290
 
67
- def labels_indices_migrated?
68
- return true if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels)
291
+ execution_args
292
+ end
293
+
294
+ # Finds the next eligible Execution, acquire an advisory lock related to it, and
295
+ # executes the job.
296
+ # @yield [Execution, nil] The next eligible Execution, or +nil+ if none found, before it is performed.
297
+ # @return [ExecutionResult, nil]
298
+ # If a job was executed, returns an array with the {Execution} record, the
299
+ # return value for the job's +#perform+ method, and the exception the job
300
+ # raised, if any (if the job raised, then the second array entry will be
301
+ # +nil+). If there were no jobs to execute, returns +nil+.
302
+ def self.perform_with_advisory_lock(lock_id:, parsed_queues: nil, queue_select_limit: nil)
303
+ job = nil
304
+ result = nil
305
+
306
+ unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(select_limit: queue_select_limit) do |jobs|
307
+ job = jobs.first
308
+
309
+ if job&.executable?
310
+ yield(job) if block_given?
69
311
 
70
- migration_pending_warning!
71
- false
312
+ result = job.perform(lock_id: lock_id)
313
+ else
314
+ job = nil
315
+ yield(nil) if block_given?
316
+ end
72
317
  end
73
318
 
74
- def active_job_id_index_removal_migrated?
75
- return true unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id)
319
+ job&.run_callbacks(:perform_unlocked)
320
+ result
321
+ end
322
+
323
+ # Fetches the scheduled execution time of the next eligible Execution(s).
324
+ # @param after [DateTime]
325
+ # @param limit [Integer]
326
+ # @param now_limit [Integer, nil]
327
+ # @return [Array<DateTime>]
328
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
329
+ query = advisory_unlocked.unfinished.schedule_ordered
76
330
 
77
- migration_pending_warning!
78
- false
331
+ after ||= Time.current
332
+ after_bind = bind_value('scheduled_at', after, ActiveRecord::Type::DateTime)
333
+ after_query = query.where(arel_table['scheduled_at'].gt(after_bind)).or query.where(scheduled_at: nil).where(arel_table['created_at'].gt(after_bind))
334
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
335
+
336
+ if now_limit&.positive?
337
+ now_query = query.where(arel_table['scheduled_at'].lt(bind_value('scheduled_at', Time.current, ActiveRecord::Type::DateTime))).or query.where(scheduled_at: nil)
338
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
79
339
  end
80
340
 
81
- def candidate_lookup_index_migrated?
82
- return true if connection.index_name_exists?(:good_jobs, :index_good_job_jobs_for_candidate_lookup)
341
+ Array(now_at) + after_at
342
+ end
343
+
344
+ # Places an ActiveJob job on a queue by creating a new {Execution} record.
345
+ # @param active_job [ActiveJob::Base]
346
+ # The job to enqueue.
347
+ # @param scheduled_at [Float]
348
+ # Epoch timestamp when the job should be executed, if blank will delegate to the ActiveJob instance
349
+ # @param create_with_advisory_lock [Boolean]
350
+ # Whether to establish a lock on the {Execution} record after it is created.
351
+ # @return [Execution]
352
+ # The new {Execution} instance representing the queued ActiveJob job.
353
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
354
+ 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|
355
+ current_job = CurrentThread.job
356
+
357
+ retried = current_job && current_job.active_job_id == active_job.job_id
358
+ if retried
359
+ job = current_job
360
+ job.assign_attributes(enqueue_args(active_job, scheduled_at: scheduled_at))
361
+ job.scheduled_at ||= Time.current
362
+ # TODO: these values ideally shouldn't be persisted until the current_job is finished
363
+ # which will require handling `retry_job` being called from outside the job context.
364
+ job.performed_at = nil
365
+ job.finished_at = nil
366
+ else
367
+ job = build_for_enqueue(active_job, scheduled_at: scheduled_at)
368
+ end
369
+
370
+ if create_with_advisory_lock
371
+ if job.persisted?
372
+ job.advisory_lock
373
+ else
374
+ job.create_with_advisory_lock = true
375
+ end
376
+ end
377
+
378
+ instrument_payload[:job] = job
379
+ job.save!
380
+
381
+ CurrentThread.execution_retried = (job if retried)
83
382
 
84
- migration_pending_warning!
85
- false
383
+ active_job.provider_job_id = job.id
384
+ raise "These should be equal" if active_job.provider_job_id != active_job.job_id
385
+
386
+ job
86
387
  end
388
+ end
389
+
390
+ def self.format_error(error)
391
+ raise ArgumentError unless error.is_a?(Exception)
392
+
393
+ [error.class.to_s, ERROR_MESSAGE_SEPARATOR, error.message].join
394
+ end
395
+
396
+ # Execute the ActiveJob job this {Execution} represents.
397
+ # @return [ExecutionResult]
398
+ # An array of the return value of the job's +#perform+ method and the
399
+ # exception raised by the job, if any. If the job completed successfully,
400
+ # the second array entry (the exception) will be +nil+ and vice versa.
401
+ def perform(lock_id:)
402
+ run_callbacks(:perform) do
403
+ raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
404
+
405
+ job_performed_at = Time.current
406
+ monotonic_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
407
+ discrete_execution = nil
408
+ result = GoodJob::CurrentThread.within do |current_thread|
409
+ current_thread.reset
410
+ current_thread.job = self
411
+
412
+ existing_performed_at = performed_at
413
+ if existing_performed_at
414
+ current_thread.execution_interrupted = existing_performed_at
415
+
416
+ interrupt_error_string = self.class.format_error(GoodJob::InterruptError.new("Interrupted after starting perform at '#{existing_performed_at}'"))
417
+ self.error = interrupt_error_string
418
+ self.error_event = ERROR_EVENT_INTERRUPTED
419
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
420
+
421
+ discrete_execution_attrs = {
422
+ error: interrupt_error_string,
423
+ finished_at: job_performed_at,
424
+ }
425
+ discrete_execution_attrs[:error_event] = GoodJob::ErrorEvents::ERROR_EVENT_ENUMS[GoodJob::ErrorEvents::ERROR_EVENT_INTERRUPTED]
426
+ discrete_execution_attrs[:duration] = monotonic_duration
427
+ discrete_executions.where(finished_at: nil).where.not(performed_at: nil).update_all(discrete_execution_attrs) # rubocop:disable Rails/SkipsModelValidations
428
+ end
429
+
430
+ transaction do
431
+ discrete_execution_attrs = {
432
+ job_class: job_class,
433
+ queue_name: queue_name,
434
+ serialized_params: serialized_params,
435
+ scheduled_at: (scheduled_at || created_at),
436
+ created_at: job_performed_at,
437
+ process_id: lock_id,
438
+ }
439
+ job_attrs = {
440
+ performed_at: job_performed_at,
441
+ executions_count: ((executions_count || 0) + 1),
442
+ locked_by_id: lock_id,
443
+ locked_at: Time.current,
444
+ }
445
+
446
+ discrete_execution = discrete_executions.create!(discrete_execution_attrs)
447
+ update!(job_attrs)
448
+ end
449
+
450
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { job: self, execution: discrete_execution, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do |instrument_payload|
451
+ value = ActiveJob::Base.execute(active_job_data)
452
+
453
+ if value.is_a?(Exception)
454
+ handled_error = value
455
+ value = nil
456
+ end
457
+ handled_error ||= current_thread.error_on_retry || current_thread.error_on_discard
458
+
459
+ error_event = if handled_error == current_thread.error_on_discard
460
+ ERROR_EVENT_DISCARDED
461
+ elsif handled_error == current_thread.error_on_retry
462
+ ERROR_EVENT_RETRIED
463
+ elsif handled_error == current_thread.error_on_retry_stopped
464
+ ERROR_EVENT_RETRY_STOPPED
465
+ elsif handled_error
466
+ ERROR_EVENT_HANDLED
467
+ end
468
+
469
+ instrument_payload.merge!(
470
+ value: value,
471
+ handled_error: handled_error,
472
+ retried: current_thread.execution_retried.present?,
473
+ error_event: error_event
474
+ )
475
+ ExecutionResult.new(value: value, handled_error: handled_error, error_event: error_event, retried: current_thread.execution_retried)
476
+ rescue StandardError => e
477
+ error_event = if e.is_a?(GoodJob::InterruptError)
478
+ ERROR_EVENT_INTERRUPTED
479
+ elsif e == current_thread.error_on_retry_stopped
480
+ ERROR_EVENT_RETRY_STOPPED
481
+ else
482
+ ERROR_EVENT_UNHANDLED
483
+ end
484
+
485
+ instrument_payload[:unhandled_error] = e
486
+ ExecutionResult.new(value: nil, unhandled_error: e, error_event: error_event)
487
+ end
488
+ end
489
+
490
+ job_attributes = if self.class.columns_hash.key?("locked_by_id")
491
+ { locked_by_id: nil, locked_at: nil }
492
+ else
493
+ {}
494
+ end
495
+
496
+ job_error = result.handled_error || result.unhandled_error
497
+ if job_error
498
+ error_string = self.class.format_error(job_error)
499
+
500
+ job_attributes[:error] = error_string
501
+ job_attributes[:error_event] = result.error_event
502
+
503
+ discrete_execution.error = error_string
504
+ discrete_execution.error_event = result.error_event
505
+ discrete_execution.error_backtrace = job_error.backtrace
506
+ else
507
+ job_attributes[:error] = nil
508
+ job_attributes[:error_event] = nil
509
+ end
87
510
 
88
- def process_lock_migrated?
89
- return true if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
511
+ job_finished_at = Time.current
512
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
513
+ job_attributes[:finished_at] = job_finished_at
90
514
 
91
- migration_pending_warning!
92
- false
515
+ discrete_execution.finished_at = job_finished_at
516
+ discrete_execution.duration = monotonic_duration
517
+
518
+ retry_unhandled_error = result.unhandled_error && GoodJob.retry_on_unhandled_error
519
+ reenqueued = result.retried? || retried_good_job_id.present? || retry_unhandled_error
520
+ if reenqueued
521
+ job_attributes[:performed_at] = nil
522
+ job_attributes[:finished_at] = nil
523
+ end
524
+
525
+ assign_attributes(job_attributes)
526
+ preserve_unhandled = (result.unhandled_error && (GoodJob.retry_on_unhandled_error || GoodJob.preserve_job_records == :on_unhandled_error))
527
+ if finished_at.blank? || GoodJob.preserve_job_records == true || reenqueued || preserve_unhandled || cron_key.present?
528
+ transaction do
529
+ discrete_execution.save!
530
+ save!
531
+ end
532
+ else
533
+ destroy!
534
+ end
535
+
536
+ result
93
537
  end
94
538
  end
95
539
 
96
- # The ActiveJob job class, as a string
97
- # @return [String]
98
- def job_class
99
- discrete? ? attributes['job_class'] : serialized_params['job_class']
540
+ # Tests whether this job is safe to be executed by this thread.
541
+ # @return [Boolean]
542
+ def executable?
543
+ reload.finished_at.blank?
544
+ rescue ActiveRecord::RecordNotFound
545
+ false
100
546
  end
101
547
 
102
- def discrete?
103
- self.class.discrete_support? && is_discrete?
548
+ def make_discrete
549
+ self.is_discrete = true
550
+ self.id = active_job_id
551
+ self.job_class = serialized_params['job_class']
552
+ self.executions_count ||= 0
553
+
554
+ current_time = Time.current
555
+ self.created_at ||= current_time
556
+ self.scheduled_at ||= current_time
104
557
  end
105
558
 
106
- # Build an ActiveJob instance and deserialize the arguments, using `#active_job_data`.
107
- #
108
- # @param ignore_deserialization_errors [Boolean]
109
- # Whether to ignore ActiveJob::DeserializationError and NameError when deserializing the arguments.
110
- # This is most useful if you aren't planning to use the arguments directly.
111
- def active_job(ignore_deserialization_errors: false)
112
- ActiveJob::Base.deserialize(active_job_data).tap do |aj|
113
- aj.send(:deserialize_arguments_if_needed)
114
- rescue ActiveJob::DeserializationError
115
- raise unless ignore_deserialization_errors
559
+ # Return formatted serialized_params for display in the dashboard
560
+ # @return [Hash]
561
+ def display_serialized_params
562
+ serialized_params.merge({
563
+ _good_job: attributes.except('serialized_params', 'locktype', 'owns_advisory_lock'),
564
+ })
565
+ end
566
+
567
+ def running?
568
+ if has_attribute?(:locktype)
569
+ self['locktype'].present?
570
+ else
571
+ advisory_locked?
116
572
  end
117
- rescue NameError
118
- raise unless ignore_deserialization_errors
573
+ end
574
+
575
+ def number
576
+ serialized_params.fetch('executions', 0) + 1
577
+ end
578
+
579
+ # Time between when this job was expected to run and when it started running
580
+ def queue_latency
581
+ now = Time.zone.now
582
+ expected_start = scheduled_at || created_at
583
+ actual_start = performed_at || finished_at || now
584
+
585
+ actual_start - expected_start unless expected_start >= now
586
+ end
587
+
588
+ # Time between when this job started and finished
589
+ def runtime_latency
590
+ (finished_at || Time.zone.now) - performed_at if performed_at
591
+ end
592
+
593
+ # Destroys this execution and all executions within the same job
594
+ def destroy_job
595
+ @_destroy_job = true
596
+ destroy!
597
+ ensure
598
+ @_destroy_job = false
599
+ end
600
+
601
+ def job_state
602
+ state = { queue_name: queue_name }
603
+ state[:scheduled_at] = scheduled_at if scheduled_at
604
+ state
119
605
  end
120
606
 
121
607
  private
122
608
 
609
+ def reset_batch_values(&block)
610
+ GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
611
+ end
612
+
613
+ def continue_discard_or_finish_batch
614
+ batch._continue_discard_or_finish(self) if batch.present?
615
+ end
616
+
123
617
  def active_job_data
124
618
  serialized_params.deep_dup
125
619
  .tap do |job_data|
126
620
  job_data["provider_job_id"] = id
127
621
  job_data["good_job_concurrency_key"] = concurrency_key if concurrency_key
128
- job_data["good_job_labels"] = Array(labels) if self.class.labels_migrated? && labels.present?
622
+ job_data["good_job_labels"] = Array(labels) if labels.present?
129
623
  end
130
624
  end
131
625
  end
@@ -72,8 +72,6 @@ module GoodJob # :nodoc:
72
72
  end
73
73
 
74
74
  def enabled?
75
- return true unless GoodJob::Setting.migrated?
76
-
77
75
  GoodJob::Setting.cron_key_enabled?(key, default: enabled_by_default?)
78
76
  end
79
77