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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -1
- data/README.md +195 -3
- data/app/controllers/good_job/batches_controller.rb +12 -0
- data/app/filters/good_job/batches_filter.rb +21 -0
- data/app/models/good_job/batch.rb +141 -0
- data/app/models/good_job/batch_record.rb +84 -0
- data/app/models/good_job/execution.rb +73 -39
- data/app/models/good_job/job.rb +1 -0
- data/app/views/good_job/batches/_jobs.erb +108 -0
- data/app/views/good_job/batches/_table.erb +61 -0
- data/app/views/good_job/batches/index.html.erb +16 -0
- data/app/views/good_job/batches/show.html.erb +30 -0
- data/app/views/good_job/shared/_navbar.erb +7 -0
- data/config/routes.rb +2 -0
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +19 -0
- data/lib/generators/good_job/templates/update/migrations/04_create_good_job_batches.rb.erb +34 -0
- data/lib/good_job/active_job_extensions/batches.rb +13 -0
- data/lib/good_job/active_job_extensions/concurrency.rb +50 -33
- data/lib/good_job/adapter.rb +69 -1
- data/lib/good_job/bulk.rb +124 -0
- data/lib/good_job/log_subscriber.rb +1 -1
- data/lib/good_job/notifier.rb +27 -23
- data/lib/good_job/scheduler.rb +13 -0
- data/lib/good_job/version.rb +1 -1
- data/lib/good_job.rb +17 -6
- metadata +15 -18
@@ -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
|
50
|
+
string = string[1..]
|
47
51
|
when '+'
|
48
52
|
ordered_queues = true
|
49
|
-
string = string[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
|
-
|
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
|
-
|
315
|
+
run_callbacks(:perform) do
|
316
|
+
raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
|
292
317
|
|
293
|
-
|
294
|
-
|
318
|
+
self.performed_at = Time.current
|
319
|
+
save! if GoodJob.preserve_job_records
|
295
320
|
|
296
|
-
|
321
|
+
result = execute
|
297
322
|
|
298
|
-
|
299
|
-
|
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
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
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
|
-
|
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
|
data/app/models/good_job/job.rb
CHANGED
@@ -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">»</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
@@ -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
|
@@ -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
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
51
|
-
|
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
|