good_job 4.1.0 → 4.1.1

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/app/charts/good_job/performance_index_chart.rb +1 -1
  4. data/app/charts/good_job/performance_show_chart.rb +1 -1
  5. data/app/controllers/good_job/application_controller.rb +1 -1
  6. data/app/controllers/good_job/frontends_controller.rb +6 -2
  7. data/app/controllers/good_job/performance_controller.rb +1 -1
  8. data/app/frontend/good_job/icons.svg +79 -0
  9. data/app/frontend/good_job/style.css +5 -0
  10. data/app/helpers/good_job/icons_helper.rb +8 -5
  11. data/app/models/concerns/good_job/advisory_lockable.rb +17 -7
  12. data/app/models/concerns/good_job/reportable.rb +8 -12
  13. data/app/models/good_job/batch.rb +10 -5
  14. data/app/models/good_job/batch_record.rb +18 -15
  15. data/app/models/good_job/discrete_execution.rb +6 -59
  16. data/app/models/good_job/execution.rb +59 -4
  17. data/app/models/good_job/execution_result.rb +6 -6
  18. data/app/models/good_job/job.rb +567 -12
  19. data/app/views/good_job/batches/_jobs.erb +1 -1
  20. data/app/views/good_job/batches/_table.erb +1 -1
  21. data/app/views/good_job/jobs/index.html.erb +1 -1
  22. data/app/views/layouts/good_job/application.html.erb +7 -7
  23. data/config/brakeman.ignore +75 -0
  24. data/config/locales/de.yml +49 -49
  25. data/config/locales/es.yml +14 -14
  26. data/config/routes.rb +3 -3
  27. data/lib/good_job/active_job_extensions/concurrency.rb +105 -98
  28. data/lib/good_job/adapter/inline_buffer.rb +73 -0
  29. data/lib/good_job/adapter.rb +59 -53
  30. data/lib/good_job/configuration.rb +3 -4
  31. data/lib/good_job/cron_manager.rb +1 -3
  32. data/lib/good_job/current_thread.rb +4 -4
  33. data/lib/good_job/version.rb +1 -1
  34. data/lib/good_job.rb +6 -5
  35. metadata +6 -20
  36. data/app/models/good_job/base_execution.rb +0 -605
  37. data/app/views/good_job/shared/icons/_arrow_clockwise.html.erb +0 -5
  38. data/app/views/good_job/shared/icons/_check.html.erb +0 -5
  39. data/app/views/good_job/shared/icons/_circle_half.html.erb +0 -4
  40. data/app/views/good_job/shared/icons/_clock.html.erb +0 -5
  41. data/app/views/good_job/shared/icons/_dash_circle.html.erb +0 -5
  42. data/app/views/good_job/shared/icons/_dots.html.erb +0 -3
  43. data/app/views/good_job/shared/icons/_eject.html.erb +0 -4
  44. data/app/views/good_job/shared/icons/_exclamation.html.erb +0 -5
  45. data/app/views/good_job/shared/icons/_globe.html.erb +0 -3
  46. data/app/views/good_job/shared/icons/_info.html.erb +0 -4
  47. data/app/views/good_job/shared/icons/_moon_stars_fill.html.erb +0 -5
  48. data/app/views/good_job/shared/icons/_pause.html.erb +0 -4
  49. data/app/views/good_job/shared/icons/_play.html.erb +0 -4
  50. data/app/views/good_job/shared/icons/_skip_forward.html.erb +0 -4
  51. data/app/views/good_job/shared/icons/_stop.html.erb +0 -4
  52. 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,352 @@ 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))).joins_advisory_locks.where(pg_locks: { locktype: nil }) }
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).joins_advisory_locks.where.not(pg_locks: { locktype: 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, (lambda do
103
+ if GoodJob.configuration.smaller_number_is_higher_priority
104
+ order('priority ASC NULLS LAST')
105
+ else
106
+ order('priority DESC NULLS LAST')
107
+ end
108
+ end)
109
+
110
+ # Order jobs by created_at, for first-in first-out
111
+ # @!method creation_ordered
112
+ # @!scope class
113
+ # @return [ActiveRecord:Relation]
114
+ scope :creation_ordered, -> { order(created_at: :asc) }
115
+
116
+ # Order jobs for de-queueing
117
+ # @!method dequeueing_ordered(parsed_queues)
118
+ # @!scope class
119
+ # @param parsed_queues [Hash]
120
+ # optional output of .queue_parser, parsed queues, will be used for
121
+ # ordered queues.
122
+ # @return [ActiveRecord::Relation]
123
+ scope :dequeueing_ordered, (lambda do |parsed_queues|
124
+ relation = self
125
+ relation = relation.queue_ordered(parsed_queues[:include]) if parsed_queues && parsed_queues[:ordered_queues] && parsed_queues[:include]
126
+ relation = relation.priority_ordered.creation_ordered
127
+
128
+ relation
129
+ end)
130
+
131
+ # Order jobs in order of queues in array param
132
+ # @!method queue_ordered(queues)
133
+ # @!scope class
134
+ # @param queues [Array<string] ordered names of queues
135
+ # @return [ActiveRecord::Relation]
136
+ scope :queue_ordered, (lambda do |queues|
137
+ clauses = queues.map.with_index do |queue_name, index|
138
+ sanitize_sql_array(["WHEN queue_name = ? THEN ?", queue_name, index])
139
+ end
140
+ order(Arel.sql("(CASE #{clauses.join(' ')} ELSE #{queues.size} END)"))
141
+ end)
142
+
143
+ # Order jobs by scheduled or created (oldest first).
144
+ # @!method schedule_ordered
145
+ # @!scope class
146
+ # @return [ActiveRecord::Relation]
147
+ scope :schedule_ordered, -> { order(coalesce_scheduled_at_created_at.asc) }
148
+
149
+ # Get completed jobs before the given timestamp. If no timestamp is
150
+ # provided, get *all* completed jobs. By default, GoodJob
151
+ # destroys jobs after they're completed, meaning this returns no jobs.
152
+ # However, if you have changed {GoodJob.preserve_job_records}, this may
153
+ # find completed Jobs.
154
+ # @!method finished(timestamp = nil)
155
+ # @!scope class
156
+ # @param timestamp (Float)
157
+ # Get jobs that finished before this time (in epoch time).
158
+ # @return [ActiveRecord::Relation]
159
+ scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(bind_value('finished_at', timestamp, ActiveRecord::Type::DateTime))) : where.not(finished_at: nil) }
160
+
161
+ # Get Jobs that started but not finished yet.
162
+ # @!method running
163
+ # @!scope class
164
+ # @return [ActiveRecord::Relation]
165
+ scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
166
+
167
+ # Get Jobs on queues that match the given queue string.
168
+ # @!method queue_string(string)
169
+ # @!scope class
170
+ # @param string [String]
171
+ # A string expression describing what queues to select. See
172
+ # {Job.queue_parser} or
173
+ # {file:README.md#optimize-queues-threads-and-processes} for more details
174
+ # on the format of the string. Note this only handles individual
175
+ # semicolon-separated segments of that string format.
176
+ # @return [ActiveRecord::Relation]
177
+ scope :queue_string, (lambda do |string|
178
+ parsed = queue_parser(string)
179
+
180
+ if parsed[:all]
181
+ all
182
+ elsif parsed[:exclude]
183
+ where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
184
+ elsif parsed[:include]
185
+ where(queue_name: parsed[:include])
186
+ end
187
+ end)
188
+
189
+ class << self
190
+ # Parse a string representing a group of queues into a more readable data
191
+ # structure.
192
+ # @param string [String] Queue string
193
+ # @return [Hash]
194
+ # How to match a given queue. It can have the following keys and values:
195
+ # - +{ all: true }+ indicates that all queues match.
196
+ # - +{ exclude: Array<String> }+ indicates the listed queue names should
197
+ # not match.
198
+ # - +{ include: Array<String> }+ indicates the listed queue names should
199
+ # match.
200
+ # - +{ include: Array<String>, ordered_queues: true }+ indicates the listed
201
+ # queue names should match, and dequeue should respect queue order.
202
+ # @example
203
+ # GoodJob::Execution.queue_parser('-queue1,queue2')
204
+ # => { exclude: [ 'queue1', 'queue2' ] }
205
+ def queue_parser(string)
206
+ string = string.strip.presence || '*'
207
+
208
+ case string.first
209
+ when '-'
210
+ exclude_queues = true
211
+ string = string[1..]
212
+ when '+'
213
+ ordered_queues = true
214
+ string = string[1..]
215
+ end
216
+
217
+ queues = string.split(',').map(&:strip)
218
+
219
+ if queues.include?('*')
220
+ { all: true }
221
+ elsif exclude_queues
222
+ { exclude: queues }
223
+ elsif ordered_queues
224
+ {
225
+ include: queues,
226
+ ordered_queues: true,
227
+ }
228
+ else
229
+ { include: queues }
230
+ end
231
+ end
232
+
233
+ def json_string(json, attr)
234
+ Arel::Nodes::Grouping.new(Arel::Nodes::InfixOperation.new('->>', json, Arel::Nodes.build_quoted(attr)))
235
+ end
236
+
237
+ def params_job_class
238
+ json_string(arel_table['serialized_params'], 'job_class')
239
+ end
240
+
241
+ def params_execution_count
242
+ Arel::Nodes::InfixOperation.new(
243
+ '::',
244
+ json_string(arel_table['serialized_params'], 'executions'),
245
+ Arel.sql('integer')
246
+ )
247
+ end
248
+
249
+ def coalesce_scheduled_at_created_at
250
+ arel_table.coalesce(arel_table['scheduled_at'], arel_table['created_at'])
251
+ end
252
+ end
253
+
254
+ def self.build_for_enqueue(active_job, scheduled_at: nil)
255
+ new(**enqueue_args(active_job, scheduled_at: scheduled_at))
256
+ end
257
+
258
+ # Construct arguments for GoodJob::Execution from an ActiveJob instance.
259
+ def self.enqueue_args(active_job, scheduled_at: nil)
260
+ execution_args = {
261
+ id: active_job.job_id,
262
+ active_job_id: active_job.job_id,
263
+ job_class: active_job.class.name,
264
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
265
+ priority: active_job.priority || DEFAULT_PRIORITY,
266
+ serialized_params: active_job.serialize,
267
+ created_at: Time.current,
268
+ }
269
+
270
+ execution_args[:scheduled_at] = if scheduled_at
271
+ scheduled_at
272
+ elsif active_job.scheduled_at
273
+ Time.zone.at(active_job.scheduled_at)
274
+ else
275
+ execution_args[:created_at]
276
+ end
277
+
278
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
279
+
280
+ if active_job.respond_to?(:good_job_labels) && active_job.good_job_labels.any?
281
+ labels = active_job.good_job_labels.dup
282
+ labels.map! { |label| label.to_s.strip.presence }
283
+ labels.tap(&:compact!).tap(&:uniq!)
284
+ execution_args[:labels] = labels
285
+ end
286
+
287
+ reenqueued_current_job = CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
288
+ current_job = CurrentThread.job
289
+
290
+ if reenqueued_current_job
291
+ execution_args[:batch_id] = current_job.batch_id
292
+ execution_args[:batch_callback_id] = current_job.batch_callback_id
293
+ execution_args[:cron_key] = current_job.cron_key
294
+ else
295
+ execution_args[:batch_id] = GoodJob::Batch.current_batch_id
296
+ execution_args[:batch_callback_id] = GoodJob::Batch.current_batch_callback_id
297
+ execution_args[:cron_key] = CurrentThread.cron_key
298
+ execution_args[:cron_at] = CurrentThread.cron_at
299
+ end
300
+
301
+ execution_args
302
+ end
303
+
304
+ # Finds the next eligible Execution, acquire an advisory lock related to it, and
305
+ # executes the job.
306
+ # @yield [Execution, nil] The next eligible Execution, or +nil+ if none found, before it is performed.
307
+ # @return [ExecutionResult, nil]
308
+ # If a job was executed, returns an array with the {Execution} record, the
309
+ # return value for the job's +#perform+ method, and the exception the job
310
+ # raised, if any (if the job raised, then the second array entry will be
311
+ # +nil+). If there were no jobs to execute, returns +nil+.
312
+ def self.perform_with_advisory_lock(lock_id:, parsed_queues: nil, queue_select_limit: nil)
313
+ job = nil
314
+ result = nil
315
+
316
+ unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(select_limit: queue_select_limit) do |jobs|
317
+ job = jobs.first
318
+
319
+ if job&.executable?
320
+ yield(job) if block_given?
321
+
322
+ result = job.perform(lock_id: lock_id)
323
+ else
324
+ job = nil
325
+ yield(nil) if block_given?
326
+ end
327
+ end
328
+
329
+ job&.run_callbacks(:perform_unlocked)
330
+ result
331
+ end
332
+
333
+ # Fetches the scheduled execution time of the next eligible Execution(s).
334
+ # @param after [DateTime]
335
+ # @param limit [Integer]
336
+ # @param now_limit [Integer, nil]
337
+ # @return [Array<DateTime>]
338
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
339
+ query = advisory_unlocked.unfinished.schedule_ordered
340
+
341
+ after ||= Time.current
342
+ after_bind = bind_value('scheduled_at', after, ActiveRecord::Type::DateTime)
343
+ 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))
344
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
345
+
346
+ if now_limit&.positive?
347
+ now_query = query.where(arel_table['scheduled_at'].lt(bind_value('scheduled_at', Time.current, ActiveRecord::Type::DateTime))).or query.where(scheduled_at: nil)
348
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
349
+ end
350
+
351
+ Array(now_at) + after_at
352
+ end
353
+
354
+ # Places an ActiveJob job on a queue by creating a new {Execution} record.
355
+ # @param active_job [ActiveJob::Base]
356
+ # The job to enqueue.
357
+ # @param scheduled_at [Float]
358
+ # Epoch timestamp when the job should be executed, if blank will delegate to the ActiveJob instance
359
+ # @param create_with_advisory_lock [Boolean]
360
+ # Whether to establish a lock on the {Execution} record after it is created.
361
+ # @return [Execution]
362
+ # The new {Execution} instance representing the queued ActiveJob job.
363
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
364
+ 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|
365
+ current_job = CurrentThread.job
366
+
367
+ retried = current_job && current_job.active_job_id == active_job.job_id
368
+ if retried
369
+ job = current_job
370
+ job.assign_attributes(enqueue_args(active_job, scheduled_at: scheduled_at))
371
+ job.scheduled_at ||= Time.current
372
+ # TODO: these values ideally shouldn't be persisted until the current_job is finished
373
+ # which will require handling `retry_job` being called from outside the job context.
374
+ job.performed_at = nil
375
+ job.finished_at = nil
376
+ else
377
+ job = build_for_enqueue(active_job, scheduled_at: scheduled_at)
378
+ end
379
+
380
+ if create_with_advisory_lock
381
+ if job.persisted?
382
+ job.advisory_lock
383
+ else
384
+ job.create_with_advisory_lock = true
385
+ end
386
+ end
387
+
388
+ instrument_payload[:job] = job
389
+ job.save!
390
+
391
+ CurrentThread.retried_job = job if retried
392
+
393
+ active_job.provider_job_id = job.id
394
+ raise "These should be equal" if active_job.provider_job_id != active_job.job_id
395
+
396
+ job
397
+ end
398
+ end
399
+
400
+ def self.format_error(error)
401
+ raise ArgumentError unless error.is_a?(Exception)
402
+
403
+ [error.class.to_s, ERROR_MESSAGE_SEPARATOR, error.message].join
404
+ end
405
+
49
406
  # TODO: it would be nice to enforce these values at the model
