good_job 1.5.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/good_job/cli.rb CHANGED
@@ -15,6 +15,23 @@ 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
+ 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
33
+ end
34
+
18
35
  # @!macro thor.desc
19
36
  # @!method $1
20
37
  # @return [void]
@@ -27,7 +44,8 @@ module GoodJob
27
44
  See option descriptions for the matching environment variable name.
28
45
 
29
46
  == Configuring queues
30
- \x5Separate multiple queues with commas; exclude queues with a leading minus;
47
+
48
+ Separate multiple queues with commas; exclude queues with a leading minus;
31
49
  separate isolated execution pools with semicolons and threads with colons.
32
50
 
33
51
  DESCRIPTION
@@ -43,10 +61,26 @@ module GoodJob
43
61
  type: :numeric,
44
62
  banner: 'SECONDS',
45
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))"
72
+ method_option :daemonize,
73
+ type: :boolean,
74
+ desc: "Run as a background daemon (default: false)"
75
+ method_option :pidfile,
76
+ type: :string,
77
+ desc: "Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)"
46
78
  def start
47
79
  set_up_application!
48
80
  configuration = GoodJob::Configuration.new(options)
49
81
 
82
+ Daemon.new(pidfile: configuration.pidfile).daemonize if configuration.daemonize?
83
+
50
84
  notifier = GoodJob::Notifier.new
51
85
  poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
52
86
  scheduler = GoodJob::Scheduler.from_configuration(configuration)
@@ -63,9 +97,8 @@ module GoodJob
63
97
  break if @stop_good_job_executable || scheduler.shutdown? || notifier.shutdown?
64
98
  end
65
99
 
66
- notifier.shutdown
67
- poller.shutdown
68
- scheduler.shutdown
100
+ executors = [notifier, poller, scheduler]
101
+ GoodJob._shutdown_all(executors, timeout: configuration.shutdown_timeout)
69
102
  end
70
103
 
71
104
  default_task :start
@@ -115,7 +148,7 @@ module GoodJob
115
148
  # Rails or from the application can be set up here.
116
149
  def set_up_application!
117
150
  require RAILS_ENVIRONMENT_RB
118
- 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)
119
152
 
120
153
  GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
121
154
  GoodJob::LogSubscriber.reset_logger
@@ -5,23 +5,28 @@ 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
20
+
21
+ # The options that were explicitly set when initializing +Configuration+.
22
+ # @return [Hash]
23
+ attr_reader :options
14
24
 
15
- # @!attribute [r] options
16
- # The options that were explicitly set when initializing +Configuration+.
17
- # @return [Hash]
18
- #
19
- # @!attribute [r] env
20
- # The environment from which to read GoodJob's environment variables. By
21
- # default, this is the current process's environment, but it can be set
22
- # to something else in {#initialize}.
23
- # @return [Hash]
24
- attr_reader :options, :env
25
+ # The environment from which to read GoodJob's environment variables. By
26
+ # default, this is the current process's environment, but it can be set
27
+ # to something else in {#initialize}.
28
+ # @return [Hash]
29
+ attr_reader :env
25
30
 
26
31
  # @param options [Hash] Any explicitly specified configuration options to
27
32
  # use. Keys are symbols that match the various methods on this class.
@@ -32,38 +37,30 @@ module GoodJob
32
37
  @env = env
33
38
  end
34
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
+
35
44
  # Specifies how and where jobs should be executed. See {Adapter#initialize}
36
45
  # for more details on possible values.
37
- #
38
- # When running inside a Rails app, you may want to use
39
- # {#rails_execution_mode}, which takes the current Rails environment into
40
- # account when determining the final value.
41
- #
42
- # @param default [Symbol]
43
- # Value to use if none was specified in the configuration.
44
46
  # @return [Symbol]
45
- def execution_mode(default: :external)
46
- if options[:execution_mode]
47
- options[:execution_mode]
48
- elsif rails_config[:execution_mode]
49
- rails_config[:execution_mode]
50
- elsif env['GOOD_JOB_EXECUTION_MODE'].present?
51
- env['GOOD_JOB_EXECUTION_MODE'].to_sym
52
- else
53
- default
54
- end
55
- 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
56
56
 
57
- # Like {#execution_mode}, but takes the current Rails environment into
58
- # account (e.g. in the +test+ environment, it falls back to +:inline+).
59
- # @return [Symbol]
60
- def rails_execution_mode
61
- if execution_mode(default: nil)
62
- execution_mode
63
- elsif Rails.env.development? || Rails.env.test?
64
- :inline
65
- else
66
- :external
57
+ if mode
58
+ mode.to_sym
59
+ elsif Rails.env.development? || Rails.env.test?
60
+ :inline
61
+ else
62
+ :external
63
+ end
67
64
  end
68
65
  end
