good_job 1.6.0 → 1.9.1

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.
data/lib/good_job/cli.rb CHANGED
@@ -15,9 +15,21 @@ module GoodJob
15
15
  # Requiring this loads the application's configuration and classes.
16
16
  RAILS_ENVIRONMENT_RB = File.expand_path("config/environment.rb")
17
17
 
18
- # @!visibility private
19
- def self.exit_on_failure?
20
- true
18
+ class << self
19
+ # Whether the CLI is running from the executable
20
+ # @return [Boolean, nil]
21
+ attr_accessor :within_exe
22
+ alias within_exe? within_exe
23
+
24
+ # Whether to log to STDOUT
25
+ # @return [Boolean, nil]
26
+ attr_accessor :log_to_stdout
27
+ alias log_to_stdout? log_to_stdout
28
+
29
+ # @!visibility private
30
+ def exit_on_failure?
31
+ true
32
+ end
21
33
  end
22
34
 
23
35
  # @!macro thor.desc
@@ -49,6 +61,14 @@ module GoodJob
49
61
  type: :numeric,
50
62
  banner: 'SECONDS',
51
63
  desc: "Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 5)"
64
+ method_option :max_cache,
65
+ type: :numeric,
66
+ banner: 'COUNT',
67
+ desc: "Maximum number of scheduled jobs to cache in memory (env var: GOOD_JOB_MAX_CACHE, default: 10000)"
68
+ method_option :shutdown_timeout,
69
+ type: :numeric,
70
+ banner: 'SECONDS',
71
+ desc: "Number of seconds to wait for jobs to finish when shutting down before stopping the thread. (env var: GOOD_JOB_SHUTDOWN_TIMEOUT, default: -1 (forever))"
52
72
  method_option :daemonize,
53
73
  type: :boolean,
54
74
  desc: "Run as a background daemon (default: false)"
@@ -77,9 +97,8 @@ module GoodJob
77
97
  break if @stop_good_job_executable || scheduler.shutdown? || notifier.shutdown?
78
98
  end
79
99
 
80
- notifier.shutdown
81
- poller.shutdown
82
- scheduler.shutdown
100
+ executors = [notifier, poller, scheduler]
101
+ GoodJob._shutdown_all(executors, timeout: configuration.shutdown_timeout)
83
102
  end
84
103
 
85
104
  default_task :start
@@ -129,7 +148,7 @@ module GoodJob
129
148
  # Rails or from the application can be set up here.
130
149
  def set_up_application!
131
150
  require RAILS_ENVIRONMENT_RB
132
- return unless defined?(GOOD_JOB_LOG_TO_STDOUT) && GOOD_JOB_LOG_TO_STDOUT && !ActiveSupport::Logger.logger_outputs_to?(GoodJob.logger, $stdout)
151
+ return unless GoodJob::CLI.log_to_stdout? && !ActiveSupport::Logger.logger_outputs_to?(GoodJob.logger, $stdout)
133
152
 
134
153
  GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
135
154
  GoodJob::LogSubscriber.reset_logger
@@ -5,12 +5,18 @@ module GoodJob
5
5
  # set options to get the final values for each option.
6
6
  #
7
7
  class Configuration
8
+ # Valid execution modes.
9
+ EXECUTION_MODES = [:async, :async_server, :external, :inline].freeze
8
10
  # Default number of threads to use per {Scheduler}
9
11
  DEFAULT_MAX_THREADS = 5
10
12
  # Default number of seconds between polls for jobs
11
- DEFAULT_POLL_INTERVAL = 5
13
+ DEFAULT_POLL_INTERVAL = 10
14
+ # Default number of threads to use per {Scheduler}
15
+ DEFAULT_MAX_CACHE = 10000
12
16
  # Default number of seconds to preserve jobs for {CLI#cleanup_preserved_jobs}
13
17
  DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO = 24 * 60 * 60
18
+ # Default to always wait for jobs to finish for {Adapter#shutdown}
19
+ DEFAULT_SHUTDOWN_TIMEOUT = -1
14
20
 
15
21
  # The options that were explicitly set when initializing +Configuration+.
16
22
  # @return [Hash]
@@ -31,38 +37,30 @@ module GoodJob
31
37
  @env = env
32
38
  end
33
39
 
