good_job 1.7.1 → 1.9.3

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
@@ -53,6 +65,10 @@ module GoodJob
53
65
  type: :numeric,
54
66
  banner: 'COUNT',
55
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))"
56
72
  method_option :daemonize,
57
73
  type: :boolean,
58
74
  desc: "Run as a background daemon (default: false)"
@@ -67,7 +83,7 @@ module GoodJob
67
83
 
68
84
  notifier = GoodJob::Notifier.new
69
85
  poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
70
- scheduler = GoodJob::Scheduler.from_configuration(configuration)
86
+ scheduler = GoodJob::Scheduler.from_configuration(configuration, warm_cache_on_initialize: true)
71
87
  notifier.recipients << [scheduler, :create_thread]
72
88
  poller.recipients << [scheduler, :create_thread]
73
89
 
@@ -81,9 +97,8 @@ module GoodJob
81
97
  break if @stop_good_job_executable || scheduler.shutdown? || notifier.shutdown?
82
98
  end
83
99
 
84
- notifier.shutdown
85
- poller.shutdown
86
- scheduler.shutdown
100
+ executors = [notifier, poller, scheduler]
101
+ GoodJob._shutdown_all(executors, timeout: configuration.shutdown_timeout)
87
102
  end
88
103
 
89
104
  default_task :start
@@ -133,7 +148,7 @@ module GoodJob
133
148
  # Rails or from the application can be set up here.
134
149
  def set_up_application!
135
150
  require RAILS_ENVIRONMENT_RB
136
- 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)
137
152
 
138
153
  GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
139
154
  GoodJob::LogSubscriber.reset_logger
@@ -5,6 +5,8 @@ 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
@@ -13,6 +15,8 @@ module GoodJob
13
15
  DEFAULT_MAX_CACHE = 10000
14
16
  # Default number of seconds to preserve jobs for {CLI#cleanup_preserved_jobs}
15
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
16
20
 
17
21
  # The options that were explicitly set when initializing +Configuration+.
18
22
  # @return [Hash]
@@ -33,40 +37,30 @@ module GoodJob
33
37
  @env = env
34
38
  end
35
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
+
36
44
  # Specifies how and where jobs should be executed. See {Adapter#initialize}
37
45
  # for more details on possible values.
38
- #
39
- # When running inside a Rails app, you may want to use
40
- # {#rails_execution_mode}, which takes the current Rails environment into
41
- # account when determining the final value.
42
- #
43
- # @param default [Symbol]
44
- # Value to use if none was specified in the configuration.
45
46
  # @return [Symbol]
46
- def execution_mode(default: :external)
47
- if defined?(GOOD_JOB_WITHIN_CLI) && GOOD_JOB_WITHIN_CLI
48
- :external
49
- elsif options[:execution_mode]
50
- options[:execution_mode]
51
- elsif rails_config[:execution_mode]
52
- rails_config[:execution_mode]
53
- elsif env['GOOD_JOB_EXECUTION_MODE'].present?
54
- env['GOOD_JOB_EXECUTION_MODE'].to_sym
55
- else
56
- default
57
- end
58
- 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
59
56
 
60
- # Like {#execution_mode}, but takes the current Rails environment into
61
- # account (e.g. in the +test+ environment, it falls back to +:inline+).
62
- # @return [Symbol]
63
- def rails_execution_mode
64
- if execution_mode(default: nil)
65
- execution_mode
66
- elsif Rails.env.development? || Rails.env.test?
67
- :inline
68
- else
69
- :external
57
+ if mode
58
+ mode.to_sym
59
+ elsif Rails.env.development? || Rails.env.test?
60
+ :inline
61
+ else
62
+ :external
63
+ end
70
64
  end
71
65
  end
72
66
 
@@ -77,10 +71,10 @@ module GoodJob
77
71
  def max_threads
78
72
  (
79
73
  options[:max_threads] ||
80
- rails_config[:max_threads] ||
81
- env['GOOD_JOB_MAX_THREADS'] ||
82
- env['RAILS_MAX_THREADS'] ||
83
- DEFAULT_MAX_THREADS
74
+ rails_config[:max_threads] ||
75
+ env['GOOD_JOB_MAX_THREADS'] ||
76
+ env['RAILS_MAX_THREADS'] ||
77
+ DEFAULT_MAX_THREADS
84
78
  ).to_i
85
79
  end
86
80
 
@@ -103,9 +97,9 @@ module GoodJob
103
97
  def poll_interval
104
98
  (
105
99
  options[:poll_interval] ||
106
- rails_config[:poll_interval] ||
107
- env['GOOD_JOB_POLL_INTERVAL'] ||
108
- DEFAULT_POLL_INTERVAL
100
+ rails_config[:poll_interval] ||
101
+ env['GOOD_JOB_POLL_INTERVAL'] ||
102
+ DEFAULT_POLL_INTERVAL
109
103
  ).to_i
110
104
  end
111
105
 
@@ -122,15 +116,27 @@ module GoodJob
122
116
  ).to_i
123
117
  end
124
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
+
125
131
  # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
126
132
  # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
127
133
  # @return [Integer]
128
134
  def cleanup_preserved_jobs_before_seconds_ago
129
135
  (
130
136
  options[:before_seconds_ago] ||
131
- rails_config[:cleanup_preserved_jobs_before_seconds_ago] ||
132
- env['GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO'] ||
133
- 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
134
140
  ).to_i
135
141
  end
136
142
 
@@ -0,0 +1,20 @@
1
+ module GoodJob
2
+ # Stores the results of job execution
3
+ class ExecutionResult
4
+ # @return [Object, nil]
5
+ attr_reader :value
6
+ # @return [Exception, nil]
7
+ attr_reader :handled_error
8
+ # @return [Exception, nil]
9
+ attr_reader :unhandled_error
10
+
11
+ # @param value [Object, nil]
12
+ # @param handled_error [Exception, nil]
13
+ # @param unhandled_error [Exception, nil]
14
+ def initialize(value:, handled_error: nil, unhandled_error: nil)
15
+ @value = value
16
+ @handled_error = handled_error
17
+ @unhandled_error = unhandled_error
18
+ end
19
+ end
20
+ end
data/lib/good_job/job.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  module GoodJob
2
- #
3
- # Represents a request to perform an +ActiveJob+ job.
4
- #
5
- class Job < ActiveRecord::Base
2
+ # ActiveRecord model that represents an +ActiveJob+ job.
3
+ # Parent class can be configured with +GoodJob.active_record_parent_class+.
4
+ # @!parse
5
+ # class Job < ActiveRecord::Base; end
6
+ class Job < Object.const_get(GoodJob.active_record_parent_class)
6
7
  include Lockable
7
8
 
8
9
  # Raised if something attempts to execute a previously completed Job again.
@@ -15,8 +16,11 @@ module GoodJob
15
16
 
16
17
  self.table_name = 'good_jobs'.freeze
17
18
 
19
+ attr_readonly :serialized_params
20
+
18
21
  # Parse a string representing a group of queues into a more readable data
19
22
  # structure.
23
+ # @param string [String] Queue string
20
24
  # @return [Hash]
21
25
  # How to match a given queue. It can have the following keys and values:
22
26
  # - +{ all: true }+ indicates that all queues match.
@@ -132,29 +136,26 @@ module GoodJob
132
136
 
133
137
  # Finds the next eligible Job, acquire an advisory lock related to it, and
134
138
  # executes the job.
135
- # @return [Array<(GoodJob::Job, Object, Exception)>, nil]
139
+ # @return [ExecutionResult, nil]
136
140
  # If a job was executed, returns an array with the {Job} record, the
137
141
  # return value for the job's +#perform+ method, and the exception the job
138
142
  # raised, if any (if the job raised, then the second array entry will be
139
143
  # +nil+). If there were no jobs to execute, returns +nil+.
140
144
  def self.perform_with_advisory_lock
141
- good_job = nil
142
- result = nil
143
- error = nil
144
-
145
145
  unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
146
146
  good_job = good_jobs.first
147
147
  # TODO: Determine why some records are fetched without an advisory lock at all
148
148
  break unless good_job&.executable?
149
149
 
150
- result, error = good_job.perform
150
+ good_job.perform
151
151
  end
152
-
153
- [good_job, result, error] if good_job
154
152
  end
155
153
 
156
154
  # Fetches the scheduled execution time of the next eligible Job(s).
157
- # @return [Array<(DateTime)>]
155
+ # @param after [DateTime]
156
+ # @param limit [Integer]
157
+ # @param now_limit [Integer, nil]
158
+ # @return [Array<DateTime>]
158
159
  def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
159
160
  query = advisory_unlocked.unfinished.schedule_ordered
160
161
 
@@ -180,7 +181,6 @@ module GoodJob
180
181
  # @return [Job]
181
182
  # The new {Job} instance representing the queued ActiveJob job.
182
183
  def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
183
- good_job = nil
184
184
  ActiveSupport::Notifications.instrument("enqueue_job.good_job", { active_job: active_job, scheduled_at: scheduled_at, create_with_advisory_lock: create_with_advisory_lock }) do |instrument_payload|
185
185
  good_job = GoodJob::Job.new(
186
186
  queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
@@ -194,49 +194,37 @@ module GoodJob
194
194
 
195
195
  good_job.save!
196
196
  active_job.provider_job_id = good_job.id
197
- end
198
197
 
199
- good_job
198
+ good_job
199
+ end
200
200
  end
201
201
 
202
202
  # Execute the ActiveJob job this {Job} represents.
203
- # @return [Array<(Object, Exception)>]
203
+ # @return [ExecutionResult]
204
204
  # An array of the return value of the job's +#perform+ method and the
205
205
  # exception raised by the job, if any. If the job completed successfully,
206
206
  # the second array entry (the exception) will be +nil+ and vice versa.
207
207
  def perform
208
208
  raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
209
209
 
210
- GoodJob::CurrentExecution.reset
211
-
212
210
  self.performed_at = Time.current
213
211
  save! if GoodJob.preserve_job_records
214
212
 
215
- result, unhandled_error = execute
216
-
217
- result_error = nil
218
- if result.is_a?(Exception)
219
- result_error = result
220
- result = nil
221
- end
222
-
223
- job_error = unhandled_error ||
224
- result_error ||
225
- GoodJob::CurrentExecution.error_on_retry ||
226
- GoodJob::CurrentExecution.error_on_discard
213
+ result = execute
227
214
 
215
+ job_error = result.handled_error || result.unhandled_error
228
216
  self.error = "#{job_error.class}: #{job_error.message}" if job_error
229
217
 
230
- if unhandled_error && GoodJob.retry_on_unhandled_error
218
+ if result.unhandled_error && GoodJob.retry_on_unhandled_error
231
219
  save!
232
- elsif GoodJob.preserve_job_records == true || (unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
220
+ elsif GoodJob.preserve_job_records == true || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
233
221
  self.finished_at = Time.current
234
222
  save!
235
223
  else
236
224
  destroy!
237
225
  end
238
226
 
239
- [result, job_error]
227
+ result
240
228
  end
241
229
 
242
230
  # Tests whether this job is safe to be executed by this thread.
@@ -247,16 +235,26 @@ module GoodJob
247
235
 
248
236
  private
249
237
 
238
+ # @return [GoodJob::ExecutionResult]
250
239
  def execute
251
240
  params = serialized_params.merge(
252
241
  "provider_job_id" => id
253
242
  )
254
243
 
244
+ GoodJob::CurrentExecution.reset
255
245
  ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
256
- [ActiveJob::Base.execute(params), nil]
246
+ value = ActiveJob::Base.execute(params)
247
+
248
+ if value.is_a?(Exception)
249
+ handled_error = value
250
+ value = nil
251
+ end
252
+ handled_error ||= GoodJob::CurrentExecution.error_on_retry || GoodJob::CurrentExecution.error_on_discard
253
+
254
+ ExecutionResult.new(value: value, handled_error: handled_error)
255
+ rescue StandardError => e
256
+ ExecutionResult.new(value: nil, unhandled_error: e)
257
257
  end
258
- rescue StandardError => e
259
- [nil, e]
260
258
  end
261
259
  end
262
260
  end
@@ -51,9 +51,10 @@ module GoodJob
51
51
  end
52
52
 
53
53
  # The Returns timestamps of when next tasks may be available.
54
- # @param count [Integer] number of timestamps to return
55
- # @param count [DateTime, Time, nil] jobs scheduled after this time
56
- # @return [Array<(Time, Timestamp)>, nil]
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]
57
58
  def next_at(after: nil, limit: nil, now_limit: nil)
58
59
  job_query.next_scheduled_at(after: after, limit: limit, now_limit: now_limit)
59
60
  end
@@ -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