good_job 1.7.0 → 1.9.2

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.
@@ -15,7 +15,7 @@ module GoodJob # :nodoc:
15
15
  # Default Postgres channel for LISTEN/NOTIFY
16
16
  CHANNEL = 'good_job'.freeze
17
17
  # Defaults for instance of Concurrent::ThreadPoolExecutor
18
- POOL_OPTIONS = {
18
+ EXECUTOR_OPTIONS = {
19
19
  name: name,
20
20
  min_threads: 0,
21
21
  max_threads: 1,
@@ -30,13 +30,13 @@ module GoodJob # :nodoc:
30
30
  # @!attribute [r] instances
31
31
  # @!scope class
32
32
  # List of all instantiated Notifiers in the current process.
33
- # @return [array<GoodJob:Adapter>]
33
+ # @return [Array<GoodJob::Adapter>]
34
34
  cattr_reader :instances, default: [], instance_reader: false
35
35
 
36
36
  # Send a message via Postgres NOTIFY
37
37
  # @param message [#to_json]
38
38
  def self.notify(message)
39
- connection = ActiveRecord::Base.connection
39
+ connection = Job.connection
40
40
  connection.exec_query <<~SQL.squish
41
41
  NOTIFY #{CHANNEL}, #{connection.quote(message.to_json)}
42
42
  SQL
@@ -53,7 +53,7 @@ module GoodJob # :nodoc:
53
53
 
54
54
  self.class.instances << self
55
55
 
56
- create_pool
56
+ create_executor
57
57
  listen
58
58
  end
59
59
 
@@ -63,34 +63,42 @@ module GoodJob # :nodoc:
63
63
  @listening.true?
64
64
  end
65
65
 
66
- # Restart the notifier.
67
- # When shutdown, start; or shutdown and start.
68
- # @param wait [Boolean] Wait for background thread to finish
69
- # @return [void]
70
- def restart(wait: true)
71
- shutdown(wait: wait)
72
- create_pool
73
- listen
74
- end
66
+ # Tests whether the notifier is running.
67
+ # @return [true, false, nil]
68
+ delegate :running?, to: :executor, allow_nil: true
69
+
70
+ # Tests whether the scheduler is shutdown.
71
+ # @return [true, false, nil]
72
+ delegate :shutdown?, to: :executor, allow_nil: true
75
73
 
76
74
  # Shut down the notifier.
77
75
  # This stops the background LISTENing thread.
78
- # If +wait+ is +true+, the notifier will wait for background thread to shutdown.
79
- # If +wait+ is +false+, this method will return immediately even though threads may still be running.
80
76
  # Use {#shutdown?} to determine whether threads have stopped.
81
- # @param wait [Boolean] Wait for actively executing threads to finish
77
+ # @param timeout [nil, Numeric] Seconds to wait for active threads.
78
+ # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
79
+ # * +-1+, the scheduler will wait until the shutdown is complete.
80
+ # * +0+, the scheduler will immediately shutdown and stop any threads.
81
+ # * A positive number will wait that many seconds before stopping any remaining active threads.
82
82
  # @return [void]
83
- def shutdown(wait: true)
84
- return unless @pool.running?
83
+ def shutdown(timeout: -1)
84
+ return if executor.nil? || executor.shutdown?
85
85
 
86
- @pool.shutdown
87
- @pool.wait_for_termination if wait
86
+ executor.shutdown if executor.running?
87
+
88
+ if executor.shuttingdown? && timeout # rubocop:disable Style/GuardClause
89
+ executor_wait = timeout.negative? ? nil : timeout
90
+ executor.kill unless executor.wait_for_termination(executor_wait)
91
+ end
88
92
  end
89
93
 
90
- # Tests whether the notifier is shutdown.
91
- # @return [true, false, nil]
92
- def shutdown?
93
- !@pool.running?
94
+ # Restart the notifier.
95
+ # When shutdown, start; or shutdown and start.
96
+ # @param timeout [nil, Numeric] Seconds to wait; shares same values as {#shutdown}.
97
+ # @return [void]
98
+ def restart(timeout: -1)
99
+ shutdown(timeout: timeout) if running?
100
+ create_executor
101
+ listen
94
102
  end
95
103
 
96
104
  # Invoked on completion of ThreadPoolExecutor task
@@ -109,36 +117,36 @@ module GoodJob # :nodoc:
109
117
 
110
118
  private
111
119
 
112
- def create_pool
113
- @pool = Concurrent::ThreadPoolExecutor.new(POOL_OPTIONS)
120
+ attr_reader :executor
121
+
122
+ def create_executor
123
+ @executor = Concurrent::ThreadPoolExecutor.new(EXECUTOR_OPTIONS)
114
124
  end
115
125
 
116
126
  def listen
117
- future = Concurrent::Future.new(args: [@recipients, @pool, @listening], executor: @pool) do |recipients, pool, listening|
127
+ future = Concurrent::Future.new(args: [@recipients, executor, @listening], executor: @executor) do |thr_recipients, thr_executor, thr_listening|
118
128
  with_listen_connection do |conn|
119
129
  ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
120
130
  conn.async_exec("LISTEN #{CHANNEL}").clear
121
131
  end
122
132
 
123
133
  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
124
- while pool.running?
125
- listening.make_true
134
+ thr_listening.make_true
135
+ while thr_executor.running?
126
136
  conn.wait_for_notify(WAIT_INTERVAL) do |channel, _pid, payload|
127
- listening.make_false
128
137
  next unless channel == CHANNEL
129
138
 
130
139
  ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
131
140
  parsed_payload = JSON.parse(payload, symbolize_names: true)
132
- recipients.each do |recipient|
141
+ thr_recipients.each do |recipient|
133
142
  target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
134
143
  target.send(method_name, parsed_payload)
135
144
  end
136
145
  end
137
- listening.make_false
138
146
  end
139
147
  end
140
148
  ensure
141
- listening.make_false
149
+ thr_listening.make_false
142
150
  ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
143
151
  conn.async_exec("UNLISTEN *").clear
144
152
  end
@@ -150,8 +158,8 @@ module GoodJob # :nodoc:
150
158
  end
151
159
 
152
160
  def with_listen_connection
153
- ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
154
- ActiveRecord::Base.connection_pool.remove(conn)
161
+ ar_conn = Job.connection_pool.checkout.tap do |conn|
162
+ Job.connection_pool.remove(conn)
155
163
  end
156
164
  pg_conn = ar_conn.raw_connection
157
165
  raise AdapterCannotListenError unless pg_conn.respond_to? :wait_for_notify
@@ -16,7 +16,7 @@ module GoodJob # :nodoc:
16
16
  # @!attribute [r] instances
17
17
  # @!scope class
18
18
  # List of all instantiated Pollers in the current process.
19
- # @return [array<GoodJob:Poller>]
19
+ # @return [Array<GoodJob::Poller>]
20
20
  cattr_reader :instances, default: [], instance_reader: false
21
21
 
22
22
  # Creates GoodJob::Poller from a GoodJob::Configuration instance.
@@ -40,35 +40,43 @@ module GoodJob # :nodoc:
40
40
 
41
41
  self.class.instances << self
42
42
 
43
- create_pool
43
+ create_timer
44
44
  end
45
45
 
46
- # Shut down the poller.
47
- # If +wait+ is +true+, the poller will wait for background thread to shutdown.
48
- # If +wait+ is +false+, this method will return immediately even though threads may still be running.
46
+ # Tests whether the timer is running.
47
+ # @return [true, false, nil]
48
+ delegate :running?, to: :timer, allow_nil: true
49
+
50
+ # Tests whether the timer is shutdown.
51
+ # @return [true, false, nil]
52
+ delegate :shutdown?, to: :timer, allow_nil: true
53
+
54
+ # Shut down the notifier.
49
55
  # Use {#shutdown?} to determine whether threads have stopped.
50
- # @param wait [Boolean] Wait for actively executing threads to finish
56
+ # @param timeout [nil, Numeric] Seconds to wait for active threads.
57
+ # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
58
+ # * +-1+, the scheduler will wait until the shutdown is complete.
59
+ # * +0+, the scheduler will immediately shutdown and stop any threads.
60
+ # * A positive number will wait that many seconds before stopping any remaining active threads.
51
61
  # @return [void]
52
- def shutdown(wait: true)
53
- return unless @timer&.running?
62
+ def shutdown(timeout: -1)
63
+ return if timer.nil? || timer.shutdown?
54
64
 
55
- @timer.shutdown
56
- @timer.wait_for_termination if wait
57
- end
65
+ timer.shutdown if timer.running?
58
66
 
59
- # Tests whether the poller is shutdown.
60
- # @return [true, false, nil]
61
- def shutdown?
62
- !@timer&.running?
67
+ if timer.shuttingdown? && timeout # rubocop:disable Style/GuardClause
68
+ timer_wait = timeout.negative? ? nil : timeout
69
+ timer.kill unless timer.wait_for_termination(timer_wait)
70
+ end
63
71
  end
64
72
 
65
73
  # Restart the poller.
66
74
  # When shutdown, start; or shutdown and start.
67
- # @param wait [Boolean] Wait for background thread to finish
75
+ # @param timeout [nil, Numeric] Seconds to wait; shares same values as {#shutdown}.
68
76
  # @return [void]
69
- def restart(wait: true)
70
- shutdown(wait: wait)
71
- create_pool
77
+ def restart(timeout: -1)
78
+ shutdown(timeout: timeout) if running?
79
+ create_timer
72
80
  end
73
81
 
74
82
  # Invoked on completion of TimerTask task.
@@ -76,12 +84,14 @@ module GoodJob # :nodoc:
76
84
  # @return [void]
77
85
  def timer_observer(time, executed_task, thread_error)
78
86
  GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
79
- instrument("finished_timer_task", { result: executed_task, error: thread_error, time: time })
87
+ ActiveSupport::Notifications.instrument("finished_timer_task", { result: executed_task, error: thread_error, time: time })
80
88
  end
81
89
 
82
90
  private
83
91
 
84
- def create_pool
92
+ attr_reader :timer
93
+
94
+ def create_timer
85
95
  return if @timer_options[:execution_interval] <= 0
86
96
 
87
97
  @timer = Concurrent::TimerTask.new(@timer_options) do
@@ -16,28 +16,28 @@ module GoodJob # :nodoc:
16
16
  #
17
17
  class Scheduler
18
18
  # Defaults for instance of Concurrent::ThreadPoolExecutor
19
- # The thread pool is where work is performed.
20
- DEFAULT_POOL_OPTIONS = {
19
+ # The thread pool executor is where work is performed.
20
+ DEFAULT_EXECUTOR_OPTIONS = {
21
21
  name: name,
22
22
  min_threads: 0,
23
23
  max_threads: Configuration::DEFAULT_MAX_THREADS,
24
24
  auto_terminate: true,
25
25
  idletime: 60,
26
- max_queue: 1, # ideally zero, but 0 == infinite
26
+ max_queue: Configuration::DEFAULT_MAX_THREADS,
27
27
  fallback_policy: :discard,
28
28
  }.freeze
29
29
 
30
30
  # @!attribute [r] instances
31
31
  # @!scope class
32
32
  # List of all instantiated Schedulers in the current process.
33
- # @return [array<GoodJob:Scheduler>]
33
+ # @return [Array<GoodJob::Scheduler>]
34
34
  cattr_reader :instances, default: [], instance_reader: false
35
35
 
36
36
  # Creates GoodJob::Scheduler(s) and Performers from a GoodJob::Configuration instance.
37
37
  # @param configuration [GoodJob::Configuration]
38
38
  # @param warm_cache_on_initialize [Boolean]
39
39
  # @return [GoodJob::Scheduler, GoodJob::MultiScheduler]
40
- def self.from_configuration(configuration, warm_cache_on_initialize: true)
40
+ def self.from_configuration(configuration, warm_cache_on_initialize: false)
41
41
  schedulers = configuration.queue_string.split(';').map do |queue_string_and_max_threads|
42
42
  queue_string, max_threads = queue_string_and_max_threads.split(':')
43
43
  max_threads = (max_threads || configuration.max_threads).to_i
@@ -61,8 +61,8 @@ module GoodJob # :nodoc:
61
61
  # @param performer [GoodJob::JobPerformer]
62
62
  # @param max_threads [Numeric, nil] number of seconds between polls for jobs
63
63
  # @param max_cache [Numeric, nil] maximum number of scheduled jobs to cache in memory
64
- # @param warm_cache_on_initialize [Boolean] whether to warm the cache immediately
65
- def initialize(performer, max_threads: nil, max_cache: nil, warm_cache_on_initialize: true)
64
+ # @param warm_cache_on_initialize [Boolean] whether to warm the cache immediately, or manually by calling +warm_cache+
65
+ def initialize(performer, max_threads: nil, max_cache: nil, warm_cache_on_initialize: false)
66
66
  raise ArgumentError, "Performer argument must implement #next" unless performer.respond_to?(:next)
67
67
 
68
68
  self.class.instances << self
@@ -70,63 +70,75 @@ module GoodJob # :nodoc:
70
70
  @performer = performer
71
71
 
72
72
  @max_cache = max_cache || 0
73
- @pool_options = DEFAULT_POOL_OPTIONS.dup
74
- @pool_options[:max_threads] = max_threads if max_threads.present?
75
- @pool_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@pool_options[:max_threads]})"
73
+ @executor_options = DEFAULT_EXECUTOR_OPTIONS.dup
74
+ if max_threads.present?
75
+ @executor_options[:max_threads] = max_threads
76
+ @executor_options[:max_queue] = max_threads
77
+ end
78
+ @executor_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@executor_options[:max_threads]})"
76
79
 