50
407
  # validates :active_job_id, presence: true
51
408
  # validates :scheduled_at, presence: true
@@ -101,7 +458,7 @@ module GoodJob
101
458
  # Tests whether the job has finished (succeeded or discarded).
102
459
  # @return [Boolean]
103
460
  def finished?
104
- finished_at.present? && retried_good_job_id.nil?
461
+ finished_at.present?
105
462
  end
106
463
 
107
464
  # Tests whether the job has finished but with an error.
@@ -193,10 +550,191 @@ module GoodJob
193
550
  end
194
551
  end
195
552
 
196
- # Utility method to determine which execution record is used to represent this job
197
- # @return [String]
198
- def _execution_id
199
- attributes['id']
553
+ # Build an ActiveJob instance and deserialize the arguments, using `#active_job_data`.
554
+ #
555
+ # @param ignore_deserialization_errors [Boolean]
556
+ # Whether to ignore ActiveJob::DeserializationError and NameError when deserializing the arguments.
557
+ # This is most useful if you aren't planning to use the arguments directly.
558
+ def active_job(ignore_deserialization_errors: false)
559
+ ActiveJob::Base.deserialize(active_job_data).tap do |aj|
560
+ aj.send(:deserialize_arguments_if_needed)
561
+ rescue ActiveJob::DeserializationError
562
+ raise unless ignore_deserialization_errors
563
+ end
564
+ rescue NameError
565
+ raise unless ignore_deserialization_errors
566
+ end
567
+
568
+ # Execute the ActiveJob job this {Execution} represents.
569
+ # @return [ExecutionResult]
570
+ # An array of the return value of the job's +#perform+ method and the
571
+ # exception raised by the job, if any. If the job completed successfully,
572
+ # the second array entry (the exception) will be +nil+ and vice versa.
573
+ def perform(lock_id:)
574
+ run_callbacks(:perform) do
575
+ raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
576
+
577
+ job_performed_at = Time.current
578
+ monotonic_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
579
+ execution = nil
580
+ result = GoodJob::CurrentThread.within do |current_thread|
581
+ current_thread.reset
582
+ current_thread.job = self
583
+
584
+ existing_performed_at = performed_at
585
+ if existing_performed_at
586
+ current_thread.execution_interrupted = existing_performed_at
587
+
588
+ interrupt_error_string = self.class.format_error(GoodJob::InterruptError.new("Interrupted after starting perform at '#{existing_performed_at}'"))
589
+ self.error = interrupt_error_string
590
+ self.error_event = :interrupted
591
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
592
+
593
+ execution_attrs = {
594
+ error: interrupt_error_string,
595
+ finished_at: job_performed_at,
596
+ error_event: :interrupted,
597
+ duration: monotonic_duration,
598
+ }
599
+ executions.where(finished_at: nil).where.not(performed_at: nil).update_all(execution_attrs) # rubocop:disable Rails/SkipsModelValidations
600
+ end
601
+
602
+ transaction do
603
+ execution_attrs = {
604
+ job_class: job_class,
605
+ queue_name: queue_name,
606
+ serialized_params: serialized_params,
607
+ scheduled_at: scheduled_at || created_at,
608
+ created_at: job_performed_at,
609
+ process_id: lock_id,
610
+ }
611
+ job_attrs = {
612
+ performed_at: job_performed_at,
613
+ executions_count: ((executions_count || 0) + 1),
614
+ locked_by_id: lock_id,
615
+ locked_at: Time.current,
616
+ }
617
+
618
+ execution = executions.create!(execution_attrs)
619
+ update!(job_attrs)
620
+ end
621
+
622
+ 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|
623
+ value = ActiveJob::Base.execute(active_job_data)
624
+
625
+ if value.is_a?(Exception)
626
+ handled_error = value
627
+ value = nil
628
+ end
629
+ handled_error ||= current_thread.error_on_retry || current_thread.error_on_discard
630
+
631
+ error_event = if handled_error == current_thread.error_on_discard
632
+ :discarded
633
+ elsif handled_error == current_thread.error_on_retry
634
+ :retried
635
+ elsif handled_error == current_thread.error_on_retry_stopped
636
+ :retry_stopped
637
+ elsif handled_error
638
+ :handled
639
+ end
640
+
641
+ instrument_payload.merge!(
642
+ value: value,
643
+ handled_error: handled_error,
644
+ retried: current_thread.retried_job.present?,
645
+ error_event: error_event
646
+ )
647
+ ExecutionResult.new(value: value, handled_error: handled_error, error_event: error_event, retried_job: current_thread.retried_job)
648
+ rescue StandardError => e
649
+ error_event = if e.is_a?(GoodJob::InterruptError)
650
+ :interrupted
651
+ elsif e == current_thread.error_on_retry_stopped
652
+ :retry_stopped
653
+ else
654
+ :unhandled
655
+ end
656
+
657
+ instrument_payload[:unhandled_error] = e
658
+ ExecutionResult.new(value: nil, unhandled_error: e, error_event: error_event)
659
+ end
660
+ end
661
+
662
+ job_attributes = { locked_by_id: nil, locked_at: nil }
663
+
664
+ job_error = result.handled_error || result.unhandled_error
665
+ if job_error
666
+ error_string = self.class.format_error(job_error)
667
+
668
+ job_attributes[:error] = error_string
669
+ job_attributes[:error_event] = result.error_event
670
+
671
+ execution.error = error_string
672
+ execution.error_event = result.error_event
673
+ execution.error_backtrace = job_error.backtrace
674
+ else
675
+ job_attributes[:error] = nil
676
+ job_attributes[:error_event] = nil
677
+ end
678
+
679
+ job_finished_at = Time.current
680
+ monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
681
+ job_attributes[:finished_at] = job_finished_at
682
+
683
+ execution.finished_at = job_finished_at
684
+ execution.duration = monotonic_duration
685
+
686
+ retry_unhandled_error = result.unhandled_error && GoodJob.retry_on_unhandled_error
687
+ reenqueued = result.retried? || retry_unhandled_error
688
+ if reenqueued
689
+ job_attributes[:performed_at] = nil
690
+ job_attributes[:finished_at] = nil
691
+ end
692
+
693
+ assign_attributes(job_attributes)
694
+ preserve_unhandled = result.unhandled_error && (GoodJob.retry_on_unhandled_error || GoodJob.preserve_job_records == :on_unhandled_error)
695
+ if finished_at.blank? || GoodJob.preserve_job_records == true || reenqueued || preserve_unhandled || cron_key.present?
696
+ transaction do
697
+ execution.save!
698
+ save!
699
+ end
700
+ else
701
+ destroy!
702
+ end
703
+
704
+ result
705
+ end
706
+ end
707
+
708
+ # Tests whether this job is safe to be executed by this thread.
709
+ # @return [Boolean]
710
+ def executable?
711
+ reload.finished_at.blank?
712
+ rescue ActiveRecord::RecordNotFound
713
+ false
714
+ end
715
+
716
+ def number
717
+ serialized_params.fetch('executions', 0) + 1
718
+ end
719
+
720
+ # Time between when this job was expected to run and when it started running
721
+ def queue_latency
722
+ now = Time.zone.now
723
+ expected_start = scheduled_at || created_at
724
+ actual_start = performed_at || finished_at || now
725
+
726
+ actual_start - expected_start unless expected_start >= now
727
+ end
728
+
729
+ # Time between when this job started and finished
730
+ def runtime_latency
731
+ (finished_at || Time.zone.now) - performed_at if performed_at
732
+ end
733
+
734
+ def job_state
735
+ state = { queue_name: queue_name }
736
+ state[:scheduled_at] = scheduled_at if scheduled_at
737
+ state
200
738
  end
