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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2993eeb7c69f566b281456e5d7122dbbc685de9efc5ce56ae7b62edbec5e9d1a
4
- data.tar.gz: 9528654eca760b6bcb2bf18890263c4213c0e4df71174e9ab5a55ac55982182b
3
+ metadata.gz: 5eb23908b9969829cfe7d7eec72e7cb9eaac26ee20786e5778c291fccf768532
4
+ data.tar.gz: 0b49c31b04c2d1b9c447a25ddaa74ca13a6434c984a79e1b47204bdf8f15192a
5
5
  SHA512:
6
- metadata.gz: 1d293a78adc12d75fde3473c84a65c7a4c7cabc6d9e35bb39d9e9c9547d376fd1ff41877b92ceba3a1e87d906a1242d224ed4f61cf9e790146ab56b01111c183
7
- data.tar.gz: 1ded68b5610c971520b7f515566d4def597cfca9dfb85e4d099422b756f40720ccba94864a2998bf7b7f86bb7d6588b59e86e5ec93e62672b899b12d2cbcba7b
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
@@ -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|
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  # GoodJob gem version.
5
- VERSION = '4.13.3'
5
+ VERSION = '4.14.0'
6
6
 
7
7
  # GoodJob version as Gem::Version object
8
8
  GEM_VERSION = Gem::Version.new(VERSION)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.13.3
4
+ version: 4.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon