good_job 3.8.0 → 3.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -20,8 +20,12 @@ module GoodJob
20
20
  self.table_name = 'good_jobs'
21
21
  self.advisory_lockable_column = 'active_job_id'
22
22
 
23
+ define_model_callbacks :perform
23
24
  define_model_callbacks :perform_unlocked, only: :after
24
25
 
26
+ set_callback :perform, :around, :reset_batch_values
27
+ set_callback :perform_unlocked, :after, :continue_discard_or_finish_batch
28
+
25
29
  # Parse a string representing a group of queues into a more readable data
26
30
  # structure.
27
31
  # @param string [String] Queue string
@@ -43,10 +47,10 @@ module GoodJob
43
47
  case string.first
44
48
  when '-'
45
49
  exclude_queues = true
46
- string = string[1..-1]
50
+ string = string[1..]
47
51
  when '+'
48
52
  ordered_queues = true
49
- string = string[1..-1]
53
+ string = string[1..]
50
54
  end
51
55
 
52
56
  queues = string.split(',').map(&:strip)
@@ -65,6 +69,9 @@ module GoodJob
65
69
  end
66
70
  end
67
71
 
72
+ belongs_to :batch, class_name: 'GoodJob::BatchRecord', optional: true, inverse_of: :executions
73
+ belongs_to :batch_callback, class_name: 'GoodJob::Batch', optional: true
74
+
68
75
  belongs_to :job, class_name: 'GoodJob::Job', foreign_key: 'active_job_id', primary_key: 'active_job_id', optional: true, inverse_of: :executions
69
76
  after_destroy -> { self.class.active_job_id(active_job_id).delete_all }, if: -> { @_destroy_job }
70
77
 
@@ -197,6 +204,38 @@ module GoodJob
197
204
  end
198
205
  end)
199
206
 
207
+ # Construct a GoodJob::Execution from an ActiveJob instance.
208
+ def self.build_for_enqueue(active_job, overrides = {})
209
+ execution_args = {
210
+ active_job_id: active_job.job_id,
211
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
212
+ priority: active_job.priority || DEFAULT_PRIORITY,
213
+ serialized_params: active_job.serialize,
214
+ scheduled_at: active_job.scheduled_at,
215
+ }
216
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
217
+
218
+ reenqueued_current_execution = CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
219
+ current_execution = CurrentThread.execution
220
+
221
+ if reenqueued_current_execution
222
+ if GoodJob::BatchRecord.migrated?
223
+ execution_args[:batch_id] = current_execution.batch_id
224
+ execution_args[:batch_callback_id] = current_execution.batch_callback_id
225
+ end
226
+ execution_args[:cron_key] = current_execution.cron_key
227
+ else
228
+ if GoodJob::BatchRecord.migrated?
229
+ execution_args[:batch_id] = GoodJob::Batch.current_batch_id
230
+ execution_args[:batch_callback_id] = GoodJob::Batch.current_batch_callback_id
231
+ end
232
+ execution_args[:cron_key] = CurrentThread.cron_key
233
+ execution_args[:cron_at] = CurrentThread.cron_at
234
+ end
235
+
236
+ new(**execution_args.merge(overrides))
237
+ end
238
+
200
239
  # Finds the next eligible Execution, acquire an advisory lock related to it, and
201
240
  # executes the job.
202
241
  # @return [ExecutionResult, nil]
@@ -244,38 +283,23 @@ module GoodJob
244
283
  # @param active_job [ActiveJob::Base]
245
284
  # The job to enqueue.
246
285
  # @param scheduled_at [Float]
247
- # Epoch timestamp when the job should be executed.
286
+ # Epoch timestamp when the job should be executed, if blank will delegate to the ActiveJob instance
248
287
  # @param create_with_advisory_lock [Boolean]
249
288
  # Whether to establish a lock on the {Execution} record after it is created.
289
+ # @param persist_immediately [Boolean]
290
+ # Whether to save the record immediately or just initialize it with values. When bulk-inserting
291
+ # jobs the caller takes care of the persistence and sets this parameter to `false`
250
292
  # @return [Execution]
251
293
  # The new {Execution} instance representing the queued ActiveJob job.
252
294
  def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
253
295
  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|
254
- execution_args = {
255
- active_job_id: active_job.job_id,
256
- queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
257
- priority: active_job.priority || DEFAULT_PRIORITY,
258
- serialized_params: active_job.serialize,
259
- scheduled_at: scheduled_at,
260
- create_with_advisory_lock: create_with_advisory_lock,
261
- }
262
-
263
- execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
264
-
265
- if CurrentThread.cron_key
266
- execution_args[:cron_key] = CurrentThread.cron_key
267
- execution_args[:cron_at] = CurrentThread.cron_at
268
- elsif CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
269
- execution_args[:cron_key] = CurrentThread.execution.cron_key
270
- end
271
-
272
- execution = GoodJob::Execution.new(**execution_args)
296
+ execution = build_for_enqueue(active_job, { scheduled_at: scheduled_at })
273
297
 
298
+ execution.create_with_advisory_lock = create_with_advisory_lock
274
299
  instrument_payload[:execution] = execution
275
300
 
276
301
  execution.save!
277
302
  active_job.provider_job_id = execution.id
278
-
279
303
  CurrentThread.execution.retried_good_job_id = execution.id if CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
280
304
 
281
305
  execution
@@ -288,27 +312,29 @@ module GoodJob
288
312
  # exception raised by the job, if any. If the job completed successfully,
289
313
  # the second array entry (the exception) will be +nil+ and vice versa.
290
314
  def perform
291
- raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
315
+ run_callbacks(:perform) do
316
+ raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
292
317
 
293
- self.performed_at = Time.current
294
- save! if GoodJob.preserve_job_records
318
+ self.performed_at = Time.current
319
+ save! if GoodJob.preserve_job_records
295
320
 
296
- result = execute
321
+ result = execute
297
322
 
298
- job_error = result.handled_error || result.unhandled_error
299
- self.error = [job_error.class, ERROR_MESSAGE_SEPARATOR, job_error.message].join if job_error
323
+ job_error = result.handled_error || result.unhandled_error
324
+ self.error = [job_error.class, ERROR_MESSAGE_SEPARATOR, job_error.message].join if job_error
300
325
 
301
- reenqueued = result.retried? || retried_good_job_id.present?
302
- if result.unhandled_error && GoodJob.retry_on_unhandled_error
303
- save!
304
- elsif GoodJob.preserve_job_records == true || reenqueued || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error) || cron_key.present?
305
- self.finished_at = Time.current
306
- save!
307
- else
308
- destroy_job
309
- end
326
+ reenqueued = result.retried? || retried_good_job_id.present?
327
+ if result.unhandled_error && GoodJob.retry_on_unhandled_error
328
+ save!
329
+ elsif GoodJob.preserve_job_records == true || reenqueued || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error) || cron_key.present?
330
+ self.finished_at = Time.current
331
+ save!
332
+ else
333
+ destroy_job
334
+ end
310
335
 
311
- result
336
+ result
337
+ end
312
338
  end
313
339
 
314
340
  # Tests whether this job is safe to be executed by this thread.
@@ -403,5 +429,13 @@ module GoodJob
403
429
  end
404
430
  end
405
431
  end
432
+
433
+ def reset_batch_values(&block)
434
+ GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
435
+ end
436
+
437
+ def continue_discard_or_finish_batch
438
+ batch._continue_discard_or_finish(self) if GoodJob::BatchRecord.migrated? && batch.present?
439
+ end
406
440
  end
407
441
  end
@@ -28,6 +28,7 @@ module GoodJob
28
28
  self.primary_key = 'active_job_id'
29
29
  self.advisory_lockable_column = 'active_job_id'
30
30
 
31
+ belongs_to :batch, class_name: 'GoodJob::BatchRecord', inverse_of: :jobs, optional: true
31
32
  has_many :executions, -> { order(created_at: :asc) }, class_name: 'GoodJob::Execution', foreign_key: 'active_job_id', inverse_of: :job # rubocop:disable Rails/HasManyOrHasOneDependent
32
33
 
33
34
  # Only the most-recent unretried execution represents a "Job"
@@ -0,0 +1,108 @@
1
+ <div class="my-3 card" data-gj-poll-replace id="jobs-table">
2
+ <div class="list-group list-group-flush text-nowrap table-jobs" role="table">
3
+ <header class="list-group-item bg-light">
4
+ <div class="row small text-muted text-uppercase align-items-center">
5
+ <div class="col-4">Jobs</div>
6
+ <div class="col-1">Queue</div>
7
+ <div class="col-1">Priority</div>
8
+ <div class="col-1 text-end">Attempts</div>
9
+ <div class="col text-end">
10
+ <%= tag.button type: "button", class: "btn btn-sm text-muted", role: "button",
11
+ data: { bs_toggle: "collapse", bs_target: ".job-params" },
12
+ aria: { expanded: false, controls: jobs.map { |job| "##{dom_id(job, "params")}" }.join(" ") } do %>
13
+ <%= render_icon "info" %>
14
+ <span class="visually-hidden">Inspect</span>
15
+ <% end %>
16
+ </div>
17
+ </div>
18
+ </header>
19
+
20
+ <% if jobs.present? %>
21
+ <% jobs.each do |job| %>
22
+ <div role="row" class="list-group-item list-group-item-action py-3">
23
+ <div class="row align-items-center">
24
+ <div class="col-4">
25
+ <%= tag.code link_to(job.id, job_path(job), class: "small text-muted text-decoration-none") %>
26
+ <%= tag.h5 tag.code(link_to(job.job_class, job_path(job), class: "text-reset text-decoration-none")), class: "text-reset mb-0" %>
27
+ </div>
28
+ <div class="col-1">
29
+ <span class="badge bg-primary bg-opacity-25 text-dark font-monospace"><%= job.queue_name %></span>
30
+ </div>
31
+ <div class="col-1 small text-center">
32
+ <span class="font-monospace fw-bold"><%= job.priority %></span>
33
+ </div>
34
+ <div class="col-1 text-center">
35
+ <% if job.executions_count > 0 && job.status != :finished %>
36
+ <%= tag.span job.executions_count, class: "badge rounded-pill bg-danger", data: {
37
+ bs_toggle: "popover",
38
+ bs_trigger: "hover focus click",
39
+ bs_placement: "bottom",
40
+ bs_content: job.recent_error
41
+ } %>
42
+ <% else %>
43
+ <span class="badge bg-secondary bg-opacity-50 rounded-pill"><%= job.executions_count %></span>
44
+ <% end %>
45
+ </div>
46
+ <div class="col d-flex gap-3 align-items-center justify-content-end">
47
+ <%= tag.span relative_time(job.last_status_at), class: "small" %>
48
+ <%= status_badge job.status %>
49
+ </div>
50
+ <div class="col-auto">
51
+ <div class="dropdown float-end">
52
+ <button class="d-flex align-items-center btn btn-sm" type="button" id="<%= dom_id(job, :actions) %>" data-bs-toggle="dropdown" aria-expanded="false">
53
+ <%= render "good_job/shared/icons/dots" %>
54
+ <span class="visually-hidden">Actions</span>
55
+ </button>
56
+ <ul class="dropdown-menu shadow" aria-labelledby="<%= dom_id(job, :actions) %>">
57
+ <li>
58
+ <% job_reschedulable = job.status.in? [:scheduled, :retried, :queued] %>
59
+ <%= link_to reschedule_job_path(job.id), method: :put, class: "dropdown-item #{'disabled' unless job_reschedulable}", title: "Reschedule job", data: { confirm: "Confirm reschedule", disable: true } do %>
60
+ <%= render "good_job/shared/icons/skip_forward" %>
61
+ Reschedule
62
+ <% end %>
63
+ </li>
64
+ <li>
65
+ <% job_discardable = job.status.in? [:scheduled, :retried, :queued] %>
66
+ <%= link_to discard_job_path(job.id), method: :put, class: "dropdown-item #{'disabled' unless job_discardable}", title: "Discard job", data: { confirm: "Confirm discard", disable: true } do %>
67
+ <%= render "good_job/shared/icons/stop" %>
68
+ Discard
69
+ <% end %>
70
+ </li>
71
+ <li>
72
+ <%= link_to retry_job_path(job.id), method: :put, class: "dropdown-item #{'disabled' unless job.status == :discarded}", title: "Retry job", data: { confirm: "Confirm retry", disable: true } do %>
73
+ <%= render "good_job/shared/icons/arrow_clockwise" %>
74
+ Retry
75
+ <% end %>
76
+ </li>
77
+ <li>
78
+ <%= link_to job_path(job.id), method: :delete, class: "dropdown-item #{'disabled' unless job.status.in? [:discarded, :finished]}", title: "Destroy job", data: { confirm: "Confirm destroy", disable: true } do %>
79
+ <%= render_icon "trash" %>
80
+ Destroy
81
+ <% end %>
82
+ </li>
83
+
84
+ <li>
85
+ <%= link_to "##{dom_id(job, 'params')}",
86
+ class: "dropdown-item",
87
+ data: { bs_toggle: "collapse" },
88
+ aria: { expanded: false, controls: dom_id(job, "params") } do %>
89
+ <%= render_icon "info" %>
90
+ Inspect
91
+ <% end %>
92
+ </li>
93
+ </ul>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ <%= tag.div id: dom_id(job, "params"), class: "job-params list-group-item collapse small bg-dark text-light" do %>
99
+ <%= tag.pre JSON.pretty_generate(job.display_serialized_params) %>
100
+ <% end %>
101
+ <% end %>
102
+ <% else %>
103
+ <div class="list-group-item py-4 text-center text-muted">
104
+ No jobs found.
105
+ </div>
106
+ <% end %>
107
+ </>
108
+ </div>
@@ -0,0 +1,61 @@
1
+ <div class="my-3 card" data-gj-poll-replace id="batches-table">
2
+ <div class="list-group list-group-flush text-nowrap table-batches" role="table">
3
+ <header class="list-group-item bg-light">
4
+ <div class="row small text-muted text-uppercase align-items-center">
5
+ <div class="col-4">Name</div>
6
+ <div class="col-1">Created</div>
7
+ <div class="col-1">Enqueued</div>
8
+ <div class="col-1">Discarded</div>
9
+ <div class="col-1">Finished</div>
10
+ <div class="col">Jobs</div>
11
+ <div class="col text-end">
12
+ <%= tag.button type: "button", class: "btn btn-sm text-muted", role: "button",
13
+ data: { bs_toggle: "collapse", bs_target: ".batch-properties" },
14
+ aria: { expanded: false, controls: batches.map { |batch| "##{dom_id(batch, "params")}" }.join(" ") } do %>
15
+ <%= render_icon "info" %>
16
+ <span class="visually-hidden">Inspect</span>
17
+ <% end %>
18
+ </div>
19
+ </div>
20
+ </header>
21
+
22
+ <% if batches.present? %>
23
+ <% batches.each do |batch| %>
24
+ <div id="<%= dom_id(batch) %>" class="list-group-item py-3" role="row">
25
+ <div class="row align-items-center">
26
+ <div class="col-4">
27
+ <%= link_to batch_path(batch), class: "text-decoration-none" do %>
28
+ <code class="small text-muted">
29
+ <%= batch.id %>
30
+ </code>
31
+ <h5 class=""><code><%= batch.on_finish %></code></h5>
32
+ <div class="text-muted"><%= batch.description %></div>
33
+ <% end %>
34
+ </div>
35
+ <div class="col-1 text-wrap"><%= relative_time(batch.created_at) %></div>
36
+ <div class="col-1 text-wrap"><%= relative_time(batch.enqueued_at) if batch.enqueued_at %></div>
37
+ <div class="col-1 text-wrap"><%= relative_time(batch.discarded_at) if batch.discarded_at %></div>
38
+ <div class="col-1 text-wrap"><%= relative_time(batch.finished_at) if batch.finished_at %></div>
39
+ <div class="col"><%= batch.jobs.count %></div>
40
+ <div class="col text-end">
41
+ <%= tag.button type: "button", class: "btn btn-sm text-muted ms-auto", role: "button",
42
+ title: "Inspect",
43
+ data: { bs_toggle: "collapse", bs_target: "##{dom_id(batch, 'properties')}" },
44
+ aria: { expanded: false, controls: dom_id(batch, "state") } do %>
45
+ <%= render_icon "info" %>
46
+ <span class="visually-hidden">Inspect</span>
47
+ <% end %>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ <%= tag.div id: dom_id(batch, "properties"), class: "batch-properties list-group-item collapse small bg-dark text-light" do %>
52
+ <%= tag.pre JSON.pretty_generate(batch.properties) %>
53
+ <% end %>
54
+ <% end %>
55
+ <% else %>
56
+ <div class="list-group-item py-4 text-center text-muted">
57
+ No batches found.
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+ </div>
@@ -0,0 +1,16 @@
1
+ <% if GoodJob::BatchRecord.migrated? %>
2
+ <%= render 'good_job/batches/table', batches: @filter.records, filter: @filter %>
3
+ <% if @filter.records.present? %>
4
+ <nav aria-label="Batch pagination" class="mt-3">
5
+ <ul class="pagination">
6
+ <li class="page-item">
7
+ <%= link_to(@filter.to_params(after_created_at: @filter.last.created_at, after_id: @filter.last.id), class: "page-link") do %>
8
+ Older batches <span aria-hidden="true">&raquo;</span>
9
+ <% end %>
10
+ </li>
11
+ </ul>
12
+ </nav>
13
+ <% end %>
14
+ <% else %>
15
+ <h3 class="text-center my-5">GoodJob has pending database migrations.</h3>
16
+ <% end %>
@@ -0,0 +1,30 @@
1
+ <div class="break-out bg-light border-bottom py-2 mb-3">
2
+ <div class="container-fluid pt-2">
3
+ <div class="row align-items-center">
4
+ <div class="col-5">
5
+ <nav aria-label="breadcrumb">
6
+ <ol class="breadcrumb small mb-0">
7
+ <li class="breadcrumb-item"><%= link_to "Batches", batches_path %></li>
8
+ <li class="breadcrumb-item active" aria-current="page"><%= tag.code @batch.id, class: "text-muted" %></li>
9
+ </ol>
10
+ <h2 class="h5 mt-2"><%= @batch.description %></h2>
11
+ </nav>
12
+ </div>
13
+ </div>
14
+ </div>
15
+ </div>
16
+
17
+ <div class="my-4">
18
+ <h5>Attributes</h5>
19
+ <%= tag.pre JSON.pretty_generate @batch.display_attributes, class: 'text-wrap text-break' %>
20
+ </div>
21
+
22
+ <div class="my-4">
23
+ <h5>Callback Jobs</h5>
24
+ <%= render 'jobs', jobs: @batch.callback_jobs.reverse %>
25
+ </div>
26
+
27
+ <div class="my-4">
28
+ <h5>Batched Jobs</h5>
29
+ <%= render 'jobs', jobs: @batch.jobs.reverse %>
30
+ </div>
@@ -14,6 +14,13 @@
14
14
  <span class="badge bg-secondary rounded-pill"><%= number_to_human(jobs_count) %></span>
15
15
  <% end %>
16
16
  </li>
17
+ <li class="nav-item">
18
+ <%= link_to batches_path, class: ["nav-link", ("active" if controller_name == 'batches')] do %>
19
+ <%= "Batches" %>
20
+ <% batches_count = GoodJob::BatchRecord.migrated? ? GoodJob::BatchRecord.all.size : 0 %>
21
+ <span class="badge bg-secondary rounded-pill"><%= batches_count %></span>
22
+ <% end %>
23
+ </li>
17
24
  <li class="nav-item">
18
25
  <%= link_to cron_entries_path, class: ["nav-link", ("active" if controller_name == 'cron_entries')] do %>
19
26
  <%= t(".cron_schedules") %>
data/config/routes.rb CHANGED
@@ -15,6 +15,8 @@ GoodJob::Engine.routes.draw do
15
15
  end
16
16
  end
17
17
 
18
+ resources :batches, only: %i[index show]
19
+
18
20
  resources :cron_entries, only: %i[index show], param: :cron_key do
19
21
  member do
20
22
  post :enqueue
@@ -19,6 +19,23 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
19
19
  t.text :cron_key
20
20
  t.uuid :retried_good_job_id
21
21
  t.datetime :cron_at
22
+
23
+ t.uuid :batch_id
24
+ t.uuid :batch_callback_id
25
+ end
26
+
27
+ create_table :good_job_batches, id: :uuid do |t|
28
+ t.timestamps
29
+ t.text :description
30
+ t.jsonb :serialized_properties
31
+ t.text :on_finish
32
+ t.text :on_success
33
+ t.text :on_discard
34
+ t.text :callback_queue_name
35
+ t.integer :callback_priority
36
+ t.datetime :enqueued_at
37
+ t.datetime :discarded_at
38
+ t.datetime :finished_at
22
39
  end
23
40
 
24
41
  create_table :good_job_processes, id: :uuid do |t|
@@ -43,5 +60,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
43
60
  add_index :good_jobs, [:finished_at], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at
44
61
  add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc },
45
62
  where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
63
+ add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL"
64
+ add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL"
46
65
  end
47
66
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ class CreateGoodJobBatches < ActiveRecord::Migration<%= migration_version %>
3
+ def change
4
+ reversible do |dir|
5
+ dir.up do
6
+ # Ensure this incremental update migration is idempotent
7
+ # with monolithic install migration.
8
+ return if connection.table_exists?(:good_job_batches)
9
+ end
10
+ end
11
+
12
+ create_table :good_job_batches, id: :uuid do |t|
13
+ t.timestamps
14
+ t.text :description
15
+ t.jsonb :serialized_properties
16
+ t.text :on_finish
17
+ t.text :on_success
18
+ t.text :on_discard
19
+ t.text :callback_queue_name
20
+ t.integer :callback_priority
21
+ t.datetime :enqueued_at
22
+ t.datetime :discarded_at
23
+ t.datetime :finished_at
24
+ end
25
+
26
+ change_table :good_jobs do |t|
27
+ t.uuid :batch_id
28
+ t.uuid :batch_callback_id
29
+
30
+ t.index :batch_id, where: "batch_id IS NOT NULL"
31
+ t.index :batch_callback_id, where: "batch_callback_id IS NOT NULL"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ module GoodJob
3
+ module ActiveJobExtensions
4
+ module Batches
5
+ extend ActiveSupport::Concern
6
+
7
+ def batch
8
+ @_batch ||= CurrentThread.execution&.batch&.to_batch
9
+ end
10
+ alias batch? batch
11
+ end
12
+ end
13
+ end
@@ -25,40 +25,13 @@ module GoodJob
25
25
  class_attribute :good_job_concurrency_config, instance_accessor: false, default: {}
26
26
  attr_writer :good_job_concurrency_key
27
27
 
28
- around_enqueue do |job, block|
29
- # Don't attempt to enforce concurrency limits with other queue adapters.
30
- next(block.call) unless job.class.queue_adapter.is_a?(GoodJob::Adapter)
31
-
32
- # Always allow jobs to be retried because the current job's execution will complete momentarily
33
- next(block.call) if CurrentThread.active_job_id == job.job_id
34
-
35
- # Only generate the concurrency key on the initial enqueue in case it is dynamic
36
- job.good_job_concurrency_key ||= job._good_job_concurrency_key
37
- key = job.good_job_concurrency_key
38
- next(block.call) if key.blank?
39
-
40
- enqueue_limit = job.class.good_job_concurrency_config[:enqueue_limit]
41
- enqueue_limit = instance_exec(&enqueue_limit) if enqueue_limit.respond_to?(:call)
42
- enqueue_limit = nil unless enqueue_limit.present? && (0...Float::INFINITY).cover?(enqueue_limit)
43
-
44
- unless enqueue_limit
45
- total_limit = job.class.good_job_concurrency_config[:total_limit]
46
- total_limit = instance_exec(&total_limit) if total_limit.respond_to?(:call)
47
- total_limit = nil unless total_limit.present? && (0...Float::INFINITY).cover?(total_limit)
28
+ if ActiveJob.gem_version >= Gem::Version.new("6.1.0")
29
+ before_enqueue do |job|
30
+ good_job_enqueue_concurrency_check(job, on_abort: -> { throw(:abort) }, on_enqueue: nil)
48
31
  end