40
+ def validate!
41
+ raise ArgumentError, "GoodJob execution mode must be one of #{EXECUTION_MODES.join(', ')}. It was '#{execution_mode}' which is not valid." unless execution_mode.in?(EXECUTION_MODES)
42
+ end
43
+
34
44
  # Specifies how and where jobs should be executed. See {Adapter#initialize}
35
45
  # for more details on possible values.
36
- #
37
- # When running inside a Rails app, you may want to use
38
- # {#rails_execution_mode}, which takes the current Rails environment into
39
- # account when determining the final value.
40
- #
41
- # @param default [Symbol]
42
- # Value to use if none was specified in the configuration.
43
46
  # @return [Symbol]
44
- def execution_mode(default: :external)
45
- if options[:execution_mode]
46
- options[:execution_mode]
47
- elsif rails_config[:execution_mode]
48
- rails_config[:execution_mode]
49
- elsif env['GOOD_JOB_EXECUTION_MODE'].present?
50
- env['GOOD_JOB_EXECUTION_MODE'].to_sym
51
- else
52
- default
53
- end
54
- end
47
+ def execution_mode
48
+ @_execution_mode ||= begin
49
+ mode = if GoodJob::CLI.within_exe?
50
+ :external
51
+ else
52
+ options[:execution_mode] ||
53
+ rails_config[:execution_mode] ||
54
+ env['GOOD_JOB_EXECUTION_MODE']
55
+ end
55
56
 
56
- # Like {#execution_mode}, but takes the current Rails environment into
57
- # account (e.g. in the +test+ environment, it falls back to +:inline+).
58
- # @return [Symbol]
59
- def rails_execution_mode
60
- if execution_mode(default: nil)
61
- execution_mode
62
- elsif Rails.env.development? || Rails.env.test?
63
- :inline
64
- else
65
- :external
57
+ if mode
58
+ mode.to_sym
59
+ elsif Rails.env.development? || Rails.env.test?
60
+ :inline
61
+ else
62
+ :external
63
+ end
66
64
  end
67
65
  end
68
66
 
@@ -73,10 +71,10 @@ module GoodJob
73
71
  def max_threads
74
72
  (
75
73
  options[:max_threads] ||
76
- rails_config[:max_threads] ||
77
- env['GOOD_JOB_MAX_THREADS'] ||
78
- env['RAILS_MAX_THREADS'] ||
79
- DEFAULT_MAX_THREADS
74
+ rails_config[:max_threads] ||
75
+ env['GOOD_JOB_MAX_THREADS'] ||
76
+ env['RAILS_MAX_THREADS'] ||
77
+ DEFAULT_MAX_THREADS
80
78
  ).to_i
81
79
  end
82
80
 
@@ -99,21 +97,46 @@ module GoodJob
99
97
  def poll_interval
100
98
  (
101
99
  options[:poll_interval] ||
102
- rails_config[:poll_interval] ||
103
- env['GOOD_JOB_POLL_INTERVAL'] ||
104
- DEFAULT_POLL_INTERVAL
100
+ rails_config[:poll_interval] ||
101
+ env['GOOD_JOB_POLL_INTERVAL'] ||
102
+ DEFAULT_POLL_INTERVAL
103
+ ).to_i
104
+ end
105
+
106
+ # The maximum number of future-scheduled jobs to store in memory.
107
+ # Storing future-scheduled jobs in memory reduces execution latency
108
+ # at the cost of increased memory usage. 10,000 stored jobs = ~20MB.
109
+ # @return [Integer]
110
+ def max_cache
111
+ (
112
+ options[:max_cache] ||
113
+ rails_config[:max_cache] ||
114
+ env['GOOD_JOB_MAX_CACHE'] ||
115
+ DEFAULT_MAX_CACHE
105
116
  ).to_i
106
117
  end
107
118
 
119
+ # The number of seconds to wait for jobs to finish when shutting down
120
+ # before stopping the thread. +-1+ is forever.
121
+ # @return [Numeric]
122
+ def shutdown_timeout
123
+ (
124
+ options[:shutdown_timeout] ||
125
+ rails_config[:shutdown_timeout] ||
126
+ env['GOOD_JOB_SHUTDOWN_TIMEOUT'] ||
127
+ DEFAULT_SHUTDOWN_TIMEOUT
128
+ ).to_f
129
+ end
130
+
108
131
  # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
109
132
  # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
110
133
  # @return [Integer]
111
134
  def cleanup_preserved_jobs_before_seconds_ago
