good_job 4.1.0 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +10 -10
  4. data/app/charts/good_job/performance_index_chart.rb +1 -1
  5. data/app/charts/good_job/performance_show_chart.rb +1 -1
  6. data/app/controllers/good_job/application_controller.rb +1 -1
  7. data/app/controllers/good_job/batches_controller.rb +6 -0
  8. data/app/controllers/good_job/frontends_controller.rb +6 -2
  9. data/app/controllers/good_job/performance_controller.rb +1 -1
  10. data/app/frontend/good_job/icons.svg +79 -0
  11. data/app/frontend/good_job/style.css +5 -0
  12. data/app/helpers/good_job/icons_helper.rb +8 -5
  13. data/app/models/concerns/good_job/advisory_lockable.rb +17 -7
  14. data/app/models/concerns/good_job/error_events.rb +2 -2
  15. data/app/models/concerns/good_job/reportable.rb +8 -12
  16. data/app/models/good_job/batch.rb +31 -9
  17. data/app/models/good_job/batch_record.rb +19 -20
  18. data/app/models/good_job/discrete_execution.rb +6 -59
  19. data/app/models/good_job/execution.rb +59 -4
  20. data/app/models/good_job/execution_result.rb +6 -6
  21. data/app/models/good_job/job.rb +543 -12
  22. data/app/models/good_job/process.rb +14 -3
  23. data/app/views/good_job/batches/_jobs.erb +1 -1
  24. data/app/views/good_job/batches/_table.erb +7 -1
  25. data/app/views/good_job/batches/show.html.erb +8 -0
  26. data/app/views/good_job/jobs/index.html.erb +1 -1
  27. data/app/views/layouts/good_job/application.html.erb +7 -7
  28. data/config/brakeman.ignore +75 -0
  29. data/config/locales/de.yml +54 -49
  30. data/config/locales/en.yml +5 -0
  31. data/config/locales/es.yml +19 -14
  32. data/config/locales/fr.yml +5 -0
  33. data/config/locales/it.yml +5 -0
  34. data/config/locales/ja.yml +10 -5
  35. data/config/locales/ko.yml +9 -4
  36. data/config/locales/nl.yml +5 -0
  37. data/config/locales/pt-BR.yml +5 -0
  38. data/config/locales/ru.yml +5 -0
  39. data/config/locales/tr.yml +5 -0
  40. data/config/locales/uk.yml +6 -1
  41. data/config/routes.rb +8 -4
  42. data/lib/good_job/active_job_extensions/concurrency.rb +109 -98
  43. data/lib/good_job/adapter/inline_buffer.rb +73 -0
  44. data/lib/good_job/adapter.rb +59 -53
  45. data/lib/good_job/capsule_tracker.rb +2 -2
  46. data/lib/good_job/configuration.rb +13 -12
  47. data/lib/good_job/cron_manager.rb +1 -3
  48. data/lib/good_job/current_thread.rb +4 -4
  49. data/lib/good_job/notifier/process_heartbeat.rb +3 -2
  50. data/lib/good_job/version.rb +1 -1
  51. data/lib/good_job.rb +6 -5
  52. metadata +6 -20
  53. data/app/models/good_job/base_execution.rb +0 -605
  54. data/app/views/good_job/shared/icons/_arrow_clockwise.html.erb +0 -5
  55. data/app/views/good_job/shared/icons/_check.html.erb +0 -5
  56. data/app/views/good_job/shared/icons/_circle_half.html.erb +0 -4
  57. data/app/views/good_job/shared/icons/_clock.html.erb +0 -5
  58. data/app/views/good_job/shared/icons/_dash_circle.html.erb +0 -5
  59. data/app/views/good_job/shared/icons/_dots.html.erb +0 -3
  60. data/app/views/good_job/shared/icons/_eject.html.erb +0 -4
  61. data/app/views/good_job/shared/icons/_exclamation.html.erb +0 -5
  62. data/app/views/good_job/shared/icons/_globe.html.erb +0 -3
  63. data/app/views/good_job/shared/icons/_info.html.erb +0 -4
  64. data/app/views/good_job/shared/icons/_moon_stars_fill.html.erb +0 -5
  65. data/app/views/good_job/shared/icons/_pause.html.erb +0 -4
  66. data/app/views/good_job/shared/icons/_play.html.erb +0 -4
  67. data/app/views/good_job/shared/icons/_skip_forward.html.erb +0 -4
  68. data/app/views/good_job/shared/icons/_stop.html.erb +0 -4
  69. data/app/views/good_job/shared/icons/_sun_fill.html.erb +0 -4
