good_job 4.1.0 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +10 -10
  4. data/app/charts/good_job/performance_index_chart.rb +1 -1
  5. data/app/charts/good_job/performance_show_chart.rb +1 -1
  6. data/app/controllers/good_job/application_controller.rb +1 -1
  7. data/app/controllers/good_job/batches_controller.rb +6 -0
  8. data/app/controllers/good_job/frontends_controller.rb +6 -2
  9. data/app/controllers/good_job/performance_controller.rb +1 -1
  10. data/app/frontend/good_job/icons.svg +79 -0
  11. data/app/frontend/good_job/style.css +5 -0
  12. data/app/helpers/good_job/icons_helper.rb +8 -5
  13. data/app/models/concerns/good_job/advisory_lockable.rb +17 -7
  14. data/app/models/concerns/good_job/error_events.rb +2 -2
  15. data/app/models/concerns/good_job/reportable.rb +8 -12
  16. data/app/models/good_job/batch.rb +31 -9
  17. data/app/models/good_job/batch_record.rb +19 -20
  18. data/app/models/good_job/discrete_execution.rb +6 -59
  19. data/app/models/good_job/execution.rb +59 -4
  20. data/app/models/good_job/execution_result.rb +6 -6
  21. data/app/models/good_job/job.rb +543 -12
  22. data/app/models/good_job/process.rb +14 -3
  23. data/app/views/good_job/batches/_jobs.erb +1 -1
  24. data/app/views/good_job/batches/_table.erb +7 -1
  25. data/app/views/good_job/batches/show.html.erb +8 -0
  26. data/app/views/good_job/jobs/index.html.erb +1 -1
  27. data/app/views/layouts/good_job/application.html.erb +7 -7
  28. data/config/brakeman.ignore +75 -0
  29. data/config/locales/de.yml +54 -49
  30. data/config/locales/en.yml +5 -0
  31. data/config/locales/es.yml +19 -14
  32. data/config/locales/fr.yml +5 -0
  33. data/config/locales/it.yml +5 -0
  34. data/config/locales/ja.yml +10 -5
  35. data/config/locales/ko.yml +9 -4
  36. data/config/locales/nl.yml +5 -0
  37. data/config/locales/pt-BR.yml +5 -0
  38. data/config/locales/ru.yml +5 -0
  39. data/config/locales/tr.yml +5 -0
  40. data/config/locales/uk.yml +6 -1
  41. data/config/routes.rb +8 -4
  42. data/lib/good_job/active_job_extensions/concurrency.rb +109 -98
  43. data/lib/good_job/adapter/inline_buffer.rb +73 -0
  44. data/lib/good_job/adapter.rb +59 -53
  45. data/lib/good_job/capsule_tracker.rb +2 -2
  46. data/lib/good_job/configuration.rb +13 -12
  47. data/lib/good_job/cron_manager.rb +1 -3
  48. data/lib/good_job/current_thread.rb +4 -4
  49. data/lib/good_job/notifier/process_heartbeat.rb +3 -2
  50. data/lib/good_job/version.rb +1 -1
  51. data/lib/good_job.rb +6 -5
  52. metadata +6 -20
  53. data/app/models/good_job/base_execution.rb +0 -605
  54. data/app/views/good_job/shared/icons/_arrow_clockwise.html.erb +0 -5
  55. data/app/views/good_job/shared/icons/_check.html.erb +0 -5
  56. data/app/views/good_job/shared/icons/_circle_half.html.erb +0 -4
  57. data/app/views/good_job/shared/icons/_clock.html.erb +0 -5
  58. data/app/views/good_job/shared/icons/_dash_circle.html.erb +0 -5
  59. data/app/views/good_job/shared/icons/_dots.html.erb +0 -3
  60. data/app/views/good_job/shared/icons/_eject.html.erb +0 -4
  61. data/app/views/good_job/shared/icons/_exclamation.html.erb +0 -5
  62. data/app/views/good_job/shared/icons/_globe.html.erb +0 -3
  63. data/app/views/good_job/shared/icons/_info.html.erb +0 -4
  64. data/app/views/good_job/shared/icons/_moon_stars_fill.html.erb +0 -5
  65. data/app/views/good_job/shared/icons/_pause.html.erb +0 -4
  66. data/app/views/good_job/shared/icons/_play.html.erb +0 -4
  67. data/app/views/good_job/shared/icons/_skip_forward.html.erb +0 -4
  68. data/app/views/good_job/shared/icons/_stop.html.erb +0 -4
  69. data/app/views/good_job/shared/icons/_sun_fill.html.erb +0 -4
@@ -2,7 +2,23 @@
2
2
 
3
3
  module GoodJob
4
4
  # Active Record model that represents an +ActiveJob+ job.
5
- class Job < BaseExecution
5
+ class Job < BaseRecord
6
+ include AdvisoryLockable
7
+ include ErrorEvents
8
+ include Filterable
9
+ include Reportable
10
+
11
+ # Raised if something attempts to execute a previously completed Execution again.
12
+ PreviouslyPerformedError = Class.new(StandardError)
13
+
14
+ # String separating Error Class from Error Message
15
+ ERROR_MESSAGE_SEPARATOR = ": "
16
+
17
+ # ActiveJob jobs without a +queue_name+ attribute are placed on this queue.
18
+ DEFAULT_QUEUE_NAME = 'default'
19
+ # ActiveJob jobs without a +priority+ attribute are given this priority.
20
+ DEFAULT_PRIORITY = 0
21
+
6
22
  # Raised when an inappropriate action is applied to a Job based on its state.
7
23
  ActionForStateMismatchError = Class.new(StandardError)
8
24
  # Raised when GoodJob is not configured as the Active Job Queue Adapter
@@ -15,12 +31,17 @@ module GoodJob
15
31
  self.table_name = 'good_jobs'
16
32
  self.advisory_lockable_column = 'id'
17
33
  self.implicit_order_column = 'created_at'
34
+ self.ignored_columns += %w[is_discrete retried_good_job_id]
35
+
36
+ define_model_callbacks :perform
37
+ define_model_callbacks :perform_unlocked, only: :after
38
+
39
+ set_callback :perform, :around, :reset_batch_values
40
+ set_callback :perform_unlocked, :after, :continue_discard_or_finish_batch
18
41
 
19
42
  belongs_to :batch, class_name: 'GoodJob::BatchRecord', inverse_of: :jobs, optional: true
20
43
  belongs_to :locked_by_process, class_name: "GoodJob::Process", foreign_key: :locked_by_id, inverse_of: :locked_jobs, optional: true
21
- has_many :executions, -> { order(created_at: :asc) }, class_name: 'GoodJob::Execution', foreign_key: 'active_job_id', primary_key: :active_job_id, inverse_of: :job, dependent: :delete_all
22
- # TODO: rename callers of discrete_execution to executions, but after v4 has some time to bake for cleaner diffs/patches
23
- has_many :discrete_executions, -> { order(created_at: :asc) }, class_name: 'GoodJob::Execution', foreign_key: 'active_job_id', primary_key: :active_job_id, inverse_of: :job, dependent: :delete_all
44
+ has_many :executions, class_name: 'GoodJob::Execution', foreign_key: 'active_job_id', primary_key: "id", inverse_of: :job, dependent: :delete_all
24
45
 
25
46
  before_create -> { self.id = active_job_id }, if: -> { active_job_id.present? }
26
47
 
@@ -36,16 +57,328 @@ module GoodJob
36
57
  # Execution errored, will run in the future
37
58
  scope :retried, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.gt(bind_value('coalesce', Time.current, ActiveRecord::Type::DateTime))).where(params_execution_count.gt(1)) }
38
59
  # Immediate/Scheduled time to run has passed, waiting for an available thread run
39
- scope :queued, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.lteq(bind_value('coalesce', Time.current, ActiveRecord::Type::DateTime))).joins_advisory_locks.where(pg_locks: { locktype: nil }) }
60
+ scope :queued, -> { where(performed_at: nil, finished_at: nil).where(coalesce_scheduled_at_created_at.lteq(bind_value('coalesce', Time.current, ActiveRecord::Type::DateTime))) }
40
61
  # Advisory locked and executing
41
- scope :running, -> { where(finished_at: nil).joins_advisory_locks.where.not(pg_locks: { locktype: nil }) }
62
+ scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
42
63
  # Finished executing (succeeded or discarded)
43
- scope :finished, -> { where.not(finished_at: nil).where(retried_good_job_id: nil) }
64
+ scope :finished, -> { where.not(finished_at: nil) }
44
65
  # Completed executing successfully
45
66
  scope :succeeded, -> { finished.where(error: nil) }
46
67
  # Errored but will not be retried
47
68
  scope :discarded, -> { finished.where.not(error: nil) }
48
69
 
70
+ # With a given class name
71
+ # @!method job_class(name)
72
+ # @!scope class
73
+ # @param name [String] Job class name
74
+ # @return [ActiveRecord::Relation]
75
+ scope :job_class, ->(name) { where(params_job_class.eq(name)) }
76
+
77
+ # Get jobs with given ActiveJob ID
78
+ # @!method active_job_id(active_job_id)
79
+ # @!scope class
80
+ # @param active_job_id [String]
81
+ # ActiveJob ID
82
+ # @return [ActiveRecord::Relation]
83
+ scope :active_job_id, ->(active_job_id) { where(active_job_id: active_job_id) }
84
+
85
+ # Get jobs that have not yet finished (succeeded or discarded).
86
+ # @!method unfinished
87
+ # @!scope class
88
+ # @return [ActiveRecord::Relation]
89
+ scope :unfinished, -> { where(finished_at: nil) }
90
+
91
+ # Get jobs that are not scheduled for a later time than now (i.e. jobs that
92
+ # are not scheduled or scheduled for earlier than the current time).
93
+ # @!method only_scheduled
94
+ # @!scope class
95
+ # @return [ActiveRecord::Relation]
96
+ scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(bind_value('scheduled_at', DateTime.current, ActiveRecord::Type::DateTime))).or(where(scheduled_at: nil)) }
97
+
98
+ # Order jobs by priority (highest priority first).
99
+ # @!method priority_ordered
100
+ # @!scope class
101
+ # @return [ActiveRecord::Relation]
102
+ scope :priority_ordered, -> { order('priority ASC NULLS LAST') }
103
+
104
+ # Order jobs by created_at, for first-in first-out
105
+ # @!method creation_ordered
106
+ # @!scope class
107
+ # @return [ActiveRecord:Relation]
108
+ scope :creation_ordered, -> { order(created_at: :asc) }
109
+
110
+ # Order jobs for de-queueing
111
+ # @!method dequeueing_ordered(parsed_queues)
112
+ # @!scope class
113
+ # @param parsed_queues [Hash]
114
+ # optional output of .queue_parser, parsed queues, will be used for
115
+ # ordered queues.
116
+ # @return [ActiveRecord::Relation]
117
+ scope :dequeueing_ordered, (lambda do |parsed_queues|
118
+ relation = self
119
+ relation = relation.queue_ordered(parsed_queues[:include]) if parsed_queues && parsed_queues[:ordered_queues] && parsed_queues[:include]
120
+ relation = relation.priority_ordered.creation_ordered
121
+
122
+ relation
123
+ end)
124
+
125
+ # Order jobs in order of queues in array param
126
+ # @!method queue_ordered(queues)
127
+ # @!scope class
128
+ # @param queues [Array<string] ordered names of queues
129
+ # @return [ActiveRecord::Relation]
130
+ scope :queue_ordered, (lambda do |queues|
131
+ clauses = queues.map.with_index do |queue_name, index|
132
+ sanitize_sql_array(["WHEN queue_name = ? THEN ?", queue_name, index])
133
+ end
134
+ order(Arel.sql("(CASE #{clauses.join(' ')} ELSE #{queues.size} END)"))
135
+ end)
136
+
137
+ # Order jobs by scheduled or created (oldest first).
138
+ # @!method schedule_ordered
139
+ # @!scope class
140
+ # @return [ActiveRecord::Relation]
141
+ scope :schedule_ordered, -> { order(coalesce_scheduled_at_created_at.asc) }
142
+
143
+ # Get Jobs on queues that match the given queue string.
144
+ # @!method queue_string(string)
145
+ # @!scope class
146
+ # @param string [String]
147
+ # A string expression describing what queues to select. See
148
+ # {Job.queue_parser} or
149
+ # {file:README.md#optimize-queues-threads-and-processes} for more details
150
+ # on the format of the string. Note this only handles individual
151
+ # semicolon-separated segments of that string format.
152
+ # @return [ActiveRecord::Relation]
153
+ scope :queue_string, (lambda do |string|
154
+ parsed = queue_parser(string)
155
+
156
+ if parsed[:all]
157
+ all
158
+ elsif parsed[:exclude]
159
+ where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
160
+ elsif parsed[:include]
161
+ where(queue_name: parsed[:include])
162
+ end
163
+ end)
164
+
165
+ class << self
166
+ # Parse a string representing a group of queues into a more readable data
167
+ # structure.
168
+ # @param string [String] Queue string
169
+ # @return [Hash]
170
+ # How to match a given queue. It can have the following keys and values:
171
+ # - +{ all: true }+ indicates that all queues match.
172
+ # - +{ exclude: Array<String> }+ indicates the listed queue names should
173
+ # not match.
174
+ # - +{ include: Array<String> }+ indicates the listed queue names should
175
+ # match.
176
+ # - +{ include: Array<String>, ordered_queues: true }+ indicates the listed
177
+ # queue names should match, and dequeue should respect queue order.
178
+ # @example
179
+ # GoodJob::Execution.queue_parser('-queue1,queue2')
180
+ # => { exclude: [ 'queue1', 'queue2' ] }
181
+ def queue_parser(string)
182
+ string = string.strip.presence || '*'
183
+
184
+ case string.first
185
+ when '-'
186
+ exclude_queues = true
187
+ string = string[1..]
188
+ when '+'
189
+ ordered_queues = true
190
+ string = string[1..]
191
+ end
192
+
193
+ queues = string.split(',').map(&:strip)
194
+
195
+ if queues.include?('*')
196
+ { all: true }
197
+ elsif exclude_queues
198
+ { exclude: queues }
199
+ elsif ordered_queues
200
+ {
201
+ include: queues,
202
+ ordered_queues: true,
203
+ }
204
+ else
205
+ { include: queues }
206
+ end
207
+ end
208
+
209
+ def json_string(json, attr)
210
+ Arel::Nodes::Grouping.new(Arel::Nodes::InfixOperation.new('->>', json, Arel::Nodes.build_quoted(attr)))
211
+ end
212
+
213
+ def params_job_class
214
+ json_string(arel_table['serialized_params'], 'job_class')
215
+ end
216
+
217
+ def params_execution_count
218
+ Arel::Nodes::InfixOperation.new(
219
+ '::',
220
+ json_string(arel_table['serialized_params'], 'executions'),
221
+ Arel.sql('integer')
222
+ )
223
+ end
224
+
225
+ def coalesce_scheduled_at_created_at
226
+ arel_table.coalesce(arel_table['scheduled_at'], arel_table['created_at'])
227
+ end
228
+ end
229
+
230
+ def self.build_for_enqueue(active_job, scheduled_at: nil)
231
+ new(**enqueue_args(active_job, scheduled_at: scheduled_at))
232
+ end
233
+
234
+ # Construct arguments for GoodJob::Execution from an ActiveJob instance.
235
+ def self.enqueue_args(active_job, scheduled_at: nil)
236
+ execution_args = {
237
+ id: active_job.job_id,
238
+ active_job_id: active_job.job_id,
239
+ job_class: active_job.class.name,
240
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
241
+ priority: active_job.priority || DEFAULT_PRIORITY,
242
+ serialized_params: active_job.serialize,
243
+ created_at: Time.current,
244
+ }
245
+
246
+ execution_args[:scheduled_at] = if scheduled_at
247
+ scheduled_at
248
+ elsif active_job.scheduled_at
249
+ Time.zone.at(active_job.scheduled_at)
250
+ else
251
+ execution_args[:created_at]
252
+ end
253
+
254
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
255
+
256
+ if active_job.respond_to?(:good_job_labels) && active_job.good_job_labels.any?
257
+ labels = active_job.good_job_labels.dup
258
+ labels.map! { |label| label.to_s.strip.presence }
259
+ labels.tap(&:compact!).tap(&:uniq!)
260
+ execution_args[:labels] = labels
261
+ end
262
+
263
+ reenqueued_current_job = CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
264
+ current_job = CurrentThread.job
265
+
266
+ if reenqueued_current_job
267
+ execution_args[:batch_id] = current_job.batch_id
268
+ execution_args[:batch_callback_id] = current_job.batch_callback_id
269
+ execution_args[:cron_key] = current_job.cron_key
270
+ else
271
+ execution_args[:batch_id] = GoodJob::Batch.current_batch_id
272
+ execution_args[:batch_callback_id] = GoodJob::Batch.current_batch_callback_id
273
+ execution_args[:cron_key] = CurrentThread.cron_key
274
+ execution_args[:cron_at] = CurrentThread.cron_at
275
+ end
276
+
277
+ execution_args
278
+ end
279
+
280
+ # Finds the next eligible Execution, acquire an advisory lock related to it, and
281
+ # executes the job.
282
+ # @yield [Execution, nil] The next eligible Execution, or +nil+ if none found, before it is performed.
283
+ # @return [ExecutionResult, nil]
284
+ # If a job was executed, returns an array with the {Execution} record, the
285
+ # return value for the job's +#perform+ method, and the exception the job
286
+ # raised, if any (if the job raised, then the second array entry will be
287
+ # +nil+). If there were no jobs to execute, returns +nil+.
288
+ def self.perform_with_advisory_lock(lock_id:, parsed_queues: nil, queue_select_limit: nil)
289
+ job = nil
290
+ result = nil
291
+
292
+ unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(select_limit: queue_select_limit) do |jobs|
293
+ job = jobs.first
294
+
295
+ if job&.executable?
296
+ yield(job) if block_given?
297
+
298
+ result = job.perform(lock_id: lock_id)
299
+ else
300
+ job = nil
301
+ yield(nil) if block_given?
302
+ end
303
+ end
304
+
305
+ job&.run_callbacks(:perform_unlocked)
306
+ result
307
+ end
308
+
309
+ # Fetches the scheduled execution time of the next eligible Execution(s).
310
+ # @param after [DateTime]
311
+ # @param limit [Integer]
312
+ # @param now_limit [Integer, nil]
313
+ # @return [Array<DateTime>]
314
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
315
+ query = advisory_unlocked.unfinished.schedule_ordered
316
+
317
+ after ||= Time.current
318
+ after_bind = bind_value('scheduled_at', after, ActiveRecord::Type::DateTime)
319
+ 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))
320
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
321
+
322
+ if now_limit&.positive?
323
+ now_query = query.where(arel_table['scheduled_at'].lt(bind_value('scheduled_at', Time.current, ActiveRecord::Type::DateTime))).or query.where(scheduled_at: nil)
324
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
325
+ end
326
+
327
+ Array(now_at) + after_at
328
+ end
329
+
330
+ # Places an ActiveJob job on a queue by creating a new {Execution} record.
331
+ # @param active_job [ActiveJob::Base]
332
+ # The job to enqueue.
333
+ # @param scheduled_at [Float]
334
+ # Epoch timestamp when the job should be executed, if blank will delegate to the ActiveJob instance
335
+ # @param create_with_advisory_lock [Boolean]
336
+ # Whether to establish a lock on the {Execution} record after it is created.
337
+ # @return [Execution]
338
+ # The new {Execution} instance representing the queued ActiveJob job.
339
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
340
+ 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|
341
+ current_job = CurrentThread.job
342
+
343
+ retried = current_job && current_job.active_job_id == active_job.job_id
344
+ if retried
345
+ job = current_job
346
+ job.assign_attributes(enqueue_args(active_job, scheduled_at: scheduled_at))
347
+ job.scheduled_at ||= Time.current
348
+ # TODO: these values ideally shouldn't be persisted until the current_job is finished
349
+ # which will require handling `retry_job` being called from outside the job context.
350
+ job.performed_at = nil
351
+ job.finished_at = nil
352
+ else
353
+ job = build_for_enqueue(active_job, scheduled_at: scheduled_at)
354
+ end
355
+
356
+ if create_with_advisory_lock
357
+ if job.persisted?
358
+ job.advisory_lock
359
+ else
360
+ job.create_with_advisory_lock = true
361
+ end
362
+ end
363
+
364
+ instrument_payload[:job] = job
365
+ job.save!
366
+
367
+ CurrentThread.retried_job = job if retried
368
+
369
+ active_job.provider_job_id = job.id
370
+ raise "These should be equal" if active_job.provider_job_id != active_job.job_id
371
+
372
+ job
373
+ end
374
+ end
375
+
376
+ def self.format_error(error)
377
+ raise ArgumentError unless error.is_a?(Exception)
378
+
379
+ [error.class.to_s, ERROR_MESSAGE_SEPARATOR, error.message].join
380
+ end
381
+
49
382
  # TODO: it would be nice to enforce these values at the model
