good_job 1.3.4 → 1.5.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
@@ -45,6 +45,8 @@ module GoodJob
45
45
  def execution_mode(default: :external)
46
46
  if options[:execution_mode]
47
47
  options[:execution_mode]
48
+ elsif rails_config[:execution_mode]
49
+ rails_config[:execution_mode]
48
50
  elsif env['GOOD_JOB_EXECUTION_MODE'].present?
49
51
  env['GOOD_JOB_EXECUTION_MODE'].to_sym
50
52
  else
@@ -58,9 +60,7 @@ module GoodJob
58
60
  def rails_execution_mode
59
61
  if execution_mode(default: nil)
60
62
  execution_mode
61
- elsif Rails.env.development?
62
- :inline
63
- elsif Rails.env.test?
63
+ elsif Rails.env.development? || Rails.env.test?
64
64
  :inline
65
65
  else
66
66
  :external
@@ -74,6 +74,7 @@ module GoodJob
74
74
  def max_threads
75
75
  (
76
76
  options[:max_threads] ||
77
+ rails_config[:max_threads] ||
77
78
  env['GOOD_JOB_MAX_THREADS'] ||
78
79
  env['RAILS_MAX_THREADS'] ||
79
80
  DEFAULT_MAX_THREADS
@@ -87,6 +88,7 @@ module GoodJob
87
88
  # @return [String]
88
89
  def queue_string
89
90
  options[:queues] ||
91
+ rails_config[:queues] ||
90
92
  env['GOOD_JOB_QUEUES'] ||
91
93
  '*'
92
94
  end
@@ -98,17 +100,28 @@ module GoodJob
98
100
  def poll_interval
99
101
  (
100
102
  options[:poll_interval] ||
103
+ rails_config[:poll_interval] ||
101
104
  env['GOOD_JOB_POLL_INTERVAL'] ||
102
105
  DEFAULT_POLL_INTERVAL
103
106
  ).to_i
104
107
  end
105
108
 
109
+ # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
110
+ # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
111
+ # @return [Boolean]
106
112
  def cleanup_preserved_jobs_before_seconds_ago
107
113
  (
108
114
  options[:before_seconds_ago] ||
115
+ rails_config[:cleanup_preserved_jobs_before_seconds_ago] ||
109
116
  env['GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO'] ||
110
117
  DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO
111
118
  ).to_i
112
119
  end
120
+
121
+ private
122
+
123
+ def rails_config
124
+ Rails.application.config.good_job
125
+ end
113
126
  end
114
127
  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
@@ -32,11 +32,18 @@ module GoodJob
32
32
  original_query = self
33
33
 
34
34
  cte_table = Arel::Table.new(:rows)
35
- composed_cte = Arel::Nodes::As.new(cte_table, original_query.select(primary_key).except(:limit).arel)
35
+ cte_query = original_query.select(primary_key).except(:limit)
36
+ cte_type = if supports_cte_materialization_specifiers?
37
+ 'MATERIALIZED'
38
+ else
39
+ ''
40
+ end
41
+
42
+ composed_cte = Arel::Nodes::As.new(cte_table, Arel::Nodes::SqlLiteral.new([cte_type, "(", cte_query.to_sql, ")"].join(' ')))
36
43
 
37
44
  query = cte_table.project(cte_table[:id])
38
- .with(composed_cte)
39
- .where(Arel.sql(sanitize_sql_for_conditions(["pg_try_advisory_lock(('x' || substr(md5(:table_name || #{connection.quote_table_name(cte_table.name)}.#{quoted_primary_key}::text), 1, 16))::bit(64)::bigint)", { table_name: table_name }])))
45
+ .with(composed_cte)
46
+ .where(Arel.sql(sanitize_sql_for_conditions(["pg_try_advisory_lock(('x' || substr(md5(:table_name || #{connection.quote_table_name(cte_table.name)}.#{quoted_primary_key}::text), 1, 16))::bit(64)::bigint)", { table_name: table_name }])))
40
47
 
41
48
  limit = original_query.arel.ast.limit
42
49
  query.limit = limit.value if limit.present?
@@ -132,6 +139,12 @@ module GoodJob
132
139
  records.each(&:advisory_unlock)
133
140
  end
134
141
  end
142
+
143
+ def supports_cte_materialization_specifiers?
144
+ return @_supports_cte_materialization_specifiers if defined?(@_supports_cte_materialization_specifiers)
145
+
146
+ @_supports_cte_materialization_specifiers = ActiveRecord::Base.connection.postgresql_version >= 120000
147
+ end
135
148
  end
136
149
 
137
150
  # Acquires an advisory lock on this record if it is not already locked by
@@ -140,10 +153,12 @@ module GoodJob
140
153
  # all remaining locks).
141
154
  # @return [Boolean] whether the lock was acquired.
142
155
  def advisory_lock
143
- where_sql = <<~SQL.squish
144
- 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)
145
159
  SQL
146
- 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?
147
162
  end
148
163
 
149
164
  # Releases an advisory lock on this record if it is locked by this database
@@ -151,10 +166,12 @@ module GoodJob
151
166
  # {#advisory_unlock} and {#advisory_lock} the same number of times.
152
167
  # @return [Boolean] whether the lock was released.
153
168
  def advisory_unlock
154
- where_sql = <<~SQL.squish
155
- pg_advisory_unlock(('x' || substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
169
+ query = <<~SQL.squish
170
+ SELECT 1 AS one
171
+ WHERE pg_advisory_unlock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
156
172
  SQL
157
- self.class.unscoped.exists?([where_sql, { table_name: self.class.table_name, id: send(self.class.primary_key) }])
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?
158
175
  end
159
176
 
160
177
  # Acquires an advisory lock on this record or raises
@@ -191,13 +208,32 @@ module GoodJob
191
208
  # Tests whether this record has an advisory lock on it.
192
209
  # @return [Boolean]
193
210
  def advisory_locked?
194
- 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?
195
221
  end
196
222
 
197
223
  # Tests whether this record is locked by the current database session.
198
224
  # @return [Boolean]
199
225
  def owns_advisory_lock?
200
- 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?
201
237
  end
202
238
 
203
239
  # Releases all advisory locks on the record that are held by the current
@@ -213,5 +249,14 @@ module GoodJob
213
249
  # Made public in Rails 5.2
214
250
  self.class.send(:sanitize_sql_for_conditions, *args)
215
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
216
261
  end
217
262
  end
@@ -218,13 +218,13 @@ module GoodJob
218
218
  #
219
219
  %w(info debug warn error fatal unknown).each do |level|
220
220
  class_eval <<-METHOD, __FILE__, __LINE__ + 1
221
- def #{level}(progname = nil, tags: [], &block)
222
- return unless logger
223
-
224
- tag_logger(*tags) do
225
- logger.#{level}(progname, &block)
226
- end
227
- end
221
+ def #{level}(progname = nil, tags: [], &block) # def info(progname = nil, tags: [], &block)
222
+ return unless logger # return unless logger
223
+ #
224
+ tag_logger(*tags) do # tag_logger(*tags) do
225
+ logger.#{level}(progname, &block) # logger.info(progname, &block)
226
+ end # end
227
+ end #
228
228
  METHOD
229
229
  end
230
230
  end
@@ -9,6 +9,9 @@ module GoodJob # :nodoc:
9
9
  # When a message is received, the notifier passes the message to each of its recipients.
10
10
  #
11
11
  class Notifier
12
+ # Raised if the Database adapter does not implement LISTEN.
13
+ AdapterCannotListenError = Class.new(StandardError)
14
+
12
15
  # Default Postgres channel for LISTEN/NOTIFY
13
16
  CHANNEL = 'good_job'.freeze
14
17
  # Defaults for instance of Concurrent::ThreadPoolExecutor
@@ -90,6 +93,20 @@ module GoodJob # :nodoc:
90
93
  !@pool.running?
91
94
  end
92
95
 
96
+ # Invoked on completion of ThreadPoolExecutor task
97
+ # @!visibility private
98
+ # @return [void]
99
+ def listen_observer(_time, _result, thread_error)
100
+ return if thread_error.is_a? AdapterCannotListenError
101
+
102
+ if thread_error
103
+ GoodJob.on_thread_error.call(thread_error) if GoodJob.on_thread_error.respond_to?(:call)
104
+ ActiveSupport::Notifications.instrument("notifier_notify_error.good_job", { error: thread_error })
105
+ end
106
+
107
+ listen unless shutdown?
108
+ end
109
+
93
110
  private
94
111
 
95
112
  def create_pool
@@ -100,7 +117,7 @@ module GoodJob # :nodoc:
100
117
  future = Concurrent::Future.new(args: [@recipients, @pool, @listening], executor: @pool) do |recipients, pool, listening|
101
118
  with_listen_connection do |conn|
102
119
  ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
103
- conn.async_exec "LISTEN #{CHANNEL}"
120
+ conn.async_exec("LISTEN #{CHANNEL}").clear
104
121
  end
105
122
 
106
123
  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
@@ -120,14 +137,11 @@ module GoodJob # :nodoc:
120
137
  listening.make_false
121
138
  end
122
139
  end
123
- end
124
- rescue StandardError => e
125
- ActiveSupport::Notifications.instrument("notifier_notify_error.good_job", { error: e })
126
- raise
127
- ensure
128
- @listening.make_false
129
- ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
130
- conn.async_exec "UNLISTEN *"
140
+ ensure
141
+ listening.make_false
142
+ ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
143
+ conn.async_exec("UNLISTEN *").clear
144
+ end
131
145
  end
132
146
  end
133
147
 
@@ -135,16 +149,14 @@ module GoodJob # :nodoc:
135
149
  future.execute
136
150
  end
137
151
 
138
- def listen_observer(_time, _result, _thread_error)
139
- listen unless shutdown?
140
- end
141
-
142
152
  def with_listen_connection
143
153
  ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
144
154
  ActiveRecord::Base.connection_pool.remove(conn)
145
155
  end
146
156
  pg_conn = ar_conn.raw_connection
147
- pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(self.class.name)}")
157
+ raise AdapterCannotListenError unless pg_conn.respond_to? :wait_for_notify
158
+
159
+ pg_conn.async_exec("SET application_name = #{pg_conn.escape_identifier(self.class.name)}").clear
148
160
  yield pg_conn
149
161
  ensure
150
162
  ar_conn&.disconnect!