data/config/routes.rb CHANGED
@@ -19,7 +19,11 @@ GoodJob::Engine.routes.draw do
19
19
  get 'jobs/metrics/primary_nav', to: 'metrics#primary_nav', as: :metrics_primary_nav
20
20
  get 'jobs/metrics/job_status', to: 'metrics#job_status', as: :metrics_job_status
21
21
 
22
- resources :batches, only: %i[index show]
22
+ resources :batches, only: %i[index show] do
23
+ member do
24
+ put :retry
25
+ end
26
+ end
23
27
 
24
28
  resources :cron_entries, only: %i[index show], param: :cron_key do
25
29
  member do
@@ -33,8 +37,8 @@ GoodJob::Engine.routes.draw do
33
37
 
34
38
  resources :performance, only: %i[index show]
35
39
 
36
- scope :frontend, controller: :frontends do
37
- get "modules/:name", action: :module, as: :frontend_module, constraints: { format: 'js' }
38
- get "static/:name", action: :static, as: :frontend_static, constraints: { format: %w[css js] }
40
+ scope :frontend, controller: :frontends, defaults: { version: GoodJob::VERSION.tr(".", "-") } do
41
+ get "modules/:version/:id", action: :module, as: :frontend_module, constraints: { format: 'js' }
42
+ get "static/:version/:id", action: :static, as: :frontend_static
39
43
  end
40
44
  end
@@ -28,28 +28,88 @@ module GoodJob
28
28
  class_attribute :good_job_concurrency_config, instance_accessor: false, default: {}
29
29
  attr_writer :good_job_concurrency_key
30
30
 
31
- if ActiveJob.gem_version >= Gem::Version.new("6.1.0")
32
- before_enqueue do |job|
33
- good_job_enqueue_concurrency_check(job, on_abort: -> { throw(:abort) }, on_enqueue: nil)
34
- end
35
- else
36
- around_enqueue do |job, block|
37
- good_job_enqueue_concurrency_check(job, on_abort: nil, on_enqueue: block)
38
- end
39
- end
40
-
41
31
  wait_key = if ActiveJob.gem_version >= Gem::Version.new("7.1.0.a")
42
32
  :polynomially_longer
43
33
  else
44
34
  :exponentially_longer
45
35
  end
46
-
47
36
  retry_on(
48
37
  GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError,
49
38
  attempts: Float::INFINITY,
50
39
  wait: wait_key
51
40
  )
52
41
 
42
+ before_enqueue do |job|
43
+ # Don't attempt to enforce concurrency limits with other queue adapters.
44
+ next unless job.class.queue_adapter.is_a?(GoodJob::Adapter)
45
+
46
+ # Always allow jobs to be retried because the current job's execution will complete momentarily
47
+ next if CurrentThread.active_job_id == job.job_id
48
+
49
+ # Only generate the concurrency key on the initial enqueue in case it is dynamic
50
+ job.good_job_concurrency_key ||= job._good_job_concurrency_key
51
+ key = job.good_job_concurrency_key
52
+ next if key.blank?
53
+
54
+ enqueue_limit = job.class.good_job_concurrency_config[:enqueue_limit]
55
+ enqueue_limit = instance_exec(&enqueue_limit) if enqueue_limit.respond_to?(:call)
56
+ enqueue_limit = nil unless enqueue_limit.present? && (0...Float::INFINITY).cover?(enqueue_limit)
57
+
58
+ unless enqueue_limit
59
+ total_limit = job.class.good_job_concurrency_config[:total_limit]
60
+ total_limit = instance_exec(&total_limit) if total_limit.respond_to?(:call)
61
+ total_limit = nil unless total_limit.present? && (0...Float::INFINITY).cover?(total_limit)
62
+ end
63
+
64
+ enqueue_throttle = job.class.good_job_concurrency_config[:enqueue_throttle]
65
+ enqueue_throttle = instance_exec(&enqueue_throttle) if enqueue_throttle.respond_to?(:call)
66
+ enqueue_throttle = nil unless enqueue_throttle.present? && enqueue_throttle.is_a?(Array) && enqueue_throttle.size == 2
67
+
68
+ limit = enqueue_limit || total_limit
69
+ throttle = enqueue_throttle
70
+ next unless limit || throttle
71
+
72
+ exceeded = nil
73
+ GoodJob::Job.transaction(requires_new: true, joinable: false) do
74
+ GoodJob::Job.advisory_lock_key(key, function: "pg_advisory_xact_lock") do
75
+ if limit
76
+ enqueue_concurrency = if enqueue_limit
77
+ GoodJob::Job.where(concurrency_key: key).unfinished.advisory_unlocked.count
78
+ else
79
+ GoodJob::Job.where(concurrency_key: key).unfinished.count
80
+ end
81
+
82
+ # The job has not yet been enqueued, so check if adding it will go over the limit
83
+ if (enqueue_concurrency + 1) > limit
84
+ logger.info "Aborted enqueue of #{job.class.name} (Job ID: #{job.job_id}) because the concurrency key '#{key}' has reached its enqueue limit of #{limit} #{'job'.pluralize(limit)}"
85
+ exceeded = :limit
86
+ next
87
+ end
88
+ end
89
+
90
+ if throttle
91
+ throttle_limit = throttle[0]
92
+ throttle_period = throttle[1]
93
+ enqueued_within_period = GoodJob::Job.where(concurrency_key: key)
94
+ .where(GoodJob::Job.arel_table[:created_at].gt(throttle_period.ago))
95
+ .count
96
+
97
+ if (enqueued_within_period + 1) > throttle_limit
98
+ logger.info "Aborted enqueue of #{job.class.name} (Job ID: #{job.job_id}) because the concurrency key '#{key}' has reached its throttle limit of #{limit} #{'job'.pluralize(limit)}"
99
+ exceeded = :throttle
100
+ next
101
+ end
102
+ end
103
+ end
104
+
105
+ # Rollback the transaction because it's potentially less expensive than committing it
106
+ # even though nothing has been altered in the transaction.
107
+ raise ActiveRecord::Rollback
108
+ end
109
+
110
+ throw :abort if exceeded
111
+ end
112
+
53
113
  before_perform do |job|
54
114
  # Don't attempt to enforce concurrency limits with other queue adapters.
55
115
  next unless job.class.queue_adapter.is_a?(GoodJob::Adapter)
@@ -80,30 +140,47 @@ module GoodJob
80
140
  next
81
141
  end
82
142
 
83
- GoodJob::Job.advisory_lock_key(key, function: "pg_advisory_lock") do
84
- if limit
85
- allowed_active_job_ids = GoodJob::Job.unfinished.where(concurrency_key: key)
86
- .advisory_locked
87
- .order(Arel.sql("COALESCE(performed_at, scheduled_at, created_at) ASC"))
88
- .limit(limit).pluck(:active_job_id)
89
- # The current job has already been locked and will appear in the previous query
90
- raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError unless allowed_active_job_ids.include?(job.job_id)
143
+ exceeded = nil
144
+ GoodJob::Job.transaction(requires_new: true, joinable: false) do
145
+ GoodJob::Job.advisory_lock_key(key, function: "pg_advisory_xact_lock") do
146
+ if limit
147
+ allowed_active_job_ids = GoodJob::Job.unfinished.where(concurrency_key: key)
148
+ .advisory_locked
149
+ .order(Arel.sql("COALESCE(performed_at, scheduled_at, created_at) ASC"))
150
+ .limit(limit).pluck(:active_job_id)
151
+ # The current job has already been locked and will appear in the previous query
152
+ unless allowed_active_job_ids.include?(job.job_id)
153
+ exceeded = :limit
154
+ next
155
+ end
156
+ end
157
+
158
+ if throttle
159
+ throttle_limit = throttle[0]
160
+ throttle_period = throttle[1]
161
+
162
+ query = Execution.joins(:job)
163
+ .where(GoodJob::Job.table_name => { concurrency_key: key })
164
+ .where(Execution.arel_table[:created_at].gt(Execution.bind_value('created_at', throttle_period.ago, ActiveRecord::Type::DateTime)))
165
+ allowed_active_job_ids = query.where(error: nil).or(query.where.not(error: "GoodJob::ActiveJobExtensions::Concurrency::ThrottleExceededError: GoodJob::ActiveJobExtensions::Concurrency::ThrottleExceededError"))
166
+ .order(created_at: :asc)
167
+ .limit(throttle_limit)
168
+ .pluck(:active_job_id)
169
+
170
+ unless allowed_active_job_ids.include?(job.job_id)
171
+ exceeded = :throttle
172
+ next
173
+ end
174
+ end
91
175
  end
92
176
 
93
- if throttle
94
- throttle_limit = throttle[0]
95
- throttle_period = throttle[1]
96
-
97
- query = DiscreteExecution.joins(:job)
98
- .where(GoodJob::Job.table_name => { concurrency_key: key })
99
- .where(DiscreteExecution.arel_table[:created_at].gt(DiscreteExecution.bind_value('created_at', throttle_period.ago, ActiveRecord::Type::DateTime)))
100
- allowed_active_job_ids = query.where(error: nil).or(query.where.not(error: "GoodJob::ActiveJobExtensions::Concurrency::ThrottleExceededError: GoodJob::ActiveJobExtensions::Concurrency::ThrottleExceededError"))
101
- .order(created_at: :asc)
102
- .limit(throttle_limit)
103
- .pluck(:active_job_id)
177
+ raise ActiveRecord::Rollback
178
+ end
104
179
 
105
- raise ThrottleExceededError unless allowed_active_job_ids.include?(job.job_id)
106
- end
180
+ if exceeded == :limit
181
+ raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError
182
+ elsif exceeded == :throttle
183
+ raise GoodJob::ActiveJobExtensions::Concurrency::ThrottleExceededError
107
184
  end
108
185
  end
109
186
  end
@@ -139,72 +216,6 @@ module GoodJob
139
216
  def _good_job_default_concurrency_key
140
217
  self.class.name.to_s
141
218
  end
142
-
143
- private
144
-
145
- def good_job_enqueue_concurrency_check(job, on_abort:, on_enqueue:)
146
- # Don't attempt to enforce concurrency limits with other queue adapters.
147
- return on_enqueue&.call unless job.class.queue_adapter.is_a?(GoodJob::Adapter)
148
-
149
- # Always allow jobs to be retried because the current job's execution will complete momentarily
150
- return on_enqueue&.call if CurrentThread.active_job_id == job.job_id
151
-
152
- # Only generate the concurrency key on the initial enqueue in case it is dynamic
153
- job.good_job_concurrency_key ||= job._good_job_concurrency_key
154
- key = job.good_job_concurrency_key
155
- return on_enqueue&.call if key.blank?
156
-
157
- enqueue_limit = job.class.good_job_concurrency_config[:enqueue_limit]
158
- enqueue_limit = instance_exec(&enqueue_limit) if enqueue_limit.respond_to?(:call)
159
- enqueue_limit = nil unless enqueue_limit.present? && (0...Float::INFINITY).cover?(enqueue_limit)
160
-
161
- unless enqueue_limit
162
- total_limit = job.class.good_job_concurrency_config[:total_limit]
163
- total_limit = instance_exec(&total_limit) if total_limit.respond_to?(:call)
164
- total_limit = nil unless total_limit.present? && (0...Float::INFINITY).cover?(total_limit)
165
- end
166
-
167
- enqueue_throttle = job.class.good_job_concurrency_config[:enqueue_throttle]
168
- enqueue_throttle = instance_exec(&enqueue_throttle) if enqueue_throttle.respond_to?(:call)
169
- enqueue_throttle = nil unless enqueue_throttle.present? && enqueue_throttle.is_a?(Array) && enqueue_throttle.size == 2
170
-
171
- limit = enqueue_limit || total_limit
172
- throttle = enqueue_throttle
173
- return on_enqueue&.call unless limit || throttle
174
-
175
- GoodJob::Job.advisory_lock_key(key, function: "pg_advisory_lock") do
176
- if limit
177
- enqueue_concurrency = if enqueue_limit
178
- GoodJob::Job.where(concurrency_key: key).unfinished.advisory_unlocked.count
179
- else
180
- GoodJob::Job.where(concurrency_key: key).unfinished.count
181
- end
182
-
183
- # The job has not yet been enqueued, so check if adding it will go over the limit
184
- if (enqueue_concurrency + 1) > limit
185
- logger.info "Aborted enqueue of #{job.class.name} (Job ID: #{job.job_id}) because the concurrency key '#{key}' has reached its enqueue limit of #{limit} #{'job'.pluralize(limit)}"
186
- on_abort&.call
187
- break
188
- end
189
- end
190
-
191
- if throttle
192
- throttle_limit = throttle[0]
193
- throttle_period = throttle[1]
194
- enqueued_within_period = GoodJob::Job.where(concurrency_key: key)
195
- .where(GoodJob::Job.arel_table[:created_at].gt(throttle_period.ago))
196
- .count
197
-
198
- if (enqueued_within_period + 1) > throttle_limit
199
- logger.info "Aborted enqueue of #{job.class.name} (Job ID: #{job.job_id}) because the concurrency key '#{key}' has reached its throttle limit of #{limit} #{'job'.pluralize(limit)}"
200
- on_abort&.call
201
- break
202
- end
203
- end
204
-
205
- on_enqueue&.call
206
- end
207
- end
208
219
  end
209
220
  end
210
221
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/attribute_accessors_per_thread'
4
+
5
+ module GoodJob
6
+ class Adapter
7
+ # The InlineBuffer is integrated into the Adapter and captures jobs that have been enqueued inline.
8
+ # The purpose is allow job records to be persisted, in a locked state, while within a transaction,
9
+ # and then execute the jobs after the transaction has been committed to ensure that the jobs
10
+ # do not run within a transaction.
11
+ #
12
+ # @private This is intended for internal GoodJob usage only.
13
+ class InlineBuffer
14
+ # @!attribute [rw] current_buffer
15
+ # @!scope class
16
+ # Current buffer of jobs to be enqueued.
17
+ # @return [GoodJob::Adapter::InlineBuffer, nil]
18
+ thread_mattr_accessor :current_buffer
19
+
20
+ # This block should be used to wrap the transaction that could enqueue jobs.
21
+ # @yield The block that may enqueue jobs.
22
+ # @return [Proc] A proc that will execute enqueued jobs after the transaction has been committed.
23
+ # @example Wrapping a transaction
24
+ # buffer = GoodJob::Adapter::InlineBuffer.capture do
25
+ # ActiveRecord::Base.transaction do
26
+ # MyJob.perform_later
27
+ # end
28
+ # end
29
+ # buffer.call
30
+ def self.capture
31
+ if current_buffer
32
+ yield
33
+ return proc {}
34
+ end
35
+
36
+ begin
37
+ self.current_buffer = new
38
+ yield
39
+ current_buffer.to_proc
40
+ ensure
41
+ self.current_buffer = nil
42
+ end
43
+ end
44
+
45
+ # Used within the adapter to wrap inline job execution
46
+ def self.perform_now_or_defer(&block)
47
+ if defer?
48
+ current_buffer.defer(block)
49
+ else
50
+ yield
51
+ end
52
+ end
53
+
54
+ def self.defer?
55
+ current_buffer.present?
56
+ end
57
+
58
+ def initialize
59
+ @callables = []
60
+ end
61
+
62
+ def defer(callable)
63
+ @callables << callable
64
+ end
65
+
66
+ def to_proc
67
+ proc do
68
+ @callables.map(&:call)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -57,7 +57,7 @@ module GoodJob
57
57
 
58
58
  Rails.application.executor.wrap do
59
59
  current_time = Time.current
60
- executions = active_jobs.map do |active_job|
60
+ jobs = active_jobs.map do |active_job|
61
61
  GoodJob::Job.build_for_enqueue(active_job).tap do |job|
62
62
  job.scheduled_at = current_time if job.scheduled_at == job.created_at
63
63
  job.created_at = current_time
@@ -65,62 +65,56 @@ module GoodJob
65
65
  end
66
66
  end
67
67
 
68
- inline_executions = []
68
+ inline_jobs = []
69
69
  GoodJob::Job.transaction(requires_new: true, joinable: false) do
