good_job 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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>