good_job 3.16.2 → 3.16.4

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: df700f1e4e911894d7f47d8b46c6159200e9f68bc6c7db9c9d8592999320851f
4
- data.tar.gz: 0f8994e16848891c3a2a41064b8ee12555c9880035735ed1635a6ffd888ef276
3
+ metadata.gz: c4853b6c3598f46587b44f56dc6c59f4694753184da2d75d84cc87b8e901a3c3
4
+ data.tar.gz: 8086b714bc3a69a9260c36ef2b8295fc86ed6797eace2d8d8a4724ba509a7e65
5
5
  SHA512:
6
- metadata.gz: f73d66cf94b957e59aaf4352d0bd0f0e4d5477144875a5923f846f5dadaf938b7a3f6c1f0cf21ae353258145e5ad2f4cc4c98a5c433d77c6b45b74c315358cdc
7
- data.tar.gz: 99a1ba23209daa2890fee61443a91ff61a5ce2c8b7a5c5eeb55f54d85cc8166b97c3551d132a765ef6907924c78291e52d1b5cec705a9829f833f688506ecd76
6
+ metadata.gz: 5962d1992f8e283a6ca8349c1a9cf7d986bce54a2dc5f0abc09c1940ec97c7bc24af022eaa8e5979bcde395a4876741f34065cc24dfd1c88cb08a2a97baae77d
7
+ data.tar.gz: a1407ceaa0930ecf591fbf330e310124f6aaf9fd5ffc05aa579ae6176b8346dc004ba2ce2dc8d15b57efe9c1b07e68a248408cf0bec501bf5f84e586903d5c73
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.16.4](https://github.com/bensheldon/good_job/tree/v3.16.4) (2023-07-30)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.16.3...v3.16.4)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Add database\_connection\_pool stats to `GoodJob::Process.current_state` [\#1019](https://github.com/bensheldon/good_job/pull/1019) ([dixpac](https://github.com/dixpac))
10
+
11
+ **Fixed bugs:**
12
+
13
+ - Move `Execution#active_job` to BaseExecution to share with Job; refactor `Batch#active_jobs` to use job directly [\#1022](https://github.com/bensheldon/good_job/pull/1022) ([bensheldon](https://github.com/bensheldon))
14
+
15
+ **Closed issues:**
16
+
17
+ - Notifier errored: ArgumentError: wrong number of arguments \(given 1, expected 0\) [\#1016](https://github.com/bensheldon/good_job/issues/1016)
18
+ - Understanding Database Connections and Cron [\#1013](https://github.com/bensheldon/good_job/issues/1013)
19
+ - Experiencing various database exceptions with Rails 7.1 [\#796](https://github.com/bensheldon/good_job/issues/796)
20
+
21
+ **Merged pull requests:**
22
+
23
+ - Refactor Notifier to ensure \#restart is threadsafe [\#1021](https://github.com/bensheldon/good_job/pull/1021) ([bensheldon](https://github.com/bensheldon))
24
+ - Notifier and CronManager share a 2-thread executor within the capsule [\#1018](https://github.com/bensheldon/good_job/pull/1018) ([bensheldon](https://github.com/bensheldon))
25
+ - Clarify database connections and recurring processes in README.md [\#1015](https://github.com/bensheldon/good_job/pull/1015) ([blumhardts](https://github.com/blumhardts))
26
+ - Deprecate `GoodJob::Lockable` and rename to `GoodJob::AdvisoryLockable` [\#1012](https://github.com/bensheldon/good_job/pull/1012) ([bensheldon](https://github.com/bensheldon))
27
+
28
+ ## [v3.16.3](https://github.com/bensheldon/good_job/tree/v3.16.3) (2023-07-18)
29
+
30
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.16.2...v3.16.3)
31
+
32
+ **Fixed bugs:**
33
+
34
+ - Fix bulk enqueue for unmigrated 'error\_event'; add `GoodJob.migrated?` check method; use custom enum implementation [\#1011](https://github.com/bensheldon/good_job/pull/1011) ([bensheldon](https://github.com/bensheldon))
35
+
36
+ **Closed issues:**
37
+
38
+ - GoodJob::Bulk.enqueue not handling missing migrations [\#1010](https://github.com/bensheldon/good_job/issues/1010)
39
+
40
+ **Merged pull requests:**
41
+
42
+ - Move shared `BaseExecution` concerns into the base class. [\#1009](https://github.com/bensheldon/good_job/pull/1009) ([dixpac](https://github.com/dixpac))
43
+
3
44
  ## [v3.16.2](https://github.com/bensheldon/good_job/tree/v3.16.2) (2023-07-13)
4
45
 
5
46
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.16.1...v3.16.2)
data/README.md CHANGED
@@ -59,8 +59,8 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
59
59
  - [Timeouts](#timeouts)
60
60
  - [Optimize queues, threads, and processes](#optimize-queues-threads-and-processes)
61
61
  - [Database connections](#database-connections)
62
- - [Production setup](#production-setup)
63
- - [Queue performance with Queue Select Limit](#queue-performance-with-queue-select-limit)
62
+ - [Production setup](#production-setup)
63
+ - [Queue performance with Queue Select Limit](#queue-performance-with-queue-select-limit)
64
64
  - [Execute jobs async / in-process](#execute-jobs-async--in-process)
65
65
  - [Migrate to GoodJob from a different ActiveJob backend](#migrate-to-goodjob-from-a-different-activejob-backend)
66
66
  - [Monitor and preserve worked jobs](#monitor-and-preserve-worked-jobs)
@@ -475,7 +475,7 @@ GoodJob's concurrency control strategy for `perform_limit` is "optimistic retry
475
475
 
476
476
  GoodJob can enqueue jobs on a recurring basis that can be used as a replacement for cron.
477
477
 
478
- Cron-style jobs are run on every GoodJob process (e.g. CLI or `async` execution mode) when `config.good_job.enable_cron = true`.
478
+ Cron-style jobs can be performed by any GoodJob process (e.g., CLI or `:async` execution mode) that has `config.good_job.enable_cron` set to `true`. That is, one or more job executor processes can be configured to perform recurring jobs.
479
479
 
480
480
  GoodJob's cron uses unique indexes to ensure that only a single job is enqueued at the given time interval. In order for this to work, GoodJob must preserve cron-created job records; these records will be automatically deleted like any other preserved record.
481
481
 
@@ -484,7 +484,7 @@ Cron-format is parsed by the [`fugit`](https://github.com/floraison/fugit) gem,
484
484
  ```ruby
485
485
  # config/environments/application.rb or a specific environment e.g. production.rb
486
486
 
487
- # Enable cron in this process; e.g. only run on the first Heroku worker process
487
+ # Enable cron in this process, e.g., only run on the first Heroku worker process
488
488
  config.good_job.enable_cron = ENV['DYNO'] == 'worker.1' # or `true` or via $GOOD_JOB_ENABLE_CRON
489
489
 
490
490
  # Configure cron with a hash that has a unique key for each recurring job
@@ -703,7 +703,9 @@ GoodJob follows semantic versioning, though updates may be encouraged through de
703
703
 
704
704
  #### Upgrading minor versions
705
705
 
706
- Upgrading between minor versions (e.g. v1.4 to v1.5) should not introduce breaking changes, but can introduce new deprecation warnings and database migration notices.
706
+ Upgrading between minor versions (e.g. v1.4 to v1.5) should not introduce breaking changes, but can introduce new deprecation warnings and database migration warnings.
707
+
708
+ Database migrations introduced in minor releases are _not required_ to be applied until the next major release. If you would like apply newly introduced migrations immediately, assert `GoodJob.migrated?` in your application's test suite.
707
709
 
708
710
  To perform upgrades to the GoodJob database tables:
709
711
 
@@ -952,24 +954,50 @@ Keep in mind, queue operations and management is an advanced discipline. This st
952
954
 
953
955
  ### Database connections
954
956
 
955
- Each GoodJob execution thread requires its own database connection that is automatically checked out from Rails’ connection pool. For example:
957
+ GoodJob job executor processes require the following database connections:
958
+
959
+ - 1 connection per execution pool thread. E.g., `--queues=mice:2;elephants:1` is 3 threads and thus 3 connections. Pool size defaults to `--max-threads`.
960
+ - 2 additional connections that GoodJob uses for utility functionality (e.g. LISTEN/NOTIFY, cron, etc.)
961
+ - 1 connection per subthread, if your application makes multithreaded database queries (e.g. `load_async`) within a job.
962
+
963
+ The executor process will not crash if the connections pool is exhausted, instead it will report an exception (eg. `ActiveRecord::ConnectionTimeoutError`).
964
+
965
+ When GoodJob runs in `:inline` mode (in Rails' test environment, by default), the default database pool configuration works.
966
+
967
+ ```yml
968
+ # config/database.yml
969
+
970
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
971
+ ```
972
+
973
+ When GoodJob runs in `:async` mode (in Rails's development environment, by default), the following database pool configuration works, where:
974
+
975
+ - `ENV.fetch("RAILS_MAX_THREADS", 5)` is the number of threads used by the web server
976
+ - `1` is the number of connections used by the job listener
977
+ - `2` is the number of connections used by the cron scheduler and executor
978
+ - `ENV.fetch("GOOD_JOB_MAX_THREADS", 5)` is the number of threads used to perform jobs
956
979
 
957
980
  ```yaml
958
981
  # config/database.yml
959
- pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5).to_i + 3 + ENV.fetch("GOOD_JOB_MAX_THREADS", 5).to_i %>
982
+
983
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5).to_i + 1 + 2 + ENV.fetch("GOOD_JOB_MAX_THREADS", 5).to_i %>
960
984
  ```
961
985
 
962
- To calculate the total number of the database connections you'll need:
986
+ When GoodJob runs in `:external` mode (in Rails' production environment, by default), the following database pool configurations work for web servers and worker processes, respectively.
963
987
 
964
- - 1 connection dedicated to the scheduler aka `LISTEN/NOTIFY`
965
- - 1 connection per query pool thread e.g. `--queues=mice:2;elephants:1` is 3 threads. Pool thread size defaults to `--max-threads`
966
- - (optional) 2 connections for Cron scheduler if you're running it
967
- - (optional) 1 connection per subthread, if your application makes multithreaded database queries within a job
968
- - When running `:async`, you must also add the number of threads by the webserver
988
+ ```yaml
989
+ # config/database.yml
990
+
991
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
992
+ ```
969
993
 
970
- The queue process will not crash if the connections pool is exhausted, instead it will report an exception (eg. `ActiveRecord::ConnectionTimeoutError`).
994
+ ```yaml
995
+ # config/database.yml
996
+
997
+ pool: <%= 1 + 2 + ENV.fetch("GOOD_JOB_MAX_THREADS", 5).to_i %>
998
+ ```
971
999
 
972
- #### Production setup
1000
+ ### Production setup
973
1001
 
974
1002
  When running GoodJob in a production environment, you should be mindful of:
975
1003
 
@@ -984,7 +1012,7 @@ The recommended way to monitor the queue in production is:
984
1012
  - keep an eye on the number of jobs in the queue (abnormal high number of unscheduled jobs means the queue could be underperforming)
985
1013
  - consider performance monitoring services which support the built-in Rails instrumentation (eg. Sentry, Skylight, etc.)
986
1014
 
987
- #### Queue performance with Queue Select Limit
1015
+ ### Queue performance with Queue Select Limit
988
1016
 
989
1017
  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.
990
1018
 
@@ -16,7 +16,7 @@ module GoodJob
16
16
  # end
17
17
  # end
18
18
  #
19
- module Lockable
19
+ module AdvisoryLockable
20
20
  extend ActiveSupport::Concern
21
21
 
22
22
  # Indicates an advisory lock is already held on a record by another
@@ -14,15 +14,32 @@ module GoodJob
14
14
  ERROR_EVENT_DISCARDED = 'discarded',
15
15
  ].freeze
16
16
 
17
- included do
18
- enum error_event: {
19
- ERROR_EVENT_INTERRUPTED => 0,
20
- ERROR_EVENT_UNHANDLED => 1,
21
- ERROR_EVENT_HANDLED => 2,
22
- ERROR_EVENT_RETRIED => 3,
23
- ERROR_EVENT_RETRY_STOPPED => 4,
24
- ERROR_EVENT_DISCARDED => 5,
25
- }.freeze, _prefix: :error_event
17
+ ERROR_EVENT_ENUMS = {
18
+ ERROR_EVENT_INTERRUPTED => 0,
19
+ ERROR_EVENT_UNHANDLED => 1,
20
+ ERROR_EVENT_HANDLED => 2,
21
+ ERROR_EVENT_RETRIED => 3,
22
+ ERROR_EVENT_RETRY_STOPPED => 4,
23
+ ERROR_EVENT_DISCARDED => 5,
24
+ }.freeze
25
+
26
+ # TODO: GoodJob v4 can make this an `enum` once migrations are guaranteed.
27
+ def error_event
28
+ return unless self.class.columns_hash['error_event']
29
+
30
+ enum = super
31
+ return unless enum
32
+
33
+ ERROR_EVENT_ENUMS.key(enum)
34
+ end
35
+
36
+ def error_event=(event)
37
+ return unless self.class.columns_hash['error_event']
38
+
39
+ enum = ERROR_EVENT_ENUMS[event]
40
+ raise(ArgumentError, "Invalid error_event: #{event}") if event && !enum
41
+
42
+ super(enum)
26
43
  end
27
44
  end
28
45
  end
@@ -4,7 +4,10 @@ module GoodJob
4
4
  # ActiveRecord model to share behavior between {Job} and {Execution} models
5
5
  # which both read out of the same table.
6
6
  class BaseExecution < BaseRecord
7
+ include AdvisoryLockable
7
8
  include ErrorEvents
9
+ include Filterable
10
+ include Reportable
8
11
 
9
12
  self.table_name = 'good_jobs'
10
13
 
@@ -57,5 +60,28 @@ module GoodJob
57
60
  def discrete?
58
61
  self.class.discrete_support? && is_discrete?
59
62
  end
63
+
64
+ # Build an ActiveJob instance and deserialize the arguments, using `#active_job_data`.
65
+ #
66
+ # @param ignore_deserialization_errors [Boolean]
67
+ # Whether to ignore ActiveJob::DeserializationError when deserializing the arguments.
68
+ # This is most useful if you aren't planning to use the arguments directly.
69
+ def active_job(ignore_deserialization_errors: false)
70
+ ActiveJob::Base.deserialize(active_job_data).tap do |aj|
71
+ aj.send(:deserialize_arguments_if_needed)
72
+ rescue ActiveJob::DeserializationError
73
+ raise unless ignore_deserialization_errors
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def active_job_data
80
+ serialized_params.deep_dup
81
+ .tap do |job_data|
82
+ job_data["provider_job_id"] = id
83
+ job_data["good_job_concurrency_key"] = concurrency_key if concurrency_key
84
+ end
85
+ end
60
86
  end
61
87
  end
@@ -124,11 +124,11 @@ module GoodJob
124
124
  end
125
125
 
126
126
  def active_jobs
127
- record.jobs.map(&:head_execution).map(&:active_job)
127
+ record.jobs.map(&:active_job)
128
128
  end
129
129
 
130
130
  def callback_active_jobs
131
- record.callback_jobs.map(&:head_execution).map(&:active_job)
131
+ record.callback_jobs.map(&:active_job)
132
132
  end
133
133
 
134
134
  def assign_properties(properties)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  class BatchRecord < BaseRecord
5
- include Lockable
5
+ include AdvisoryLockable
6
6
 
7
7
  self.table_name = 'good_job_batches'
8
8
 
@@ -3,10 +3,6 @@
3
3
  module GoodJob
4
4
  # ActiveRecord model that represents an +ActiveJob+ job.
5
5
  class Execution < BaseExecution
6
- include Lockable
7
- include Filterable
8
- include Reportable
9
-
10
6
  # Raised if something attempts to execute a previously completed Execution again.
11
7
  PreviouslyPerformedError = Class.new(StandardError)
12
8
 
@@ -384,7 +380,7 @@ module GoodJob
384
380
  error: interrupt_error_string,
385
381
  finished_at: Time.current,
386
382
  }
387
- discrete_execution_attrs[:error_event] = GoodJob::DiscreteExecution.error_events[GoodJob::DiscreteExecution::ERROR_EVENT_INTERRUPTED] if self.class.error_event_migrated?
383
+ discrete_execution_attrs[:error_event] = GoodJob::ErrorEvents::ERROR_EVENT_ENUMS[GoodJob::ErrorEvents::ERROR_EVENT_INTERRUPTED] if self.class.error_event_migrated?
388
384
  discrete_executions.where(finished_at: nil).where.not(performed_at: nil).update_all(discrete_execution_attrs) # rubocop:disable Rails/SkipsModelValidations
389
385
  end
390
386
  end
@@ -512,19 +508,6 @@ module GoodJob
512
508
  self.scheduled_at ||= current_time
513
509
  end
514
510
 
515
- # Build an ActiveJob instance and deserialize the arguments, using `#active_job_data`.
516
- #
517
- # @param ignore_deserialization_errors [Boolean]
518
- # Whether to ignore ActiveJob::DeserializationError when deserializing the arguments.
519
- # This is most useful if you aren't planning to use the arguments directly.
520
- def active_job(ignore_deserialization_errors: false)
521
- ActiveJob::Base.deserialize(active_job_data).tap do |aj|
522
- aj.send(:deserialize_arguments_if_needed)
523
- rescue ActiveJob::DeserializationError
524
- raise unless ignore_deserialization_errors
525
- end
526
- end
527
-
528
511
  # Return formatted serialized_params for display in the dashboard
529
512
  # @return [Hash]
530
513
  def display_serialized_params
@@ -569,14 +552,6 @@ module GoodJob
569
552
 
570
553
  private
571
554
 
572
- def active_job_data
573
- serialized_params.deep_dup
574
- .tap do |job_data|
575
- job_data["provider_job_id"] = id
576
- job_data["good_job_concurrency_key"] = concurrency_key if concurrency_key
577
- end
578
- end
579
-
580
555
  def reset_batch_values(&block)
581
556
  GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
582
557
  end
@@ -7,10 +7,6 @@ module GoodJob
7
7
  # A single row from the +good_jobs+ table of executions is fetched to represent a Job.
8
8
  #
9
9
  class Job < BaseExecution
10
- include Filterable
11
- include Lockable
12
- include Reportable
13
-
14
10
  # Raised when an inappropriate action is applied to a Job based on its state.
15
11
  ActionForStateMismatchError = Class.new(StandardError)
16
12
  # Raised when an action requires GoodJob to be the ActiveJob Queue Adapter but GoodJob is not.
@@ -228,9 +224,12 @@ module GoodJob
228
224
 
229
225
  update_execution = proc do
230
226
  execution.update(
231
- finished_at: Time.current,
232
- error: GoodJob::Execution.format_error(job_error),
233
- error_event: :discarded
227
+ {
228
+ finished_at: Time.current,
229
+ error: GoodJob::Execution.format_error(job_error),
230
+ }.tap do |attrs|
231
+ attrs[:error_event] = ERROR_EVENT_DISCARDED if self.class.error_event_migrated?
232
+ end
234
233
  )
235
234
  end
236
235
 
@@ -5,8 +5,8 @@ require 'socket'
5
5
  module GoodJob # :nodoc:
6
6
  # ActiveRecord model that represents an GoodJob process (either async or CLI).
7
7
  class Process < BaseRecord
8
+ include AdvisoryLockable
8
9
  include AssignableConnection
9
- include Lockable
10
10
 
11
11
  # Interval until the process record being updated
12
12
  STALE_INTERVAL = 30.seconds
@@ -63,6 +63,10 @@ module GoodJob # :nodoc:
63
63
  cron_enabled: GoodJob.configuration.enable_cron?,
64
64
  total_succeeded_executions_count: GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:succeeded_executions_count) },
65
65
  total_errored_executions_count: GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:errored_executions_count) },
66
+ database_connection_pool: {
67
+ size: connection_pool.size,
68
+ active: connection_pool.connections.count(&:in_use?),
69
+ },
66
70
  }
67
71
  end
68
72
 
@@ -64,7 +64,15 @@ module GoodJob
64
64
 
65
65
  inline_executions = []
66
66
  GoodJob::Execution.transaction(requires_new: true, joinable: false) do
67
- results = GoodJob::Execution.insert_all(executions.map(&:attributes), returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
67
+ execution_attributes = executions.map do |execution|
68
+ if GoodJob::Execution.error_event_migrated?
69
+ execution.attributes
70
+ else
71
+ execution.attributes.except('error_event')
72
+ end
73
+ end
74
+
75
+ results = GoodJob::Execution.insert_all(execution_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
68
76
 
69
77
  job_id_to_provider_job_id = results.each_with_object({}) { |result, hash| hash[result['active_job_id']] = result['id'] }
70
78
  active_jobs.each do |active_job|
@@ -29,13 +29,14 @@ module GoodJob
29
29
  @mutex.synchronize do
30
30
  return unless startable?(force: force)
31
31
 
32
- @notifier = GoodJob::Notifier.new(enable_listening: @configuration.enable_listen_notify)
32
+ @shared_executor = GoodJob::SharedExecutor.new
33
+ @notifier = GoodJob::Notifier.new(enable_listening: @configuration.enable_listen_notify, executor: @shared_executor.executor)
33
34
  @poller = GoodJob::Poller.new(poll_interval: @configuration.poll_interval)
34
35
  @scheduler = GoodJob::Scheduler.from_configuration(@configuration, warm_cache_on_initialize: true)
35
36
  @notifier.recipients << [@scheduler, :create_thread]
36
37
  @poller.recipients << [@scheduler, :create_thread]
37
38
 
38
- @cron_manager = GoodJob::CronManager.new(@configuration.cron_entries, start_on_initialize: true) if @configuration.enable_cron?
39
+ @cron_manager = GoodJob::CronManager.new(@configuration.cron_entries, start_on_initialize: true, executor: @shared_executor.executor) if @configuration.enable_cron?
39
40
 
40
41
  @startable = false
41
42
  @running = true
@@ -51,7 +52,7 @@ module GoodJob
51
52
  # @return [void]
52
53
  def shutdown(timeout: :default)
53
54
  timeout = @configuration.shutdown_timeout if timeout == :default
54
- GoodJob._shutdown_all([@notifier, @poller, @scheduler, @cron_manager].compact, timeout: timeout)
55
+ GoodJob._shutdown_all([@shared_executor, @notifier, @poller, @scheduler, @cron_manager].compact, timeout: timeout)
55
56
  @startable = false
56
57
  @running = false
57
58
  end
@@ -73,7 +74,7 @@ module GoodJob
73
74
 
74
75
  # @return [Boolean] Whether the capsule has been shutdown.
75
76
  def shutdown?
76
- [@notifier, @poller, @scheduler, @cron_manager].compact.all?(&:shutdown?)
77
+ [@shared_executor, @notifier, @poller, @scheduler, @cron_manager].compact.all?(&:shutdown?)
77
78
  end
78
79
 
79
80
  # Creates an execution thread(s) with the given attributes.
@@ -51,13 +51,10 @@ module GoodJob
51
51
  # @param warn [Boolean] whether to print a warning when over the limit
52
52
  # @return [Integer]
53
53
  def self.total_estimated_threads(warn: false)
54
- configuration = new({})
55
-
56
- cron_threads = configuration.enable_cron? ? 2 : 0
57
- notifier_threads = 1
54
+ utility_threads = GoodJob::SharedExecutor::MAX_THREADS
58
55
  scheduler_threads = GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats[:max_threads] }
59
56
 
60
- good_job_threads = cron_threads + notifier_threads + scheduler_threads
57
+ good_job_threads = utility_threads + scheduler_threads
61
58
  puma_threads = (Puma::Server.current&.max_threads if defined?(Puma::Server)) || 0
62
59
 
63
60
  total_threads = good_job_threads + puma_threads
@@ -31,7 +31,8 @@ module GoodJob # :nodoc:
31
31
 
32
32
  # @param cron_entries [Array<CronEntry>]
33
33
  # @param start_on_initialize [Boolean]
34
- def initialize(cron_entries = [], start_on_initialize: false)
34
+ def initialize(cron_entries = [], start_on_initialize: false, executor: Concurrent.global_io_executor)
35
+ @executor = executor
35
36
  @running = false
36
37
  @cron_entries = cron_entries
37
38
  @tasks = Concurrent::Hash.new
@@ -84,7 +85,7 @@ module GoodJob # :nodoc:
84
85
  def create_task(cron_entry)
85
86
  cron_at = cron_entry.next_at
86
87
  delay = [(cron_at - Time.current).to_f, 0].max
87
- future = Concurrent::ScheduledTask.new(delay, args: [self, cron_entry, cron_at]) do |thr_scheduler, thr_cron_entry, thr_cron_at|
88
+ future = Concurrent::ScheduledTask.new(delay, args: [self, cron_entry, cron_at], executor: @executor) do |thr_scheduler, thr_cron_entry, thr_cron_at|
88
89
  # Re-schedule the next cron task before executing the current task
89
90
  thr_scheduler.create_task(thr_cron_entry)
90
91
 
@@ -20,16 +20,6 @@ module GoodJob # :nodoc:
20
20
 
21
21
  # Default Postgres channel for LISTEN/NOTIFY
22
22
  CHANNEL = 'good_job'
23
- # Defaults for instance of Concurrent::ThreadPoolExecutor
24
- EXECUTOR_OPTIONS = {
25
- name: name,
26
- min_threads: 0,
27
- max_threads: 1,
28
- auto_terminate: true,
29
- idletime: 60,
30
- max_queue: 1,
31
- fallback_policy: :discard,
32
- }.freeze
33
23
  # Seconds to block while LISTENing for a message
34
24
  WAIT_INTERVAL = 1
35
25
  # Seconds to wait if database cannot be connected to
@@ -70,19 +60,30 @@ module GoodJob # :nodoc:
70
60
 
71
61
  # @param recipients [Array<#call, Array(Object, Symbol)>]
72
62
  # @param enable_listening [true, false]
73
- def initialize(*recipients, enable_listening: true)
63
+ # @param executor [Concurrent::ExecutorService]
64
+ def initialize(*recipients, enable_listening: true, executor: Concurrent.global_io_executor)
74
65
  @recipients = Concurrent::Array.new(recipients)
66
+ @enable_listening = enable_listening
67
+ @executor = executor
68
+
69
+ @mutex = Mutex.new
70
+ @shutdown_event = Concurrent::Event.new.tap(&:set)
71
+ @running = Concurrent::AtomicBoolean.new(false)
75
72
  @connected = Concurrent::AtomicBoolean.new(false)
76
73
  @listening = Concurrent::AtomicBoolean.new(false)
77
74
  @connection_errors_count = Concurrent::AtomicFixnum.new(0)
78
75
  @connection_errors_reported = Concurrent::AtomicBoolean.new(false)
79
76
  @enable_listening = enable_listening
77
+ @task = nil
80
78
 
81
- create_executor
82
- listen
79
+ start
83
80
  self.class.instances << self
84
81
  end
85
82
 
83
+ def running?
84
+ @executor.running? && @running.true?
85
+ end
86
+
86
87
  # Tests whether the notifier is active and has acquired a dedicated database connection.
87
88
  # @return [true, false, nil]
88
89
  def connected?
@@ -95,15 +96,9 @@ module GoodJob # :nodoc:
95
96
  @listening.true?
96
97
  end
97
98
 
98
- # Tests whether the notifier is running.
99
- # @!method running?
100
- # @return [true, false, nil]
101
- delegate :running?, to: :executor, allow_nil: true
102
-
103
- # Tests whether the scheduler is shutdown.
104
- # @!method shutdown?
105
- # @return [true, false, nil]
106
- delegate :shutdown?, to: :executor, allow_nil: true
99
+ def shutdown?
100
+ @shutdown_event.set?
101
+ end
107
102
 
108
103
  # Shut down the notifier.
109
104
  # This stops the background LISTENing thread.
@@ -111,17 +106,21 @@ module GoodJob # :nodoc:
111
106
  # @param timeout [Numeric, nil] Seconds to wait for active threads.
112
107
  # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
113
108
  # * +-1+, the scheduler will wait until the shutdown is complete.
114
- # * +0+, the scheduler will immediately shutdown and stop any threads.
115
109
  # * A positive number will wait that many seconds before stopping any remaining active threads.
116
110
  # @return [void]
117
111
  def shutdown(timeout: -1)
118
- return if executor.nil? || executor.shutdown?
119
-
120
- executor.shutdown if executor.running?
121
-
122
- if executor.shuttingdown? && timeout # rubocop:disable Style/GuardClause
123
- executor_wait = timeout.negative? ? nil : timeout
124
- executor.kill unless executor.wait_for_termination(executor_wait)
112
+ synchronize do
113
+ @running.make_false
114
+
115
+ if @executor.shutdown? || @task&.complete?
116
+ # clean up in the even the executor is killed
117
+ @connected.make_false
118
+ @listening.make_false
119
+ @shutdown_event.set
120
+ else
121
+ @shutdown_event.wait(timeout == -1 ? nil : timeout) unless timeout.nil?
122
+ end
123
+ @shutdown_event.set?
125
124
  end
126
125
  end
127
126
 
@@ -130,9 +129,10 @@ module GoodJob # :nodoc:
130
129
  # @param timeout [nil, Numeric] Seconds to wait; shares same values as {#shutdown}.
131
130
  # @return [void]
132
131
  def restart(timeout: -1)
133
- shutdown(timeout: timeout) if running?
134
- create_executor
135
- listen
132
+ synchronize do
133
+ shutdown(timeout: timeout) unless @shutdown_event.set?
134
+ start
135
+ end
136
136
  end
137
137
 
138
138
  # Invoked on completion of ThreadPoolExecutor task
@@ -160,21 +160,27 @@ module GoodJob # :nodoc:
160
160
  end
161
161
  end
162
162
 
163
- return if shutdown?
164
-
165
- listen(delay: connection_error ? RECONNECT_INTERVAL : 0)
163
+ if @running.true?
164
+ create_listen_task(delay: connection_error ? RECONNECT_INTERVAL : 0)
165
+ else
166
+ @shutdown_event.set
167
+ end
166
168
  end
167
169
 
168
170
  private
169
171
 
170
- attr_reader :executor
172
+ def start
173
+ synchronize do
174
+ return if @running.true?
171
175
 
172
- def create_executor
173
- @executor = Concurrent::ThreadPoolExecutor.new(EXECUTOR_OPTIONS)
176
+ @running.make_true
177
+ @shutdown_event.reset
178
+ create_listen_task(delay: 0)
179
+ end
174
180
  end
175
181
 
176
- def listen(delay: 0)
177
- future = Concurrent::ScheduledTask.new(delay, args: [@recipients, executor, @enable_listening, @listening], executor: @executor) do |thr_recipients, thr_executor, thr_enable_listening, thr_listening|
182
+ def create_listen_task(delay: 0)
183
+ @task = Concurrent::ScheduledTask.new(delay, args: [@recipients, @running, @executor, @enable_listening, @listening], executor: @executor) do |thr_recipients, thr_running, thr_executor, thr_enable_listening, thr_listening|
178
184
  with_connection do
179
185
  begin
180
186
  Rails.application.executor.wrap do
@@ -188,7 +194,7 @@ module GoodJob # :nodoc:
188
194
  end
189
195
  end
190
196
 
191
- while thr_executor.running?
197
+ while thr_executor.running? && thr_running.true?
192
198
  run_callbacks :tick do
193
199
  wait_for_notify do |channel, payload|
194
200
  next unless channel == CHANNEL
@@ -219,8 +225,9 @@ module GoodJob # :nodoc:
219
225
  end
220
226
  end
221
227
 
222
- future.add_observer(self, :listen_observer)
223
- future.execute
228
+ @task.add_observer(self, :listen_observer)
229
+ @task.execute
230
+ @task
224
231
  end
225
232
 
226
233
  def with_connection
@@ -263,5 +270,13 @@ module GoodJob # :nodoc:
263
270
  @connection_errors_count.value = 0
264
271
  @connection_errors_reported.make_false
265
272
  end
273
+
274
+ def synchronize(&block)
275
+ if @mutex.owned?
276
+ yield
277
+ else
278
+ @mutex.synchronize(&block)
279
+ end
280
+ end
266
281
  end
267
282
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ class SharedExecutor
5
+ MAX_THREADS = 2
6
+
7
+ # @!attribute [r] instances
8
+ # @!scope class
9
+ # List of all instantiated SharedExecutor in the current process.
10
+ # @return [Array<GoodJob::SharedExecutor>, nil]
11
+ cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
12
+
13
+ attr_reader :executor
14
+
15
+ def initialize
16
+ self.class.instances << self
17
+ create_executor
18
+ end
19
+
20
+ def running?
21
+ @executor&.running?
22
+ end
23
+
24
+ def shutdown?
25
+ if @executor
26
+ @executor.shutdown?
27
+ else
28
+ true
29
+ end
30
+ end
31
+
32
+ # Shut down the SharedExecutor.
33
+ # Use {#shutdown?} to determine whether threads have stopped.
34
+ # @param timeout [Numeric, nil] Seconds to wait for active threads.
35
+ # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
36
+ # * +-1+, the scheduler will wait until the shutdown is complete.
37
+ # * +0+, the scheduler will immediately shutdown and stop any threads.
38
+ # * A positive number will wait that many seconds before stopping any remaining active threads.
39
+ # @return [void]
40
+ def shutdown(timeout: -1)
41
+ return if @executor.nil? || @executor.shutdown?
42
+
43
+ @executor.shutdown if @executor.running?
44
+
45
+ if @executor.shuttingdown? && timeout # rubocop:disable Style/GuardClause
46
+ executor_wait = timeout.negative? ? nil : timeout
47
+ @executor.kill unless @executor.wait_for_termination(executor_wait)
48
+ end
49
+ end
50
+
51
+ def restart(timeout: -1)
52
+ shutdown(timeout: timeout) if running?
53
+ create_executor
54
+ end
55
+
56
+ private
57
+
58
+ def create_executor
59
+ @executor = Concurrent::ThreadPoolExecutor.new(
60
+ min_threads: 0,
61
+ max_threads: MAX_THREADS,
62
+ auto_terminate: true,
63
+ idletime: 60,
64
+ max_queue: 0,
65
+ fallback_policy: :discard
66
+ )
67
+ end
68
+ end
69
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  # GoodJob gem version.
5
- VERSION = '3.16.2'
5
+ VERSION = '3.16.4'
6
6
 
7
7
  # GoodJob version as Gem::Version object
8
8
  GEM_VERSION = Gem::Version.new(VERSION)
data/lib/good_job.rb CHANGED
@@ -31,6 +31,7 @@ require "good_job/notifier"
31
31
  require "good_job/poller"
32
32
  require "good_job/probe_server"
33
33
  require "good_job/scheduler"
34
+ require "good_job/shared_executor"
34
35
 
35
36
  # GoodJob is a multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.
36
37
  #
@@ -246,5 +247,17 @@ module GoodJob
246
247
  end
247
248
  end
248
249
 
250
+ include ActiveSupport::Deprecation::DeprecatedConstantAccessor
251
+ deprecate_constant :Lockable, 'GoodJob::AdvisoryLockable', deprecator: deprecator
252
+
253
+ # Whether all GoodJob migrations have been applied.
254
+ # For use in tests/CI to validate GoodJob is up-to-date.
255
+ # @return [Boolean]
256
+ def self.migrated?
257
+ # Always update with the most recent migration check
258
+ GoodJob::Execution.reset_column_information
259
+ GoodJob::Execution.error_event_migrated?
260
+ end
261
+
249
262
  ActiveSupport.run_load_hooks(:good_job, self)
250
263
  end
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.16.2
4
+ version: 3.16.4
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-07-13 00:00:00.000000000 Z
11
+ date: 2023-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -343,9 +343,9 @@ files:
343
343
  - app/frontend/good_job/vendor/rails_ujs.js
344
344
  - app/frontend/good_job/vendor/stimulus.js
345
345
  - app/helpers/good_job/application_helper.rb
346
+ - app/models/concerns/good_job/advisory_lockable.rb
346
347
  - app/models/concerns/good_job/error_events.rb
347
348
  - app/models/concerns/good_job/filterable.rb
348
- - app/models/concerns/good_job/lockable.rb
349
349
  - app/models/concerns/good_job/reportable.rb
350
350
  - app/models/good_job/active_record_parent_class.rb
351
351
  - app/models/good_job/base_execution.rb
@@ -437,6 +437,7 @@ files:
437
437
  - lib/good_job/poller.rb
438
438
  - lib/good_job/probe_server.rb
439
439
  - lib/good_job/scheduler.rb
440
+ - lib/good_job/shared_executor.rb
440
441
  - lib/good_job/version.rb
441
442
  homepage: https://github.com/bensheldon/good_job
442
443
  licenses: