good_job 1.3.5 → 1.6.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.
@@ -2,9 +2,9 @@ module ActiveJob # :nodoc:
2
2
  module QueueAdapters # :nodoc:
3
3
  # See {GoodJob::Adapter} for details.
4
4
  class GoodJobAdapter < GoodJob::Adapter
5
- def initialize(execution_mode: nil, max_threads: nil, poll_interval: nil, scheduler: nil, inline: false)
6
- configuration = GoodJob::Configuration.new({ execution_mode: execution_mode }, env: ENV)
7
- super(execution_mode: configuration.rails_execution_mode, max_threads: max_threads, poll_interval: poll_interval, scheduler: scheduler, inline: inline)
5
+ def initialize(**options)
6
+ configuration = GoodJob::Configuration.new(options, env: ENV)
7
+ super(**options.merge(execution_mode: configuration.rails_execution_mode))
8
8
  end
9
9
  end
10
10
  end
@@ -1,16 +1,15 @@
1
1
  require "rails"
2
-
3
2
  require "active_job"
4
3
  require "active_job/queue_adapters"
5
4
 
6
5
  require "zeitwerk"
7
-
8
- loader = Zeitwerk::Loader.for_gem
9
- loader.inflector.inflect(
10
- 'cli' => "CLI"
11
- )
12
- loader.push_dir(File.join(__dir__, ["generators"]))
13
- loader.setup
6
+ Zeitwerk::Loader.for_gem.tap do |loader|
7
+ loader.inflector.inflect({
8
+ "cli" => "CLI",
9
+ })
10
+ loader.ignore(File.join(File.dirname(__FILE__), "generators"))
11
+ loader.setup
12
+ end
14
13
 
15
14
  require "good_job/railtie"
16
15
 
@@ -20,13 +20,22 @@ module GoodJob
20
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
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
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+.
23
- # @param scheduler [nil, Scheduler] (deprecated) a scheduler to be managed by the adapter
24
- # @param notifier [nil, Notifier] (deprecated) a notifier to be managed by the adapter
25
- # @param inline [nil, Boolean] (deprecated) whether to run in inline execution mode
26
- def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, scheduler: nil, notifier: nil, inline: false)
27
- if inline && execution_mode.nil?
28
- ActiveSupport::Deprecation.warn('GoodJob::Adapter#new(inline: true) is deprecated; use GoodJob::Adapter.new(execution_mode: :inline) instead')
29
- execution_mode = :inline
23
+ def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil)
24
+ if caller[0..4].find { |c| c.include?("/config/application.rb") || c.include?("/config/environments/") }
25
+ ActiveSupport::Deprecation.warn(<<~DEPRECATION)
26
+ GoodJob no longer recommends creating a GoodJob::Adapter instance:
27
+
28
+ config.active_job.queue_adapter = GoodJob::Adapter.new...
29
+
30
+ Instead, configure GoodJob through configuration:
31
+
32
+ config.active_job.queue_adapter = :good_job
33
+ config.good_job.execution_mode = :#{execution_mode}
34
+ config.good_job.max_threads = #{max_threads}
35
+ config.good_job.poll_interval = #{poll_interval}
36
+ # etc...
37
+
38
+ DEPRECATION
30
39
  end
31
40
 
