good_job 3.0.2 → 3.3.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.
@@ -7,9 +7,9 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
7
7
  t.text :queue_name
8
8
  t.integer :priority
9
9
  t.jsonb :serialized_params
10
- t.timestamp :scheduled_at
11
- t.timestamp :performed_at
12
- t.timestamp :finished_at
10
+ t.datetime :scheduled_at
11
+ t.datetime :performed_at
12
+ t.datetime :finished_at
13
13
  t.text :error
14
14
 
15
15
  t.timestamps
@@ -18,7 +18,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
18
18
  t.text :concurrency_key
19
19
  t.text :cron_key
20
20
  t.uuid :retried_good_job_id
21
- t.timestamp :cron_at
21
+ t.datetime :cron_at
22
22
  end
23
23
 
24
24
  create_table :good_job_processes, id: :uuid do |t|
@@ -7,9 +7,9 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
7
7
  t.text :queue_name
8
8
  t.integer :priority
9
9
  t.jsonb :serialized_params
10
- t.timestamp :scheduled_at
11
- t.timestamp :performed_at
12
- t.timestamp :finished_at
10
+ t.datetime :scheduled_at
11
+ t.datetime :performed_at
12
+ t.datetime :finished_at
13
13
  t.text :error
14
14
 
15
15
  t.timestamps
@@ -18,7 +18,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
18
18
  t.text :concurrency_key
19
19
  t.text :cron_key
20
20
  t.uuid :retried_good_job_id
21
- t.timestamp :cron_at
21
+ t.datetime :cron_at
22
22
  end
23
23
 
24
24
  create_table :good_job_processes, id: :uuid do |t|
@@ -26,7 +26,7 @@ module GoodJob
26
26
  # @return [void]
27
27
  def write_pid
28
28
  File.open(pidfile, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(::Process.pid.to_s) }
29
- at_exit { File.delete(pidfile) if File.exist?(pidfile) }
29
+ at_exit { File.delete(pidfile) if File.exist?(pidfile) } # rubocop:disable Lint/NonAtomicFileOperation
30
30
  rescue Errno::EEXIST
31
31
  check_pid
32
32
  retry
@@ -34,7 +34,7 @@ module GoodJob
34
34
 
35
35
  # @return [void]
36
36
  def delete_pid
37
- File.delete(pidfile) if File.exist?(pidfile)
37
+ File.delete(pidfile) if File.exist?(pidfile) # rubocop:disable Lint/NonAtomicFileOperation
38
38
  end
39
39
 
40
40
  # @return [void]
@@ -24,7 +24,7 @@ module GoodJob
24
24
  # Perform the next eligible job
25
25
  # @return [Object, nil] Returns job result or +nil+ if no job was found
26
26
  def next
27
- job_query.perform_with_advisory_lock
27
+ job_query.perform_with_advisory_lock(parsed_queues: parsed_queues)
28
28
  end
29
29
 
30
30
  # Tests whether this performer should be used in GoodJob's current state.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # GoodJob gem version.
4
- VERSION = '3.0.2'
4
+ VERSION = '3.3.0'
5
5
  end
@@ -112,9 +112,11 @@ module GoodJob # :nodoc:
112
112
  class: job_class,
113
113
  cron: schedule,
114
114
  set: display_property(set),
115
- args: display_property(args),
116
115
  description: display_property(description),
117
- }
116
+ }.tap do |properties|
117
+ properties[:args] = display_property(args) if args.present?
118
+ properties[:kwargs] = display_property(kwargs) if kwargs.present?
119
+ end
118
120
  end
119
121
 
120
122
  private
@@ -4,6 +4,7 @@ module GoodJob
4
4
  class Execution < BaseRecord
5
5
  include Lockable
6
6
  include Filterable
7
+ include Reportable
7
8
 
8
9
  # Raised if something attempts to execute a previously completed Execution again.
9
10
  PreviouslyPerformedError = Class.new(StandardError)
@@ -29,15 +30,21 @@ module GoodJob
29
30
  # not match.
30
31
  # - +{ include: Array<String> }+ indicates the listed queue names should
31
32
  # match.
33
+ # - +{ include: Array<String>, ordered_queues: true }+ indicates the listed
34
+ # queue names should match, and dequeue should respect queue order.
32
35
  # @example
33
36
  # GoodJob::Execution.queue_parser('-queue1,queue2')
34
37
  # => { exclude: [ 'queue1', 'queue2' ] }
35
38
  def self.queue_parser(string)
36
39
  string = string.presence || '*'
37
40
 
38
- if string.first == '-'
41
+ case string.first
42
+ when '-'
39
43
  exclude_queues = true
40
44
  string = string[1..-1]
45
+ when '+'
46
+ ordered_queues = true
47
+ string = string[1..-1]
41
48
  end
42
49
 
43
50
  queues = string.split(',').map(&:strip)
@@ -46,6 +53,11 @@ module GoodJob
46
53
  { all: true }
47
54
  elsif exclude_queues
48
55
  { exclude: queues }
56
+ elsif ordered_queues
57
+ {
58
+ include: queues,
59
+ ordered_queues: true,
60
+ }
49
61
  else
50
62
  { include: queues }
51
63
  end
@@ -88,6 +100,42 @@ module GoodJob
88
100
  # @return [ActiveRecord::Relation]
89
101
  scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
90
102
 
103
+ # Order jobs by created_at, for first-in first-out
104
+ # @!method creation_ordered
105
+ # @!scope class
106
+ # @return [ActiveRecord:Relation]
107
+ scope :creation_ordered, -> { order('created_at ASC') }
108
+
109
+ # Order jobs for de-queueing
110
+ # @!method dequeueing_ordered
111
+ # @!scope class
112
+ # @param parsed_queues [Hash]
113
+ # optional output of .queue_parser, parsed queues, will be used for
114
+ # ordered queues.
115
+ # @return [ActiveRecord::Relation]
116
+ scope :dequeueing_ordered, (lambda do |parsed_queues|
117
+ relation = self
118
+ relation = relation.queue_ordered(parsed_queues[:include]) if parsed_queues && parsed_queues[:ordered_queues] && parsed_queues[:include]
119
+ relation = relation.priority_ordered.creation_ordered
120
+
121
+ relation
122
+ end)
123
+
124
+ # Order jobs in order of queues in array param
125
+ # @!method queue_ordered
126
+ # @!scope class
127
+ # @param queues [Array<string] ordered names of queues
128
+ # @return [ActiveRecord::Relation]
129
+ scope :queue_ordered, (lambda do |queues|
130
+ clauses = queues.map.with_index do |queue_name, index|
131
+ "WHEN queue_name = '#{queue_name}' THEN #{index}"
132
+ end
133
+
134
+ order(
135
+ Arel.sql("(CASE #{clauses.join(' ')} ELSE #{queues.length} END)")
136
+ )
137
+ end)
138
+
91
139
  # Order jobs by scheduled or created (oldest first).
92
140
  # @!method schedule_ordered
93
141
  # @!scope class
@@ -153,8 +201,8 @@ module GoodJob
153
201
  # return value for the job's +#perform+ method, and the exception the job
154
202
  # raised, if any (if the job raised, then the second array entry will be
155
203
  # +nil+). If there were no jobs to execute, returns +nil+.
156
- def self.perform_with_advisory_lock
157
- unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock(unlock_session: true) do |executions|
204
+ def self.perform_with_advisory_lock(parsed_queues: nil)
205
+ unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(unlock_session: true) do |executions|
158
206
  execution = executions.first
159
207
  break if execution.blank?
160
208
  break :unlocked unless execution&.executable?
@@ -265,56 +313,31 @@ module GoodJob
265
313
  end
266
314
  end
267
315
 
268
- # There are 3 buckets of non-overlapping statuses:
269
- # 1. The job will be executed
270
- # - queued: The job will execute immediately when an execution thread becomes available.
271
- # - scheduled: The job is scheduled to execute in the future.
272
- # - retried: The job previously errored on execution and will be re-executed in the future.
273
- # 2. The job is being executed
274
- # - running: the job is actively being executed by an execution thread
275
- # 3. The job will not execute
276
- # - finished: The job executed successfully
277
- # - discarded: The job previously errored on execution and will not be re-executed in the future.
278
- #
279
- # @return [Symbol]
280
- def status
281
- if finished_at.present?
282
- if error.present?
283
- :discarded
284
- else
285
- :finished
286
- end
287
- elsif (scheduled_at || created_at) > DateTime.current
288
- if serialized_params.fetch('executions', 0) > 1
289
- :retried
290
- else
291
- :scheduled
292
- end
293
- elsif running?
294
- :running
295
- else
296
- :queued
297
- end
316
+ # Return formatted serialized_params for display in the dashboard
317
+ # @return [Hash]
318
+ def display_serialized_params
319
+ serialized_params.merge({
320
+ _good_job: attributes.except('serialized_params', 'locktype', 'owns_advisory_lock'),
321
+ })
298
322
  end
299
323
 
300
324
  def running?
301
- performed_at? && !finished_at?
325
+ if has_attribute?(:locktype)
326
+ self['locktype'].present?
327
+ else
328
+ advisory_locked?
329
+ end
302
330
  end
303
331
 
304
332
  def number
305
333
  serialized_params.fetch('executions', 0) + 1
306
334
  end
307
335
 
308
- # The last relevant timestamp for this execution
309
- def last_status_at
310
- finished_at || performed_at || scheduled_at || created_at
311
- end
312
-
313
336
  # Time between when this job was expected to run and when it started running
314
337
  def queue_latency
315
338
  now = Time.zone.now
316
339
  expected_start = scheduled_at || created_at
317
- actual_start = performed_at || now
340
+ actual_start = performed_at || finished_at || now
318
341
 
319
342
  actual_start - expected_start unless expected_start >= now
320
343
  end
@@ -3,10 +3,12 @@ module GoodJob
3
3
  # ActiveRecord model that represents an +ActiveJob+ job.
4
4
  # There is not a table in the database whose discrete rows represents "Jobs".
5
5
  # The +good_jobs+ table is a table of individual {GoodJob::Execution}s that share the same +active_job_id+.
6
- # A single row from the +good_jobs+ table of executions is fetched to represent an Job
6
+ # A single row from the +good_jobs+ table of executions is fetched to represent a Job.
7
+ #
7
8
  class Job < BaseRecord
8
9
  include Filterable
9
10
  include Lockable
11
+ include Reportable
10
12
 
11
13
  # Raised when an inappropriate action is applied to a Job based on its state.
12
14
  ActionForStateMismatchError = Class.new(StandardError)
@@ -72,9 +74,25 @@ module GoodJob
72
74
  serialized_params['job_class']
73
75
  end
74
76
 
75
- # The status of the Job, based on the state of its most recent execution.
76
- # @return [Symbol]
77
- delegate :status, :last_status_at, to: :head_execution
77
+ # Override #reload to add a custom scope to ensure the reloaded record is the head execution
78
+ # @return [Job]
79
+ def reload(options = nil)
80
+ self.class.connection.clear_query_cache
81
+
82
+ # override with the `where(retried_good_job_id: nil)` scope
83
+ override_query = self.class.where(retried_good_job_id: nil)
84
+ fresh_object =
85
+ if options && options[:lock]
86
+ self.class.unscoped { override_query.lock(options[:lock]).find(id) }
87
+ else
88
+ self.class.unscoped { override_query.find(id) }
89
+ end
90
+
91
+ @attributes = fresh_object.instance_variable_get(:@attributes)
92
+ @new_record = false
93
+ @previously_new_record = false
94
+ self
95
+ end
78
96
 
79
97
  # This job's most recent {Execution}
80
98
  # @param reload [Booelan] whether to reload executions
@@ -94,7 +112,7 @@ module GoodJob
94
112
  # The number of times this job has been executed, according to ActiveJob's serialized state.
95
113
  # @return [Numeric]
96
114
  def executions_count
97
- aj_count = head_execution.serialized_params.fetch('executions', 0)
115
+ aj_count = serialized_params.fetch('executions', 0)
98
116
  # The execution count within serialized_params is not updated
99
117
  # once the underlying execution has been executed.
100
118
  if status.in? [:discarded, :finished, :running]
@@ -114,7 +132,15 @@ module GoodJob
114
132
  # If the job has been retried, the error will be fetched from the previous {Execution} record.
115
133
  # @return [String]
116
134
  def recent_error
117
- head_execution.error || executions[-2]&.error
135
+ error || executions[-2]&.error
136
+ end
137
+
138
+ # Return formatted serialized_params for display in the dashboard
139
+ # @return [Hash]
140
+ def display_serialized_params
141
+ serialized_params.merge({
142
+ _good_job: attributes.except('serialized_params', 'locktype', 'owns_advisory_lock'),
143
+ })
118
144
  end
119
145
 
120
146
  # Tests whether the job is being executed right now.
@@ -192,7 +218,6 @@ module GoodJob
192
218
 
193
219
  raise ActionForStateMismatchError if execution.finished_at.present?
194
220
 
195
- execution = head_execution(reload: true)
196
221
  execution.update(scheduled_at: scheduled_at)
197
222
  end
198
223
  end
@@ -45,6 +45,8 @@ module GoodJob # :nodoc:
45
45
  hostname: Socket.gethostname,
46
46
  pid: ::Process.pid,
47
47
  proctitle: $PROGRAM_NAME,
48
+ preserve_job_records: GoodJob.preserve_job_records,
49
+ retry_on_unhandled_error: GoodJob.retry_on_unhandled_error,
48
50
  schedulers: GoodJob::Scheduler.instances.map(&:name),
49
51
  }
50
52
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ module GoodJob
3
+ module Reportable
4
+ # There are 3 buckets of non-overlapping statuses:
5
+ # 1. The job will be executed
6
+ # - queued: The job will execute immediately when an execution thread becomes available.
7
+ # - scheduled: The job is scheduled to execute in the future.
8
+ # - retried: The job previously errored on execution and will be re-executed in the future.
9
+ # 2. The job is being executed
10
+ # - running: the job is actively being executed by an execution thread
11
+ # 3. The job will not execute
12
+ # - finished: The job executed successfully
13
+ # - discarded: The job previously errored on execution and will not be re-executed in the future.
14
+ #
15
+ # @return [Symbol]
16
+ def status
17
+ if finished_at.present?
18
+ if error.present? && retried_good_job_id.present?
19
+ :retried
20
+ elsif error.present? && retried_good_job_id.nil?
21
+ :discarded
22
+ else
23
+ :finished
24
+ end
25
+ elsif (scheduled_at || created_at) > DateTime.current
26
+ if serialized_params.fetch('executions', 0) > 1
27
+ :retried
28
+ else
29
+ :scheduled
30
+ end
31
+ elsif running?
32
+ :running
33
+ else
34
+ :queued
35
+ end
36
+ end
37
+
38
+ # The last relevant timestamp for this execution
39
+ def last_status_at
40
+ finished_at || performed_at || scheduled_at || created_at
41
+ end
42
+ end
43
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-07-10 00:00:00.000000000 Z
11
+ date: 2022-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -365,6 +365,7 @@ files:
365
365
  - app/assets/good_job/modules/checkbox_toggle.js
366
366
  - app/assets/good_job/modules/document_ready.js
367
367
  - app/assets/good_job/modules/live_poll.js
368
+ - app/assets/good_job/modules/popovers.js
368
369
  - app/assets/good_job/modules/toasts.js
369
370
  - app/assets/good_job/scripts.js
370
371
  - app/assets/good_job/style.css
@@ -398,7 +399,9 @@ files:
398
399
  - app/views/good_job/shared/icons/_check.html.erb
399
400
  - app/views/good_job/shared/icons/_clock.html.erb
400
401
  - app/views/good_job/shared/icons/_dash_circle.html.erb
402
+ - app/views/good_job/shared/icons/_dots.html.erb
401
403
  - app/views/good_job/shared/icons/_exclamation.html.erb
404
+ - app/views/good_job/shared/icons/_info.html.erb
402
405
  - app/views/good_job/shared/icons/_play.html.erb
403
406
  - app/views/good_job/shared/icons/_skip_forward.html.erb
404
407
  - app/views/good_job/shared/icons/_stop.html.erb
@@ -446,6 +449,7 @@ files:
446
449
  - lib/models/good_job/job.rb
447
450
  - lib/models/good_job/lockable.rb
448
451
  - lib/models/good_job/process.rb
452
+ - lib/models/good_job/reportable.rb
449
453
  homepage: https://github.com/bensheldon/good_job
450
454
  licenses:
451
455
  - MIT