good_job 3.30.0 → 3.99.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,413 @@ 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.in?([nil, false])
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
+ DEPRECATION
318
+ end
319
+
320
+ execution_args = {
321
+ active_job_id: active_job.job_id,
322
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
323
+ priority: active_job.priority || DEFAULT_PRIORITY,
324
+ serialized_params: active_job.serialize,
325
+ }
326
+ execution_args[:scheduled_at] = Time.zone.at(active_job.scheduled_at) if active_job.scheduled_at
327
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
328
+
329
+ if active_job.respond_to?(:good_job_labels) && active_job.good_job_labels.any? && labels_migrated?
330
+ labels = active_job.good_job_labels.dup
331
+ labels.map! { |label| label.to_s.strip.presence }
332
+ labels.tap(&:compact!).tap(&:uniq!)
333
+ execution_args[:labels] = labels
334
+ end
335
+
336
+ reenqueued_current_execution = CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
337
+ current_execution = CurrentThread.execution
338
+
339
+ if reenqueued_current_execution
340
+ if GoodJob::BatchRecord.migrated?
341
+ execution_args[:batch_id] = current_execution.batch_id
342
+ execution_args[:batch_callback_id] = current_execution.batch_callback_id
343
+ end
344
+ execution_args[:cron_key] = current_execution.cron_key
345
+ else
346
+ if GoodJob::BatchRecord.migrated?
347
+ execution_args[:batch_id] = GoodJob::Batch.current_batch_id
348
+ execution_args[:batch_callback_id] = GoodJob::Batch.current_batch_callback_id
349
+ end
350
+ execution_args[:cron_key] = CurrentThread.cron_key
351
+ execution_args[:cron_at] = CurrentThread.cron_at
352
+ end
353
+
354
+ execution_args.merge(overrides)
355
+ end
356
+
357
+ # Finds the next eligible Execution, acquire an advisory lock related to it, and
358
+ # executes the job.
359
+ # @yield [Execution, nil] The next eligible Execution, or +nil+ if none found, before it is performed.
360
+ # @return [ExecutionResult, nil]
361
+ # If a job was executed, returns an array with the {Execution} record, the
362
+ # return value for the job's +#perform+ method, and the exception the job
363
+ # raised, if any (if the job raised, then the second array entry will be
364
+ # +nil+). If there were no jobs to execute, returns +nil+.
365
+ def self.perform_with_advisory_lock(lock_id:, parsed_queues: nil, queue_select_limit: nil)
366
+ execution = nil
367
+ result = nil
368
+
369
+ unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(select_limit: queue_select_limit) do |executions|
370
+ execution = executions.first
371
+ if execution&.executable?
372
+ yield(execution) if block_given?
373
+ result = execution.perform(lock_id: lock_id)
374
+ else
375
+ execution = nil
376
+ yield(nil) if block_given?
377
+ end
378
+ end
379
+
380
+ execution&.run_callbacks(:perform_unlocked)
381
+ result
382
+ end
383
+
384
+ # Fetches the scheduled execution time of the next eligible Execution(s).
385
+ # @param after [DateTime]
386
+ # @param limit [Integer]
387
+ # @param now_limit [Integer, nil]
388
+ # @return [Array<DateTime>]
389
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
390
+ query = advisory_unlocked.unfinished.schedule_ordered
391
+
392
+ after ||= Time.current
393
+ after_bind = bind_value('scheduled_at', after, ActiveRecord::Type::DateTime)
394
+ 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))
395
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
396
+
397
+ if now_limit&.positive?
398
+ now_query = query.where(arel_table['scheduled_at'].lt(bind_value('scheduled_at', Time.current, ActiveRecord::Type::DateTime))).or query.where(scheduled_at: nil)
399
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
400
+ end
401
+
402
+ Array(now_at) + after_at
403
+ end
404
+
405
+ # Places an ActiveJob job on a queue by creating a new {Execution} record.
406
+ # @param active_job [ActiveJob::Base]
407
+ # The job to enqueue.
408
+ # @param scheduled_at [Float]
409
+ # Epoch timestamp when the job should be executed, if blank will delegate to the ActiveJob instance
410
+ # @param create_with_advisory_lock [Boolean]
411
+ # Whether to establish a lock on the {Execution} record after it is created.
412
+ # @return [Execution]
413
+ # The new {Execution} instance representing the queued ActiveJob job.
414
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
415
+ 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|
416
+ current_execution = CurrentThread.execution
417
+
418
+ retried = current_execution && current_execution.active_job_id == active_job.job_id
419
+ if retried
420
+ if current_execution.discrete?
421
+ execution = current_execution
422
+ execution.assign_attributes(enqueue_args(active_job, { scheduled_at: scheduled_at }))
423
+ execution.scheduled_at ||= Time.current
424
+ # TODO: these values ideally shouldn't be persisted until the current_execution is finished
425
+ # which will require handling `retry_job` being called from outside the execution context.
426
+ execution.performed_at = nil
427
+ execution.finished_at = nil
428
+ else
429
+ execution = build_for_enqueue(active_job, { scheduled_at: scheduled_at })
430
+ end
431
+ else
432
+ execution = build_for_enqueue(active_job, { scheduled_at: scheduled_at })
433
+ execution.make_discrete if discrete_support?
434
+ end
435
+
436
+ if create_with_advisory_lock
437
+ if execution.persisted?
438
+ execution.advisory_lock
439
+ else
440
+ execution.create_with_advisory_lock = true
441
+ end
442
+ end
443
+
444
+ instrument_payload[:execution] = execution
445
+ execution.save!
446
+
447
+ if retried
448
+ CurrentThread.execution_retried = execution
449
+ CurrentThread.execution.retried_good_job_id = execution.id unless current_execution.discrete?
450
+ else
451
+ CurrentThread.execution_retried = nil
452
+ end
453
+
454
+ active_job.provider_job_id = execution.id
455
+ execution
456
+ end
457
+ end
458
+
459
+ def self.format_error(error)
460
+ raise ArgumentError unless error.is_a?(Exception)
461
+
462
+ [error.class.to_s, ERROR_MESSAGE_SEPARATOR, error.message].join
463
+ end
464
+
465
+ # Execute the ActiveJob job this {Execution} represents.
466
+ # @return [ExecutionResult]
467
+ # An array of the return value of the job's +#perform+ method and the
468
+ # exception raised by the job, if any. If the job completed successfully,
469
+ # the second array entry (the exception) will be +nil+ and vice versa.
470
+ def perform(lock_id:)
471
+ run_callbacks(:perform) do
472
+ raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
473
+
474
+ job_performed_at = Time.current
475
+ monotonic_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
476
+ discrete_execution = nil
477
+ result = GoodJob::CurrentThread.within do |current_thread|
478
+ current_thread.reset
479
+ current_thread.execution = self
480
+
481
+ existing_performed_at = performed_at
482
+ if existing_performed_at
483
+ current_thread.execution_interrupted = existing_performed_at
484
+
485
+ if discrete?
486
+ interrupt_error_string = self.class.format_error(GoodJob::InterruptError.new("Interrupted after starting perform at '#{existing_performed_at}'"))
487
+ self.error = interrupt_error_string
488
+ self.error_event = ERROR_EVENT_INTERRUPTED if self.class.error_event_migrated?
489
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
490
+
491
+ discrete_execution_attrs = {
492
+ error: interrupt_error_string,
493
+ finished_at: job_performed_at,
494
+ }
495
+ discrete_execution_attrs[:error_event] = GoodJob::ErrorEvents::ERROR_EVENT_ENUMS[GoodJob::ErrorEvents::ERROR_EVENT_INTERRUPTED] if self.class.error_event_migrated?
496
+ discrete_execution_attrs[:duration] = monotonic_duration if GoodJob::DiscreteExecution.duration_interval_usable?
497
+ discrete_executions.where(finished_at: nil).where.not(performed_at: nil).update_all(discrete_execution_attrs) # rubocop:disable Rails/SkipsModelValidations
498
+ end
499
+ end
500
+
501
+ if discrete?
502
+ transaction do
503
+ discrete_execution_attrs = {
504
+ job_class: job_class,
505
+ queue_name: queue_name,
506
+ serialized_params: serialized_params,
507
+ scheduled_at: (scheduled_at || created_at),
508
+ created_at: job_performed_at,
509
+ }
510
+ discrete_execution_attrs[:process_id] = lock_id if GoodJob::DiscreteExecution.columns_hash.key?("process_id")
511
+
512
+ execution_attrs = {
513
+ performed_at: job_performed_at,
514
+ executions_count: ((executions_count || 0) + 1),
515
+ }
516
+ if GoodJob::Execution.columns_hash.key?("locked_by_id")
517
+ execution_attrs[:locked_by_id] = lock_id
518
+ execution_attrs[:locked_at] = Time.current
519
+ end
520
+
521
+ discrete_execution = discrete_executions.create!(discrete_execution_attrs)
522
+ update!(execution_attrs)
523
+ end
524
+ else
525
+ execution_attrs = {
526
+ performed_at: job_performed_at,
527
+ }
528
+ if GoodJob::Execution.columns_hash.key?("locked_by_id")
529
+ execution_attrs[:locked_by_id] = lock_id
530
+ execution_attrs[:locked_at] = Time.current
531
+ end
532
+
533
+ update!(execution_attrs)
534
+ end
535
+
536
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { execution: self, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do |instrument_payload|
537
+ value = ActiveJob::Base.execute(active_job_data)
538
+
539
+ if value.is_a?(Exception)
540
+ handled_error = value
541
+ value = nil
542
+ end
543
+ handled_error ||= current_thread.error_on_retry || current_thread.error_on_discard
544
+
545
+ error_event = if handled_error == current_thread.error_on_discard
546
+ ERROR_EVENT_DISCARDED
547
+ elsif handled_error == current_thread.error_on_retry
548
+ ERROR_EVENT_RETRIED
549
+ elsif handled_error == current_thread.error_on_retry_stopped
550
+ ERROR_EVENT_RETRY_STOPPED
551
+ elsif handled_error
552
+ ERROR_EVENT_HANDLED
553
+ end
554
+
555
+ instrument_payload.merge!(
556
+ value: value,
557
+ handled_error: handled_error,
558
+ retried: current_thread.execution_retried.present?,
559
+ error_event: error_event
560
+ )
561
+ ExecutionResult.new(value: value, handled_error: handled_error, error_event: error_event, retried: current_thread.execution_retried)
562
+ rescue StandardError => e
563
+ error_event = if e.is_a?(GoodJob::InterruptError)
564
+ ERROR_EVENT_INTERRUPTED
565
+ elsif e == current_thread.error_on_retry_stopped
566
+ ERROR_EVENT_RETRY_STOPPED
567
+ else
568
+ ERROR_EVENT_UNHANDLED
569
+ end
570
+
571
+ instrument_payload[:unhandled_error] = e
572
+ ExecutionResult.new(value: nil, unhandled_error: e, error_event: error_event)
573
+ end
574
+ end
575
+
576
+ job_attributes = if self.class.columns_hash.key?("locked_by_id")
577
+ { locked_by_id: nil, locked_at: nil }
578
+ else
579
+ {}
580
+ end
581
+
582
+ job_error = result.handled_error || result.unhandled_error
583
+ if job_error
584
+ error_string = self.class.format_error(job_error)
585
+
586
+ job_attributes[:error] = error_string
587
+ job_attributes[:error_event] = result.error_event if self.class.error_event_migrated?
588
+ if discrete_execution
589
+ discrete_execution.error = error_string
590
+ discrete_execution.error_event = result.error_event
591
+ discrete_execution.error_backtrace = job_error.backtrace if discrete_execution.class.backtrace_migrated?
592
+ end
593
+ else
594
+ job_attributes[:error] = nil
595
+ job_attributes[:error_event] = nil
596
+ end
597
+ job_attributes.delete(:error_event) unless self.class.error_event_migrated?
598
+
599
+ job_finished_at = Time.current
600
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
601
+ job_attributes[:finished_at] = job_finished_at
602
+ if discrete_execution
603
+ discrete_execution.finished_at = job_finished_at
604
+ discrete_execution.duration = monotonic_duration if GoodJob::DiscreteExecution.duration_interval_usable?
605
+ end
606
+
607
+ retry_unhandled_error = result.unhandled_error && GoodJob.retry_on_unhandled_error
608
+ reenqueued = result.retried? || retried_good_job_id.present? || retry_unhandled_error
609
+ if reenqueued
610
+ if discrete_execution
611
+ job_attributes[:performed_at] = nil
612
+ job_attributes[:finished_at] = nil
613
+ else
614
+ job_attributes[:retried_good_job_id] = retried_good_job_id
615
+ job_attributes[:finished_at] = nil if retry_unhandled_error
616
+ end
617
+ end
618
+
619
+ preserve_unhandled = (result.unhandled_error && (GoodJob.retry_on_unhandled_error || GoodJob.preserve_job_records == :on_unhandled_error))
620
+ if GoodJob.preserve_job_records == true || reenqueued || preserve_unhandled || cron_key.present?
621
+ if discrete_execution
622
+ transaction do
623
+ discrete_execution.save!
624
+ update!(job_attributes)
625
+ end
626
+ else
627
+ update!(job_attributes)
628
+ end
629
+ else
630
+ destroy_job
631
+ end
632
+
633
+ result
634
+ end
635
+ end
636
+
637
+ # Tests whether this job is safe to be executed by this thread.
638
+ # @return [Boolean]
639
+ def executable?
640
+ reload.finished_at.blank?
641
+ rescue ActiveRecord::RecordNotFound
642
+ false
643
+ end
644
+
645
+ def make_discrete
646
+ self.is_discrete = true
647
+ self.id = active_job_id
648
+ self.job_class = serialized_params['job_class']
649
+ self.executions_count ||= 0
650
+
651
+ current_time = Time.current
652
+ self.created_at ||= current_time
653
+ self.scheduled_at ||= current_time
654
+ end
655
+
656
+ # Return formatted serialized_params for display in the dashboard
657
+ # @return [Hash]
658
+ def display_serialized_params
659
+ serialized_params.merge({
660
+ _good_job: attributes.except('serialized_params', 'locktype', 'owns_advisory_lock'),
661
+ })
662
+ end
663
+
664
+ def running?
665
+ if has_attribute?(:locktype)
666
+ self['locktype'].present?
667
+ else
668
+ advisory_locked?
669
+ end
670
+ end
671
+
672
+ def number
673
+ serialized_params.fetch('executions', 0) + 1
674
+ end
675
+
676
+ # Time between when this job was expected to run and when it started running
677
+ def queue_latency
678
+ now = Time.zone.now
679
+ expected_start = scheduled_at || created_at
680
+ actual_start = performed_at || finished_at || now
681
+
682
+ actual_start - expected_start unless expected_start >= now
683
+ end
684
+
685
+ # Time between when this job started and finished
686
+ def runtime_latency
687
+ (finished_at || Time.zone.now) - performed_at if performed_at
688
+ end
689
+
690
+ # Destroys this execution and all executions within the same job
691
+ def destroy_job
692
+ @_destroy_job = true
693
+ destroy!
694
+ ensure
695
+ @_destroy_job = false
696
+ end
697
+
698
+ def job_state
699
+ state = { queue_name: queue_name }
700
+ state[:scheduled_at] = scheduled_at if scheduled_at
701
+ state
702
+ end
703
+
121
704
  private
122
705
 
706
+ def reset_batch_values(&block)
707
+ GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
708
+ end
709
+
710
+ def continue_discard_or_finish_batch
711
+ batch._continue_discard_or_finish(self) if GoodJob::BatchRecord.migrated? && batch.present?
712
+ end
713
+
123
714
  def active_job_data
124
715
  serialized_params.deep_dup
125
716
  .tap do |job_data|
@@ -28,13 +28,17 @@ module GoodJob # :nodoc:
28
28
  false
29
29
  end
30
30
 
31
- def self.monotonic_duration_migrated?
31
+ def self.duration_interval_migrated?
32
32
  return true if columns_hash["duration"].present?
33
33
 
34
34
  migration_pending_warning!
35
35
  false
36
36
  end
37
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
+
38
42
  def number
39
43
  serialized_params.fetch('executions', 0) + 1
40
44
  end
@@ -46,7 +50,8 @@ module GoodJob # :nodoc:
46
50
 
47
51
  # Monotonic time between when this job started and finished
48
52
  def runtime_latency
49
- if self.class.monotonic_duration_migrated?
53
+ # migrated and Rails greater than 6.1
54
+ if self.class.duration_interval_usable?
50
55
  duration
51
56
  elsif performed_at
52
57
  (finished_at || Time.current) - performed_at