70
- execution_attributes = executions.map(&:attributes)
71
- results = GoodJob::Job.insert_all(execution_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
70
+ job_attributes = jobs.map(&:attributes)
71
+ results = GoodJob::Job.insert_all(job_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
72
72
 
73
73
  job_id_to_provider_job_id = results.each_with_object({}) { |result, hash| hash[result['active_job_id']] = result['id'] }
74
74
  active_jobs.each do |active_job|
75
75
  active_job.provider_job_id = job_id_to_provider_job_id[active_job.job_id]
76
76
  active_job.successfully_enqueued = active_job.provider_job_id.present? if active_job.respond_to?(:successfully_enqueued=)
77
77
  end
78
- executions.each do |execution|
79
- execution.instance_variable_set(:@new_record, false) if job_id_to_provider_job_id[execution.active_job_id]
78
+ jobs.each do |job|
79
+ job.instance_variable_set(:@new_record, false) if job_id_to_provider_job_id[job.active_job_id]
80
80
  end
81
- executions = executions.select(&:persisted?) # prune unpersisted executions
81
+ jobs = jobs.select(&:persisted?) # prune unpersisted jobs
82
82
 
83
83
  if execute_inline?
84
- inline_executions = executions.select { |execution| (execution.scheduled_at.nil? || execution.scheduled_at <= Time.current) }
85
- inline_executions.each(&:advisory_lock!)
84
+ inline_jobs = jobs.select { |job| job.scheduled_at.nil? || job.scheduled_at <= Time.current }
85
+ inline_jobs.each(&:advisory_lock!)
86
86
  end
87
87
  end
88
88
 
89
- @capsule.tracker.register
90
- begin
91
- until inline_executions.empty?
92
- begin
93
- inline_execution = inline_executions.shift
94
- inline_result = inline_execution.perform(lock_id: @capsule.tracker.id_for_lock)
95
-
96
- retried_execution = inline_result.retried
97
- while retried_execution && retried_execution.scheduled_at <= Time.current
98
- inline_execution = retried_execution
99
- inline_result = inline_execution.perform(lock_id: @capsule.tracker.id_for_lock)
100
- retried_execution = inline_result.retried
89
+ if inline_jobs.any?
90
+ deferred = InlineBuffer.defer?
91
+ InlineBuffer.perform_now_or_defer do
92
+ @capsule.tracker.register do
93
+ until inline_jobs.empty?
94
+ inline_job = inline_jobs.shift
95
+ perform_inline(inline_job, notify: deferred ? send_notify?(inline_job) : false)
101
96
  end
102
97
  ensure
103
- inline_execution.advisory_unlock
104
- inline_execution.run_callbacks(:perform_unlocked)
98
+ inline_jobs.each(&:advisory_unlock)
105
99
  end
106
- raise inline_result.unhandled_error if inline_result.unhandled_error
107
100
  end
108
- ensure
109
- @capsule.tracker.unregister
110
- inline_executions.each(&:advisory_unlock)
111
101
  end
112
102
 
113
- non_inline_executions = executions.reject(&:finished_at)
114
- if non_inline_executions.any?
103
+ non_inline_jobs = if InlineBuffer.defer?
104
+ jobs - inline_jobs
105
+ else
106
+ jobs.reject(&:finished_at)
107
+ end
108
+ if non_inline_jobs.any?
115
109
  job_id_to_active_jobs = active_jobs.index_by(&:job_id)
116
- non_inline_executions.group_by(&:queue_name).each do |queue_name, executions_by_queue|
117
- executions_by_queue.group_by(&:scheduled_at).each do |scheduled_at, executions_by_queue_and_scheduled_at|
118
- state = { queue_name: queue_name, count: executions_by_queue_and_scheduled_at.size }
110
+ non_inline_jobs.group_by(&:queue_name).each do |queue_name, jobs_by_queue|
111
+ jobs_by_queue.group_by(&:scheduled_at).each do |scheduled_at, jobs_by_queue_and_scheduled_at|
112
+ state = { queue_name: queue_name, count: jobs_by_queue_and_scheduled_at.size }
119
113
  state[:scheduled_at] = scheduled_at if scheduled_at
120
114
 
121
115
  executed_locally = execute_async? && @capsule&.create_thread(state)
122
116
  unless executed_locally
123
- state[:count] = job_id_to_active_jobs.values_at(*executions_by_queue_and_scheduled_at.map(&:active_job_id)).count { |active_job| send_notify?(active_job) }
117
+ state[:count] = job_id_to_active_jobs.values_at(*jobs_by_queue_and_scheduled_at.map(&:active_job_id)).count { |active_job| send_notify?(active_job) }
124
118
  Notifier.notify(state) unless state[:count].zero?
125
119
  end
126
120
  end
@@ -148,43 +142,32 @@ module GoodJob
148
142
  will_retry_inline = will_execute_inline && CurrentThread.job&.active_job_id == active_job.job_id && !CurrentThread.retry_now
149
143
 
150
144
  if will_retry_inline
151
- execution = GoodJob::Job.enqueue(
145
+ job = GoodJob::Job.enqueue(
152
146
  active_job,
153
147
  scheduled_at: scheduled_at
154
148
  )
155
149
  elsif will_execute_inline
156
- execution = GoodJob::Job.enqueue(
150
+ job = GoodJob::Job.enqueue(
157
151
  active_job,
158
152
  scheduled_at: scheduled_at,
159
153
  create_with_advisory_lock: true
160
154
  )
161
- begin
162
- result = @capsule.tracker.register { execution.perform(lock_id: @capsule.tracker.id_for_lock) }
163
-
164
- retried_execution = result.retried
165
- while retried_execution && (retried_execution.scheduled_at.nil? || retried_execution.scheduled_at <= Time.current)
166
- execution = retried_execution
167
- result = @capsule.tracker.register { execution.perform(lock_id: @capsule.tracker.id_for_lock) }
168
- retried_execution = result.retried
155
+ InlineBuffer.perform_now_or_defer do
156
+ @capsule.tracker.register do
157
+ perform_inline(job, notify: send_notify?(active_job))
169
158
  end
170
-
171
- Notifier.notify(retried_execution.job_state) if retried_execution&.scheduled_at && retried_execution.scheduled_at > Time.current && send_notify?(active_job)
172
- ensure
173
- execution.advisory_unlock
174
- execution.run_callbacks(:perform_unlocked)
175
159
  end
176
- raise result.unhandled_error if result.unhandled_error
177
160
  else
178
- execution = GoodJob::Job.enqueue(
161
+ job = GoodJob::Job.enqueue(
179
162
  active_job,
180
163
  scheduled_at: scheduled_at
181
164
  )
182
165
 
183
- executed_locally = execute_async? && @capsule&.create_thread(execution.job_state)
184
- Notifier.notify(execution.job_state) if !executed_locally && send_notify?(active_job)
166
+ executed_locally = execute_async? && @capsule&.create_thread(job.job_state)
167
+ Notifier.notify(job.job_state) if !executed_locally && send_notify?(active_job)
185
168
  end
186
169
 
187
- execution
170
+ job
188
171
  end
189
172
  end
190
173
 
@@ -250,5 +233,28 @@ module GoodJob
250
233
 
251
234
  !(active_job.good_job_notify == false || (active_job.class.good_job_notify == false && active_job.good_job_notify.nil?))
252
235
  end
236
+
237
+ # @param job [GoodJob::Job] the job to perform, which must be enqueued and advisory locked already
238
+ # @param notify [Boolean] whether to send a NOTIFY event for a retried job
239
+ def perform_inline(job, notify: true)
240
+ result = nil
241
+ retried_job = nil
242
+
243
+ loop do
244
+ result = job.perform(lock_id: @capsule.tracker.id_for_lock)
245
+ retried_job = result.retried_job
246
+ break if retried_job.nil? || retried_job.scheduled_at.nil? || retried_job.scheduled_at > Time.current
247
+
248
+ job = retried_job
249
+ end
250
+
251
+ Notifier.notify(retried_job.job_state) if notify && retried_job&.scheduled_at && retried_job.scheduled_at > Time.current
252
+ result
253
+ ensure
254
+ job.advisory_unlock
255
+ job.run_callbacks(:perform_unlocked)
256
+
257
+ raise result.unhandled_error if result.unhandled_error
258
+ end
253
259
  end
254
260
  end
@@ -57,7 +57,7 @@ module GoodJob # :nodoc:
57
57
  if @record
58
58
  @record.refresh_if_stale
59
59
  else
60
- @record = GoodJob::Process.create_record(id: @record_id)
60
+ @record = GoodJob::Process.find_or_create_record(id: @record_id)
61
61
  create_refresh_task
62
62
  end
63
63
  value = @record&.id
@@ -89,7 +89,7 @@ module GoodJob # :nodoc:
89
89
  @advisory_locked_connection = WeakRef.new(@record.class.connection)
90
90
  end
91
91
  else
92
- @record = GoodJob::Process.create_record(id: @record_id, with_advisory_lock: true)
92
+ @record = GoodJob::Process.find_or_create_record(id: @record_id, with_advisory_lock: true)
93
93
  @advisory_locked_connection = WeakRef.new(@record.class.connection)
94
94
  create_refresh_task
95
95
  end
@@ -35,8 +35,6 @@ module GoodJob
35
35
  DEFAULT_DASHBOARD_LIVE_POLL_ENABLED = true
36
36
  # Default enqueue_after_transaction_commit
37
37
  DEFAULT_ENQUEUE_AFTER_TRANSACTION_COMMIT = false
38
- # Default smaller_number_is_higher_priority
39
- DEFAULT_SMALLER_NUMBER_IS_HIGHER_PRIORITY = true
40
38
 
41
39
  def self.validate_execution_mode(execution_mode)
42
40
  raise ArgumentError, "GoodJob execution mode must be one of #{EXECUTION_MODES.join(', ')}. It was '#{execution_mode}' which is not valid." unless execution_mode.in?(EXECUTION_MODES)
@@ -150,11 +148,10 @@ module GoodJob
150
148
  # poll (using this interval) for new queued jobs to execute.
151
149
  # @return [Integer]
152
150
  def poll_interval
153
- interval = (
151
+ interval =
154
152
  options[:poll_interval] ||
155
- rails_config[:poll_interval] ||
156
- env['GOOD_JOB_POLL_INTERVAL']
157
- )
153
+ rails_config[:poll_interval] ||
154
+ env['GOOD_JOB_POLL_INTERVAL']
158
155
 
159
156
  if interval
160
157
  interval.to_i
@@ -348,12 +345,6 @@ module GoodJob
348
345
  DEFAULT_ENABLE_LISTEN_NOTIFY
349
346
  end
350
347
 
351
- def smaller_number_is_higher_priority
352
- return rails_config[:smaller_number_is_higher_priority] unless rails_config[:smaller_number_is_higher_priority].nil?
353
-
354
- DEFAULT_SMALLER_NUMBER_IS_HIGHER_PRIORITY
355
- end
356
-
357
348
  def dashboard_default_locale
358
349
  rails_config[:dashboard_default_locale] || DEFAULT_DASHBOARD_DEFAULT_LOCALE
359
350
  end
@@ -386,6 +377,16 @@ module GoodJob
386
377
  end || false
387
378
  end
388
379
 
380
+ # Whether to take an advisory lock on the process record in the notifier reactor.
381
+ # @return [Boolean]
382
+ def advisory_lock_heartbeat
383
+ return options[:advisory_lock_heartbeat] unless options[:advisory_lock_heartbeat].nil?
384
+ return rails_config[:advisory_lock_heartbeat] unless rails_config[:advisory_lock_heartbeat].nil?
385
+ return ActiveModel::Type::Boolean.new.cast(env['GOOD_JOB_ADVISORY_LOCK_HEARTBEAT']) unless env['GOOD_JOB_ADVISORY_LOCK_HEARTBEAT'].nil?
386
+
387
+ Rails.env.development?
388
+ end
389
+
389
390
  private
390
391
 
391
392
  def rails_config
@@ -55,9 +55,7 @@ module GoodJob # :nodoc:
55
55
  # @param timeout [Numeric, nil] Unused but retained for compatibility
56
56
  def shutdown(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
57
57
  @running = false
58
- @tasks.each do |_cron_key, task|
59
- task.cancel
60
- end
58
+ @tasks.each_value(&:cancel)
61
59
  @tasks.clear
62
60
  end
63
61
 
@@ -15,7 +15,7 @@ module GoodJob
15
15
  error_on_retry_stopped
16
16
  job
17
17
  execution_interrupted
18
- execution_retried
18
+ retried_job
19
19
  retry_now
20
20
  ].freeze
21
21
 
@@ -61,11 +61,11 @@ module GoodJob
61
61
  # @return [Boolean, nil]
62
62
  thread_mattr_accessor :execution_interrupted
63
63
 
64
- # @!attribute [rw] execution_retried
64
+ # @!attribute [rw] retried_job
65
65
  # @!scope class
66
66
  # Execution Retried
67
- # @return [Boolean, nil]
68
- thread_mattr_accessor :execution_retried
67
+ # @return [GoodJob::Job, nil]
68
+ thread_mattr_accessor :retried_job
69
69
 
70
70
  # @!attribute [rw] retry_now
71
71
  # @!scope class
@@ -14,9 +14,10 @@ module GoodJob # :nodoc:
14
14
 
15
15
  # Registers the current process.
16
16
  def register_process
17
+ @advisory_lock_heartbeat = GoodJob.configuration.advisory_lock_heartbeat
17
18
  GoodJob::Process.override_connection(connection) do
18
19
  GoodJob::Process.cleanup
19
- @capsule.tracker.register(with_advisory_lock: true)
20
+ @capsule.tracker.register(with_advisory_lock: @advisory_lock_heartbeat)
20
21
  end
21
22
  end
22
23
 
@@ -33,7 +34,7 @@ module GoodJob # :nodoc:
33
34
  # Deregisters the current process.
34
35
  def deregister_process
35
36
  GoodJob::Process.override_connection(connection) do
36
- @capsule.tracker.unregister(with_advisory_lock: true)
37
+ @capsule.tracker.unregister(with_advisory_lock: @advisory_lock_heartbeat)
37
38
  end
38
39
  end
39
40
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  # GoodJob gem version.
5
- VERSION = '4.1.0'
5
+ VERSION = '4.2.0'
6
6
 
7
7
  # GoodJob version as Gem::Version object
8
8
  GEM_VERSION = Gem::Version.new(VERSION)