50
383
  # validates :active_job_id, presence: true
51
384
  # validates :scheduled_at, presence: true
@@ -101,7 +434,7 @@ module GoodJob
101
434
  # Tests whether the job has finished (succeeded or discarded).
102
435
  # @return [Boolean]
103
436
  def finished?
104
- finished_at.present? && retried_good_job_id.nil?
437
+ finished_at.present?
105
438
  end
106
439
 
107
440
  # Tests whether the job has finished but with an error.
@@ -193,10 +526,191 @@ module GoodJob
193
526
  end
194
527
  end
195
528
 
196
- # Utility method to determine which execution record is used to represent this job
197
- # @return [String]
198
- def _execution_id
199
- attributes['id']
529
+ # Build an ActiveJob instance and deserialize the arguments, using `#active_job_data`.
530
+ #
531
+ # @param ignore_deserialization_errors [Boolean]
532
+ # Whether to ignore ActiveJob::DeserializationError and NameError when deserializing the arguments.
533
+ # This is most useful if you aren't planning to use the arguments directly.
534
+ def active_job(ignore_deserialization_errors: false)
535
+ ActiveJob::Base.deserialize(active_job_data).tap do |aj|
536
+ aj.send(:deserialize_arguments_if_needed)
537
+ rescue ActiveJob::DeserializationError
538
+ raise unless ignore_deserialization_errors
539
+ end
540
+ rescue NameError
541
+ raise unless ignore_deserialization_errors
542
+ end
543
+
544
+ # Execute the ActiveJob job this {Execution} represents.
545
+ # @return [ExecutionResult]
546
+ # An array of the return value of the job's +#perform+ method and the
547
+ # exception raised by the job, if any. If the job completed successfully,
548
+ # the second array entry (the exception) will be +nil+ and vice versa.
549
+ def perform(lock_id:)
550
+ run_callbacks(:perform) do
551
+ raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
552
+
553
+ job_performed_at = Time.current
554
+ monotonic_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
555
+ execution = nil
556
+ result = GoodJob::CurrentThread.within do |current_thread|
557
+ current_thread.reset
558
+ current_thread.job = self
559
+
560
+ existing_performed_at = performed_at
561
+ if existing_performed_at
562
+ current_thread.execution_interrupted = existing_performed_at
563
+
564
+ interrupt_error_string = self.class.format_error(GoodJob::InterruptError.new("Interrupted after starting perform at '#{existing_performed_at}'"))
565
+ self.error = interrupt_error_string
566
+ self.error_event = :interrupted
567
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
568
+
569
+ execution_attrs = {
570
+ error: interrupt_error_string,
571
+ finished_at: job_performed_at,
572
+ error_event: :interrupted,
573
+ duration: monotonic_duration,
574
+ }
575
+ executions.where(finished_at: nil).where.not(performed_at: nil).update_all(execution_attrs) # rubocop:disable Rails/SkipsModelValidations
576
+ end
577
+
578
+ transaction do
579
+ execution_attrs = {
580
+ job_class: job_class,
581
+ queue_name: queue_name,
582
+ serialized_params: serialized_params,
583
+ scheduled_at: scheduled_at || created_at,
584
+ created_at: job_performed_at,
585
+ process_id: lock_id,
586
+ }
587
+ job_attrs = {
588
+ performed_at: job_performed_at,
589
+ executions_count: ((executions_count || 0) + 1),
590
+ locked_by_id: lock_id,
591
+ locked_at: Time.current,
592
+ }
593
+
594
+ execution = executions.create!(execution_attrs)
595
+ update!(job_attrs)
596
+ end
597
+
598
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { job: self, execution: execution, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do |instrument_payload|
599
+ value = ActiveJob::Base.execute(active_job_data)
600
+
601
+ if value.is_a?(Exception)
602
+ handled_error = value
603
+ value = nil
604
+ end
605
+ handled_error ||= current_thread.error_on_retry || current_thread.error_on_discard
606
+
607
+ error_event = if handled_error == current_thread.error_on_discard
608
+ :discarded
609
+ elsif handled_error == current_thread.error_on_retry
610
+ :retried
611
+ elsif handled_error == current_thread.error_on_retry_stopped
612
+ :retry_stopped
613
+ elsif handled_error
614
+ :handled
615
+ end
616
+
617
+ instrument_payload.merge!(
618
+ value: value,
619
+ handled_error: handled_error,
620
+ retried: current_thread.retried_job.present?,
621
+ error_event: error_event
622
+ )
623
+ ExecutionResult.new(value: value, handled_error: handled_error, error_event: error_event, retried_job: current_thread.retried_job)
624
+ rescue StandardError => e
625
+ error_event = if e.is_a?(GoodJob::InterruptError)
626
+ :interrupted
627
+ elsif e == current_thread.error_on_retry_stopped
628
+ :retry_stopped
629
+ else
630
+ :unhandled
631
+ end
632
+
633
+ instrument_payload[:unhandled_error] = e
634
+ ExecutionResult.new(value: nil, unhandled_error: e, error_event: error_event)
635
+ end
636
+ end
637
+
638
+ job_attributes = { locked_by_id: nil, locked_at: nil }
639
+
640
+ job_error = result.handled_error || result.unhandled_error
641
+ if job_error
642
+ error_string = self.class.format_error(job_error)
643
+
644
+ job_attributes[:error] = error_string
645
+ job_attributes[:error_event] = result.error_event
646
+
647
+ execution.error = error_string
648
+ execution.error_event = result.error_event
649
+ execution.error_backtrace = job_error.backtrace
650
+ else
651
+ job_attributes[:error] = nil
652
+ job_attributes[:error_event] = nil
653
+ end
654
+
655
+ job_finished_at = Time.current
656
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
657
+ job_attributes[:finished_at] = job_finished_at
658
+
659
+ execution.finished_at = job_finished_at
660
+ execution.duration = monotonic_duration
661
+
662
+ retry_unhandled_error = result.unhandled_error && GoodJob.retry_on_unhandled_error
663
+ reenqueued = result.retried? || retry_unhandled_error
664
+ if reenqueued
665
+ job_attributes[:performed_at] = nil
666
+ job_attributes[:finished_at] = nil
667
+ end
668
+
669
+ assign_attributes(job_attributes)
670
+ preserve_unhandled = result.unhandled_error && (GoodJob.retry_on_unhandled_error || GoodJob.preserve_job_records == :on_unhandled_error)
671
+ if finished_at.blank? || GoodJob.preserve_job_records == true || reenqueued || preserve_unhandled || cron_key.present?
672
+ transaction do
673
+ execution.save!
674
+ save!
675
+ end
676
+ else
677
+ destroy!
678
+ end
679
+
680
+ result
681
+ end
682
+ end
683
+
684
+ # Tests whether this job is safe to be executed by this thread.
685
+ # @return [Boolean]
686
+ def executable?
687
+ reload.finished_at.blank?
688
+ rescue ActiveRecord::RecordNotFound
689
+ false
690
+ end
691
+
692
+ def number
693
+ serialized_params.fetch('executions', 0) + 1
694
+ end
695
+
696
+ # Time between when this job was expected to run and when it started running
697
+ def queue_latency
698
+ now = Time.zone.now
699
+ expected_start = scheduled_at || created_at
700
+ actual_start = performed_at || finished_at || now
701
+
702
+ actual_start - expected_start unless expected_start >= now
703
+ end
704
+
705
+ # Time between when this job started and finished
706
+ def runtime_latency
707
+ (finished_at || Time.zone.now) - performed_at if performed_at
708
+ end
709
+
710
+ def job_state
711
+ state = { queue_name: queue_name }
712
+ state[:scheduled_at] = scheduled_at if scheduled_at
713
+ state
200
714
  end
201
715
 
202
716
  private
@@ -222,6 +736,23 @@ module GoodJob
222
736
  update_record.call
223
737
  end
224
738
  end
739
+
740
+ def reset_batch_values(&block)
741
+ GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
742
+ end
743
+
744
+ def continue_discard_or_finish_batch
745
+ batch._continue_discard_or_finish(self) if batch.present?
746
+ end
747
+
748
+ def active_job_data
749
+ serialized_params.deep_dup
750
+ .tap do |job_data|
751
+ job_data["provider_job_id"] = id
752
+ job_data["good_job_concurrency_key"] = concurrency_key if concurrency_key
753
+ job_data["good_job_labels"] = Array(labels) if labels.present?
754
+ end
755
+ end
225
756
  end
226
757
  end
227
758
 
@@ -20,9 +20,9 @@ module GoodJob # :nodoc:
20
20
  advisory: 0,
21
21
  }
22
22
  if Gem::Version.new(Rails.version) >= Gem::Version.new('7.1.0.a')
23
- enum :lock_type, lock_type_enum, validate: { allow_nil: true }
23
+ enum :lock_type, lock_type_enum, validate: { allow_nil: true }, scopes: false
24
24
  else
25
- enum lock_type: lock_type_enum
25
+ enum lock_type: lock_type_enum, _scopes: false
26
26
  end
27
27
 
28
28
  has_many :locked_jobs, class_name: "GoodJob::Job", foreign_key: :locked_by_id, inverse_of: :locked_by_process, dependent: nil
@@ -56,7 +56,7 @@ module GoodJob # :nodoc:
56
56
  end
57
57
  end
58
58
 
59
- def self.create_record(id:, with_advisory_lock: false)
59
+ def self.find_or_create_record(id:, with_advisory_lock: false)
60
60
  attributes = {
61
61
  id: id,
62
62
  state: process_state,
@@ -66,6 +66,17 @@ module GoodJob # :nodoc:
66
66
  attributes[:lock_type] = :advisory
67
67
  end
68
68
  create!(attributes)
69
+ rescue ActiveRecord::RecordNotUnique
70
+ find_by(id: id).tap do |existing_record|
71
+ next unless existing_record
72
+
73
+ if with_advisory_lock
74
+ existing_record.advisory_lock!
75
+ existing_record.update(lock_type: :advisory, state: process_state, updated_at: Time.current)
76
+ else
77
+ existing_record.update(lock_type: nil, state: process_state, updated_at: Time.current)
78
+ end
79
+ end
69
80
  end
70
81
 
71
82
  def self.process_state
@@ -30,7 +30,7 @@
30
30
  <span class="badge bg-primary text-dark font-monospace"><%= job.queue_name %></span>
31
31
  </div>
32
32
  <div class="col-4 col-lg-1 text-lg-end">
33
- <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.priority" %>Priority</div>
33
+ <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.priority" %></div>
34
34
  <span class="font-monospace fw-bold"><%= job.priority %></span>
35
35
  </div>
36
36
  <div class="col-4 col-lg-1 text-lg-end">
@@ -56,9 +56,15 @@
56
56
  </div>
57
57
  <div class="col-6 col-lg-1">
58
58
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.batch.jobs" %></div>
59
- <%= batch.jobs.count %>
59
+ <%= batch.jobs.size %>
60
60
  </div>
61
61
  <div class="col text-end">
62
+ <% if batch.discarded? %>
63
+ <%= link_to retry_batch_path(batch), method: :put, class: "btn btn-sm btn-outline-primary", title: t("good_job.batches.actions.retry"), data: { confirm: t("good_job.batches.actions.confirm_retry") } do %>
64
+ <%= render_icon "arrow_clockwise" %>
65
+ <%= t "good_job.batches.actions.retry" %>
66
+ <% end %>
67
+ <% end %>
62
68
  <%= tag.button type: "button", class: "btn btn-sm text-muted", role: "button",
63
69
  title: t("good_job.actions.inspect"),
64
70
  data: { bs_toggle: "collapse", bs_target: "##{dom_id(batch, 'properties')}" },
@@ -10,6 +10,14 @@
10
10
  <h2 class="h5 mt-2"><%= @batch.description %></h2>
11
11
  </nav>
12
12
  </div>
13
+ <div class="col text-end">
14
+ <% if @batch.discarded? %>
15
+ <%= button_to retry_batch_path(@batch), method: :put, class: "btn btn-sm btn-outline-primary", form_class: "d-inline-block", aria: { label: t("good_job.batches.actions.retry") }, title: t("good_job.batches.actions.retry"), data: { confirm: t("good_job.batches.actions.confirm_retry") } do %>
16
+ <%= render_icon "arrow_clockwise" %>
17
+ <%= t "good_job.actions.retry" %>
18
+ <% end %>
19
+ <% end %>
20
+ </div>
13
21
  </div>
14
22
  </div>
15
23
  </div>