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.
@@ -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,8 +16,8 @@ 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,
@@ -30,14 +30,14 @@ module GoodJob # :nodoc:
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,66 +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
73
+ @executor_options = DEFAULT_EXECUTOR_OPTIONS.dup
74
74
  if max_threads.present?
75
- @pool_options[:max_threads] = max_threads
76
- @pool_options[:max_queue] = max_threads
75
+ @executor_options[:max_threads] = max_threads
76
+ @executor_options[:max_queue] = max_threads
77
77
  end
78
- @pool_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@pool_options[:max_threads]})"
78
+ @executor_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@executor_options[:max_threads]})"
79
79
 
80
- create_pool
80
+ create_executor
81
81
  warm_cache if warm_cache_on_initialize
82
82
  end
83
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
+
84
92
  # Shut down the scheduler.
85
- # This stops all threads in the pool.
86
- # If +wait+ is +true+, the scheduler will wait for any active tasks to finish.
87
- # 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.
88
94
  # Use {#shutdown?} to determine whether threads have stopped.
89
- # @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.
90
100
  # @return [void]
91
- def shutdown(wait: true)
92
- return unless @pool&.running?
93
-
94
- instrument("scheduler_shutdown_start", { wait: wait })
95
- instrument("scheduler_shutdown", { wait: wait }) do
96
- @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
97
110
 
98
- @pool.shutdown
99
- @pool.wait_for_termination if wait
100
- # 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
101
115
  end
102
116
  end
103
117
 
104
- # Tests whether the scheduler is shutdown.
105
- # @return [true, false, nil]
106
- def shutdown?
107
- !@pool&.running?
108
- end
109
-
110
118
  # Restart the Scheduler.
111
119
  # When shutdown, start; or shutdown and start.
112
- # @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}.
113
121
  # @return [void]
114
- def restart(wait: true)
122
+ def restart(timeout: -1)
115
123
  instrument("scheduler_restart_pools") do
116
- shutdown(wait: wait) unless shutdown?
117
- create_pool
124
+ shutdown(timeout: timeout) if running?
125
+ create_executor
118
126
  warm_cache
119
127
  end
120
128
  end
121
129
 
122
130
  # Wakes a thread to allow the performer to execute a task.
123
- # @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?}.
124
132
  # @return [nil, Boolean] Whether work was started.
125
- # Returns +nil+ if the scheduler is unable to take new work, for example if the thread pool is shut down or at capacity.
126
- # Returns +true+ if the performer started executing work.
127
- # 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.
128
137
  def create_thread(state = nil)
129
- return nil unless @pool.running?
138
+ return nil unless executor.running?
130
139
 
131
140
  if state
132
- return false unless @performer.next?(state)
141
+ return false unless performer.next?(state)
133
142
 
134
143
  if state[:scheduled_at]
135
144
  scheduled_at = if state[:scheduled_at].is_a? String
@@ -144,18 +153,12 @@ module GoodJob # :nodoc:
144
153
  delay ||= 0
145
154
  run_now = delay <= 0.01
146
155
  if run_now
147
- return nil unless @pool.ready_worker_count.positive?
156
+ return nil unless executor.ready_worker_count.positive?
148
157
  elsif @max_cache.positive?
149
158
  return nil unless remaining_cache_count.positive?
150
159
  end
151
160
 
152
- future = Concurrent::ScheduledTask.new(delay, args: [@performer], executor: @pool, timer_set: timer_set) do |performer|
153
- output = nil
154
- Rails.application.executor.wrap { output = performer.next }
155
- output
156
- end
157
- future.add_observer(self, :task_observer)
158
- future.execute
161
+ create_task(delay)
159
162
 
160
163
  run_now ? true : nil
161
164
  end
@@ -169,45 +172,61 @@ module GoodJob # :nodoc:
169
172
  create_task if output
170
173
  end
171
174
 
172
- def warm_cache
173
- return if @max_cache.zero?
174
-
175
- @performer.next_at(
176
- limit: @max_cache,
177
- now_limit: @pool_options[:max_threads]
178
- ).each do |scheduled_at|
179
- create_thread({ scheduled_at: scheduled_at })
180
- end
181
- end
182
-
175
+ # Information about the Scheduler
176
+ # @return [Hash]
183
177
  def stats
184
178
  {
185
- name: @performer.name,
186
- max_threads: @pool_options[:max_threads],
187
- active_threads: @pool_options[:max_threads] - @pool.ready_worker_count,
188
- available_threads: @pool.ready_worker_count,
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,
189
183
  max_cache: @max_cache,
190
184
  active_cache: cache_count,
191
185
  available_cache: remaining_cache_count,
192
186
  }
193
187
  end
194
188
 
189
+ # Preload existing runnable and future-scheduled jobs
190
+ # @return [void]
191
+ def warm_cache
192
+ return if @max_cache.zero?
193
+
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
203
+ end
204
+
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
212
+ end
213
+
195
214
  private
196
215
 
197
- attr_reader :timer_set
216
+ attr_reader :performer, :executor, :timer_set
198
217
 
199
- def create_pool
200
- instrument("scheduler_create_pool", { performer_name: @performer.name, max_threads: @pool_options[:max_threads] }) do
201
- @timer_set = Concurrent::TimerSet.new
202
- @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)
203
222
  end
204
223
  end
205
224
 
206
225
  def create_task(delay = 0)
207
- future = Concurrent::ScheduledTask.new(delay, args: [@performer], executor: @pool, timer_set: timer_set) do |performer|
208
- output = nil
209
- Rails.application.executor.wrap { output = performer.next }
210
- output
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
211
230
  end
212
231
  future.add_observer(self, :task_observer)
213
232
  future.execute
@@ -224,7 +243,7 @@ module GoodJob # :nodoc:
224
243
  end
225
244
 
226
245
  def cache_count
227
- timer_set.instance_variable_get(:@queue).length
246
+ timer_set.length
228
247
  end
229
248
 
230
249
  def remaining_cache_count
@@ -249,5 +268,21 @@ module GoodJob # :nodoc:
249
268
  end
250
269
  end
251
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
252
287
  end
253
288
  end