69
66
 
@@ -74,10 +71,10 @@ module GoodJob
74
71
  def max_threads
75
72
  (
76
73
  options[:max_threads] ||
77
- rails_config[:max_threads] ||
78
- env['GOOD_JOB_MAX_THREADS'] ||
79
- env['RAILS_MAX_THREADS'] ||
80
- DEFAULT_MAX_THREADS
74
+ rails_config[:max_threads] ||
75
+ env['GOOD_JOB_MAX_THREADS'] ||
76
+ env['RAILS_MAX_THREADS'] ||
77
+ DEFAULT_MAX_THREADS
81
78
  ).to_i
82
79
  end
83
80
 
@@ -100,24 +97,63 @@ module GoodJob
100
97
  def poll_interval
101
98
  (
102
99
  options[:poll_interval] ||
103
- rails_config[:poll_interval] ||
104
- env['GOOD_JOB_POLL_INTERVAL'] ||
105
- 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
106
116
  ).to_i
107
117
  end
108
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
+
109
131
  # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
110
132
  # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
111
- # @return [Boolean]
133
+ # @return [Integer]
112
134
  def cleanup_preserved_jobs_before_seconds_ago
113
135
  (
114
136
  options[:before_seconds_ago] ||
115
- rails_config[:cleanup_preserved_jobs_before_seconds_ago] ||
116
- env['GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO'] ||
117
- 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
118
140
  ).to_i
119
141
  end
120
142
 
143
+ # Tests whether to daemonize the process.
144
+ # @return [Boolean]
145
+ def daemonize?
146
+ options[:daemonize] || false
147
+ end
148
+
149
+ # Path of the pidfile to create when running as a daemon.
150
+ # @return [Pathname,String]
151
+ def pidfile
152
+ options[:pidfile] ||
153
+ env['GOOD_JOB_PIDFILE'] ||
154
+ Rails.application.root.join('tmp', 'pids', 'good_job.pid')
155
+ end
156
+
121
157
  private
122
158
 
123
159
  def rails_config
@@ -0,0 +1,59 @@
1
+ module GoodJob
2
+ #
3
+ # Manages daemonization of the current process.
4
+ #
5
+ class Daemon
6
+ # The path of the generated pidfile.
7
+ # @return [Pathname,String]
8
+ attr_reader :pidfile
9
+
10
+ # @param pidfile [Pathname,String] Pidfile path
11
+ def initialize(pidfile:)
12
+ @pidfile = pidfile
13
+ end
14
+
15
+ # Daemonizes the current process and writes out a pidfile.
16
+ def daemonize
17
+ check_pid
18
+ Process.daemon
19
+ write_pid
20
+ end
21
+
22
+ private
23
+
24
+ def write_pid
25
+ File.open(pidfile, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(Process.pid.to_s) }
26
+ at_exit { File.delete(pidfile) if File.exist?(pidfile) }
27
+ rescue Errno::EEXIST
28
+ check_pid
29
+ retry
30
+ end
31
+
32
+ def delete_pid
33
+ File.delete(pidfile) if File.exist?(pidfile)
34
+ end
35
+
36
+ def check_pid
37
+ case pid_status(pidfile)
38
+ when :running, :not_owned
39
+ abort "A server is already running. Check #{pidfile}"
40
+ when :dead
41
+ File.delete(pidfile)
42
+ end
43
+ end
44
+
45
+ def pid_status(pidfile)
46
+ return :exited unless File.exist?(pidfile)
47
+
48
+ pid = ::File.read(pidfile).to_i
49
+ return :dead if pid.zero?
50
+
51
+ Process.kill(0, pid) # check process status
52
+ :running
53
+ rescue Errno::ESRCH
54
+ :dead
55
+ rescue Errno::EPERM
56
+ :not_owned
57
+ end
58
+ end
59
+ end
data/lib/good_job/job.rb CHANGED
@@ -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
@@ -92,8 +92,6 @@ module GoodJob
92
92
  # @return [ActiveRecord::Relation]
93
93
  scope :owns_advisory_locked, -> { joins_advisory_locks.where('"pg_locks"."pid" = pg_backend_pid()') }
94
94
 
95
- # @!attribute [r] create_with_advisory_lock
96
- # @return [Boolean]
97
95
  # Whether an advisory lock should be acquired in the same transaction
98
96
  # that created the record.
99
97
  #
@@ -107,6 +105,8 @@ module GoodJob
107
105
  # record = MyLockableRecord.create(create_with_advisory_lock: true)
108
106
  # record.advisory_locked?
109
107
  # => true
108
+ #
109
+ # @return [Boolean]
110
110
  attr_accessor :create_with_advisory_lock
111
111
 
112
112
  after_create -> { advisory_lock }, if: :create_with_advisory_lock
@@ -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