good_job 3.16.2 → 3.16.4

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: 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: