good_job 3.7.4 → 3.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fda5fd7d15b1e93616e9dfa44dad4ed89abbad549c5c83d85d3d0fadcb5a3082
4
- data.tar.gz: f114b7657cdd5d1845e9314688c0befaa3c1615df6b203fc6d9cd44e894e7071
3
+ metadata.gz: 69560b4af2d1cab2a3783fb80573794066c3704aacafa7bd315a9dc6e1b75441
4
+ data.tar.gz: 55dff98e3c8ebbcebd5f09664d02f4ddd23b298c0ee2bc7cb163e43f6963de96
5
5
  SHA512:
6
- metadata.gz: d1b5bbbc7f8e407a74d65976996f29df1bea1271a327733a5c685804984c7d5e95d696e3ec823995e856e17e7c22f24edbf7c8425d7caf7ec4795f94f5038357
7
- data.tar.gz: fad2cc50096beae6594326189defab7eec6527e2b2e297518e5c42f220eea3a6201ce7375864b95085ed0462d019da6829e8d34ee02e6ac042968a8f0096a059
6
+ metadata.gz: dd4763e6d473a22ea7b0f6f7b501249c7e5e29d06c1fdd9b3d028c923ba407fb485eea748fd37857a5eb09f5b26659d67f4792f4a66848542bfc1f47f7384577
7
+ data.tar.gz: 89e165d003d156fa16450762b8a8a269172509557ab59be60e7d54794d079c9963f8c11f45047ecfdea8e0963d901c8d9e1c297743392a05817d21d417c78dab
data/CHANGELOG.md CHANGED
@@ -1,10 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.9.0](https://github.com/bensheldon/good_job/tree/v3.9.0) (2023-01-31)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.8.0...v3.9.0)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Abort enqueue when the concurrency limit is reached [\#820](https://github.com/bensheldon/good_job/pull/820) ([TAGraves](https://github.com/TAGraves))
10
+ - Add bulk enqueue functionality [\#790](https://github.com/bensheldon/good_job/pull/790) ([julik](https://github.com/julik))
11
+
12
+ **Merged pull requests:**
13
+
14
+ - Bump alex-page/github-project-automation-plus from 0.8.2 to 0.8.3 [\#819](https://github.com/bensheldon/good_job/pull/819) ([dependabot[bot]](https://github.com/apps/dependabot))
15
+ - Bump concurrent-ruby from 1.1.10 to 1.2.0 [\#818](https://github.com/bensheldon/good_job/pull/818) ([dependabot[bot]](https://github.com/apps/dependabot))
16
+ - Bump rails from 6.1.7 to 6.1.7.2 [\#817](https://github.com/bensheldon/good_job/pull/817) ([dependabot[bot]](https://github.com/apps/dependabot))
17
+ - Bump selenium-webdriver from 4.7.1 to 4.8.0 [\#816](https://github.com/bensheldon/good_job/pull/816) ([dependabot[bot]](https://github.com/apps/dependabot))
18
+ - Bump rubocop from 1.43.0 to 1.44.1 [\#815](https://github.com/bensheldon/good_job/pull/815) ([dependabot[bot]](https://github.com/apps/dependabot))
19
+ - Ensure that anytime the Notifier uses autoloaded constants \(ActiveRecord\), they are wrapped with a Rails Executor [\#797](https://github.com/bensheldon/good_job/pull/797) ([bensheldon](https://github.com/bensheldon))
20
+ - Remove support for Ruby 2.5 and JRuby 9.2; reactivate appraisal tests for Rails HEAD [\#756](https://github.com/bensheldon/good_job/pull/756) ([bensheldon](https://github.com/bensheldon))
21
+
22
+ ## [v3.8.0](https://github.com/bensheldon/good_job/tree/v3.8.0) (2023-01-27)
23
+
24
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.7.4...v3.8.0)
25
+
26
+ **Implemented enhancements:**
27
+
28
+ - Capture and log ActiveJob IDs that are interrupted when Scheduler is forced to shutdown [\#794](https://github.com/bensheldon/good_job/pull/794) ([bensheldon](https://github.com/bensheldon))
29
+
30
+ **Fixed bugs:**
31
+
32
+ - Ensure Concurrency Keys are string-like and return a better error when they cannot be cast to a string [\#791](https://github.com/bensheldon/good_job/pull/791) ([Earlopain](https://github.com/Earlopain))
33
+
34
+ **Closed issues:**
35
+
36
+ - Work is not being picked up at the expected rate [\#802](https://github.com/bensheldon/good_job/issues/802)
37
+ - Cleaning up preserved jobs only removes a subset of the jobs [\#801](https://github.com/bensheldon/good_job/issues/801)
38
+ - Dashboard fails to execute JS on latest Firefox 108 [\#792](https://github.com/bensheldon/good_job/issues/792)
39
+ - Concurrency key doesn't handle Hash: TypeError \(can't cast Hash\) [\#784](https://github.com/bensheldon/good_job/issues/784)
40
+
41
+ **Merged pull requests:**
42
+
43
+ - Bump fugit from 1.8.0 to 1.8.1 [\#808](https://github.com/bensheldon/good_job/pull/808) ([dependabot[bot]](https://github.com/apps/dependabot))
44
+ - Bump rubocop-rspec from 2.17.1 to 2.18.1 [\#807](https://github.com/bensheldon/good_job/pull/807) ([dependabot[bot]](https://github.com/apps/dependabot))
45
+ - Bump globalid from 1.0.0 to 1.0.1 [\#804](https://github.com/bensheldon/good_job/pull/804) ([dependabot[bot]](https://github.com/apps/dependabot))
46
+ - Bump rack from 2.2.4 to 2.2.6.2 [\#803](https://github.com/bensheldon/good_job/pull/803) ([dependabot[bot]](https://github.com/apps/dependabot))
47
+ - Bump nokogiri from 1.13.10 to 1.14.0 [\#800](https://github.com/bensheldon/good_job/pull/800) ([dependabot[bot]](https://github.com/apps/dependabot))
48
+ - Bump rubocop from 1.42.0 to 1.43.0 [\#799](https://github.com/bensheldon/good_job/pull/799) ([dependabot[bot]](https://github.com/apps/dependabot))
49
+ - Bump rubocop-rspec from 2.16.0 to 2.17.1 [\#798](https://github.com/bensheldon/good_job/pull/798) ([dependabot[bot]](https://github.com/apps/dependabot))
50
+ - Add French translation [\#795](https://github.com/bensheldon/good_job/pull/795) ([francois-ferrandis](https://github.com/francois-ferrandis))
51
+ - Bump rubocop-rails from 2.17.3 to 2.17.4 [\#780](https://github.com/bensheldon/good_job/pull/780) ([dependabot[bot]](https://github.com/apps/dependabot))
52
+
3
53
  ## [v3.7.4](https://github.com/bensheldon/good_job/tree/v3.7.4) (2023-01-10)
4
54
 
5
55
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.7.3...v3.7.4)
6
56
 
7
- **Merged pull requests:**
57
+ **Fixed bugs:**
8
58
 
9
59
  - Update to es-module-shims v1.6.3 and use an inline script entry-point; remove script.js entrypoint; remove sourcemap references [\#793](https://github.com/bensheldon/good_job/pull/793) ([bensheldon](https://github.com/bensheldon))
10
60
 
@@ -37,13 +87,16 @@
37
87
 
38
88
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.7.1...v3.7.2)
39
89
 
90
+ **Fixed bugs:**
91
+
92
+ - Ignore ActiveJob::DeserializationError when discarding jobs [\#771](https://github.com/bensheldon/good_job/pull/771) ([nickcampbell18](https://github.com/nickcampbell18))
93
+
40
94
  **Closed issues:**
41
95
 
42
96
  - Unable to discard failed jobs which crashed with `ActiveJob::DeserializationError` [\#770](https://github.com/bensheldon/good_job/issues/770)
43
97
 
44
98
  **Merged pull requests:**
45
99
 
46
- - Ignore ActiveJob::DeserializationError when discarding jobs [\#771](https://github.com/bensheldon/good_job/pull/771) ([nickcampbell18](https://github.com/nickcampbell18))
47
100
  - Bump rubocop from 1.39.0 to 1.40.0 [\#769](https://github.com/bensheldon/good_job/pull/769) ([dependabot[bot]](https://github.com/apps/dependabot))
48
101
 
49
102
  ## [v3.7.1](https://github.com/bensheldon/good_job/tree/v3.7.1) (2022-12-12)
data/README.md CHANGED
@@ -58,6 +58,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
58
58
  - [Database connections](#database-connections)
59
59
  - [Production setup](#production-setup)
60
60
  - [Queue performance with Queue Select Limit](#queue-performance-with-queue-select-limit)
61
+ - [Bulk enqueue](#bulk-enqueue)
61
62
  - [Execute jobs async / in-process](#execute-jobs-async--in-process)
62
63
  - [Migrate to GoodJob from a different ActiveJob backend](#migrate-to-goodjob-from-a-different-activejob-backend)
63
64
  - [Monitor and preserve worked jobs](#monitor-and-preserve-worked-jobs)
@@ -146,7 +147,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
146
147
  ## Compatibility
147
148
 
148
149
  - **Ruby on Rails:** 6.0+
149
- - **Ruby:** Ruby 2.5+. JRuby 9.2.13+
150
+ - **Ruby:** Ruby 2.6+. JRuby 9.3+
150
151
  - **Postgres:** 10.0+
151
152
 
152
153
  ## Configuration
@@ -793,6 +794,26 @@ To explain where this value is used, here is the pseudo-query that GoodJob uses
793
794
  )
794
795
  ```
795
796
 
797
+ ### Bulk enqueue
798
+
799
+ GoodJob's Bulk-enqueue functionality can buffer and enqueue multiple jobs at once, using a single INSERT statement. This can more performant when enqueuing a large number of jobs.
800
+
801
+ ```ruby
802
+ # Capture jobs using `.perform_later`:
803
+ active_jobs = GoodJob::Bulk.enqueue do
804
+ MyJob.perform_later
805
+ AnotherJob.perform_later
806
+ # If an exception is raised within this block, no jobs will be inserted.
807
+ end
808
+
809
+ # All ActiveJob instances are returned from GoodJob::Bulk.enqueue.
810
+ # Jobs that have been successfully enqueued have a `provider_job_id` set.
811
+ active_jobs.all?(&:provider_job_id)
812
+
813
+ # Bulk enqueue ActiveJob instances directly without using `.perform_later`:
814
+ GoodJob::Bulk.enqueue(MyJob.new, AnotherJob.new)
815
+ ```
816
+
796
817
  ### Execute jobs async / in-process
797
818
 
798
819
  GoodJob can execute jobs "async" in the same process as the web server (e.g. `bin/rails s`). GoodJob's async execution mode offers benefits of economy by not requiring a separate job worker process, but with the tradeoff of increased complexity. Async mode can be configured in two ways:
@@ -80,7 +80,7 @@ module GoodJob
80
80
  def destroy
81
81
  @job = Job.find(params[:id])
82
82
  @job.destroy_job
83
- redirect_to jobs_path, notice: "Job has been destroyed" # rubocop:disable Rails/I18nLocaleTexts
83
+ redirect_to jobs_path, notice: "Job has been destroyed"
84
84
  end
85
85
 
86
86
  private
@@ -43,10 +43,10 @@ module GoodJob
43
43
  case string.first
44
44
  when '-'
45
45
  exclude_queues = true
46
- string = string[1..-1]
46
+ string = string[1..]
47
47
  when '+'
48
48
  ordered_queues = true
49
- string = string[1..-1]
49
+ string = string[1..]
50
50
  end
51
51
 
52
52
  queues = string.split(',').map(&:strip)
@@ -197,6 +197,28 @@ module GoodJob
197
197
  end
198
198
  end)
199
199
 
200
+ # Construct a GoodJob::Execution from an ActiveJob instance.
201
+ def self.build_for_enqueue(active_job, overrides = {})
202
+ execution_args = {
203
+ active_job_id: active_job.job_id,
204
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
205
+ priority: active_job.priority || DEFAULT_PRIORITY,
206
+ serialized_params: active_job.serialize,
207
+ scheduled_at: active_job.scheduled_at,
208
+ }
209
+
210
+ execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
211
+
212
+ if CurrentThread.cron_key
213
+ execution_args[:cron_key] = CurrentThread.cron_key
214
+ execution_args[:cron_at] = CurrentThread.cron_at
215
+ elsif CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
216
+ execution_args[:cron_key] = CurrentThread.execution.cron_key
217
+ end
218
+
219
+ new(**execution_args.merge(overrides))
220
+ end
221
+
200
222
  # Finds the next eligible Execution, acquire an advisory lock related to it, and
201
223
  # executes the job.
202
224
  # @return [ExecutionResult, nil]
@@ -212,6 +234,7 @@ module GoodJob
212
234
  break if execution.blank?
213
235
  break :unlocked unless execution&.executable?
214
236
 
237
+ yield(execution) if block_given?
215
238
  result = execution.perform
216
239
  end
217
240
  execution&.run_callbacks(:perform_unlocked)
@@ -243,33 +266,19 @@ module GoodJob
243
266
  # @param active_job [ActiveJob::Base]
244
267
  # The job to enqueue.
245
268
  # @param scheduled_at [Float]
246
- # Epoch timestamp when the job should be executed.
269
+ # Epoch timestamp when the job should be executed, if blank will delegate to the ActiveJob instance
247
270
  # @param create_with_advisory_lock [Boolean]
248
271
  # Whether to establish a lock on the {Execution} record after it is created.
272
+ # @param persist_immediately [Boolean]
273
+ # Whether to save the record immediately or just initialize it with values. When bulk-inserting
274
+ # jobs the caller takes care of the persistence and sets this parameter to `false`
249
275
  # @return [Execution]
250
276
  # The new {Execution} instance representing the queued ActiveJob job.
251
277
  def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
252
278
  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|
253
- execution_args = {
254
- active_job_id: active_job.job_id,
255
- queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
256
- priority: active_job.priority || DEFAULT_PRIORITY,
257
- serialized_params: active_job.serialize,
258
- scheduled_at: scheduled_at,
259
- create_with_advisory_lock: create_with_advisory_lock,
260
- }
261
-
262
- execution_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
263
-
264
- if CurrentThread.cron_key
265
- execution_args[:cron_key] = CurrentThread.cron_key
266
- execution_args[:cron_at] = CurrentThread.cron_at
267
- elsif CurrentThread.active_job_id && CurrentThread.active_job_id == active_job.job_id
268
- execution_args[:cron_key] = CurrentThread.execution.cron_key
269
- end
270
-
271
- execution = GoodJob::Execution.new(**execution_args)
279
+ execution = build_for_enqueue(active_job, { scheduled_at: scheduled_at })
272
280
 
281
+ execution.create_with_advisory_lock = create_with_advisory_lock
273
282
  instrument_payload[:execution] = execution
274
283
 
275
284
  execution.save!
@@ -193,6 +193,7 @@ module GoodJob
193
193
  binds = [
194
194
  ActiveRecord::Relation::QueryAttribute.new('key', key, ActiveRecord::Type::String.new),
195
195
  ]
196
+
196
197
  locked = connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).first['locked']
197
198
  return locked unless block_given?
198
199
  return nil unless locked
@@ -0,0 +1,83 @@
1
+ ---
2
+ fr:
3
+ datetime:
4
+ distance_in_words:
5
+ about_x_hours:
6
+ one: environ 1 heure
7
+ other: environ %{count} heures
8
+ about_x_months:
9
+ one: environ 1 heure
10
+ other: environ %{count} heures
11
+ about_x_years:
12
+ one: environ 1 an
13
+ other: environ %{count} ans
14
+ almost_x_years:
15
+ one: presque 1 an
16
+ other: presque %{count} ans
17
+ half_a_minute: une demi-minute
18
+ less_than_x_minutes:
19
+ one: moins d'une minute
20
+ other: moins de %{count} minutes
21
+ less_than_x_seconds:
22
+ one: moins d'une seconde
23
+ other: moins de %{count} secondes
24
+ over_x_years:
25
+ one: plus d'un an
26
+ other: plus de %{count} ans
27
+ x_days:
28
+ one: 1 jour
29
+ other: "%{count} jours"
30
+ x_minutes:
31
+ one: 1 minute
32
+ other: "%{count} minutes"
33
+ x_months:
34
+ one: 1 mois
35
+ other: "%{count} mois"
36
+ x_seconds:
37
+ one: 1 seconde
38
+ other: "%{count} secondes"
39
+ x_years:
40
+ one: 1 an
41
+ other: "%{count} ans"
42
+ duration:
43
+ hours: "%{hour}h %{min}m"
44
+ less_than_10_seconds: "%{sec}s"
45
+ milliseconds: "%{ms}ms"
46
+ minutes: "%{min}m %{sec}s"
47
+ seconds: "%{sec}s"
48
+ good_job:
49
+ shared:
50
+ footer:
51
+ last_update_html: Dernière mise à jour <time id="page-updated-at" datetime="%{time}">%{time}</time>
52
+ wording: N'oublie pas, toi aussi tu fais du bon boulot !
53
+ navbar:
54
+ cron_schedules: Cron
55
+ jobs: Jobs
56
+ live_poll: En direct
57
+ name: "GoodJob 👍"
58
+ processes: Processus
59
+ status:
60
+ discarded: Abandonnés
61
+ queued: À la file
62
+ retried: Réessayés
63
+ running: En cours
64
+ scheduled: Programmés
65
+ succeeded: Réussis
66
+ number:
67
+ format:
68
+ delimiter: " "
69
+ separator: ","
70
+ human:
71
+ decimal_units:
72
+ format: "%n%u"
73
+ units:
74
+ billion: B
75
+ million: M
76
+ quadrillion: q
77
+ thousand: k
78
+ trillion: T
79
+ unit: ''
80
+ format:
81
+ delimiter: " "
82
+ precision: 3
83
+ separator: ","
@@ -4,6 +4,8 @@ module GoodJob
4
4
  module Concurrency
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ VALID_TYPES = [String, Symbol, Numeric, Date, Time, TrueClass, FalseClass, NilClass].freeze
8
+
7
9
  class ConcurrencyExceededError < StandardError
8
10
  def backtrace
9
11
  [] # suppress backtrace
@@ -23,40 +25,13 @@ module GoodJob
23
25
  class_attribute :good_job_concurrency_config, instance_accessor: false, default: {}
24
26
  attr_writer :good_job_concurrency_key
25
27
 
26
- around_enqueue do |job, block|
27
- # Don't attempt to enforce concurrency limits with other queue adapters.
28
- next(block.call) unless job.class.queue_adapter.is_a?(GoodJob::Adapter)
29
-
30
- # Always allow jobs to be retried because the current job's execution will complete momentarily
31
- next(block.call) if CurrentThread.active_job_id == job.job_id
32
-
33
- enqueue_limit = job.class.good_job_concurrency_config[:enqueue_limit]
34
- enqueue_limit = instance_exec(&enqueue_limit) if enqueue_limit.respond_to?(:call)
35
- enqueue_limit = nil unless enqueue_limit.present? && (0...Float::INFINITY).cover?(enqueue_limit)
36
-
37
- unless enqueue_limit
38
- total_limit = job.class.good_job_concurrency_config[:total_limit]
39
- total_limit = instance_exec(&total_limit) if total_limit.respond_to?(:call)
40
- 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)
41
31
  end
42
-
43
- limit = enqueue_limit || total_limit
44
- next(block.call) unless limit
45
-
46
- # Only generate the concurrency key on the initial enqueue in case it is dynamic
47
- job.good_job_concurrency_key ||= job._good_job_concurrency_key
48
- key = job.good_job_concurrency_key
49
- next(block.call) if key.blank?
50
-
51
- GoodJob::Execution.advisory_lock_key(key, function: "pg_advisory_lock") do
52
- enqueue_concurrency = if enqueue_limit
53
- GoodJob::Execution.where(concurrency_key: key).unfinished.advisory_unlocked.count
54
- else
55
- GoodJob::Execution.where(concurrency_key: key).unfinished.count
56
- end
57
-
58
- # The job has not yet been enqueued, so check if adding it will go over the limit
59
- 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)
60
35
  end
61
36
  end
62
37
 
@@ -111,17 +86,60 @@ module GoodJob
111
86
  @good_job_concurrency_key || _good_job_concurrency_key
112
87
  end
113
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
+
114
133
  # Generates the concurrency key from the configuration
115
134
  # @return [Object] concurrency key
116
135
  def _good_job_concurrency_key
117
136
  key = self.class.good_job_concurrency_config[:key]
118
137
  return if key.blank?
119
138
 
120
- if key.respond_to? :call
121
- instance_exec(&key)
122
- else
123
- key
124
- end
139
+ key = key.respond_to?(:call) ? instance_exec(&key) : key
140
+ raise TypeError, "Concurrency key must be a String; was a #{key.class}" unless VALID_TYPES.any? { |type| key.is_a?(type) }
141
+
142
+ key
125
143
  end
126
144
  end
127
145
  end
@@ -40,6 +40,70 @@ module GoodJob
40
40
  enqueue_at(active_job, nil)
41
41
  end
42
42
 
43
+ # Enqueues multiple ActiveJob instances at once
44
+ # @param active_jobs [Array<ActiveJob::Base>] jobs to be enqueued
45
+ # @return [Integer] number of jobs that were successfully enqueued
46
+ def enqueue_all(active_jobs)
47
+ active_jobs = Array(active_jobs)
48
+ return 0 if active_jobs.empty?
49
+
50
+ current_time = Time.current
51
+ executions = active_jobs.map do |active_job|
52
+ GoodJob::Execution.build_for_enqueue(active_job, {
53
+ id: SecureRandom.uuid,
54
+ created_at: current_time,
55
+ updated_at: current_time,
56
+ })
57
+ end
58
+
59
+ inline_executions = []
60
+ GoodJob::Execution.transaction(requires_new: true, joinable: false) do
61
+ results = GoodJob::Execution.insert_all(executions.map(&:attributes), returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
62
+
63
+ job_id_to_provider_job_id = results.each_with_object({}) { |result, hash| hash[result['active_job_id']] = result['id'] }
64
+ active_jobs.each do |active_job|
65
+ active_job.provider_job_id = job_id_to_provider_job_id[active_job.job_id]
66
+ end
67
+ executions.each do |execution|
68
+ execution.instance_variable_set(:@new_record, false) if job_id_to_provider_job_id[execution.active_job_id]
69
+ end
70
+ executions = executions.select(&:persisted?) # prune unpersisted executions
71
+
72
+ if execute_inline?
73
+ inline_executions = executions.select { |execution| (execution.scheduled_at.nil? || execution.scheduled_at <= Time.current) }
74
+ inline_executions.each(&:advisory_lock!)
75
+ end
76
+ end
77
+
78
+ begin
79
+ until inline_executions.empty?
80
+ begin
81
+ inline_execution = inline_executions.shift
82
+ inline_result = inline_execution.perform
83
+ ensure
84
+ inline_execution.advisory_unlock
85
+ inline_execution.run_callbacks(:perform_unlocked)
86
+ end
87
+ raise inline_result.unhandled_error if inline_result.unhandled_error
88
+ end
89
+ ensure
90
+ inline_executions.each(&:advisory_unlock)
91
+ end
92
+
93
+ executions.reject(&:finished_at).group_by(&:queue_name).each do |queue_name, executions_by_queue|
94
+ executions_by_queue.group_by(&:scheduled_at).each do |scheduled_at, executions_by_queue_and_scheduled_at|
95
+ # TODO: have Adapter#create_thread handle state[:count] values
96
+ state = { queue_name: queue_name, count: executions_by_queue_and_scheduled_at.size }
97
+ state[:scheduled_at] = scheduled_at if scheduled_at
98
+
99
+ executed_locally = execute_async? && @scheduler&.create_thread(state)
100
+ Notifier.notify(state) unless executed_locally
101
+ end
102
+ end
103
+
104
+ active_jobs.count(&:provider_job_id)
105
+ end
106
+
43
107
  # Enqueues an ActiveJob job to be run at a specific time.
44
108
  # For use by Rails; you should generally not call this directly.
45
109
  # @param active_job [ActiveJob::Base] the job to be enqueued from +#perform_later+
@@ -47,8 +111,12 @@ module GoodJob
47
111
  # @return [GoodJob::Execution]
48
112
  def enqueue_at(active_job, timestamp)
49
113
  scheduled_at = timestamp ? Time.zone.at(timestamp) : nil
50
- will_execute_inline = execute_inline? && (scheduled_at.nil? || scheduled_at <= Time.current)
51
114
 
115
+ # If there is a currently open Bulk in the current thread, direct the
116
+ # job there to be enqueued using enqueue_all
117
+ return if GoodJob::Bulk.capture(active_job, queue_adapter: self)
118
+
119
+ will_execute_inline = execute_inline? && (scheduled_at.nil? || scheduled_at <= Time.current)
52
120
  execution = GoodJob::Execution.enqueue(
53
121
  active_job,
54
122
  scheduled_at: scheduled_at,
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ require 'active_support/core_ext/module/attribute_accessors_per_thread'
3
+
4
+ module GoodJob
5
+ module Bulk
6
+ Error = Class.new(StandardError)
7
+
8
+ # @!attribute [rw] current_buffer
9
+ # @!scope class
10
+ # Current buffer of jobs to be enqueued.
11
+ # @return [GoodJob::Bulk::Buffer, nil]
12
+ thread_mattr_accessor :current_buffer
13
+
14
+ # Capture jobs to a buffer. Pass either a block, or specific Active Jobs to be buffered.
15
+ # @param active_jobs [Array<ActiveJob::Base>] Active Jobs to be buffered.
16
+ # @param queue_adapter Override the jobs implict queue adapter with an explicit one.
17
+ # @return [nil, Array<ActiveJob::Base>] The ActiveJob instances that have been buffered; nil if no active buffer
18
+ def self.capture(active_jobs = nil, queue_adapter: nil)
19
+ raise(ArgumentError, "Use either the block form or the argument form, not both") if block_given? && active_jobs
20
+
21
+ if block_given?
22
+ begin
23
+ original_buffer = current_buffer
24
+ self.current_buffer = Buffer.new
25
+ yield
26
+ current_buffer.active_jobs
27
+ ensure
28
+ self.current_buffer = original_buffer
29
+ end
30
+ else
31
+ current_buffer&.add(active_jobs, queue_adapter: queue_adapter)
32
+ end
33
+ end
34
+
35
+ # Capture jobs to a buffer and enqueue them all at once; or enqueue the current buffer.
36
+ # @param active_jobs [Array<ActiveJob::Base>] Active Jobs to be enqueued.
37
+ # @return [Array<ActiveJob::Base>] The ActiveJob instances that have been captured; check provider_job_id to confirm enqueued.
38
+ def self.enqueue(active_jobs = nil)
39
+ raise(ArgumentError, "Use either the block form or the argument form, not both") if block_given? && active_jobs
40
+
41
+ if block_given?
42
+ capture do
43
+ yield
44
+ current_buffer&.enqueue
45
+ end
46
+ elsif active_jobs.present?
47
+ buffer = Buffer.new
48
+ buffer.add(active_jobs)
49
+ buffer.enqueue
50
+ buffer.active_jobs
51
+ elsif current_buffer.present?
52
+ current_buffer.enqueue
53
+ current_buffer.active_jobs
54
+ end
55
+ end
56
+
57
+ # Temporarily unset the current buffer; used to enqueue buffered jobs.
58
+ # @return [void]
59
+ def self.unbuffer
60
+ original_buffer = current_buffer
61
+ self.current_buffer = nil
62
+ yield
63
+ ensure
64
+ self.current_buffer = original_buffer
65
+ end
66
+
67
+ class Buffer
68
+ def initialize
69
+ @values = []
70
+ end
71
+
72
+ def add(active_jobs, queue_adapter: nil)
73
+ new_pairs = Array(active_jobs).map do |active_job|
74
+ adapter = queue_adapter || active_job.class.queue_adapter
75
+ raise Error, "Jobs must have a Queue Adapter" unless adapter
76
+
77
+ [active_job, adapter]
78
+ end
79
+ @values.append(*new_pairs)
80
+
81
+ true
82
+ end
83
+
84
+ def enqueue
85
+ Bulk.unbuffer do
86
+ active_jobs_by_queue_adapter.each do |adapter, jobs|
87
+ jobs = jobs.reject(&:provider_job_id) # Do not re-enqueue already enqueued jobs
88
+
89
+ if adapter.respond_to?(:enqueue_all)
90
+ unbulkable_jobs, bulkable_jobs = jobs.partition do |job|
91
+ job.respond_to?(:good_job_concurrency_key) && job.good_job_concurrency_key &&
92
+ (job.class.good_job_concurrency_config[:enqueue_limit] || job.class.good_job_concurrency_config[:total_limit])
93
+ end
94
+ adapter.enqueue_all(bulkable_jobs) if bulkable_jobs.any?
95
+ else
96
+ unbulkable_jobs = jobs
97
+ end
98
+
99
+ unbulkable_jobs.each do |job|
100
+ job.enqueue
101
+ rescue GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError
102
+ # ignore
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def active_jobs_by_queue_adapter
109
+ @values.each_with_object({}) do |(job, adapter), memo|
110
+ memo[adapter] ||= []
111
+ memo[adapter] << job
112
+ end
113
+ end
114
+
115
+ def active_jobs
116
+ @values.map(&:first)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -10,6 +10,8 @@ module GoodJob
10
10
  # The JobPerformer must be safe to execute across multiple threads.
11
11
  #
12
12
  class JobPerformer
13
+ cattr_accessor :performing_active_job_ids, default: Concurrent::Set.new
14
+
13
15
  # @param queue_string [String] Queues to execute jobs from
14
16
  def initialize(queue_string)
15
17
  @queue_string = queue_string
@@ -24,7 +26,13 @@ module GoodJob
24
26
  # Perform the next eligible job
25
27
  # @return [Object, nil] Returns job result or +nil+ if no job was found
26
28
  def next
27
- job_query.perform_with_advisory_lock(parsed_queues: parsed_queues, queue_select_limit: GoodJob.configuration.queue_select_limit)
29
+ active_job_id = nil
30
+ job_query.perform_with_advisory_lock(parsed_queues: parsed_queues, queue_select_limit: GoodJob.configuration.queue_select_limit) do |execution|
31
+ active_job_id = execution.active_job_id
32
+ performing_active_job_ids << active_job_id
33
+ end
34
+ ensure
35
+ performing_active_job_ids.delete(active_job_id)
28
36
  end
29
37
 
30
38
  # Tests whether this performer should be used in GoodJob's current state.
@@ -85,6 +85,19 @@ module GoodJob
85
85
  end
86
86
  end
87
87
 
88
+ def scheduler_shutdown_kill(event)
89
+ process_id = event.payload[:process_id]
90
+
91
+ warn(tags: [process_id]) do
92
+ active_job_ids = event.payload.fetch(:active_job_ids, [])
93
+ if active_job_ids.any?
94
+ "GoodJob scheduler has been killed. The following Active Jobs were interrupted: #{active_job_ids.join(' ')}"
95
+ else
96
+ "GoodJob scheduler has been killed."
97
+ end
98
+ end
99
+ end
100
+
88
101
  # @!macro notification_responder
89
102
  def scheduler_restart_pools(event)
90
103
  process_id = event.payload[:process_id]
@@ -168,35 +168,37 @@ module GoodJob # :nodoc:
168
168
  future = Concurrent::ScheduledTask.new(delay, args: [@recipients, executor, @listening], executor: @executor) do |thr_recipients, thr_executor, thr_listening|
169
169
  with_connection do
170
170
  begin
171
- run_callbacks :listen do
172
- ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
173
- connection.execute("LISTEN #{CHANNEL}")
171
+ Rails.application.executor.wrap do
172
+ run_callbacks :listen do
173
+ ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
174
+ connection.execute("LISTEN #{CHANNEL}")
175
+ end
176
+ thr_listening.make_true
174
177
  end
175
- thr_listening.make_true
176
178
  end
177
179
 
178
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
179
- while thr_executor.running?
180
- wait_for_notify do |channel, payload|
181
- next unless channel == CHANNEL
182
-
183
- ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
184
- parsed_payload = JSON.parse(payload, symbolize_names: true)
185
- thr_recipients.each do |recipient|
186
- target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
187
- target.send(method_name, parsed_payload)
188
- end
189
- end
180
+ while thr_executor.running?
181
+ wait_for_notify do |channel, payload|
182
+ next unless channel == CHANNEL
190
183
 
191
- reset_connection_errors
184
+ ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
185
+ parsed_payload = JSON.parse(payload, symbolize_names: true)
186
+ thr_recipients.each do |recipient|
187
+ target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
188
+ target.send(method_name, parsed_payload)
189
+ end
192
190
  end
191
+
192
+ reset_connection_errors
193
193
  end
194
194
  end
195
195
  ensure
196
- run_callbacks :unlisten do
197
- thr_listening.make_false
198
- ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
199
- connection.execute("UNLISTEN *")
196
+ Rails.application.executor.wrap do
197
+ run_callbacks :unlisten do
198
+ thr_listening.make_false
199
+ ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
200
+ connection.execute("UNLISTEN *")
201
+ end
200
202
  end
201
203
  end
202
204
  end
@@ -207,8 +209,10 @@ module GoodJob # :nodoc:
207
209
  end
208
210
 
209
211
  def with_connection
210
- self.connection = Execution.connection_pool.checkout.tap do |conn|
211
- Execution.connection_pool.remove(conn)
212
+ Rails.application.executor.wrap do
213
+ self.connection = Execution.connection_pool.checkout.tap do |conn|
214
+ Execution.connection_pool.remove(conn)
215
+ end
212
216
  end
213
217
  connection.execute("SET application_name = #{connection.quote(self.class.name)}")
214
218
 
@@ -121,7 +121,11 @@ module GoodJob # :nodoc:
121
121
 
122
122
  if executor.shuttingdown? && timeout
123
123
  executor_wait = timeout.negative? ? nil : timeout
124
- executor.kill unless executor.wait_for_termination(executor_wait)
124
+
125
+ unless executor.wait_for_termination(executor_wait)
126
+ instrument("scheduler_shutdown_kill", { active_job_ids: @performer.performing_active_job_ids.to_a })
127
+ executor.kill
128
+ end
125
129
  end
126
130
  end
127
131
  end
@@ -151,6 +155,19 @@ module GoodJob # :nodoc:
151
155
  if state
152
156
  return false unless performer.next?(state)
153
157
 
158
+ if state[:count]
159
+ # When given state for multiple jobs, try to create a thread for each one.
160
+ # Return true if a thread can be created for all of them, nil if partial or none.
161
+
162
+ state_without_count = state.without(:count)
163
+ result = state[:count].times do
164
+ value = create_thread(state_without_count)
165
+ break(value) unless value
166
+ end
167
+
168
+ return result.nil? ? nil : true
169
+ end
170
+
154
171
  if state[:scheduled_at]
155
172
  scheduled_at = if state[:scheduled_at].is_a? String
156
173
  Time.zone.parse state[:scheduled_at]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # GoodJob gem version.
4
- VERSION = '3.7.4'
4
+ VERSION = '3.9.0'
5
5
  end
data/lib/good_job.rb CHANGED
@@ -10,6 +10,7 @@ require "active_job/queue_adapters/good_job_adapter"
10
10
  require "good_job/active_job_extensions/concurrency"
11
11
 
12
12
  require "good_job/assignable_connection"
13
+ require "good_job/bulk"
13
14
  require "good_job/cleanup_tracker"
14
15
  require "good_job/cli"
15
16
  require "good_job/configuration"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.4
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-01-10 00:00:00.000000000 Z
11
+ date: 2023-01-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -136,20 +136,6 @@ dependencies:
136
136
  - - ">="
137
137
  - !ruby/object:Gem::Version
138
138
  version: '0'
139
- - !ruby/object:Gem::Dependency
140
- name: database_cleaner
141
- requirement: !ruby/object:Gem::Requirement
142
- requirements:
143
- - - ">="
144
- - !ruby/object:Gem::Version
145
- version: '0'
146
- type: :development
147
- prerelease: false
148
- version_requirements: !ruby/object:Gem::Requirement
149
- requirements:
150
- - - ">="
151
- - !ruby/object:Gem::Version
152
- version: '0'
153
139
  - !ruby/object:Gem::Dependency
154
140
  name: dotenv
155
141
  requirement: !ruby/object:Gem::Requirement
@@ -392,6 +378,7 @@ files:
392
378
  - app/views/layouts/good_job/application.html.erb
393
379
  - config/locales/en.yml
394
380
  - config/locales/es.yml
381
+ - config/locales/fr.yml
395
382
  - config/locales/nl.yml
396
383
  - config/locales/ru.yml
397
384
  - config/locales/ua.yml
@@ -408,6 +395,7 @@ files:
408
395
  - lib/good_job/active_job_extensions/concurrency.rb
409
396
  - lib/good_job/adapter.rb
410
397
  - lib/good_job/assignable_connection.rb
398
+ - lib/good_job/bulk.rb
411
399
  - lib/good_job/cleanup_tracker.rb
412
400
  - lib/good_job/cli.rb
413
401
  - lib/good_job/configuration.rb
@@ -450,14 +438,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
450
438
  requirements:
451
439
  - - ">="
452
440
  - !ruby/object:Gem::Version
453
- version: 2.5.0
441
+ version: 2.6.0
454
442
  required_rubygems_version: !ruby/object:Gem::Requirement
455
443
  requirements:
456
444
  - - ">="
457
445
  - !ruby/object:Gem::Version
458
446
  version: '0'
459
447
  requirements: []
460
- rubygems_version: 3.1.6
448
+ rubygems_version: 3.4.5
461
449
  signing_key:
462
450
  specification_version: 4
463
451
  summary: A multithreaded, Postgres-based ActiveJob backend for Ruby on Rails