77
- create_pool
80
+ create_executor
78
81
  warm_cache if warm_cache_on_initialize
79
82
  end
80
83
 
84
+ # Tests whether the scheduler is running.
85
+ # @return [true, false, nil]
86
+ delegate :running?, to: :executor, allow_nil: true
87
+
88
+ # Tests whether the scheduler is shutdown.
89
+ # @return [true, false, nil]
90
+ delegate :shutdown?, to: :executor, allow_nil: true
91
+
81
92
  # Shut down the scheduler.
82
- # This stops all threads in the pool.
83
- # If +wait+ is +true+, the scheduler will wait for any active tasks to finish.
84
- # If +wait+ is +false+, this method will return immediately even though threads may still be running.
93
+ # This stops all threads in the thread pool.
85
94
  # Use {#shutdown?} to determine whether threads have stopped.
86
- # @param wait [Boolean] Wait for actively executing jobs to finish
95
+ # @param timeout [nil, Numeric] Seconds to wait for actively executing jobs to finish
96
+ # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
97
+ # * +-1+, the scheduler will wait until the shutdown is complete.
98
+ # * +0+, the scheduler will immediately shutdown and stop any active tasks.
99
+ # * A positive number will wait that many seconds before stopping any remaining active tasks.
87
100
  # @return [void]
88
- def shutdown(wait: true)
89
- return unless @pool&.running?
90
-
91
- instrument("scheduler_shutdown_start", { wait: wait })
92
- instrument("scheduler_shutdown", { wait: wait }) do
93
- @timer_set.shutdown
101
+ def shutdown(timeout: -1)
102
+ return if executor.nil? || executor.shutdown?
103
+
104
+ instrument("scheduler_shutdown_start", { timeout: timeout })
105
+ instrument("scheduler_shutdown", { timeout: timeout }) do
106
+ if executor.running?
107
+ @timer_set.shutdown
108
+ executor.shutdown
109
+ end
94
110
 
95
- @pool.shutdown
96
- @pool.wait_for_termination if wait
97
- # TODO: Should be killed if wait is not true
111
+ if executor.shuttingdown? && timeout
112
+ executor_wait = timeout.negative? ? nil : timeout
113
+ executor.kill unless executor.wait_for_termination(executor_wait)
114
+ end
98
115
  end
99
116
  end
100
117
 
101
- # Tests whether the scheduler is shutdown.
102
- # @return [true, false, nil]
103
- def shutdown?
104
- !@pool&.running?
105
- end
106
-
107
118
  # Restart the Scheduler.
108
119
  # When shutdown, start; or shutdown and start.
109
- # @param wait [Boolean] Wait for actively executing jobs to finish
120
+ # @param timeout [nil, Numeric] Seconds to wait for actively executing jobs to finish; shares same values as {#shutdown}.
110
121
  # @return [void]
111
- def restart(wait: true)
122
+ def restart(timeout: -1)
112
123
  instrument("scheduler_restart_pools") do
113
- shutdown(wait: wait) unless shutdown?
114
- create_pool
124
+ shutdown(timeout: timeout) if running?
125
+ create_executor
115
126
  warm_cache
116
127
  end
117
128
  end
118
129
 
119
130
  # Wakes a thread to allow the performer to execute a task.
120
- # @param state [nil, Object] Contextual information for the performer. See {Performer#next?}.
131
+ # @param state [nil, Object] Contextual information for the performer. See {JobPerformer#next?}.
121
132
  # @return [nil, Boolean] Whether work was started.
122
- # Returns +nil+ if the scheduler is unable to take new work, for example if the thread pool is shut down or at capacity.
123
- # Returns +true+ if the performer started executing work.
124
- # Returns +false+ if the performer decides not to attempt to execute a task based on the +state+ that is passed to it.
133
+ #
134
+ # * +nil+ if the scheduler is unable to take new work, for example if the thread pool is shut down or at capacity.
135
+ # * +true+ if the performer started executing work.
136
+ # * +false+ if the performer decides not to attempt to execute a task based on the +state+ that is passed to it.
125
137
  def create_thread(state = nil)
126
- return nil unless @pool.running?
138
+ return nil unless executor.running?
127
139
 
128
140
  if state
129
- return false unless @performer.next?(state)
141
+ return false unless performer.next?(state)
130
142
 
131
143
  if state[:scheduled_at]
132
144
  scheduled_at = if state[:scheduled_at].is_a? String
@@ -141,18 +153,12 @@ module GoodJob # :nodoc:
141
153
  delay ||= 0
142
154
  run_now = delay <= 0.01
143
155
  if run_now
144
- return nil unless @pool.ready_worker_count.positive?
156
+ return nil unless executor.ready_worker_count.positive?
145
157
  elsif @max_cache.positive?
146
158
  return nil unless remaining_cache_count.positive?
147
159
  end
148
160
 
149
- future = Concurrent::ScheduledTask.new(delay, args: [@performer], executor: @pool, timer_set: timer_set) do |performer|
150
- output = nil
151
- Rails.application.executor.wrap { output = performer.next }
152
- output
153
- end
154
- future.add_observer(self, :task_observer)
155
- future.execute
161
+ create_task(delay)
156
162
 
157
163
  run_now ? true : nil
158
164
  end
@@ -163,43 +169,69 @@ module GoodJob # :nodoc:
163
169
  def task_observer(time, output, thread_error)
164
170
  GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
165
171
  instrument("finished_job_task", { result: output, error: thread_error, time: time })
166
- create_thread if output
172
+ create_task if output
167
173
  end
168
174
 