49
-
50
- limit = enqueue_limit || total_limit
51
- next(block.call) unless limit
52
-
53
- GoodJob::Execution.advisory_lock_key(key, function: "pg_advisory_lock") do
54
- enqueue_concurrency = if enqueue_limit
55
- GoodJob::Execution.where(concurrency_key: key).unfinished.advisory_unlocked.count
56
- else
57
- GoodJob::Execution.where(concurrency_key: key).unfinished.count
58
- end
59
-
60
- # The job has not yet been enqueued, so check if adding it will go over the limit
61
- block.call unless (enqueue_concurrency + 1) > limit
32
+ else
33
+ around_enqueue do |job, block|
34
+ good_job_enqueue_concurrency_check(job, on_abort: nil, on_enqueue: block)
62
35
  end
63
36
  end
64
37
 
@@ -113,6 +86,50 @@ module GoodJob
113
86
  @good_job_concurrency_key || _good_job_concurrency_key
114
87
  end
115
88
 
89
+ private
90
+
91
+ def good_job_enqueue_concurrency_check(job, on_abort:, on_enqueue:)
92
+ # Don't attempt to enforce concurrency limits with other queue adapters.
93
+ return on_enqueue&.call unless job.class.queue_adapter.is_a?(GoodJob::Adapter)
94
+
95
+ # Always allow jobs to be retried because the current job's execution will complete momentarily
96
+ return on_enqueue&.call if CurrentThread.active_job_id == job.job_id
97
+
98
+ # Only generate the concurrency key on the initial enqueue in case it is dynamic
99
+ job.good_job_concurrency_key ||= job._good_job_concurrency_key
100
+ key = job.good_job_concurrency_key
101
+ return on_enqueue&.call if key.blank?
102
+
103
+ enqueue_limit = job.class.good_job_concurrency_config[:enqueue_limit]
104
+ enqueue_limit = instance_exec(&enqueue_limit) if enqueue_limit.respond_to?(:call)
105
+ enqueue_limit = nil unless enqueue_limit.present? && (0...Float::INFINITY).cover?(enqueue_limit)
106
+
107
+ unless enqueue_limit
108
+ total_limit = job.class.good_job_concurrency_config[:total_limit]
109
+ total_limit = instance_exec(&total_limit) if total_limit.respond_to?(:call)
110
+ total_limit = nil unless total_limit.present? && (0...Float::INFINITY).cover?(total_limit)
111
+ end
112
+
113
+ limit = enqueue_limit || total_limit
114
+ return on_enqueue&.call unless limit
115
+
116
+ GoodJob::Execution.advisory_lock_key(key, function: "pg_advisory_lock") do
117
+ enqueue_concurrency = if enqueue_limit
118
+ GoodJob::Execution.where(concurrency_key: key).unfinished.advisory_unlocked.count
119
+ else
120
+ GoodJob::Execution.where(concurrency_key: key).unfinished.count
121
+ end
122
+
123
+ # The job has not yet been enqueued, so check if adding it will go over the limit
124
+ if (enqueue_concurrency + 1) > limit
125
+ logger.info "Aborted enqueue of #{job.class.name} (Job ID: #{job.job_id}) because the concurrency key '#{key}' has reached its limit of #{limit} #{'job'.pluralize(limit)}"
126
+ on_abort&.call
127
+ else
128
+ on_enqueue&.call
129
+ end
130
+ end
131
+ end
132
+
116
133
  # Generates the concurrency key from the configuration
117
134
  # @return [Object] concurrency key
118
135
  def _good_job_concurrency_key