good_job 1.8.0 → 1.9.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.
@@ -4,22 +4,24 @@ module GoodJob
4
4
  #
5
5
  class Adapter
6
6
  # Valid execution modes.
7
- EXECUTION_MODES = [:async, :external, :inline].freeze
7
+ EXECUTION_MODES = [:async, :async_server, :external, :inline].freeze
8
8
 
9
- # @param execution_mode [nil, Symbol] specifies how and where jobs should be executed. You can also set this with the environment variable +GOOD_JOB_EXECUTION_MODE+.
9
+ # @param execution_mode [Symbol, nil] specifies how and where jobs should be executed. You can also set this with the environment variable +GOOD_JOB_EXECUTION_MODE+.
10
10
  #
11
11
  # - +:inline+ executes jobs immediately in whatever process queued them (usually the web server process). This should only be used in test and development environments.
12
12
  # - +:external+ causes the adapter to enqueue jobs, but not execute them. When using this option (the default for production environments), you'll need to use the command-line tool to actually execute your jobs.
13
- # - +:async+ causes the adapter to execute you jobs in separate threads in whatever process queued them (usually the web process). This is akin to running the command-line tool's code inside your web server. It can be more economical for small workloads (you don't need a separate machine or environment for running your jobs), but if your web server is under heavy load or your jobs require a lot of resources, you should choose `:external` instead.
13
+ # - +:async_server+ executes jobs in separate threads within the Rails webserver process (`bundle exec rails server`). It can be more economical for small workloads because you don't need a separate machine or environment for running your jobs, but if your web server is under heavy load or your jobs require a lot of resources, you should choose +:external+ instead.
14
+ # When not in the Rails webserver, jobs will execute in +:external+ mode to ensure jobs are not executed within `rails console`, `rails db:migrate`, `rails assets:prepare`, etc.
15
+ # - +:async+ executes jobs in any Rails process.
14
16
  #
15
17
  # The default value depends on the Rails environment:
16
18
  #
17
19
  # - +development+ and +test+: +:inline+
18
20
  # - +production+ and all other environments: +:external+
19
21
  #
20
- # @param max_threads [nil, Integer] sets the number of threads per scheduler to use when +execution_mode+ is set to +:async+. The +queues+ parameter can specify a number of threads for each group of queues which will override this value. You can also set this with the environment variable +GOOD_JOB_MAX_THREADS+. Defaults to +5+.
21
- # @param queues [nil, String] determines which queues to execute jobs from when +execution_mode+ is set to +:async+. See {file:README.md#optimize-queues-threads-and-processes} for more details on the format of this string. You can also set this with the environment variable +GOOD_JOB_QUEUES+. Defaults to +"*"+.
22
- # @param poll_interval [nil, Integer] sets the number of seconds between polls for jobs when +execution_mode+ is set to +:async+. You can also set this with the environment variable +GOOD_JOB_POLL_INTERVAL+. Defaults to +1+.
22
+ # @param max_threads [Integer, nil] sets the number of threads per scheduler to use when +execution_mode+ is set to +:async+. The +queues+ parameter can specify a number of threads for each group of queues which will override this value. You can also set this with the environment variable +GOOD_JOB_MAX_THREADS+. Defaults to +5+.
23
+ # @param queues [String, nil] determines which queues to execute jobs from when +execution_mode+ is set to +:async+. See {file:README.md#optimize-queues-threads-and-processes} for more details on the format of this string. You can also set this with the environment variable +GOOD_JOB_QUEUES+. Defaults to +"*"+.
24
+ # @param poll_interval [Integer, nil] sets the number of seconds between polls for jobs when +execution_mode+ is set to +:async+. You can also set this with the environment variable +GOOD_JOB_POLL_INTERVAL+. Defaults to +1+.
23
25
  def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil)
24
26
  if caller[0..4].find { |c| c.include?("/config/application.rb") || c.include?("/config/environments/") }
