good_job 4.13.3 → 4.14.1

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: af515dffcbbdb5c6f3e02ec7a1426b7d59aea355b33791c81cc20d043bcab7f4
4
+ data.tar.gz: e44f93e479b483af4c3848c7957df934198fb780260c3008c427ff864c3d67a8
5
5
  SHA512:
6
- metadata.gz: 1d293a78adc12d75fde3473c84a65c7a4c7cabc6d9e35bb39d9e9c9547d376fd1ff41877b92ceba3a1e87d906a1242d224ed4f61cf9e790146ab56b01111c183
7
- data.tar.gz: 1ded68b5610c971520b7f515566d4def597cfca9dfb85e4d099422b756f40720ccba94864a2998bf7b7f86bb7d6588b59e86e5ec93e62672b899b12d2cbcba7b
6
+ metadata.gz: 812281e391af58a897dd119e875be51c7569eaefa021f7fc05d42d69b185819c4077c04e8f40d23c3cf498862e04f8f9285fe9a29931a1d9bba4f1a61650ca37
7
+ data.tar.gz: 1941b6294d3339c590e2c37188a2f1568a3c32bc5e1f9cb2a76f0c58b94cce6781797d3d86853111a842f435adb8262611eca426dc16001bb6bc1b0a8437bde4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.14.1](https://github.com/bensheldon/good_job/tree/v4.14.1) (2026-04-03)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.14.0...v4.14.1)
6
+
7
+ **Fixed bugs:**
8
+
9
+ - Fix N+1 queries on cron entries dashboard index page [\#1727](https://github.com/bensheldon/good_job/pull/1727) ([clinejj](https://github.com/clinejj))
10
+
11
+ ## [v4.14.0](https://github.com/bensheldon/good_job/tree/v4.14.0) (2026-03-31)
12
+
13
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.13.3...v4.14.0)
14
+
15
+ **Implemented enhancements:**
16
+
17
+ - Consider using pg\_cron for Cron-style repeating/recurring jobs [\#328](https://github.com/bensheldon/good_job/issues/328)
18
+ - Add Batch.enqueue\_all for bulk-enqueuing multiple batches [\#1726](https://github.com/bensheldon/good_job/pull/1726) ([AliOsm](https://github.com/AliOsm))
19
+ - Allow perform\_all\_later to enqueue to Batches [\#1720](https://github.com/bensheldon/good_job/pull/1720) ([bensheldon](https://github.com/bensheldon))
20
+
21
+ **Closed issues:**
22
+
23
+ - perform\_all\_later not intercepted by Batch [\#1719](https://github.com/bensheldon/good_job/issues/1719)
24
+ - Deprecate and drop :on\_unhandled\_error option [\#1706](https://github.com/bensheldon/good_job/issues/1706)
25
+
26
+ **Merged pull requests:**
27
+
28
+ - Bump actions/upload-artifact from 6 to 7 [\#1718](https://github.com/bensheldon/good_job/pull/1718) ([dependabot[bot]](https://github.com/apps/dependabot))
29
+
3
30
  ## [v4.13.3](https://github.com/bensheldon/good_job/tree/v4.13.3) (2026-02-18)
4
31
 
5
32
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.13.2...v4.13.3)
@@ -4,6 +4,10 @@ module GoodJob
4
4
  class CronEntriesController < GoodJob::ApplicationController
5
5
  def index
6
6
  @cron_entries = CronEntry.all
7
+ @last_jobs = CronEntry.last_jobs_by_key(@cron_entries)
8
+ @enabled_states = GoodJob::Setting.cron_keys_enabled(
9
+ @cron_entries.map { |entry| [entry.key, entry.enabled_by_default?] }
10
+ )
7
11
  end
8
12
 
9
13
  def show
@@ -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
@@ -18,6 +18,30 @@ module GoodJob # :nodoc:
18
18
  configuration.cron_entries
19
19
  end
20
20
 
21
+ def self.last_jobs_by_key(cron_entries)
22
+ cron_keys = cron_entries.map { |entry| entry.key.to_s }
23
+ return {} if cron_keys.empty?
24
+
25
+ from_clause = GoodJob::Job.sanitize_sql_array(
26
+ ["unnest(ARRAY[?]::text[]) AS cron_keys(cron_key)", cron_keys]
27
+ )
28
+
29
+ join_clause = <<~SQL.squish
30
+ CROSS JOIN LATERAL (
31
+ SELECT * FROM good_jobs
32
+ WHERE good_jobs.cron_key = cron_keys.cron_key
33
+ ORDER BY cron_at DESC NULLS LAST
34
+ LIMIT 1
35
+ ) AS lateral_jobs
36
+ SQL
37
+
38
+ GoodJob::Job
39
+ .select("lateral_jobs.*")
40
+ .from(from_clause)
41
+ .joins(join_clause)
42
+ .index_by(&:cron_key)
43
+ end
44
+
21
45
  def self.find(key, configuration: nil)
22
46
  all(configuration: configuration).find { |entry| entry.key == key.to_sym }.tap do |cron_entry|
23
47
  raise ActiveRecord::RecordNotFound unless cron_entry
@@ -144,13 +168,13 @@ module GoodJob # :nodoc:
144
168
  (last_job.cron_at || last_job.created_at).localtime
145
169
  end
146
170
 
147
- private
148
-
149
171
  def enabled_by_default?
150
172
  value = params.fetch(:enabled_by_default, true)
151
173
  value.respond_to?(:call) ? value.call : value
152
174
  end
153
175
 
176
+ private
177
+
154
178
  def cron
155
179
  params.fetch(:cron)
156
180
  end
@@ -19,6 +19,16 @@ module GoodJob
19
19
  end
20
20
  end
21
21
 
22
+ def self.cron_keys_enabled(keys_with_defaults)
23
+ cron_disabled = find_by(key: CRON_KEYS_DISABLED)&.value || []
24
+ cron_enabled = find_by(key: CRON_KEYS_ENABLED)&.value || []
25
+
26
+ keys_with_defaults.to_h do |key, default|
27
+ key_s = key.to_s
28
+ [key_s, default ? cron_disabled.exclude?(key_s) : cron_enabled.include?(key_s)]
29
+ end
30
+ end
31
+
22
32
  def self.cron_key_enable(key)
23
33
  key_string = key.to_s
24
34
  enabled_setting = find_or_initialize_by(key: CRON_KEYS_ENABLED) do |record|
@@ -39,14 +39,16 @@
39
39
  <%= relative_time cron_entry.next_at %>
40
40
  </div>
41
41
  <div class="col-6 col-lg-1 text-wrap small">
42
- <% if cron_entry.last_job.present? %>
42
+ <% last_job = @last_jobs[cron_entry.key.to_s] %>
43
+ <% if last_job.present? %>
44
+ <% last_job_at = (last_job.cron_at || last_job.created_at).localtime %>
43
45
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.cron.last_run" %></div>
44
- <%= link_to relative_time(cron_entry.last_job_at), cron_entry_path(cron_entry), title: "Job #{cron_entry.last_job.id}" %>
46
+ <%= link_to relative_time(last_job_at), cron_entry_path(cron_entry), title: "Job #{last_job.id}" %>
45
47
  <% end %>
46
48
  </div>
47
49
  <div class="col-6 col-lg-1 text-wrap small">
48
50
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.cron.status" %></div>
49
- <% if cron_entry.enabled? %>
51
+ <% if @enabled_states[cron_entry.key.to_s] %>
50
52
  <span class="text-success"><%= t "good_job.models.cron.states.active" %></span>
51
53
  <% else %>
52
54
  <span class="text-muted"><%= t "good_job.models.cron.states.paused" %></span>
@@ -57,7 +59,7 @@
57
59
  <%= render_icon "skip_forward" %>
58
60
  <% end %>
59
61
 
60
- <% if cron_entry.enabled? %>
62
+ <% if @enabled_states[cron_entry.key.to_s] %>
61
63
  <%= button_to disable_cron_entry_path(cron_entry), method: :put, class: "btn btn-sm btn-outline-primary", form_class: "d-inline-block", aria: { label: t("good_job.cron_entries.actions.disable") }, title: t("good_job.cron_entries.actions.disable"), data: { turbo_confirm: t("good_job.cron_entries.actions.confirm_disable") } do %>
62
64
  <%= render_icon "pause" %>
63
65
  <% end %>
@@ -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.1'
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon