good_job 3.29.5 → 3.30.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +3 -3
  4. data/app/controllers/good_job/performances_controller.rb +23 -0
  5. data/app/filters/good_job/base_filter.rb +1 -1
  6. data/app/helpers/good_job/application_helper.rb +1 -0
  7. data/app/models/concerns/good_job/error_events.rb +2 -2
  8. data/app/models/good_job/base_execution.rb +594 -2
  9. data/app/models/good_job/discrete_execution.rb +18 -2
  10. data/app/models/good_job/execution.rb +2 -597
  11. data/app/models/good_job/job.rb +1 -1
  12. data/app/views/good_job/performances/show.html.erb +50 -0
  13. data/app/views/good_job/shared/_navbar.erb +5 -0
  14. data/config/locales/de.yml +11 -0
  15. data/config/locales/en.yml +11 -0
  16. data/config/locales/es.yml +11 -0
  17. data/config/locales/fr.yml +11 -0
  18. data/config/locales/it.yml +11 -0
  19. data/config/locales/ja.yml +11 -0
  20. data/config/locales/ko.yml +11 -0
  21. data/config/locales/nl.yml +11 -0
  22. data/config/locales/pt-BR.yml +11 -0
  23. data/config/locales/ru.yml +11 -0
  24. data/config/locales/tr.yml +11 -0
  25. data/config/locales/uk.yml +11 -0
  26. data/config/routes.rb +2 -0
  27. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +1 -0
  28. data/lib/generators/good_job/templates/update/migrations/13_create_good_job_process_lock_ids.rb.erb +1 -0
  29. data/lib/generators/good_job/templates/update/migrations/14_create_good_job_process_lock_indexes.rb.erb +1 -0
  30. data/lib/generators/good_job/templates/update/migrations/15_create_good_job_execution_duration.rb.erb +15 -0
  31. data/lib/good_job/version.rb +1 -1
  32. data/lib/good_job.rb +2 -2
  33. metadata +5 -2
@@ -4,20 +4,206 @@ 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
76
+
77
+ belongs_to :batch, class_name: 'GoodJob::BatchRecord', optional: true, inverse_of: :executions
78
+ has_many :discrete_executions, class_name: 'GoodJob::DiscreteExecution', foreign_key: 'active_job_id', primary_key: 'active_job_id', inverse_of: :execution # rubocop:disable Rails/HasManyOrHasOneDependent
13
79
 
14
80
  # With a given class name
15
81
  # @!method job_class(name)
16
82
  # @!scope class
17
- # @param name [String] Execution class name
83
+ # @param name [String] Job class name
18
84
  # @return [ActiveRecord::Relation]
19
85
  scope :job_class, ->(name) { where(params_job_class.eq(name)) }
20
86
 
87
+ after_destroy lambda {
88
+ GoodJob::DiscreteExecution.where(active_job_id: active_job_id).delete_all if discrete? # TODO: move into association `dependent: :delete_all` after v4
89
+ self.class.active_job_id(active_job_id).delete_all
90
+ }, if: -> { @_destroy_job }
91
+
92
+ # Get jobs with given ActiveJob ID
93
+ # @!method active_job_id(active_job_id)
94
+ # @!scope class
95
+ # @param active_job_id [String]
96
+ # ActiveJob ID
97
+ # @return [ActiveRecord::Relation]
98
+ scope :active_job_id, ->(active_job_id) { where(active_job_id: active_job_id) }
99
+
100
+ # Get jobs that have not yet finished (succeeded or discarded).
101
+ # @!method unfinished
102
+ # @!scope class
103
+ # @return [ActiveRecord::Relation]
104
+ scope :unfinished, -> { where(finished_at: nil) }
105
+
106
+ # Get jobs that are not scheduled for a later time than now (i.e. jobs that
107
+ # are not scheduled or scheduled for earlier than the current time).
108
+ # @!method only_scheduled
109
+ # @!scope class
110
+ # @return [ActiveRecord::Relation]
111
+ scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(bind_value('scheduled_at', DateTime.current, ActiveRecord::Type::DateTime))).or(where(scheduled_at: nil)) }
112
+
113
+ # Order jobs by priority (highest priority first).
114
+ # @!method priority_ordered
115
+ # @!scope class
116
+ # @return [ActiveRecord::Relation]
117
+ scope :priority_ordered, (lambda do
118
+ if GoodJob.configuration.smaller_number_is_higher_priority
119
+ order('priority ASC NULLS LAST')
120
+ else
121
+ order('priority DESC NULLS LAST')
122
+ end
123
+ end)
124
+
125
+ # Order jobs by created_at, for first-in first-out
126
+ # @!method creation_ordered
127
+ # @!scope class
128
+ # @return [ActiveRecord:Relation]
129
+ scope :creation_ordered, -> { order(created_at: :asc) }
130
+
131
+ # Order jobs for de-queueing
132
+ # @!method dequeueing_ordered(parsed_queues)
133
+ # @!scope class
134
+ # @param parsed_queues [Hash]
135
+ # optional output of .queue_parser, parsed queues, will be used for
136
+ # ordered queues.
137
+ # @return [ActiveRecord::Relation]
138
+ scope :dequeueing_ordered, (lambda do |parsed_queues|
139
+ relation = self
140
+ relation = relation.queue_ordered(parsed_queues[:include]) if parsed_queues && parsed_queues[:ordered_queues] && parsed_queues[:include]
141
+ relation = relation.priority_ordered.creation_ordered
142
+
143
+ relation
144
+ end)
145
+
146
+ # Order jobs in order of queues in array param
147
+ # @!method queue_ordered(queues)
148
+ # @!scope class
149
+ # @param queues [Array<string] ordered names of queues
150
+ # @return [ActiveRecord::Relation]
151
+ scope :queue_ordered, (lambda do |queues|
152
+ clauses = queues.map.with_index do |queue_name, index|
153
+ "WHEN queue_name = '#{queue_name}' THEN #{index}"
154
+ end
155
+
156
+ order(
157
+ Arel.sql("(CASE #{clauses.join(' ')} ELSE #{queues.length} END)")
158
+ )
159
+ end)
160
+
161
+ # Order jobs by scheduled or created (oldest first).
162
+ # @!method schedule_ordered
163
+ # @!scope class
164
+ # @return [ActiveRecord::Relation]
165
+ scope :schedule_ordered, -> { order(coalesce_scheduled_at_created_at.asc) }
166
+
167
+ # Get completed jobs before the given timestamp. If no timestamp is
168
+ # provided, get *all* completed jobs. By default, GoodJob
169
+ # destroys jobs after they're completed, meaning this returns no jobs.
170
+ # However, if you have changed {GoodJob.preserve_job_records}, this may
171
+ # find completed Jobs.
172
+ # @!method finished(timestamp = nil)
173
+ # @!scope class
174
+ # @param timestamp (Float)
175
+ # Get jobs that finished before this time (in epoch time).
176
+ # @return [ActiveRecord::Relation]
177
+ scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(bind_value('finished_at', timestamp, ActiveRecord::Type::DateTime))) : where.not(finished_at: nil) }
178
+
179
+ # Get Jobs that started but not finished yet.
180
+ # @!method running
181
+ # @!scope class
182
+ # @return [ActiveRecord::Relation]
183
+ scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
184
+
185
+ # Get Jobs on queues that match the given queue string.
186
+ # @!method queue_string(string)
187
+ # @!scope class
188
+ # @param string [String]
189
+ # A string expression describing what queues to select. See
190
+ # {Job.queue_parser} or
191
+ # {file:README.md#optimize-queues-threads-and-processes} for more details
192
+ # on the format of the string. Note this only handles individual
193
+ # semicolon-separated segments of that string format.
194
+ # @return [ActiveRecord::Relation]
195
+ scope :queue_string, (lambda do |string|
196
+ parsed = queue_parser(string)
197
+
198
+ if parsed[:all]
199
+ all
200
+ elsif parsed[:exclude]
201
+ where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
202
+ elsif parsed[:include]
203
+ where(queue_name: parsed[:include])
204
+ end
205
+ end)
206
+
21
207
  class << self
22
208
  def json_string(json, attr)
23
209
  Arel::Nodes::Grouping.new(Arel::Nodes::InfixOperation.new('->>', json, Arel::Nodes.build_quoted(attr)))
@@ -118,8 +304,414 @@ module GoodJob
118
304
  raise unless ignore_deserialization_errors
119
305
  end
120
306
 
307
+ def self.build_for_enqueue(active_job, overrides = {})
308
+ new(**enqueue_args(active_job, overrides))
309
+ end
310
+
311
+ # Construct arguments for GoodJob::Execution from an ActiveJob instance.
312
+ def self.enqueue_args(active_job, overrides = {})
313
+ if active_job.priority && GoodJob.configuration.smaller_number_is_higher_priority.nil?
314
+ GoodJob.deprecator.warn(<<~DEPRECATION)
315
+ The next major version of GoodJob (v4.0) will change job `priority` to give smaller numbers higher priority (default: `0`), in accordance with Active Job's definition of priority.
316
+ To opt-in to this behavior now, set `config.good_job.smaller_number_is_higher_priority = true` in your GoodJob initializer or application.rb.
317
+ To not opt-in yet, but silence this deprecation warning, set `config.good_job.smaller_number_is_higher_priority = false`.
318
+ DEPRECATION
319
+ end
320
+
321
+ execution_args = {
322
+ active_job_id: active_job.job_id,
323
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
324
+ priority: active_job.priority || DEFAULT_PRIORITY,
325
+ serialized_params: active_job.serialize,
326
+ }
327
+ execution_args[:scheduled_at] = Time.zone.at(active_job.scheduled_at) if active_job.scheduled_at
328
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
329
+
330
+ if active_job.respond_to?(:good_job_labels) && active_job.good_job_labels.any? && labels_migrated?
331
+ labels = active_job.good_job_labels.dup
332
+ labels.map! { |label| label.to_s.strip.presence }
333
+ labels.tap(&:compact!).tap(&:uniq!)
334
+ execution_args[:labels] = labels
335
+ end
336
+
337
+ reenqueued_current_execution = CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
338
+ current_execution = CurrentThread.execution
339
+
340
+ if reenqueued_current_execution
341
+ if GoodJob::BatchRecord.migrated?
342
+ execution_args[:batch_id] = current_execution.batch_id
343
+ execution_args[:batch_callback_id] = current_execution.batch_callback_id
344
+ end
345
+ execution_args[:cron_key] = current_execution.cron_key
346
+ else
347
+ if GoodJob::BatchRecord.migrated?
348
+ execution_args[:batch_id] = GoodJob::Batch.current_batch_id
349
+ execution_args[:batch_callback_id] = GoodJob::Batch.current_batch_callback_id
350
+ end
351
+ execution_args[:cron_key] = CurrentThread.cron_key
352
+ execution_args[:cron_at] = CurrentThread.cron_at
353
+ end
354
+
355
+ execution_args.merge(overrides)
356
+ end
357
+
358
+ # Finds the next eligible Execution, acquire an advisory lock related to it, and
359
+ # executes the job.
360
+ # @yield [Execution, nil] The next eligible Execution, or +nil+ if none found, before it is performed.
361
+ # @return [ExecutionResult, nil]
362
+ # If a job was executed, returns an array with the {Execution} record, the
363
+ # return value for the job's +#perform+ method, and the exception the job
364
+ # raised, if any (if the job raised, then the second array entry will be
365
+ # +nil+). If there were no jobs to execute, returns +nil+.
366
+ def self.perform_with_advisory_lock(lock_id:, parsed_queues: nil, queue_select_limit: nil)
367
+ execution = nil
368
+ result = nil
369
+
370
+ unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(select_limit: queue_select_limit) do |executions|
371
+ execution = executions.first
372
+ if execution&.executable?
373
+ yield(execution) if block_given?
374
+ result = execution.perform(lock_id: lock_id)
375
+ else
376
+ execution = nil
377
+ yield(nil) if block_given?
378
+ end
379
+ end
380
+
381
+ execution&.run_callbacks(:perform_unlocked)
382
+ result
383
+ end
384
+
385
+ # Fetches the scheduled execution time of the next eligible Execution(s).
386
+ # @param after [DateTime]
387
+ # @param limit [Integer]
388
+ # @param now_limit [Integer, nil]
389
+ # @return [Array<DateTime>]
390
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
391
+ query = advisory_unlocked.unfinished.schedule_ordered
392
+
393
+ after ||= Time.current
394
+ after_bind = bind_value('scheduled_at', after, ActiveRecord::Type::DateTime)
395
+ 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))
396
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
397
+
398
+ if now_limit&.positive?
399
+ now_query = query.where(arel_table['scheduled_at'].lt(bind_value('scheduled_at', Time.current, ActiveRecord::Type::DateTime))).or query.where(scheduled_at: nil)
400
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
401
+ end
402
+
403
+ Array(now_at) + after_at
404
+ end
405
+
406
+ # Places an ActiveJob job on a queue by creating a new {Execution} record.
407
+ # @param active_job [ActiveJob::Base]
408
+ # The job to enqueue.
409
+ # @param scheduled_at [Float]
410
+ # Epoch timestamp when the job should be executed, if blank will delegate to the ActiveJob instance
411
+ # @param create_with_advisory_lock [Boolean]
412
+ # Whether to establish a lock on the {Execution} record after it is created.
413
+ # @return [Execution]
414
+ # The new {Execution} instance representing the queued ActiveJob job.
415
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
416
+ 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|
417
+ current_execution = CurrentThread.execution
418
+
419
+ retried = current_execution && current_execution.active_job_id == active_job.job_id
420
+ if retried
421
+ if current_execution.discrete?
422
+ execution = current_execution
423
+ execution.assign_attributes(enqueue_args(active_job, { scheduled_at: scheduled_at }))
424
+ execution.scheduled_at ||= Time.current
425
+ # TODO: these values ideally shouldn't be persisted until the current_execution is finished
426
+ # which will require handling `retry_job` being called from outside the execution context.
427
+ execution.performed_at = nil
428
+ execution.finished_at = nil
429
+ else
430
+ execution = build_for_enqueue(active_job, { scheduled_at: scheduled_at })
431
+ end
432
+ else
433
+ execution = build_for_enqueue(active_job, { scheduled_at: scheduled_at })
434
+ execution.make_discrete if discrete_support?
435
+ end
436
+
437
+ if create_with_advisory_lock
438
+ if execution.persisted?
439
+ execution.advisory_lock
440
+ else
441
+ execution.create_with_advisory_lock = true
442
+ end
443
+ end
444
+
445
+ instrument_payload[:execution] = execution
446
+ execution.save!
447
+
448
+ if retried
449
+ CurrentThread.execution_retried = execution
450
+ CurrentThread.execution.retried_good_job_id = execution.id unless current_execution.discrete?
451
+ else
452
+ CurrentThread.execution_retried = nil
453
+ end
454
+
455
+ active_job.provider_job_id = execution.id
456
+ execution
457
+ end
458
+ end
459
+
460
+ def self.format_error(error)
461
+ raise ArgumentError unless error.is_a?(Exception)
462
+
463
+ [error.class.to_s, ERROR_MESSAGE_SEPARATOR, error.message].join
464
+ end
465
+
466
+ # Execute the ActiveJob job this {Execution} represents.
467
+ # @return [ExecutionResult]
468
+ # An array of the return value of the job's +#perform+ method and the
469
+ # exception raised by the job, if any. If the job completed successfully,
470
+ # the second array entry (the exception) will be +nil+ and vice versa.
471
+ def perform(lock_id:)
472
+ run_callbacks(:perform) do
473
+ raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
474
+
475
+ job_performed_at = Time.current
476
+ monotonic_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
477
+ discrete_execution = nil
478
+ result = GoodJob::CurrentThread.within do |current_thread|
479
+ current_thread.reset
480
+ current_thread.execution = self
481
+
482
+ existing_performed_at = performed_at
483
+ if existing_performed_at
484
+ current_thread.execution_interrupted = existing_performed_at
485
+
486
+ if discrete?
487
+ interrupt_error_string = self.class.format_error(GoodJob::InterruptError.new("Interrupted after starting perform at '#{existing_performed_at}'"))
488
+ self.error = interrupt_error_string
489
+ self.error_event = ERROR_EVENT_INTERRUPTED if self.class.error_event_migrated?
490
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
491
+
492
+ discrete_execution_attrs = {
493
+ error: interrupt_error_string,
494
+ finished_at: job_performed_at,
495
+ }
496
+ discrete_execution_attrs[:error_event] = GoodJob::ErrorEvents::ERROR_EVENT_ENUMS[GoodJob::ErrorEvents::ERROR_EVENT_INTERRUPTED] if self.class.error_event_migrated?
497
+ discrete_execution_attrs[:duration] = monotonic_duration if GoodJob::DiscreteExecution.duration_interval_usable?
498
+ discrete_executions.where(finished_at: nil).where.not(performed_at: nil).update_all(discrete_execution_attrs) # rubocop:disable Rails/SkipsModelValidations
499
+ end
500
+ end
501
+
502
+ if discrete?
503
+ transaction do
504
+ discrete_execution_attrs = {
505
+ job_class: job_class,
506
+ queue_name: queue_name,
507
+ serialized_params: serialized_params,
508
+ scheduled_at: (scheduled_at || created_at),
509
+ created_at: job_performed_at,
510
+ }
511
+ discrete_execution_attrs[:process_id] = lock_id if GoodJob::DiscreteExecution.columns_hash.key?("process_id")
512
+
513
+ execution_attrs = {
514
+ performed_at: job_performed_at,
515
+ executions_count: ((executions_count || 0) + 1),
516
+ }
517
+ if GoodJob::Execution.columns_hash.key?("locked_by_id")
518
+ execution_attrs[:locked_by_id] = lock_id
519
+ execution_attrs[:locked_at] = Time.current
520
+ end
521
+
522
+ discrete_execution = discrete_executions.create!(discrete_execution_attrs)
523
+ update!(execution_attrs)
524
+ end
525
+ else
526
+ execution_attrs = {
527
+ performed_at: job_performed_at,
528
+ }
529
+ if GoodJob::Execution.columns_hash.key?("locked_by_id")
530
+ execution_attrs[:locked_by_id] = lock_id
531
+ execution_attrs[:locked_at] = Time.current
532
+ end
533
+
534
+ update!(execution_attrs)
535
+ end
536
+
537
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { execution: self, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do |instrument_payload|
538
+ value = ActiveJob::Base.execute(active_job_data)
539
+
540
+ if value.is_a?(Exception)
541
+ handled_error = value
542
+ value = nil
543
+ end
544
+ handled_error ||= current_thread.error_on_retry || current_thread.error_on_discard
545
+
546
+ error_event = if handled_error == current_thread.error_on_discard
547
+ ERROR_EVENT_DISCARDED
548
+ elsif handled_error == current_thread.error_on_retry
549
+ ERROR_EVENT_RETRIED
550
+ elsif handled_error == current_thread.error_on_retry_stopped
551
+ ERROR_EVENT_RETRY_STOPPED
552
+ elsif handled_error
553
+ ERROR_EVENT_HANDLED
554
+ end
555
+
556
+ instrument_payload.merge!(
557
+ value: value,
558
+ handled_error: handled_error,
559
+ retried: current_thread.execution_retried.present?,
560
+ error_event: error_event
561
+ )
562
+ ExecutionResult.new(value: value, handled_error: handled_error, error_event: error_event, retried: current_thread.execution_retried)
563
+ rescue StandardError => e
564
+ error_event = if e.is_a?(GoodJob::InterruptError)
565
+ ERROR_EVENT_INTERRUPTED
566
+ elsif e == current_thread.error_on_retry_stopped
567
+ ERROR_EVENT_RETRY_STOPPED
568
+ else
569
+ ERROR_EVENT_UNHANDLED
570
+ end
571
+
572
+ instrument_payload[:unhandled_error] = e
573
+ ExecutionResult.new(value: nil, unhandled_error: e, error_event: error_event)
574
+ end
575
+ end
576
+
577
+ job_attributes = if self.class.columns_hash.key?("locked_by_id")
578
+ { locked_by_id: nil, locked_at: nil }
579
+ else
580
+ {}
581
+ end
582
+
583
+ job_error = result.handled_error || result.unhandled_error
584
+ if job_error
585
+ error_string = self.class.format_error(job_error)
586
+
587
+ job_attributes[:error] = error_string
588
+ job_attributes[:error_event] = result.error_event if self.class.error_event_migrated?
589
+ if discrete_execution
590
+ discrete_execution.error = error_string
591
+ discrete_execution.error_event = result.error_event
592
+ discrete_execution.error_backtrace = job_error.backtrace if discrete_execution.class.backtrace_migrated?
593
+ end
594
+ else
595
+ job_attributes[:error] = nil
596
+ job_attributes[:error_event] = nil
597
+ end
598
+ job_attributes.delete(:error_event) unless self.class.error_event_migrated?
599
+
600
+ job_finished_at = Time.current
601
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
602
+ job_attributes[:finished_at] = job_finished_at
603
+ if discrete_execution
604
+ discrete_execution.finished_at = job_finished_at
605
+ discrete_execution.duration = monotonic_duration if GoodJob::DiscreteExecution.duration_interval_usable?
606
+ end
607
+
608
+ retry_unhandled_error = result.unhandled_error && GoodJob.retry_on_unhandled_error
609
+ reenqueued = result.retried? || retried_good_job_id.present? || retry_unhandled_error
610
+ if reenqueued
611
+ if discrete_execution
612
+ job_attributes[:performed_at] = nil
613
+ job_attributes[:finished_at] = nil
614
+ else
615
+ job_attributes[:retried_good_job_id] = retried_good_job_id
616
+ job_attributes[:finished_at] = nil if retry_unhandled_error
617
+ end
618
+ end
619
+
620
+ preserve_unhandled = (result.unhandled_error && (GoodJob.retry_on_unhandled_error || GoodJob.preserve_job_records == :on_unhandled_error))
621
+ if GoodJob.preserve_job_records == true || reenqueued || preserve_unhandled || cron_key.present?
622
+ if discrete_execution
623
+ transaction do
624
+ discrete_execution.save!
625
+ update!(job_attributes)
626
+ end
627
+ else
628
+ update!(job_attributes)
629
+ end
630
+ else
631
+ destroy_job
632
+ end
633
+
634
+ result
635
+ end
636
+ end
637
+
638
+ # Tests whether this job is safe to be executed by this thread.
639
+ # @return [Boolean]
640
+ def executable?
641
+ reload.finished_at.blank?
642
+ rescue ActiveRecord::RecordNotFound
643
+ false
644
+ end
645
+
646
+ def make_discrete
647
+ self.is_discrete = true
648
+ self.id = active_job_id
649
+ self.job_class = serialized_params['job_class']
650
+ self.executions_count ||= 0
651
+
652
+ current_time = Time.current
653
+ self.created_at ||= current_time
654
+ self.scheduled_at ||= current_time
655
+ end
656
+
657
+ # Return formatted serialized_params for display in the dashboard
658
+ # @return [Hash]
659
+ def display_serialized_params
660
+ serialized_params.merge({
661
+ _good_job: attributes.except('serialized_params', 'locktype', 'owns_advisory_lock'),
662
+ })
663
+ end
664
+
665
+ def running?
666
+ if has_attribute?(:locktype)
667
+ self['locktype'].present?
668
+ else
669
+ advisory_locked?
670
+ end
671
+ end
672
+
673
+ def number
674
+ serialized_params.fetch('executions', 0) + 1
675
+ end
676
+
677
+ # Time between when this job was expected to run and when it started running
678
+ def queue_latency
679
+ now = Time.zone.now
680
+ expected_start = scheduled_at || created_at
681
+ actual_start = performed_at || finished_at || now
682
+
683
+ actual_start - expected_start unless expected_start >= now
684
+ end
685
+
686
+ # Time between when this job started and finished
687
+ def runtime_latency
688
+ (finished_at || Time.zone.now) - performed_at if performed_at
689
+ end
690
+
691
+ # Destroys this execution and all executions within the same job
692
+ def destroy_job
693
+ @_destroy_job = true
694
+ destroy!
695
+ ensure
696
+ @_destroy_job = false
697
+ end
698
+
699
+ def job_state
700
+ state = { queue_name: queue_name }
701
+ state[:scheduled_at] = scheduled_at if scheduled_at
702
+ state
703
+ end
704
+
121
705
  private
122
706
 
707
+ def reset_batch_values(&block)
708
+ GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
709
+ end
710
+
711
+ def continue_discard_or_finish_batch
712
+ batch._continue_discard_or_finish(self) if GoodJob::BatchRecord.migrated? && batch.present?
713
+ end
714
+
123
715
  def active_job_data
124
716
  serialized_params.deep_dup
125
717
  .tap do |job_data|
@@ -28,6 +28,17 @@ module GoodJob # :nodoc:
28
28
  false
29
29
  end
30
30
 
31
+ def self.duration_interval_migrated?
32
+ return true if columns_hash["duration"].present?
33
+
34
+ migration_pending_warning!
35
+ false
36
+ end
37
+
38
+ def self.duration_interval_usable?
39
+ duration_interval_migrated? && Gem::Version.new(Rails.version) >= Gem::Version.new('6.1.0.a')
40
+ end
41
+
31
42
  def number
32
43
  serialized_params.fetch('executions', 0) + 1
33
44
  end
@@ -37,9 +48,14 @@ module GoodJob # :nodoc:
37
48
  created_at - scheduled_at
38
49
  end
39
50
 
40
- # Time between when this job started and finished
51
+ # Monotonic time between when this job started and finished
41
52
  def runtime_latency
42
- (finished_at || Time.current) - performed_at if performed_at
53
+ # migrated and Rails greater than 6.1
54
+ if self.class.duration_interval_usable?
55
+ duration
56
+ elsif performed_at
57
+ (finished_at || Time.current) - performed_at
58
+ end
43
59
  end
44
60
 
45
61
  def last_status_at