good_job 3.5.1 → 3.6.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: 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