good_job 4.1.0 → 4.1.1

Sign up to get free protection for your applications and to get access to all the features.
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>