good_job 4.13.3 → 4.14.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/app/models/good_job/batch.rb +223 -0
- data/lib/good_job/adapter.rb +4 -0
- data/lib/good_job/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5eb23908b9969829cfe7d7eec72e7cb9eaac26ee20786e5778c291fccf768532
|
|
4
|
+
data.tar.gz: 0b49c31b04c2d1b9c447a25ddaa74ca13a6434c984a79e1b47204bdf8f15192a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d05ad24c88ef9f7a396b8f3f6a966c5683eb3b984ad853075c11368dd4d9412264e33e76da8cbe62f207dd05fbfc265dbf5c3f20b3c80a4c031a12f44c3126ba
|
|
7
|
+
data.tar.gz: 0e30892a4f9ad554c15ab7be7c11c2e04f5f87f49963826a4d4e21188b79e5b7b6d89ac9c06906793c259b4ca792aee8072d927efd135cc5a5f81fa6abff2a13
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v4.14.0](https://github.com/bensheldon/good_job/tree/v4.14.0) (2026-03-31)
|
|
4
|
+
|
|
5
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.13.3...v4.14.0)
|
|
6
|
+
|
|
7
|
+
**Implemented enhancements:**
|
|
8
|
+
|
|
9
|
+
- Consider using pg\_cron for Cron-style repeating/recurring jobs [\#328](https://github.com/bensheldon/good_job/issues/328)
|
|
10
|
+
- Add Batch.enqueue\_all for bulk-enqueuing multiple batches [\#1726](https://github.com/bensheldon/good_job/pull/1726) ([AliOsm](https://github.com/AliOsm))
|
|
11
|
+
- Allow perform\_all\_later to enqueue to Batches [\#1720](https://github.com/bensheldon/good_job/pull/1720) ([bensheldon](https://github.com/bensheldon))
|
|
12
|
+
|
|
13
|
+
**Closed issues:**
|
|
14
|
+
|
|
15
|
+
- perform\_all\_later not intercepted by Batch [\#1719](https://github.com/bensheldon/good_job/issues/1719)
|
|
16
|
+
- Deprecate and drop :on\_unhandled\_error option [\#1706](https://github.com/bensheldon/good_job/issues/1706)
|
|
17
|
+
|
|
18
|
+
**Merged pull requests:**
|
|
19
|
+
|
|
20
|
+
- Bump actions/upload-artifact from 6 to 7 [\#1718](https://github.com/bensheldon/good_job/pull/1718) ([dependabot[bot]](https://github.com/apps/dependabot))
|
|
21
|
+
|
|
3
22
|
## [v4.13.3](https://github.com/bensheldon/good_job/tree/v4.13.3) (2026-02-18)
|
|
4
23
|
|
|
5
24
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.13.2...v4.13.3)
|
|
@@ -61,6 +61,104 @@ module GoodJob
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Bulk-enqueue multiple batches with their jobs in minimal DB round-trips.
|
|
65
|
+
#
|
|
66
|
+
# Instead of creating batches one-at-a-time (~7 queries per batch), this method
|
|
67
|
+
# inserts all batch records and jobs in a fixed number of queries:
|
|
68
|
+
# 1. INSERT all BatchRecords (insert_all!)
|
|
69
|
+
# 2. INSERT all Jobs (insert_all)
|
|
70
|
+
# 3. NOTIFY per distinct queue/scheduled_at
|
|
71
|
+
#
|
|
72
|
+
# @param batch_job_pairs [Array<Array(GoodJob::Batch, Array<ActiveJob::Base>)>]
|
|
73
|
+
# Array of [batch, jobs] pairs. Each batch must be new (not yet persisted).
|
|
74
|
+
# @return [Array<GoodJob::Batch>] The enqueued batches
|
|
75
|
+
# @raise [ArgumentError] if any batch is already persisted
|
|
76
|
+
def self.enqueue_all(batch_job_pairs)
|
|
77
|
+
batch_job_pairs = Array(batch_job_pairs)
|
|
78
|
+
return [] if batch_job_pairs.empty?
|
|
79
|
+
|
|
80
|
+
batch_job_pairs.each do |(batch, _)|
|
|
81
|
+
raise ArgumentError, "All batches must be new (not persisted)" if batch.persisted?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Rails.application.executor.wrap do
|
|
85
|
+
current_time = Time.current
|
|
86
|
+
adapter = ActiveJob::Base.queue_adapter
|
|
87
|
+
execute_inline = adapter.respond_to?(:execute_inline?) && adapter.execute_inline?
|
|
88
|
+
|
|
89
|
+
# Phase 1: Insert all batch records
|
|
90
|
+
batch_rows = _build_batch_rows(batch_job_pairs, current_time)
|
|
91
|
+
BatchRecord.insert_all!(batch_rows) # rubocop:disable Rails/SkipsModelValidations
|
|
92
|
+
_mark_batches_persisted(batch_job_pairs, batch_rows, current_time)
|
|
93
|
+
|
|
94
|
+
# Phase 2: Build and partition jobs by concurrency limits
|
|
95
|
+
build_result = _build_and_partition_jobs(batch_job_pairs, current_time)
|
|
96
|
+
|
|
97
|
+
# Phase 3: Insert bulkable jobs
|
|
98
|
+
persisted_jobs = []
|
|
99
|
+
inline_jobs = []
|
|
100
|
+
|
|
101
|
+
if build_result[:bulkable].any?
|
|
102
|
+
Job.transaction(requires_new: true, joinable: false) do
|
|
103
|
+
persisted_jobs = _insert_jobs(build_result[:bulkable], build_result[:active_jobs_by_job_id])
|
|
104
|
+
|
|
105
|
+
if execute_inline
|
|
106
|
+
inline_jobs = persisted_jobs.select { |job| job.scheduled_at.nil? || job.scheduled_at <= Time.current }
|
|
107
|
+
inline_jobs.each(&:advisory_lock!)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Phase 4: Handle empty batches — they need _continue_discard_or_finish
|
|
113
|
+
# to trigger on_success/on_finish callbacks (batch_record.rb:77).
|
|
114
|
+
batches_with_jobs = Set.new
|
|
115
|
+
build_result[:bulkable].each { |entry| batches_with_jobs.add(entry[:batch]) }
|
|
116
|
+
build_result[:unbulkable].each { |entry| batches_with_jobs.add(entry[:batch]) }
|
|
117
|
+
|
|
118
|
+
empty_batches = batch_job_pairs.map(&:first).reject { |batch| batches_with_jobs.include?(batch) }
|
|
119
|
+
if empty_batches.any?
|
|
120
|
+
buffer = GoodJob::Adapter::InlineBuffer.capture do
|
|
121
|
+
empty_batches.each do |batch|
|
|
122
|
+
batch._record.reload
|
|
123
|
+
batch._record._continue_discard_or_finish(lock: true)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
buffer.call
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Phase 5: Enqueue concurrency-limited jobs individually
|
|
130
|
+
build_result[:unbulkable].each do |entry|
|
|
131
|
+
within_thread(batch_id: entry[:batch].id) do
|
|
132
|
+
entry[:active_job].enqueue
|
|
133
|
+
end
|
|
134
|
+
rescue GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError
|
|
135
|
+
# ignore — matches Bulk::Buffer behavior (bulk.rb:107-109)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Phase 6: Execute inline jobs
|
|
139
|
+
if inline_jobs.any?
|
|
140
|
+
GoodJob::Adapter::InlineBuffer.perform_now_or_defer do
|
|
141
|
+
GoodJob.capsule.tracker.register do
|
|
142
|
+
until inline_jobs.empty?
|
|
143
|
+
inline_job = inline_jobs.shift
|
|
144
|
+
active_job = build_result[:active_jobs_by_job_id][inline_job.active_job_id]
|
|
145
|
+
adapter.send(:perform_inline, inline_job, notify: adapter.send(:send_notify?, active_job))
|
|
146
|
+
end
|
|
147
|
+
ensure
|
|
148
|
+
inline_jobs.each(&:advisory_unlock)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Phase 7: Send NOTIFY for non-inline jobs
|
|
154
|
+
non_inline_jobs = persisted_jobs - inline_jobs
|
|
155
|
+
non_inline_jobs = non_inline_jobs.reject(&:finished_at) if inline_jobs.any?
|
|
156
|
+
_send_notifications(non_inline_jobs, build_result[:active_jobs_by_job_id], adapter) if non_inline_jobs.any?
|
|
157
|
+
|
|
158
|
+
batch_job_pairs.map(&:first)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
64
162
|
def self.primary_key
|
|
65
163
|
:id
|
|
66
164
|
end
|
|
@@ -183,6 +281,131 @@ module GoodJob
|
|
|
183
281
|
record
|
|
184
282
|
end
|
|
185
283
|
|
|
284
|
+
# @!visibility private
|
|
285
|
+
def self._build_batch_rows(batch_job_pairs, current_time)
|
|
286
|
+
batch_job_pairs.map do |batch, _jobs|
|
|
287
|
+
record = batch._record
|
|
288
|
+
{
|
|
289
|
+
id: SecureRandom.uuid,
|
|
290
|
+
created_at: current_time,
|
|
291
|
+
updated_at: current_time,
|
|
292
|
+
enqueued_at: current_time,
|
|
293
|
+
on_finish: record.on_finish,
|
|
294
|
+
on_success: record.on_success,
|
|
295
|
+
on_discard: record.on_discard,
|
|
296
|
+
callback_queue_name: record.callback_queue_name,
|
|
297
|
+
callback_priority: record.callback_priority,
|
|
298
|
+
description: record.description,
|
|
299
|
+
# record.serialized_properties returns the internally-stored form
|
|
300
|
+
# which already includes _aj_symbol_keys metadata from
|
|
301
|
+
# PropertySerializer.dump. Pass it directly — insert_all! handles
|
|
302
|
+
# jsonb encoding, and PropertySerializer.load will deserialize
|
|
303
|
+
# correctly on read.
|
|
304
|
+
serialized_properties: record.serialized_properties || {},
|
|
305
|
+
}
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# @!visibility private
|
|
310
|
+
def self._mark_batches_persisted(batch_job_pairs, batch_rows, current_time)
|
|
311
|
+
batch_job_pairs.each_with_index do |(batch, _jobs), index|
|
|
312
|
+
record = batch._record
|
|
313
|
+
record.id = batch_rows[index][:id]
|
|
314
|
+
record.created_at = current_time
|
|
315
|
+
record.updated_at = current_time
|
|
316
|
+
record.enqueued_at = current_time
|
|
317
|
+
record.instance_variable_set(:@new_record, false)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# @!visibility private
|
|
322
|
+
def self._build_and_partition_jobs(batch_job_pairs, current_time)
|
|
323
|
+
bulkable = []
|
|
324
|
+
unbulkable = []
|
|
325
|
+
active_jobs_by_job_id = {}
|
|
326
|
+
|
|
327
|
+
batch_job_pairs.each do |batch, jobs|
|
|
328
|
+
next if jobs.blank?
|
|
329
|
+
|
|
330
|
+
jobs.each do |active_job|
|
|
331
|
+
active_jobs_by_job_id[active_job.job_id] = active_job
|
|
332
|
+
|
|
333
|
+
# Jobs with concurrency limits must be enqueued individually so the
|
|
334
|
+
# before_enqueue concurrency check runs. Mirrors Bulk::Buffer#enqueue
|
|
335
|
+
# partitioning (bulk.rb:95-98).
|
|
336
|
+
if active_job.respond_to?(:good_job_concurrency_key) &&
|
|
337
|
+
active_job.good_job_concurrency_key.present? &&
|
|
338
|
+
(active_job.class.good_job_concurrency_config[:enqueue_limit] ||
|
|
339
|
+
active_job.class.good_job_concurrency_config[:total_limit])
|
|
340
|
+
unbulkable << { batch: batch, active_job: active_job }
|
|
341
|
+
else
|
|
342
|
+
good_job = Job.build_for_enqueue(active_job)
|
|
343
|
+
|
|
344
|
+
# Normalize timestamps (mirrors Adapter#enqueue_all, adapter.rb:62-65)
|
|
345
|
+
good_job.scheduled_at = current_time if good_job.scheduled_at == good_job.created_at
|
|
346
|
+
good_job.created_at = current_time
|
|
347
|
+
good_job.updated_at = current_time
|
|
348
|
+
|
|
349
|
+
# Set batch_id directly — can't use thread-local for multi-batch bulk
|
|
350
|
+
good_job.batch_id = batch.id
|
|
351
|
+
good_job.batch_callback_id = nil
|
|
352
|
+
|
|
353
|
+
bulkable << { batch: batch, active_job: active_job, good_job: good_job }
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
{ bulkable: bulkable, unbulkable: unbulkable, active_jobs_by_job_id: active_jobs_by_job_id }
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @!visibility private
|
|
362
|
+
def self._insert_jobs(bulkable_entries, active_jobs_by_job_id)
|
|
363
|
+
job_attributes = bulkable_entries.map { |entry| entry[:good_job].attributes }
|
|
364
|
+
results = Job.insert_all(job_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
|
|
365
|
+
|
|
366
|
+
job_id_map = results.each_with_object({}) { |row, hash| hash[row['active_job_id']] = row['id'] }
|
|
367
|
+
|
|
368
|
+
# Set provider_job_id on ActiveJob instances (mirrors adapter.rb:74-76)
|
|
369
|
+
active_jobs_by_job_id.each_value do |active_job|
|
|
370
|
+
active_job.provider_job_id = job_id_map[active_job.job_id]
|
|
371
|
+
active_job.successfully_enqueued = active_job.provider_job_id.present? if active_job.respond_to?(:successfully_enqueued=)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Mark Job AR objects as persisted (mirrors adapter.rb:78-80)
|
|
375
|
+
bulkable_entries.each do |entry|
|
|
376
|
+
entry[:good_job].instance_variable_set(:@new_record, false) if job_id_map[entry[:good_job].active_job_id]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
bulkable_entries.pluck(:good_job).select(&:persisted?)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# @!visibility private
|
|
383
|
+
def self._send_notifications(jobs, active_jobs_by_job_id, adapter)
|
|
384
|
+
return unless GoodJob.configuration.enable_listen_notify
|
|
385
|
+
|
|
386
|
+
jobs.group_by(&:queue_name).each do |queue_name, jobs_by_queue|
|
|
387
|
+
jobs_by_queue.group_by(&:scheduled_at).each do |scheduled_at, grouped_jobs|
|
|
388
|
+
state = { queue_name: queue_name, count: grouped_jobs.size }
|
|
389
|
+
state[:scheduled_at] = scheduled_at if scheduled_at
|
|
390
|
+
|
|
391
|
+
executed_locally = adapter.respond_to?(:execute_async?) && adapter.execute_async? && GoodJob.capsule&.create_thread(state)
|
|
392
|
+
unless executed_locally
|
|
393
|
+
state[:count] = grouped_jobs.count { |job| _send_notify?(active_jobs_by_job_id[job.active_job_id]) }
|
|
394
|
+
Notifier.notify(state) unless state[:count].zero?
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Mirrors Adapter#send_notify? (adapter.rb:242-247)
|
|
401
|
+
# @!visibility private
|
|
402
|
+
def self._send_notify?(active_job)
|
|
403
|
+
return true unless active_job.respond_to?(:good_job_notify)
|
|
404
|
+
|
|
405
|
+
!(active_job.good_job_notify == false ||
|
|
406
|
+
(active_job.class.good_job_notify == false && active_job.good_job_notify.nil?))
|
|
407
|
+
end
|
|
408
|
+
|
|
186
409
|
private
|
|
187
410
|
|
|
188
411
|
attr_accessor :record
|
data/lib/good_job/adapter.rb
CHANGED
|
@@ -55,6 +55,10 @@ module GoodJob
|
|
|
55
55
|
active_jobs = Array(active_jobs)
|
|
56
56
|
return 0 if active_jobs.empty?
|
|
57
57
|
|
|
58
|
+
# If there is a currently open Bulk in the current thread, direct the
|
|
59
|
+
# jobs there to (eventually) be enqueued using enqueue_all
|
|
60
|
+
return if GoodJob::Bulk.capture(active_jobs, queue_adapter: self)
|
|
61
|
+
|
|
58
62
|
Rails.application.executor.wrap do
|
|
59
63
|
current_time = Time.current
|
|
60
64
|
jobs = active_jobs.map do |active_job|
|
data/lib/good_job/version.rb
CHANGED