112
135
  (
113
136
  options[:before_seconds_ago] ||
114
- rails_config[:cleanup_preserved_jobs_before_seconds_ago] ||
115
- env['GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO'] ||
116
- DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO
137
+ rails_config[:cleanup_preserved_jobs_before_seconds_ago] ||
138
+ env['GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO'] ||
139
+ DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO
117
140
  ).to_i
118
141
  end
119
142
 
data/lib/good_job/job.rb CHANGED
@@ -2,7 +2,7 @@ module GoodJob
2
2
  #
3
3
  # Represents a request to perform an +ActiveJob+ job.
4
4
  #
5
- class Job < ActiveRecord::Base
5
+ class Job < Object.const_get(GoodJob.active_record_parent_class)
6
6
  include Lockable
7
7
 
8
8
  # Raised if something attempts to execute a previously completed Job again.
@@ -15,6 +15,8 @@ module GoodJob
15
15
 
16
16
  self.table_name = 'good_jobs'.freeze
17
17
 
18
+ attr_readonly :serialized_params
19
+
18
20
  # Parse a string representing a group of queues into a more readable data
19
21
  # structure.
20
22
  # @return [Hash]
@@ -72,6 +74,12 @@ module GoodJob
72
74
  # @return [ActiveRecord::Relation]
73
75
  scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
74
76
 
77
+ # Order jobs by scheduled (unscheduled or soonest first).
78
+ # @!method schedule_ordered
79
+ # @!scope class
80
+ # @return [ActiveRecord::Relation]
81
+ scope :schedule_ordered, -> { order(Arel.sql('COALESCE(scheduled_at, created_at) ASC')) }
82
+
75
83
  # Get Jobs were completed before the given timestamp. If no timestamp is
76
84
  # provided, get all jobs that have been completed. By default, GoodJob
77
85
  # deletes jobs after they are completed and this will find no jobs.
@@ -147,6 +155,23 @@ module GoodJob
147
155
  [good_job, result, error] if good_job
148
156
  end
149
157
 
158
+ # Fetches the scheduled execution time of the next eligible Job(s).
159
+ # @return [Array<(DateTime)>]
160
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
161
+ query = advisory_unlocked.unfinished.schedule_ordered
162
+
163
+ after ||= Time.current
164
+ after_query = query.where('scheduled_at > ?', after).or query.where(scheduled_at: nil).where('created_at > ?', after)
165
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
166
+
167
+ if now_limit&.positive?
168
+ now_query = query.where('scheduled_at < ?', Time.current).or query.where(scheduled_at: nil)
169
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
170
+ end
171
+
172
+ Array(now_at) + after_at
173
+ end
174
+
150
175
  # Places an ActiveJob job on a queue by creating a new {Job} record.
151
176
  # @param active_job [ActiveJob::Base]
152
177
  # The job to enqueue.
@@ -39,6 +39,8 @@ module GoodJob
39
39
  # @return [Boolean] whether the performer's {#next} method should be
40
40
  # called in the current state.
41
41
  def next?(state = {})
42
+ return true unless state[:queue_name]
43
+
42
44
  if parsed_queues[:exclude]
43
45
  parsed_queues[:exclude].exclude?(state[:queue_name])
44
46
  elsif parsed_queues[:include]
@@ -48,6 +50,15 @@ module GoodJob
48
50
  end
49
51
  end
50
52
 
53
+ # The Returns timestamps of when next tasks may be available.
54
+ # @param after [DateTime, Time, nil] future jobs scheduled after this time
55
+ # @param limit [Integer] number of future timestamps to return
56
+ # @param now_limit [Integer] number of past timestamps to return
57
+ # @return [Array<(Time, DateTime)>, nil]
58
+ def next_at(after: nil, limit: nil, now_limit: nil)
59
+ job_query.next_scheduled_at(after: after, limit: limit, now_limit: now_limit)
60
+ end
61
+
51
62
  private
52
63
 
53
64
  attr_reader :queue_string
@@ -143,7 +143,7 @@ module GoodJob
143
143
  def supports_cte_materialization_specifiers?
144
144
  return @_supports_cte_materialization_specifiers if defined?(@_supports_cte_materialization_specifiers)
145
145
 
146
- @_supports_cte_materialization_specifiers = ActiveRecord::Base.connection.postgresql_version >= 120000
146
+ @_supports_cte_materialization_specifiers = connection.postgresql_version >= 120000
147
147
  end
148
148
  end
149
149
 
@@ -158,7 +158,7 @@ module GoodJob
158
158
  WHERE pg_try_advisory_lock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
159
159
  SQL
160
160
  binds = [[nil, self.class.table_name], [nil, send(self.class.primary_key)]]
161
- ActiveRecord::Base.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).any?
161
+ self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).any?
162
162
  end
163
163
 
164
164
  # Releases an advisory lock on this record if it is locked by this database
@@ -1,16 +1,16 @@
1
1
  module GoodJob
2
2
  # Delegates the interface of a single {Scheduler} to multiple Schedulers.
3
3
  class MultiScheduler
4
- # @return [array<Scheduler>] List of the scheduler delegates
4
+ # @return [Array<Scheduler>] List of the scheduler delegates
5
5
  attr_reader :schedulers
6
6
 
7
7
  def initialize(schedulers)
8
8
  @schedulers = schedulers
9
9
  end
10
10
 
11
- # Delegates to {Scheduler#shutdown}.
12
- def shutdown(wait: true)
13
- schedulers.each { |s| s.shutdown(wait: wait) }
11
+ # Delegates to {Scheduler#running?}.
12
+ def running?
13
+ schedulers.all?(&:running?)
14
14
  end
15
15
 
16
16
  # Delegates to {Scheduler#shutdown?}.
@@ -18,9 +18,14 @@ module GoodJob
18
18
  schedulers.all?(&:shutdown?)
19
19
  end
20
20
 
21
+ # Delegates to {Scheduler#shutdown}.
22
+ def shutdown(timeout: -1)
23
+ GoodJob._shutdown_all(schedulers, timeout: timeout)
24
+ end
25
+
21
26
  # Delegates to {Scheduler#restart}.
22
- def restart(wait: true)
23
- schedulers.each { |s| s.restart(wait: wait) }
27
+ def restart(timeout: -1)
28
+ GoodJob._shutdown_all(schedulers, :restart, timeout: timeout)
24
29
  end
25
30
 
26
31
  # Delegates to {Scheduler#create_thread}.
@@ -39,7 +44,7 @@ module GoodJob
39
44
 
40
45
  if results.any?
41
46
  true
42
- elsif results.any? { |result| result == false }
47
+ elsif results.any?(false)
43
48
  false
44
49
  else # rubocop:disable Style/EmptyElse
45
50
  nil
@@ -15,7 +15,7 @@ module GoodJob # :nodoc:
15
15
  # Default Postgres channel for LISTEN/NOTIFY
16
16
  CHANNEL = 'good_job'.freeze
17
17
  # Defaults for instance of Concurrent::ThreadPoolExecutor
