good_job 3.16.3 → 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: 3929d3de9d0c69de6aa34b238573dd19eabb0b6753b28a06b186cfc6972b621b
4
- data.tar.gz: d927b1cc39f993f64a4b07594cb7215975e02d55cb212b420122b2c672d5f2d4
3
+ metadata.gz: c4853b6c3598f46587b44f56dc6c59f4694753184da2d75d84cc87b8e901a3c3
4
+ data.tar.gz: 8086b714bc3a69a9260c36ef2b8295fc86ed6797eace2d8d8a4724ba509a7e65
5
5
  SHA512:
6
- metadata.gz: 34ac854233932322ad76eaea4d41879b9d97c08a204b6f689affedbe861b86bc5c83b6c02dd1132c267f7f9129d4e0ace042e9299f4fe522fdf9d590b6c0cf33
7
- data.tar.gz: a8007ea55894eb72494935dff5269d1e54d157eab334b823769884cc3d83d1a37be3d9cb44e603ba5a132cb3993ba8dd8e306c4d3974f9a2bf4dc2603add5706
6
+ metadata.gz: 5962d1992f8e283a6ca8349c1a9cf7d986bce54a2dc5f0abc09c1940ec97c7bc24af022eaa8e5979bcde395a4876741f34065cc24dfd1c88cb08a2a97baae77d
7
+ data.tar.gz: a1407ceaa0930ecf591fbf330e310124f6aaf9fd5ffc05aa579ae6176b8346dc004ba2ce2dc8d15b57efe9c1b07e68a248408cf0bec501bf5f84e586903d5c73
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
3
28
  ## [v3.16.3](https://github.com/bensheldon/good_job/tree/v3.16.3) (2023-07-18)
4
29
 
5
30
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.16.2...v3.16.3)
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
@@ -954,24 +954,50 @@ Keep in mind, queue operations and management is an advanced discipline. This st
954
954
 
955
955
  ### Database connections
956
956
 
957
- 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
958
979
 
959
980
  ```yaml
960
981
  # config/database.yml
961
- 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 %>
962
984
  ```
963
985
 
964
- 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.
965
987
 
966
- - 1 connection dedicated to the scheduler aka `LISTEN/NOTIFY`
967
- - 1 connection per query pool thread e.g. `--queues=mice:2;elephants:1` is 3 threads. Pool thread size defaults to `--max-threads`
968
- - (optional) 2 connections for Cron scheduler if you're running it
969
- - (optional) 1 connection per subthread, if your application makes multithreaded database queries within a job
970
- - 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
+ ```
971
993
 
972
- 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
+ ```
973
999
 
974
- #### Production setup
1000
+ ### Production setup
975
1001
 
976
1002
  When running GoodJob in a production environment, you should be mindful of:
977
1003
 
@@ -986,7 +1012,7 @@ The recommended way to monitor the queue in production is:
986
1012
  - keep an eye on the number of jobs in the queue (abnormal high number of unscheduled jobs means the queue could be underperforming)
987
1013
  - consider performance monitoring services which support the built-in Rails instrumentation (eg. Sentry, Skylight, etc.)
988
1014
 
989
- #### Queue performance with Queue Select Limit
1015
+ ### Queue performance with Queue Select Limit
990
1016
 
991
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.
992
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
@@ -4,9 +4,9 @@ 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
8
9
  include Filterable
9
- include Lockable
10
10
  include Reportable
11
11
 
12
12
  self.table_name = 'good_jobs'
@@ -60,5 +60,28 @@ module GoodJob
60
60
  def discrete?
61
61
  self.class.discrete_support? && is_discrete?
62
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
63
86
  end
64
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
 
@@ -508,19 +508,6 @@ module GoodJob
508
508
  self.scheduled_at ||= current_time
509
509
  end
510
510
 
511
- # Build an ActiveJob instance and deserialize the arguments, using `#active_job_data`.
512
- #
513
- # @param ignore_deserialization_errors [Boolean]
514
- # Whether to ignore ActiveJob::DeserializationError when deserializing the arguments.
515
- # This is most useful if you aren't planning to use the arguments directly.
516
- def active_job(ignore_deserialization_errors: false)
517
- ActiveJob::Base.deserialize(active_job_data).tap do |aj|
518
- aj.send(:deserialize_arguments_if_needed)
519
- rescue ActiveJob::DeserializationError
520
- raise unless ignore_deserialization_errors
521
- end
522
- end
523
-
524
511
  # Return formatted serialized_params for display in the dashboard
525
512
  # @return [Hash]
526
513
  def display_serialized_params
@@ -565,14 +552,6 @@ module GoodJob
565
552
 
566
553
  private
567
554
 
568
- def active_job_data
569
- serialized_params.deep_dup
570
- .tap do |job_data|
571
- job_data["provider_job_id"] = id
572
- job_data["good_job_concurrency_key"] = concurrency_key if concurrency_key
573
- end
574
- end
575
-
576
555
  def reset_batch_values(&block)
577
556
  GoodJob::Batch.within_thread(batch_id: nil, batch_callback_id: nil, &block)
578
557
  end
@@ -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
 
@@ -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.3'
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,6 +247,9 @@ module GoodJob
246
247
  end
247
248
  end
248
249
 
250
+ include ActiveSupport::Deprecation::DeprecatedConstantAccessor
251
+ deprecate_constant :Lockable, 'GoodJob::AdvisoryLockable', deprecator: deprecator
252
+
249
253
  # Whether all GoodJob migrations have been applied.
250
254
  # For use in tests/CI to validate GoodJob is up-to-date.
251
255
  # @return [Boolean]
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.3
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-18 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: