good_job 3.5.1 → 3.6.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: 1d98ed0a03595d8d2a70f2a811296cf01d1b0a4d9ff480d0a480fb91591ecafd
4
- data.tar.gz: 90307d30a72de7f7af5d33a7d64ec9eaa67bb42fc8e8179e48c96f22a0440b5e
3
+ metadata.gz: a3932cd8e0d33ab7f8a56a43c9c458f69db206291b092d9fcac16dfef11849ad
4
+ data.tar.gz: 695e46da59becc3450c0a0e33c1557784cef56794db979a648dce5d792c57651
5
5
  SHA512:
6
- metadata.gz: 710500b2704938b319863efe46d462296afa19f11177fb7e03f64e3cfa32872b1d1c8e793a58e6561e0f15a36125b4c3485be99edf6d1e2f8d9953da408ecb3b
7
- data.tar.gz: 6d0ae65d53a40340e846cd393a3936fe6ece5b7f5e2ccfdf17ee99f628f528135af11bb2714ee223f1e800b4ced333acc7616a153774d85d9889a0e17753a162
6
+ metadata.gz: 0ce689ec771af8a472d964cf14739ecc1fd2bf5c903692fbb2d73aeb19870560919c42b9aff5a0ca1671068ad6d914ceaa503aab84649e281ac53c5a17f3a6b5
7
+ data.tar.gz: 8c85d21ef4dbe48400c24dffa86f7ea78a5ae827ce8057cd8ab2efc439a980c24b61ae8224e14153a0a23b5933d49d8bf7c6e6d0e269b7cdeb7f28ced476b509
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.6.0](https://github.com/bensheldon/good_job/tree/v3.6.0) (2022-10-22)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.5.1...v3.6.0)
6
+
7
+ **Fixed bugs:**
8
+
9
+ - 3.4.8 release breaks job retrying [\#728](https://github.com/bensheldon/good_job/issues/728)
10
+
11
+ **Merged pull requests:**
12
+
13
+ - Redo: When not preserving job records, ensure all prior executions are deleted after successful retry [\#730](https://github.com/bensheldon/good_job/pull/730) ([bensheldon](https://github.com/bensheldon))
14
+ - Add configurable limit \(`queue_select_limit`\) when querying candidate jobs [\#727](https://github.com/bensheldon/good_job/pull/727) ([mitchellhenke](https://github.com/mitchellhenke))
15
+ - Add index to `good_jobs` to improve querying candidate jobs [\#726](https://github.com/bensheldon/good_job/pull/726) ([mitchellhenke](https://github.com/mitchellhenke))
16
+
3
17
  ## [v3.5.1](https://github.com/bensheldon/good_job/tree/v3.5.1) (2022-10-20)
4
18
 
5
19
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.5.0...v3.5.1)
data/README.md CHANGED
@@ -57,6 +57,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
57
57
  - [Optimize queues, threads, and processes](#optimize-queues-threads-and-processes)
58
58
  - [Database connections](#database-connections)
59
59
  - [Production setup](#production-setup)
60
+ - [Queue performance with Queue Select Limit](#queue-performance-with-queue-select-limit)
60
61
  - [Execute jobs async / in-process](#execute-jobs-async--in-process)
61
62
  - [Migrate to GoodJob from a different ActiveJob backend](#migrate-to-goodjob-from-a-different-activejob-backend)
62
63
  - [Monitor and preserve worked jobs](#monitor-and-preserve-worked-jobs)
@@ -176,6 +177,7 @@ Options:
176
177
  [--daemonize] # Run as a background daemon (default: false)
177
178
  [--pidfile=PIDFILE] # Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)
178
179
  [--probe-port=PORT] # Port for http health check (env var: GOOD_JOB_PROBE_PORT, default: nil)
180
+ [--queue-select-limit=COUNT] # The number of queued jobs to select when polling for a job to run. (env var: GOOD_JOB_QUEUE_SELECT_LIMIT, default: nil)"
179
181
 
180
182
  Executes queued jobs.
181
183
 
@@ -757,6 +759,45 @@ The recommended way to monitor the queue in production is:
757
759
  - keep an eye on the number of jobs in the queue (abnormal high number of unscheduled jobs means the queue could be underperforming)
758
760
  - consider performance monitoring services which support the built-in Rails instrumentation (eg. Sentry, Skylight, etc.)
759
761
 
762
+ #### Queue performance with Queue Select Limit
763
+
764
+ GoodJob’s advisory locking strategy uses a materialized CTE (Common Table Expression). This strategy can be non-performant when querying a very large queue of executable jobs (100,000+) because the database query must materialize all executable jobs before acquiring an advisory lock.
765
+
766
+ GoodJob offers an optional optimization to limit the number of jobs that are queried: Queue Select Limit.
767
+
768
+ ```none
769
+ # CLI option
770
+ --queue-select-limit=1000
771
+
772
+ # Rails configuration
773
+ config.good_job.queue_select_limit = 1000
774
+
775
+ # Environment Variable
776
+ GOOD_JOB_QUEUE_SELECT_LIMIT=1000
777
+ ```
778
+
779
+ The Queue Select Limit value should be set to a rough upper-bound that exceeds all GoodJob execution threads / database connections. `1000` is a number that likely exceeds the available database connections on most PaaS offerings, but still offers a performance boost for GoodJob when executing very large queues.
780
+
781
+ To explain where this value is used, here is the pseudo-query that GoodJob uses to find executable jobs:
782
+
783
+ ```sql
784
+ SELECT *
785
+ FROM good_jobs
786
+ WHERE id IN (
787
+ WITH rows AS MATERIALIZED (
788
+ SELECT id, active_job_id
789
+ FROM good_jobs
790
+ WHERE (scheduled_at <= NOW() OR scheduled_at IS NULL) AND finished_at IS NULL
791
+ ORDER BY priority DESC NULLS LAST, created_at ASC
792
+ [LIMIT 1000] -- <= introduced when queue_select_limit is set
793
+ )
794
+ SELECT id
795
+ FROM rows
796
+ WHERE pg_try_advisory_lock(('x' || substr(md5('good_jobs' || '-' || active_job_id::text), 1, 16))::bit(64)::bigint)
797
+ LIMIT 1
798
+ )
799
+ ```
800
+
760
801
  ### Execute jobs async / in-process
761
802
 
762
803
  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:
@@ -66,6 +66,7 @@ module GoodJob
66
66
  end
67
67
 
68
68
  belongs_to :job, class_name: 'GoodJob::Job', foreign_key: 'active_job_id', primary_key: 'active_job_id', optional: true, inverse_of: :executions
69
+ after_destroy -> { self.class.active_job_id(active_job_id).delete_all }, if: -> { @_destroy_job }
69
70
 
70
71
  # Get executions with given ActiveJob ID
71
72
  # @!method active_job_id
@@ -203,10 +204,10 @@ module GoodJob
203
204
  # return value for the job's +#perform+ method, and the exception the job
204
205
  # raised, if any (if the job raised, then the second array entry will be
205
206
  # +nil+). If there were no jobs to execute, returns +nil+.
206
- def self.perform_with_advisory_lock(parsed_queues: nil)
207
+ def self.perform_with_advisory_lock(parsed_queues: nil, queue_select_limit: nil)
207
208
  execution = nil
208
209
  result = nil
209
- unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(unlock_session: true) do |executions|
210
+ unfinished.dequeueing_ordered(parsed_queues).only_scheduled.limit(1).with_advisory_lock(unlock_session: true, select_limit: queue_select_limit) do |executions|
210
211
  execution = executions.first
211
212
  break if execution.blank?
212
213
  break :unlocked unless execution&.executable?
@@ -296,13 +297,14 @@ module GoodJob
296
297
  job_error = result.handled_error || result.unhandled_error
297
298
  self.error = [job_error.class, ERROR_MESSAGE_SEPARATOR, job_error.message].join if job_error
298
299
 
300
+ reenqueued = result.retried? || retried_good_job_id.present?
299
301
  if result.unhandled_error && GoodJob.retry_on_unhandled_error
300
302
  save!
301
- elsif GoodJob.preserve_job_records == true || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
303
+ elsif GoodJob.preserve_job_records == true || reenqueued || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
302
304
  self.finished_at = Time.current
303
305
  save!
304
306
  else
305
- destroy!
307
+ destroy_job
306
308
  end
307
309
 
308
310
  result
@@ -354,6 +356,14 @@ module GoodJob
354
356
  (finished_at || Time.zone.now) - performed_at if performed_at
355
357
  end
356
358
 
359
+ # Destroys this execution and all executions within the same job
360
+ def destroy_job
361
+ @_destroy_job = true
362
+ destroy!
363
+ ensure
364
+ @_destroy_job = false
365
+ end
366
+
357
367
  private
358
368
 
359
369
  def active_job_data
@@ -379,7 +389,7 @@ module GoodJob
379
389
  end
380
390
  handled_error ||= current_thread.error_on_retry || current_thread.error_on_discard
381
391
 
382
- ExecutionResult.new(value: value, handled_error: handled_error)
392
+ ExecutionResult.new(value: value, handled_error: handled_error, retried: current_thread.error_on_retry.present?)
383
393
  rescue StandardError => e
384
394
  ExecutionResult.new(value: nil, unhandled_error: e)
385
395
  end
@@ -8,14 +8,18 @@ module GoodJob
8
8
  attr_reader :handled_error
9
9
  # @return [Exception, nil]
10
10
  attr_reader :unhandled_error
11
+ # @return [Exception, nil]
12
+ attr_reader :retried
13
+ alias retried? retried
11
14
 
12
15
  # @param value [Object, nil]
13
16
  # @param handled_error [Exception, nil]
14
17
  # @param unhandled_error [Exception, nil]
15
- def initialize(value:, handled_error: nil, unhandled_error: nil)
18
+ def initialize(value:, handled_error: nil, unhandled_error: nil, retried: false)
16
19
  @value = value
17
20
  @handled_error = handled_error
18
21
  @unhandled_error = unhandled_error
22
+ @retried = retried
19
23
  end
20
24
  end
21
25
  end
@@ -37,11 +37,12 @@ module GoodJob
37
37
  # @param function [String, Symbol] Postgres Advisory Lock function name to use
38
38
  # @return [ActiveRecord::Relation]
39
39
  # A relation selecting only the records that were locked.
40
- scope :advisory_lock, (lambda do |column: _advisory_lockable_column, function: advisory_lockable_function|
40
+ scope :advisory_lock, (lambda do |column: _advisory_lockable_column, function: advisory_lockable_function, select_limit: nil|
41
41
  original_query = self
42
42
 
43
43
  cte_table = Arel::Table.new(:rows)
44
44
  cte_query = original_query.select(primary_key, column).except(:limit)
45
+ cte_query = cte_query.limit(select_limit) if select_limit
45
46
  cte_type = if supports_cte_materialization_specifiers?
46
47
  'MATERIALIZED'
47
48
  else
@@ -154,10 +155,10 @@ module GoodJob
154
155
  # MyLockableRecord.order(created_at: :asc).limit(2).with_advisory_lock do |record|
155
156
  # do_something_with record
156
157
  # end
157
- def with_advisory_lock(column: _advisory_lockable_column, function: advisory_lockable_function, unlock_session: false)
158
+ def with_advisory_lock(column: _advisory_lockable_column, function: advisory_lockable_function, unlock_session: false, select_limit: nil)
158
159
  raise ArgumentError, "Must provide a block" unless block_given?
159
160
 
160
- records = advisory_lock(column: column, function: function).to_a
161
+ records = advisory_lock(column: column, function: function, select_limit: select_limit).to_a
161
162
 
162
163
  begin
163
164
  unscoped { yield(records) }
@@ -41,5 +41,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
41
41
  add_index :good_jobs, [:cron_key, :cron_at], name: :index_good_jobs_on_cron_key_and_cron_at, unique: true
42
42
  add_index :good_jobs, [:active_job_id], name: :index_good_jobs_on_active_job_id
43
43
  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
+ add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc },
45
+ where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
44
46
  end
45
47
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ class CreateIndexGoodJobsJobsOnPriorityCreatedAtWhenUnfinished < ActiveRecord::Migration<%= migration_version %>
3
+ disable_ddl_transaction!
4
+
5
+ def change
6
+ reversible do |dir|
7
+ dir.up do
8
+ # Ensure this incremental update migration is idempotent
9
+ # with monolithic install migration.
10
+ return if connection.index_name_exists?(:good_jobs, :index_good_jobs_jobs_on_priority_created_at_when_unfinished)
11
+ end
12
+ end
13
+
14
+ add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc },
15
+ where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished,
16
+ algorithm: :concurrently
17
+ end
18
+ end
data/lib/good_job/cli.rb CHANGED
@@ -81,7 +81,12 @@ module GoodJob
81
81
  desc: "Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)"
82
82
  method_option :probe_port,
83
83
  type: :numeric,
84
+ banner: 'PORT',
84
85
  desc: "Port for http health check (env var: GOOD_JOB_PROBE_PORT, default: nil)"
86
+ method_option :queue_select_limit,
87
+ type: :numeric,
88
+ banner: 'COUNT',
89
+ desc: "The number of queued jobs to select when polling for a job to run. (env var: GOOD_JOB_QUEUE_SELECT_LIMIT, default: nil)"
85
90
 
86
91
  def start
87
92
  set_up_application!
@@ -208,6 +208,19 @@ module GoodJob
208
208
  cron.map { |cron_key, params| GoodJob::CronEntry.new(params.merge(key: cron_key)) }
209
209
  end
210
210
 
211
+ # The number of queued jobs to select when polling for a job to run.
212
+ # This limit is intended to avoid locking a large number of rows when selecting eligible jobs
213
+ # from the queue. This value should be higher than the total number of threads across all good_job
214
+ # processes to ensure a thread can retrieve an eligible and unlocked job.
215
+ # @return [Integer, nil]
216
+ def queue_select_limit
217
+ (
218
+ options[:queue_select_limit] ||
219
+ rails_config[:queue_select_limit] ||
220
+ env['GOOD_JOB_QUEUE_SELECT_LIMIT']
221
+ )&.to_i
222
+ end
223
+
211
224
  # Whether to destroy discarded jobs when cleaning up preserved jobs.
212
225
  # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
213
226
  # @return [Boolean]
@@ -24,7 +24,7 @@ module GoodJob
24
24
  # Perform the next eligible job
25
25
  # @return [Object, nil] Returns job result or +nil+ if no job was found
26
26
  def next
27
- job_query.perform_with_advisory_lock(parsed_queues: parsed_queues)
27
+ job_query.perform_with_advisory_lock(parsed_queues: parsed_queues, queue_select_limit: GoodJob.configuration.queue_select_limit)
28
28
  end
29
29
 
30
30
  # Tests whether this performer should be used in GoodJob's current state.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # GoodJob gem version.
4
- VERSION = '3.5.1'
4
+ VERSION = '3.6.0'
5
5
  end
data/lib/good_job.rb CHANGED
@@ -57,7 +57,7 @@ module GoodJob
57
57
  # By default, GoodJob deletes job records after the job is completed successfully.
58
58
  # If you want to preserve jobs for latter inspection, set this to +true+.
59
59
  # If you want to preserve only jobs that finished with error for latter inspection, set this to +:on_unhandled_error+.
60
- # @return [Boolean, nil]
60
+ # @return [Boolean, Symbol, nil]
61
61
  mattr_accessor :preserve_job_records, default: true
62
62
 
63
63
  # @!attribute [rw] retry_on_unhandled_error
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.5.1
4
+ version: 3.6.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: 2022-10-20 00:00:00.000000000 Z
11
+ date: 2022-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -402,6 +402,7 @@ files:
402
402
  - lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb
403
403
  - lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb
404
404
  - lib/generators/good_job/templates/update/migrations/02_create_good_job_settings.rb.erb
405
+ - lib/generators/good_job/templates/update/migrations/03_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb.erb
405
406
  - lib/generators/good_job/update_generator.rb
406
407
  - lib/good_job.rb
407
408
  - lib/good_job/active_job_extensions/concurrency.rb