25
27
  ActiveSupport::Deprecation.warn(<<~DEPRECATION)
@@ -46,8 +48,7 @@ module GoodJob
46
48
  poll_interval: poll_interval,
47
49
  }
48
50
  )
49
-
50
- raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(@configuration.execution_mode)
51
+ @configuration.validate!
51
52
 
52
53
  if execute_async? # rubocop:disable Style/GuardClause
53
54
  @notifier = GoodJob::Notifier.new
@@ -69,7 +70,7 @@ module GoodJob
69
70
  # Enqueues an ActiveJob job to be run at a specific time.
70
71
  # For use by Rails; you should generally not call this directly.
71
72
  # @param active_job [ActiveJob::Base] the job to be enqueued from +#perform_later+
72
- # @param timestamp [Integer] the epoch time to perform the job
73
+ # @param timestamp [Integer, nil] the epoch time to perform the job
73
74
  # @return [GoodJob::Job]
74
75
  def enqueue_at(active_job, timestamp)
75
76
  good_job = GoodJob::Job.enqueue(
@@ -96,22 +97,21 @@ module GoodJob
96
97
  end
97
98
 
98
99
  # Shut down the thread pool executors.
99
- # @param timeout [nil, Numeric] Seconds to wait for active threads.
100
- #
100
+ # @param timeout [nil, Numeric, Symbol] Seconds to wait for active threads.
101
101
  # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
102
102
  # * +-1+, the scheduler will wait until the shutdown is complete.
103
103
  # * +0+, the scheduler will immediately shutdown and stop any threads.
104
104
  # * A positive number will wait that many seconds before stopping any remaining active threads.
105
- # @param wait [Boolean] Deprecated. Use +timeout:+ instead.
105
+ # @param wait [Boolean, nil] Deprecated. Use +timeout:+ instead.
106
106
  # @return [void]
107
107
  def shutdown(timeout: :default, wait: nil)
108
- timeout = if wait.present?
108
+ timeout = if wait.nil?
109
+ timeout
110
+ else
109
111
  ActiveSupport::Deprecation.warn(
110
112
  "Using `GoodJob::Adapter.shutdown` with `wait:` kwarg is deprecated; use `timeout:` kwarg instead e.g. GoodJob::Adapter.shutdown(timeout: #{wait ? '-1' : 'nil'})"
111
113
  )
112
114
  wait ? -1 : nil
113
- else
114
- timeout
115
115
  end
116
116
 
117
117
  timeout = if timeout == :default
@@ -125,18 +125,36 @@ module GoodJob
125
125
  end
126
126
 
127
127
  # Whether in +:async+ execution mode.
128
+ # @return [Boolean]
128
129
  def execute_async?
129
- @configuration.execution_mode == :async
130
+ @configuration.execution_mode == :async ||
131
+ @configuration.execution_mode == :async_server && in_server_process?
130
132
  end
131
133
 
132
134
  # Whether in +:external+ execution mode.
135
+ # @return [Boolean]
133
136
  def execute_externally?
134
- @configuration.execution_mode == :external
137
+ @configuration.execution_mode == :external ||
138
+ @configuration.execution_mode == :async_server && !in_server_process?
135
139
  end
136
140
 
137
141
  # Whether in +:inline+ execution mode.
142
+ # @return [Boolean]
138
143
  def execute_inline?
139
144
  @configuration.execution_mode == :inline
140
145
  end
146
+
147
+ private
148
+
149
+ # Whether running in a web server process.
150
+ # @return [Boolean, nil]
151
+ def in_server_process?
152
+ return @_in_server_process if defined? @_in_server_process
153
+
154
+ @_in_server_process = Rails.const_defined?('Server') ||
155
+ caller.grep(%r{config.ru}).any? || # EXAMPLE: config.ru:3:in `block in <main>' OR config.ru:3:in `new_from_string'
156
+ caller.grep(%{/rack/handler/}).any? || # EXAMPLE: iodine-0.7.44/lib/rack/handler/iodine.rb:13:in `start'
157
+ (Concurrent.on_jruby? && caller.grep(%r{jruby/rack/rails_booter}).any?) # EXAMPLE: uri:classloader:/jruby/rack/rails_booter.rb:83:in `load_environment'
158
+ end
141
159
  end
142
160
  end
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
@@ -71,7 +83,7 @@ module GoodJob
71
83
 
72
84
  notifier = GoodJob::Notifier.new
73
85
  poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
74
- scheduler = GoodJob::Scheduler.from_configuration(configuration)
86
+ scheduler = GoodJob::Scheduler.from_configuration(configuration, warm_cache_on_initialize: true)
75
87
  notifier.recipients << [scheduler, :create_thread]
76
88
  poller.recipients << [scheduler, :create_thread]
77
89
 
@@ -136,7 +148,7 @@ module GoodJob
136
148
  # Rails or from the application can be set up here.
137
149
  def set_up_application!
138
150
  require RAILS_ENVIRONMENT_RB
139
- 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)
140
152
 
141
153
  GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
142
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,7 +15,7 @@ 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
16
- # Default to always wait for jobs to finish for {#shutdown}
18
+ # Default to always wait for jobs to finish for {Adapter#shutdown}
17
19
  DEFAULT_SHUTDOWN_TIMEOUT = -1
18
20
 
19
21
  # The options that were explicitly set when initializing +Configuration+.
@@ -35,40 +37,30 @@ module GoodJob
35
37
  @env = env
36
38
  end
37
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
+
38
44
  # Specifies how and where jobs should be executed. See {Adapter#initialize}
39
45
  # for more details on possible values.
40
- #
41
- # When running inside a Rails app, you may want to use
42
- # {#rails_execution_mode}, which takes the current Rails environment into
43
- # account when determining the final value.
44
- #
45
- # @param default [Symbol]
46
- # Value to use if none was specified in the configuration.
47
46
  # @return [Symbol]
48
- def execution_mode(default: :external)
49
- if defined?(GOOD_JOB_WITHIN_CLI) && GOOD_JOB_WITHIN_CLI
50
- :external
51
- elsif options[:execution_mode]
52
- options[:execution_mode]
53
- elsif rails_config[:execution_mode]
54
- rails_config[:execution_mode]
55
- elsif env['GOOD_JOB_EXECUTION_MODE'].present?
56
- env['GOOD_JOB_EXECUTION_MODE'].to_sym
57
- else
58
- default
59
- end
60
- 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
61
56
 
62
- # Like {#execution_mode}, but takes the current Rails environment into
63
- # account (e.g. in the +test+ environment, it falls back to +:inline+).
64
- # @return [Symbol]
65
- def rails_execution_mode
66
- if execution_mode(default: nil)
67
- execution_mode
68
- elsif Rails.env.development? || Rails.env.test?
69
- :inline
70
- else
71
- :external
57
+ if mode
58
+ mode.to_sym
59
+ elsif Rails.env.development? || Rails.env.test?
60
+ :inline
61
+ else
62
+ :external
63
+ end
72
64
  end
73
65
  end
74
66
 
@@ -92,9 +84,9 @@ module GoodJob
92
84
  # on the format of this string.
93
85
  # @return [String]
94
86
  def queue_string
95
- options[:queues] ||
96
- rails_config[:queues] ||
97
- env['GOOD_JOB_QUEUES'] ||
87
+ options[:queues].presence ||
88
+ rails_config[:queues].presence ||
89
+ env['GOOD_JOB_QUEUES'].presence ||
98
90
  '*'
99
91
  end
100
92
 
@@ -3,7 +3,6 @@ require 'active_support/core_ext/module/attribute_accessors_per_thread'
3
3
  module GoodJob
4
4
  # Thread-local attributes for passing values from Instrumentation.
5
5
  # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
6
-
7
6
  module CurrentExecution
8
7
  # @!attribute [rw] error_on_retry
9
8
  # @!scope class
@@ -13,6 +13,7 @@ module GoodJob
13
13
  end
14
14
 
15
15
  # Daemonizes the current process and writes out a pidfile.
16
+ # @return [void]
16
17
  def daemonize
17
18
  check_pid
18
19
  Process.daemon
@@ -21,6 +22,7 @@ module GoodJob
21
22
 
22
23
  private
23
24
 
25
+ # @return [void]
24
26
  def write_pid
25
27
  File.open(pidfile, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(Process.pid.to_s) }
26
28
  at_exit { File.delete(pidfile) if File.exist?(pidfile) }
@@ -29,10 +31,12 @@ module GoodJob
29
31
  retry
30
32
  end
31
33
 
34
+ # @return [void]
32
35
  def delete_pid
33
36
  File.delete(pidfile) if File.exist?(pidfile)
34
37
  end
35
38
 
39
+ # @return [void]
36
40
  def check_pid
37
41
  case pid_status(pidfile)
38
42
  when :running, :not_owned
@@ -42,6 +46,8 @@ module GoodJob
42
46
  end
43
47
  end
44
48
 
49
+ # @param pidfile [Pathname, String]
50
+ # @return [Symbol]
45
51
  def pid_status(pidfile)
46
52
  return :exited unless File.exist?(pidfile)
47
53
 
@@ -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.
@@ -19,6 +20,7 @@ module GoodJob
19
20
 
20
21
  # Parse a string representing a group of queues into a more readable data
21
22
  # structure.
23
+ # @param string [String] Queue string
22
24
  # @return [Hash]
23
25
  # How to match a given queue. It can have the following keys and values:
24
26
  # - +{ all: true }+ indicates that all queues match.
@@ -48,6 +50,14 @@ module GoodJob
48
50
  end
49
51
  end
50
52
 
53
+ # Get Jobs with given class name
54
+ # @!method with_job_class
55
+ # @!scope class
56
+ # @param string [String]
57
+ # Job class name
58
+ # @return [ActiveRecord::Relation]
59
+ scope :with_job_class, ->(job_class) { where("serialized_params->>'job_class' = ?", job_class) }
60
+
51
61
  # Get Jobs that have not yet been completed.
52
62
  # @!method unfinished
53
63
  # @!scope class
@@ -92,6 +102,12 @@ module GoodJob
92
102
  # @return [ActiveRecord::Relation]
93
103
  scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
94
104
 
105
+ # Get Jobs that started but not finished yet.
106
+ # @!method running
107
+ # @!scope class
108
+ # @return [ActiveRecord::Relation]
109
+ scope :running, -> { where.not(performed_at: nil).where(finished_at: nil) }
110
+
95
111
  # Get Jobs on queues that match the given queue string.
96
112
  # @!method queue_string(string)
97
113
  # @!scope class
@@ -134,29 +150,26 @@ module GoodJob
134
150
 
135
151
  # Finds the next eligible Job, acquire an advisory lock related to it, and
136
152
  # executes the job.
137
- # @return [Array<(GoodJob::Job, Object, Exception)>, nil]
153
+ # @return [ExecutionResult, nil]
138
154
  # If a job was executed, returns an array with the {Job} record, the
139
155
  # return value for the job's +#perform+ method, and the exception the job
140
156
  # raised, if any (if the job raised, then the second array entry will be
141
157
  # +nil+). If there were no jobs to execute, returns +nil+.
142
158
  def self.perform_with_advisory_lock
143
- good_job = nil
144
- result = nil
145
- error = nil
146
-
147
159
  unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
148
160
  good_job = good_jobs.first
149
161
  # TODO: Determine why some records are fetched without an advisory lock at all
150
162
  break unless good_job&.executable?
151
163
 
152
- result, error = good_job.perform
164
+ good_job.perform
153
165
  end
154
-
155
- [good_job, result, error] if good_job
156
166
  end
157
167
 
158
168
  # Fetches the scheduled execution time of the next eligible Job(s).
159
- # @return [Array<(DateTime)>]
169
+ # @param after [DateTime]
170
+ # @param limit [Integer]
171
+ # @param now_limit [Integer, nil]
172
+ # @return [Array<DateTime>]
160
173
  def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
161
174
  query = advisory_unlocked.unfinished.schedule_ordered
162
175
 
@@ -182,7 +195,6 @@ module GoodJob
182
195
  # @return [Job]
183
196
  # The new {Job} instance representing the queued ActiveJob job.
184
197
  def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
185
- good_job = nil
186
198
  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|
187
199
  good_job = GoodJob::Job.new(
188
200
  queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
@@ -196,49 +208,37 @@ module GoodJob
196
208
 
197
209
  good_job.save!
198
210
  active_job.provider_job_id = good_job.id
199
- end
200
211
 
201
- good_job
212
+ good_job
213
+ end
202
214
  end
203
215
 
204
216
  # Execute the ActiveJob job this {Job} represents.
205
- # @return [Array<(Object, Exception)>]
217
+ # @return [ExecutionResult]
206
218
  # An array of the return value of the job's +#perform+ method and the
207
219
  # exception raised by the job, if any. If the job completed successfully,
208
220
  # the second array entry (the exception) will be +nil+ and vice versa.
209
221
  def perform
210
222
  raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
211
223
 
212
- GoodJob::CurrentExecution.reset
213
-
214
224
  self.performed_at = Time.current
215
225
  save! if GoodJob.preserve_job_records
216
226
 
217
- result, unhandled_error = execute
218
-
219
- result_error = nil
220
- if result.is_a?(Exception)
221
- result_error = result
222
- result = nil
223
- end
224
-
225
- job_error = unhandled_error ||
226
- result_error ||
227
- GoodJob::CurrentExecution.error_on_retry ||
228
- GoodJob::CurrentExecution.error_on_discard
227
+ result = execute
229
228
 
229
+ job_error = result.handled_error || result.unhandled_error
230
230
  self.error = "#{job_error.class}: #{job_error.message}" if job_error
231
231
 
232
- if unhandled_error && GoodJob.retry_on_unhandled_error
232
+ if result.unhandled_error && GoodJob.retry_on_unhandled_error
233
233
  save!
234
- elsif GoodJob.preserve_job_records == true || (unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
234
+ elsif GoodJob.preserve_job_records == true || (result.unhandled_error && GoodJob.preserve_job_records == :on_unhandled_error)
235
235
  self.finished_at = Time.current
236
236
  save!
237
237
  else
238
238
  destroy!
239
239
  end
240
240
 
241
- [result, job_error]
241
+ result
242
242
  end
243
243
 
244
244
  # Tests whether this job is safe to be executed by this thread.
@@ -249,16 +249,26 @@ module GoodJob
249
249
 
250
250
  private
251
251
 
252
+ # @return [ExecutionResult]
252
253
  def execute
253
254
  params = serialized_params.merge(
254
255
  "provider_job_id" => id
255
256
  )
256
257
 
258
+ GoodJob::CurrentExecution.reset
257
259
  ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
258
- [ActiveJob::Base.execute(params), nil]
260
+ value = ActiveJob::Base.execute(params)
261
+
262
+ if value.is_a?(Exception)
263
+ handled_error = value
264
+ value = nil
265
+ end
266
+ handled_error ||= GoodJob::CurrentExecution.error_on_retry || GoodJob::CurrentExecution.error_on_discard
267
+
268
+ ExecutionResult.new(value: value, handled_error: handled_error)
269
+ rescue StandardError => e
270
+ ExecutionResult.new(value: nil, unhandled_error: e)
259
271
  end
260
- rescue StandardError => e
261
- [nil, e]
262
272
  end
263
273
  end
264
274
  end