32
41
  configuration = GoodJob::Configuration.new(
@@ -42,9 +51,9 @@ module GoodJob
42
51
  raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(@execution_mode)
43
52
 
44
53
  if @execution_mode == :async # rubocop:disable Style/GuardClause
45
- @notifier = notifier || GoodJob::Notifier.new
54
+ @notifier = GoodJob::Notifier.new
46
55
  @poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
47
- @scheduler = scheduler || GoodJob::Scheduler.from_configuration(configuration)
56
+ @scheduler = GoodJob::Scheduler.from_configuration(configuration)
48
57
  @notifier.recipients << [@scheduler, :create_thread]
49
58
  @poller.recipients << [@scheduler, :create_thread]
50
59
  end
@@ -108,11 +117,5 @@ module GoodJob
108
117
  def execute_inline?
109
118
  @execution_mode == :inline
110
119
  end
111
-
112
- # (deprecated) Whether in +:inline+ execution mode.
113
- def inline?
114
- ActiveSupport::Deprecation.warn('GoodJob::Adapter::inline? is deprecated; use GoodJob::Adapter::execute_inline? instead')
115
- execute_inline?
116
- end
117
120
  end
118
121
  end
@@ -15,6 +15,11 @@ 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
21
+ end
22
+
18
23
  # @!macro thor.desc
19
24
  # @!method $1
20
25
  # @return [void]
@@ -27,7 +32,8 @@ module GoodJob
27
32
  See option descriptions for the matching environment variable name.
28
33
 
29
34
  == Configuring queues
30
- \x5Separate multiple queues with commas; exclude queues with a leading minus;
35
+
36
+ Separate multiple queues with commas; exclude queues with a leading minus;
31
37
  separate isolated execution pools with semicolons and threads with colons.
32
38
 
33
39
  DESCRIPTION
@@ -43,10 +49,18 @@ module GoodJob
43
49
  type: :numeric,
44
50
  banner: 'SECONDS',
45
51
  desc: "Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 5)"
52
+ method_option :daemonize,
53
+ type: :boolean,
54
+ desc: "Run as a background daemon (default: false)"
55
+ method_option :pidfile,
56
+ type: :string,
57
+ desc: "Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)"
46
58
  def start
47
59
  set_up_application!
48
60
  configuration = GoodJob::Configuration.new(options)
49
61
 
62
+ Daemon.new(pidfile: configuration.pidfile).daemonize if configuration.daemonize?
63
+
50
64
  notifier = GoodJob::Notifier.new
51
65
  poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
52
66
  scheduler = GoodJob::Scheduler.from_configuration(configuration)
@@ -12,16 +12,15 @@ module GoodJob
12
12
  # Default number of seconds to preserve jobs for {CLI#cleanup_preserved_jobs}
13
13
  DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO = 24 * 60 * 60
14
14
 
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
15
+ # The options that were explicitly set when initializing +Configuration+.
16
+ # @return [Hash]
17
+ attr_reader :options
18
+
19
+ # The environment from which to read GoodJob's environment variables. By
20
+ # default, this is the current process's environment, but it can be set
21
+ # to something else in {#initialize}.
22
+ # @return [Hash]
23
+ attr_reader :env
25
24
 
26
25
  # @param options [Hash] Any explicitly specified configuration options to
27
26
  # use. Keys are symbols that match the various methods on this class.
@@ -45,6 +44,8 @@ module GoodJob
45
44
  def execution_mode(default: :external)
46
45
  if options[:execution_mode]
47
46
  options[:execution_mode]
47
+ elsif rails_config[:execution_mode]
48
+ rails_config[:execution_mode]
48
49
  elsif env['GOOD_JOB_EXECUTION_MODE'].present?
49
50
  env['GOOD_JOB_EXECUTION_MODE'].to_sym
50
51
  else
@@ -58,9 +59,7 @@ module GoodJob
58
59
  def rails_execution_mode
59
60
  if execution_mode(default: nil)
60
61
  execution_mode
61
- elsif Rails.env.development?
62
- :inline
63
- elsif Rails.env.test?
62
+ elsif Rails.env.development? || Rails.env.test?
64
63
  :inline
65
64
  else
66
65
  :external
@@ -74,6 +73,7 @@ module GoodJob
74
73
  def max_threads
75
74
  (
76
75
  options[:max_threads] ||
76
+ rails_config[:max_threads] ||
77
77
  env['GOOD_JOB_MAX_THREADS'] ||
78
78
  env['RAILS_MAX_THREADS'] ||
79
79
  DEFAULT_MAX_THREADS
@@ -87,6 +87,7 @@ module GoodJob
87
87
  # @return [String]
88
88
  def queue_string
89
89
  options[:queues] ||
90
+ rails_config[:queues] ||
90
91
  env['GOOD_JOB_QUEUES'] ||
91
92
  '*'
92
93
  end
@@ -98,17 +99,42 @@ module GoodJob
98
99
  def poll_interval
99
100
  (
100
101
  options[:poll_interval] ||
102
+ rails_config[:poll_interval] ||
101
103
  env['GOOD_JOB_POLL_INTERVAL'] ||
102
104
  DEFAULT_POLL_INTERVAL
103
105
  ).to_i
104
106
  end
105
107
 
108
+ # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
109
+ # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
110
+ # @return [Integer]
106
111
  def cleanup_preserved_jobs_before_seconds_ago
107
112
  (
108
113
  options[:before_seconds_ago] ||
114
+ rails_config[:cleanup_preserved_jobs_before_seconds_ago] ||
109
115
  env['GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO'] ||
110
116
  DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO
111
117
  ).to_i
112
118
  end
119
+
120
+ # Tests whether to daemonize the process.
121
+ # @return [Boolean]
122
+ def daemonize?
123
+ options[:daemonize] || false
124
+ end
125
+
126
+ # Path of the pidfile to create when running as a daemon.
127
+ # @return [Pathname,String]
128
+ def pidfile
129
+ options[:pidfile] ||
130
+ env['GOOD_JOB_PIDFILE'] ||
131
+ Rails.application.root.join('tmp', 'pids', 'good_job.pid')
132
+ end
133
+
134
+ private
135
+
136
+ def rails_config
137
+ Rails.application.config.good_job
138
+ end
113
139
  end
114
140
  end
@@ -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
@@ -139,7 +139,7 @@ module GoodJob
139
139
  unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
140
140
  good_job = good_jobs.first
141
141
  # TODO: Determine why some records are fetched without an advisory lock at all
142
- break unless good_job&.owns_advisory_lock?
142
+ break unless good_job&.executable?
143
143
 
144
144
  result, error = good_job.perform
145
145
  end
@@ -216,6 +216,12 @@ module GoodJob
216
216
  [result, job_error]
217
217
  end
218
218
 
219
+ # Tests whether this job is safe to be executed by this thread.
220
+ # @return [Boolean]
221
+ def executable?
222
+ self.class.unscoped.unfinished.owns_advisory_locked.exists?(id: id)
223
+ end
224
+
219
225
  private
220
226
 
221
227
  def execute
@@ -0,0 +1,63 @@
1
+ require 'concurrent/delay'
2
+
3
+ module GoodJob
4
+ #
5
+ # JobPerformer queries the database for jobs and performs them on behalf of a
6
+ # {Scheduler}. It mainly functions as glue between a {Scheduler} and the jobs
7
+ # it should be executing.
8
+ #
9
+ # The JobPerformer must be safe to execute across multiple threads.
10
+ #
11
+ class JobPerformer
12
+ # @param queue_string [String] Queues to execute jobs from
13
+ def initialize(queue_string)
14
+ @queue_string = queue_string
15
+
16
+ @job_query = Concurrent::Delay.new { GoodJob::Job.queue_string(queue_string) }
17
+ @parsed_queues = Concurrent::Delay.new { GoodJob::Job.queue_parser(queue_string) }
18
+ end
19
+
20
+ # A meaningful name to identify the performer in logs and for debugging.
21
+ # @return [String] The queues from which Jobs are worked
22
+ def name
23
+ @queue_string
24
+ end
25
+
26
+ # Perform the next eligible job
27
+ # @return [nil, Object] Returns job result or +nil+ if no job was found
28
+ def next
29
+ job_query.perform_with_advisory_lock
30
+ end
31
+
32
+ # Tests whether this performer should be used in GoodJob's current state.
33
+ #
34
+ # For example, state will be a LISTEN/NOTIFY message that is passed down
35
+ # from the Notifier to the Scheduler. The Scheduler is able to ask
36
+ # its performer "does this message relate to you?", and if not, ignore it
37
+ # to minimize thread wake-ups, database queries, and thundering herds.
38
+ #
39
+ # @return [Boolean] whether the performer's {#next} method should be
40
+ # called in the current state.
41
+ def next?(state = {})
42
+ if parsed_queues[:exclude]
43
+ parsed_queues[:exclude].exclude?(state[:queue_name])
44
+ elsif parsed_queues[:include]
45
+ parsed_queues[:include].include?(state[:queue_name])
46
+ else
47
+ true
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :queue_string
54
+
55
+ def job_query
56
+ @job_query.value
57
+ end
58
+
59
+ def parsed_queues
60
+ @parsed_queues.value
61
+ end
62
+ end
63
+ end
@@ -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
@@ -141,9 +141,9 @@ module GoodJob
141
141
  end
142
142
 
143
143
  def supports_cte_materialization_specifiers?
144
- return @supports_cte_materialization_specifiers if defined?(@supports_cte_materialization_specifiers)
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 = ActiveRecord::Base.connection.postgresql_version >= 120000
147
147
  end
148
148
  end
149
149
 
@@ -153,10 +153,12 @@ module GoodJob
153
153
  # all remaining locks).
154
154
  # @return [Boolean] whether the lock was acquired.
155
155
  def advisory_lock
156
- where_sql = <<~SQL.squish
157
- pg_try_advisory_lock(('x' || substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
156
+ query = <<~SQL.squish
157
+ SELECT 1 AS one
158
+ WHERE pg_try_advisory_lock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
158
159
  SQL
159
- self.class.unscoped.exists?([where_sql, { table_name: self.class.table_name, id: send(self.class.primary_key) }])
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?
160
162
  end
161
163
 
162
164
  # Releases an advisory lock on this record if it is locked by this database
@@ -166,9 +168,10 @@ module GoodJob
166
168
  def advisory_unlock
167
169
  query = <<~SQL.squish
168
170
  SELECT 1 AS one
169
- WHERE pg_advisory_unlock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
171
+ WHERE pg_advisory_unlock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
170
172
  SQL
171
- self.class.connection.execute(sanitize_sql_for_conditions([query, { table_name: self.class.table_name, id: send(self.class.primary_key) }])).ntuples.positive?
173
+ binds = [[nil, self.class.table_name], [nil, send(self.class.primary_key)]]
174
+ self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Unlock', binds).any?
172
175
  end
173
176
 
174
177
  # Acquires an advisory lock on this record or raises
@@ -205,13 +208,32 @@ module GoodJob
205
208
  # Tests whether this record has an advisory lock on it.
206
209
  # @return [Boolean]
207
210
  def advisory_locked?
208
- self.class.unscoped.advisory_locked.exists?(id: send(self.class.primary_key))
211
+ query = <<~SQL.squish
212
+ SELECT 1 AS one
213
+ FROM pg_locks
214
+ WHERE pg_locks.locktype = 'advisory'
215
+ AND pg_locks.objsubid = 1
216
+ AND pg_locks.classid = ('x' || substr(md5($1 || $2::text), 1, 16))::bit(32)::int
217
+ AND pg_locks.objid = (('x' || substr(md5($3 || $4::text), 1, 16))::bit(64) << 32)::bit(32)::int
218
+ SQL
219
+ binds = [[nil, self.class.table_name], [nil, send(self.class.primary_key)], [nil, self.class.table_name], [nil, send(self.class.primary_key)]]
220
+ self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Locked?', binds).any?
209
221
  end
210
222
 
211
223
  # Tests whether this record is locked by the current database session.
212
224
  # @return [Boolean]
213
225
  def owns_advisory_lock?
214
- self.class.unscoped.owns_advisory_locked.exists?(id: send(self.class.primary_key))
226
+ query = <<~SQL.squish
227
+ SELECT 1 AS one
228
+ FROM pg_locks
229
+ WHERE pg_locks.locktype = 'advisory'
230
+ AND pg_locks.objsubid = 1
231
+ AND pg_locks.classid = ('x' || substr(md5($1 || $2::text), 1, 16))::bit(32)::int
232
+ AND pg_locks.objid = (('x' || substr(md5($3 || $4::text), 1, 16))::bit(64) << 32)::bit(32)::int
233
+ AND pg_locks.pid = pg_backend_pid()
234
+ SQL
235
+ binds = [[nil, self.class.table_name], [nil, send(self.class.primary_key)], [nil, self.class.table_name], [nil, send(self.class.primary_key)]]
236
+ self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Owns Advisory Lock?', binds).any?
215
237
  end
216
238
 
217
239
  # Releases all advisory locks on the record that are held by the current
@@ -227,5 +249,14 @@ module GoodJob
227
249
  # Made public in Rails 5.2
228
250
  self.class.send(:sanitize_sql_for_conditions, *args)
229
251
  end
252
+
253
+ def pg_or_jdbc_query(query)
254
+ if Concurrent.on_jruby?
255
+ # Replace $1 bind parameters with ?
256
+ query.gsub(/\$\d*/, '?')
257
+ else
258
+ query
259
+ end
260
+ end
230
261
  end
231
262
  end