201
739
 
202
740
  private
@@ -222,6 +760,23 @@ module GoodJob
222
760
  update_record.call
223
761
  end
224
762
  end
763
+
764
+ def reset_batch_values(&block)
765
+ GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
766
+ end
767
+
768
+ def continue_discard_or_finish_batch
769
+ batch._continue_discard_or_finish(self) if batch.present?
770
+ end
771
+
772
+ def active_job_data
773
+ serialized_params.deep_dup
774
+ .tap do |job_data|
775
+ job_data["provider_job_id"] = id
776
+ job_data["good_job_concurrency_key"] = concurrency_key if concurrency_key
777
+ job_data["good_job_labels"] = Array(labels) if labels.present?
778
+ end
779
+ end
225
780
  end
226
781
  end
227
782
 
@@ -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,7 +56,7 @@
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
62
  <%= tag.button type: "button", class: "btn btn-sm text-muted", role: "button",
@@ -7,7 +7,7 @@
7
7
  <nav aria-label="<%= t ".job_pagination" %>" class="mt-3">
8
8
  <ul class="pagination">
9
9
  <li class="page-item">
10
- <%= link_to(@filter.to_params(after_scheduled_at: (@filter.last.scheduled_at || @filter.last.created_at), after_id: @filter.last.id), class: "page-link") do %>
10
+ <%= link_to(@filter.to_params(after_scheduled_at: @filter.last.scheduled_at || @filter.last.created_at, after_id: @filter.last.id), class: "page-link") do %>
11
11
  <%= t ".older_jobs" %> <span aria-hidden="true">&raquo;</span>
12
12
  <% end %>
13
13
  </li>
@@ -21,13 +21,13 @@
21
21
  </script>
22
22
 
23
23
  <%# Do not use asset tag helpers to avoid paths being overriden by config.asset_host %>
24
- <%= tag.link rel: "stylesheet", href: frontend_static_path(:bootstrap, format: :css, v: GoodJob::VERSION, locale: nil), nonce: content_security_policy_nonce %>
25
- <%= tag.link rel: "stylesheet", href: frontend_static_path(:style, format: :css, v: GoodJob::VERSION, locale: nil), nonce: content_security_policy_nonce %>
26
- <%= tag.script "", src: frontend_static_path(:bootstrap, format: :js, v: GoodJob::VERSION, locale: nil), nonce: content_security_policy_nonce %>
27
- <%= tag.script "", src: frontend_static_path(:chartjs, format: :js, v: GoodJob::VERSION, locale: nil), nonce: content_security_policy_nonce %>
28
- <%= tag.script "", src: frontend_static_path(:rails_ujs, format: :js, v: GoodJob::VERSION, locale: nil), nonce: content_security_policy_nonce %>
29
- <%= tag.script "", src: frontend_static_path(:es_module_shims, format: :js, v: GoodJob::VERSION, locale: nil), async: true, nonce: content_security_policy_nonce %>
30
- <% importmaps = GoodJob::FrontendsController.js_modules.keys.index_with { |module_name| frontend_module_path(module_name, format: :js, locale: nil, v: GoodJob::VERSION) } %>
24
+ <%= tag.link rel: "stylesheet", href: frontend_static_path(:bootstrap, format: :css, locale: nil), nonce: content_security_policy_nonce %>
25
+ <%= tag.link rel: "stylesheet", href: frontend_static_path(:style, format: :css, locale: nil), nonce: content_security_policy_nonce %>
26
+ <%= tag.script "", src: frontend_static_path(:bootstrap, format: :js, locale: nil), nonce: content_security_policy_nonce %>
27
+ <%= tag.script "", src: frontend_static_path(:chartjs, format: :js, locale: nil), nonce: content_security_policy_nonce %>
28
+ <%= tag.script "", src: frontend_static_path(:rails_ujs, format: :js, locale: nil), nonce: content_security_policy_nonce %>
29
+ <%= tag.script "", src: frontend_static_path(:es_module_shims, format: :js, locale: nil), async: true, nonce: content_security_policy_nonce %>
30
+ <% importmaps = GoodJob::FrontendsController.js_modules.keys.index_with { |module_name| frontend_module_path(module_name, format: :js, locale: nil) } %>
31
31
  <%= tag.script({ imports: importmaps }.to_json.html_safe, type: "importmap", nonce: content_security_policy_nonce) %>
32
32
  <%= tag.script "", type: "module", nonce: content_security_policy_nonce do %> import "application"; <% end %>
33
33
  </head>