18
- POOL_OPTIONS = {
18
+ EXECUTOR_OPTIONS = {
19
19
  name: name,
20
20
  min_threads: 0,
21
21
  max_threads: 1,
@@ -30,13 +30,13 @@ module GoodJob # :nodoc:
30
30
  # @!attribute [r] instances
31
31
  # @!scope class
32
32
  # List of all instantiated Notifiers in the current process.
33
- # @return [array<GoodJob:Adapter>]
33
+ # @return [Array<GoodJob::Adapter>]
34
34
  cattr_reader :instances, default: [], instance_reader: false
35
35
 
36
36
  # Send a message via Postgres NOTIFY
37
37
  # @param message [#to_json]
38
38
  def self.notify(message)
39
- connection = ActiveRecord::Base.connection
39
+ connection = Job.connection
40
40
  connection.exec_query <<~SQL.squish
41
41
  NOTIFY #{CHANNEL}, #{connection.quote(message.to_json)}
42
42
  SQL
@@ -53,7 +53,7 @@ module GoodJob # :nodoc:
53
53
 
54
54
  self.class.instances << self
55
55
 
56
- create_pool
56
+ create_executor
57
57
  listen
58
58
  end
59
59
 
@@ -63,34 +63,43 @@ module GoodJob # :nodoc:
63
63
  @listening.true?
64
64
  end
65
65
 
66
- # Restart the notifier.
67
- # When shutdown, start; or shutdown and start.
68
- # @param wait [Boolean] Wait for background thread to finish
69
- # @return [void]
70
- def restart(wait: true)
71
- shutdown(wait: wait)
72
- create_pool
73
- listen
74
- end
66
+ # Tests whether the notifier is running.
67
+ # @return [true, false, nil]
68
+ delegate :running?, to: :executor, allow_nil: true
69
+
70
+ # Tests whether the scheduler is shutdown.
71
+ # @return [true, false, nil]
72
+ delegate :shutdown?, to: :executor, allow_nil: true
75
73
 
76
74
  # Shut down the notifier.
77
75
  # This stops the background LISTENing thread.
78
- # If +wait+ is +true+, the notifier will wait for background thread to shutdown.
79
- # If +wait+ is +false+, this method will return immediately even though threads may still be running.
80
76
  # Use {#shutdown?} to determine whether threads have stopped.
81
- # @param wait [Boolean] Wait for actively executing threads to finish
77
+ # @param timeout [nil, Numeric] Seconds to wait for active threads.
78
+ #
79
+ # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
80
+ # * +-1+, the scheduler will wait until the shutdown is complete.
81
+ # * +0+, the scheduler will immediately shutdown and stop any threads.
82
+ # * A positive number will wait that many seconds before stopping any remaining active threads.
82
83
  # @return [void]
83
- def shutdown(wait: true)
84
- return unless @pool.running?
84
+ def shutdown(timeout: -1)
85
+ return if executor.nil? || executor.shutdown?
85
86
 
86
- @pool.shutdown
87
- @pool.wait_for_termination if wait
87
+ executor.shutdown if executor.running?
88
+
89
+ if executor.shuttingdown? && timeout # rubocop:disable Style/GuardClause
90
+ executor_wait = timeout.negative? ? nil : timeout
91
+ executor.kill unless executor.wait_for_termination(executor_wait)
92
+ end
88
93
  end
89
94
 
90
- # Tests whether the notifier is shutdown.
91
- # @return [true, false, nil]
92
- def shutdown?
93
- !@pool.running?
95
+ # Restart the notifier.
96
+ # When shutdown, start; or shutdown and start.
97
+ # @param timeout [nil, Numeric] Seconds to wait; shares same values as {#shutdown}.
98
+ # @return [void]
99
+ def restart(timeout: -1)
100
+ shutdown(timeout: timeout) if running?
101
+ create_executor
102
+ listen
94
103
  end
95
104
 
96
105
  # Invoked on completion of ThreadPoolExecutor task
@@ -109,36 +118,36 @@ module GoodJob # :nodoc:
109
118
 
110
119
  private
111
120
 
112
- def create_pool
113
- @pool = Concurrent::ThreadPoolExecutor.new(POOL_OPTIONS)
121
+ attr_reader :executor
122
+
123
+ def create_executor
124
+ @executor = Concurrent::ThreadPoolExecutor.new(EXECUTOR_OPTIONS)
114
125
  end
115
126
 
116
127
  def listen
117
- future = Concurrent::Future.new(args: [@recipients, @pool, @listening], executor: @pool) do |recipients, pool, listening|
128
+ future = Concurrent::Future.new(args: [@recipients, executor, @listening], executor: @executor) do |thr_recipients, thr_executor, thr_listening|
118
129
  with_listen_connection do |conn|
119
130
  ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
120
131
  conn.async_exec("LISTEN #{CHANNEL}").clear
121
132
  end
122
133
 
123
134
  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
124
- while pool.running?
125
- listening.make_true
135
+ thr_listening.make_true
136
+ while thr_executor.running?
126
137
  conn.wait_for_notify(WAIT_INTERVAL) do |channel, _pid, payload|
127
- listening.make_false
128
138
  next unless channel == CHANNEL
129
139
 
130
140
  ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
131
141
  parsed_payload = JSON.parse(payload, symbolize_names: true)
132
- recipients.each do |recipient|
142
+ thr_recipients.each do |recipient|
133
143
  target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
134
144
  target.send(method_name, parsed_payload)
135
145
  end
136
146
  end
137
- listening.make_false
138
147
  end
139
148
  end
140
149
  ensure
141
- listening.make_false
150
+ thr_listening.make_false
142
151
  ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
143
152
  conn.async_exec("UNLISTEN *").clear
144
153
  end
@@ -150,8 +159,8 @@ module GoodJob # :nodoc:
150
159
  end
151
160
 
152
161
  def with_listen_connection
153
- ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
154
- ActiveRecord::Base.connection_pool.remove(conn)
162
+ ar_conn = Job.connection_pool.checkout.tap do |conn|
163
+ Job.connection_pool.remove(conn)
155
164
  end
156
165
  pg_conn = ar_conn.raw_connection
157
166
  raise AdapterCannotListenError unless pg_conn.respond_to? :wait_for_notify