175
+ # Information about the Scheduler
176
+ # @return [Hash]
177
+ def stats
178
+ {
179
+ name: performer.name,
180
+ max_threads: @executor_options[:max_threads],
181
+ active_threads: @executor_options[:max_threads] - executor.ready_worker_count,
182
+ available_threads: executor.ready_worker_count,
183
+ max_cache: @max_cache,
184
+ active_cache: cache_count,
185
+ available_cache: remaining_cache_count,
186
+ }
187
+ end
188
+
189
+ # Preload existing runnable and future-scheduled jobs
190
+ # @return [void]
169
191
  def warm_cache
170
192
  return if @max_cache.zero?
171
193
 
172
- @performer.next_at(
173
- limit: @max_cache,
174
- now_limit: @pool_options[:max_threads]
175
- ).each do |scheduled_at|
176
- create_thread({ scheduled_at: scheduled_at })
194
+ future = Concurrent::Future.new(args: [self, @performer], executor: executor) do |thr_scheduler, thr_performer|
195
+ Rails.application.executor.wrap do
196
+ thr_performer.next_at(
197
+ limit: @max_cache,
198
+ now_limit: @executor_options[:max_threads]
199
+ ).each do |scheduled_at|
200
+ thr_scheduler.create_thread({ scheduled_at: scheduled_at })
201
+ end
202
+ end
177
203
  end
178
- end
179
204
 
180
- def stats
181
- {
182
- name: @performer.name,
183
- max_threads: @pool_options[:max_threads],
184
- active_threads: @pool.ready_worker_count - @pool_options[:max_threads],
185
- inactive_threads: @pool.ready_worker_count,
186
- max_cache: @max_cache,
187
- cache_count: cache_count,
188
- cache_remaining: remaining_cache_count,
189
- }
205
+ observer = lambda do |_time, _output, thread_error|
206
+ GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
207
+ create_task # If cache-warming exhausts the threads, ensure there isn't an executable task remaining
208
+ end
209
+ future.add_observer(observer, :call)
210
+
211
+ future.execute
190
212
  end
191
213
 
192
214
  private
193
215
 
194
- attr_reader :timer_set
216
+ attr_reader :performer, :executor, :timer_set
195
217
 
196
- def create_pool
197
- instrument("scheduler_create_pool", { performer_name: @performer.name, max_threads: @pool_options[:max_threads] }) do
198
- @timer_set = Concurrent::TimerSet.new
199
- @pool = ThreadPoolExecutor.new(@pool_options)
218
+ def create_executor
219
+ instrument("scheduler_create_pool", { performer_name: performer.name, max_threads: @executor_options[:max_threads] }) do
220
+ @timer_set = TimerSet.new
221
+ @executor = ThreadPoolExecutor.new(@executor_options)
200
222
  end
201
223
  end
202
224
 
225
+ def create_task(delay = 0)
226
+ future = Concurrent::ScheduledTask.new(delay, args: [performer], executor: executor, timer_set: timer_set) do |thr_performer|
227
+ Rails.application.executor.wrap do
228
+ thr_performer.next
229
+ end
230
+ end
231
+ future.add_observer(self, :task_observer)
232
+ future.execute
233
+ end
234
+
203
235
  def instrument(name, payload = {}, &block)
204
236
  payload = payload.reverse_merge({
205
237
  scheduler: self,
@@ -211,7 +243,7 @@ module GoodJob # :nodoc:
211
243
  end
212
244
 
213
245
  def cache_count
214
- timer_set.instance_variable_get(:@queue).length
246
+ timer_set.length
215
247
  end
216
248
 
217
249
  def remaining_cache_count
@@ -236,5 +268,21 @@ module GoodJob # :nodoc:
236
268
  end
237
269
  end
238
270
  end
271
+
272
+ # Custom sub-class of +Concurrent::TimerSet+ for additional behavior.
273
+ # @private
274
+ class TimerSet < Concurrent::TimerSet
275
+ # Number of scheduled jobs in the queue
276
+ # @return [Integer]
277
+ def length
278
+ @queue.length
279
+ end
280
+
281
+ # Clear the queue
282
+ # @return [void]
283
+ def reset
284
+ synchronize { @queue.clear }
285
+ end
286
+ end
239
287
  end
240
288
  end