good_job 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +10 -10
  4. data/app/charts/good_job/performance_index_chart.rb +1 -1
  5. data/app/charts/good_job/performance_show_chart.rb +1 -1
  6. data/app/controllers/good_job/application_controller.rb +1 -1
  7. data/app/controllers/good_job/batches_controller.rb +6 -0
  8. data/app/controllers/good_job/frontends_controller.rb +6 -2
  9. data/app/controllers/good_job/performance_controller.rb +1 -1
  10. data/app/frontend/good_job/icons.svg +79 -0
  11. data/app/frontend/good_job/style.css +5 -0
  12. data/app/helpers/good_job/icons_helper.rb +8 -5
  13. data/app/models/concerns/good_job/advisory_lockable.rb +17 -7
  14. data/app/models/concerns/good_job/error_events.rb +2 -2
  15. data/app/models/concerns/good_job/reportable.rb +8 -12
  16. data/app/models/good_job/batch.rb +31 -9
  17. data/app/models/good_job/batch_record.rb +19 -20
  18. data/app/models/good_job/discrete_execution.rb +6 -59
  19. data/app/models/good_job/execution.rb +59 -4
  20. data/app/models/good_job/execution_result.rb +6 -6
  21. data/app/models/good_job/job.rb +543 -12
  22. data/app/models/good_job/process.rb +14 -3
  23. data/app/views/good_job/batches/_jobs.erb +1 -1
  24. data/app/views/good_job/batches/_table.erb +7 -1
  25. data/app/views/good_job/batches/show.html.erb +8 -0
  26. data/app/views/good_job/jobs/index.html.erb +1 -1
  27. data/app/views/layouts/good_job/application.html.erb +7 -7
  28. data/config/brakeman.ignore +75 -0
  29. data/config/locales/de.yml +54 -49
  30. data/config/locales/en.yml +5 -0
  31. data/config/locales/es.yml +19 -14
  32. data/config/locales/fr.yml +5 -0
  33. data/config/locales/it.yml +5 -0
  34. data/config/locales/ja.yml +10 -5
  35. data/config/locales/ko.yml +9 -4
  36. data/config/locales/nl.yml +5 -0
  37. data/config/locales/pt-BR.yml +5 -0
  38. data/config/locales/ru.yml +5 -0
  39. data/config/locales/tr.yml +5 -0
  40. data/config/locales/uk.yml +6 -1
  41. data/config/routes.rb +8 -4
  42. data/lib/good_job/active_job_extensions/concurrency.rb +109 -98
  43. data/lib/good_job/adapter/inline_buffer.rb +73 -0
  44. data/lib/good_job/adapter.rb +59 -53
  45. data/lib/good_job/capsule_tracker.rb +2 -2
  46. data/lib/good_job/configuration.rb +13 -12
  47. data/lib/good_job/cron_manager.rb +1 -3
  48. data/lib/good_job/current_thread.rb +4 -4
  49. data/lib/good_job/notifier/process_heartbeat.rb +3 -2
  50. data/lib/good_job/version.rb +1 -1
  51. data/lib/good_job.rb +6 -5
  52. metadata +6 -20
  53. data/app/models/good_job/base_execution.rb +0 -605
  54. data/app/views/good_job/shared/icons/_arrow_clockwise.html.erb +0 -5
  55. data/app/views/good_job/shared/icons/_check.html.erb +0 -5
  56. data/app/views/good_job/shared/icons/_circle_half.html.erb +0 -4
  57. data/app/views/good_job/shared/icons/_clock.html.erb +0 -5
  58. data/app/views/good_job/shared/icons/_dash_circle.html.erb +0 -5
  59. data/app/views/good_job/shared/icons/_dots.html.erb +0 -3
  60. data/app/views/good_job/shared/icons/_eject.html.erb +0 -4
  61. data/app/views/good_job/shared/icons/_exclamation.html.erb +0 -5
  62. data/app/views/good_job/shared/icons/_globe.html.erb +0 -3
  63. data/app/views/good_job/shared/icons/_info.html.erb +0 -4
  64. data/app/views/good_job/shared/icons/_moon_stars_fill.html.erb +0 -5
  65. data/app/views/good_job/shared/icons/_pause.html.erb +0 -4
  66. data/app/views/good_job/shared/icons/_play.html.erb +0 -4
  67. data/app/views/good_job/shared/icons/_skip_forward.html.erb +0 -4
  68. data/app/views/good_job/shared/icons/_stop.html.erb +0 -4
  69. data/app/views/good_job/shared/icons/_sun_fill.html.erb +0 -4
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)