good_job 1.6.0 → 1.9.1

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.
@@ -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,44 @@ 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
+ #
58
+ # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
59
+ # * +-1+, the scheduler will wait until the shutdown is complete.
60
+ # * +0+, the scheduler will immediately shutdown and stop any threads.
61
+ # * A positive number will wait that many seconds before stopping any remaining active threads.
51
62
  # @return [void]
52
- def shutdown(wait: true)
53
- return unless @timer&.running?
63
+ def shutdown(timeout: -1)
64
+ return if timer.nil? || timer.shutdown?
54
65
 
55
- @timer.shutdown
56
- @timer.wait_for_termination if wait
57
- end
66
+ timer.shutdown if timer.running?
58
67
 
59
- # Tests whether the poller is shutdown.
60
- # @return [true, false, nil]
61
- def shutdown?
62
- !@timer&.running?
68
+ if timer.shuttingdown? && timeout # rubocop:disable Style/GuardClause
69
+ timer_wait = timeout.negative? ? nil : timeout
70
+ timer.kill unless timer.wait_for_termination(timer_wait)
71
+ end
63
72
  end
64
73
 
65
74
  # Restart the poller.
66
75
  # When shutdown, start; or shutdown and start.
67
- # @param wait [Boolean] Wait for background thread to finish
76
+ # @param timeout [nil, Numeric] Seconds to wait; shares same values as {#shutdown}.
68
77
  # @return [void]
69
- def restart(wait: true)
70
- shutdown(wait: wait)
71
- create_pool
78
+ def restart(timeout: -1)
79
+ shutdown(timeout: timeout) if running?
80
+ create_timer
72
81
  end
73
82
 
74
83
  # Invoked on completion of TimerTask task.
@@ -76,12 +85,14 @@ module GoodJob # :nodoc:
76
85
  # @return [void]
77
86
  def timer_observer(time, executed_task, thread_error)
78
87
  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 })
88
+ ActiveSupport::Notifications.instrument("finished_timer_task", { result: executed_task, error: thread_error, time: time })
80
89
  end
81
90
 
82
91
  private
83
92
 
84
- def create_pool
93
+ attr_reader :timer
94
+
95
+ def create_timer
85
96
  return if @timer_options[:execution_interval] <= 0
86
97
 
87
98
  @timer = Concurrent::TimerTask.new(@timer_options) do
@@ -19,5 +19,9 @@ module GoodJob
19
19
  GoodJob::CurrentExecution.error_on_discard = event.payload[:error]
20
20
  end
21
21
  end
22
+
23
+ config.after_initialize do
24
+ GoodJob::Scheduler.instances.each(&:warm_cache)
25
+ end
22
26
  end
23
27
  end
@@ -1,5 +1,6 @@
1
1
  require "concurrent/executor/thread_pool_executor"
2
- require "concurrent/timer_task"
2
+ require "concurrent/executor/timer_set"
3
+ require "concurrent/scheduled_task"
3
4
  require "concurrent/utility/processor_counter"
4
5
 
5
6
  module GoodJob # :nodoc:
@@ -15,33 +16,39 @@ module GoodJob # :nodoc:
15
16
  #
16
17
  class Scheduler
17
18
  # Defaults for instance of Concurrent::ThreadPoolExecutor
18
- # The thread pool is where work is performed.
19
- DEFAULT_POOL_OPTIONS = {
19
+ # The thread pool executor is where work is performed.
20
+ DEFAULT_EXECUTOR_OPTIONS = {
20
21
  name: name,
21
22
  min_threads: 0,
22
23
  max_threads: Configuration::DEFAULT_MAX_THREADS,
23
24
  auto_terminate: true,
24
25
  idletime: 60,
25
- max_queue: 0,
26
+ max_queue: Configuration::DEFAULT_MAX_THREADS,
26
27
  fallback_policy: :discard,
27
28
  }.freeze
28
29
 
29
30
  # @!attribute [r] instances
30
31
  # @!scope class
31
32
  # List of all instantiated Schedulers in the current process.
32
- # @return [array<GoodJob:Scheduler>]
33
+ # @return [Array<GoodJob::Scheduler>]
33
34
  cattr_reader :instances, default: [], instance_reader: false
34
35
 
35
36
  # Creates GoodJob::Scheduler(s) and Performers from a GoodJob::Configuration instance.
36
37
  # @param configuration [GoodJob::Configuration]
38
+ # @param warm_cache_on_initialize [Boolean]
37
39
  # @return [GoodJob::Scheduler, GoodJob::MultiScheduler]
38
- def self.from_configuration(configuration)
40
+ def self.from_configuration(configuration, warm_cache_on_initialize: true)
39
41
  schedulers = configuration.queue_string.split(';').map do |queue_string_and_max_threads|
40
42
  queue_string, max_threads = queue_string_and_max_threads.split(':')
41
43
  max_threads = (max_threads || configuration.max_threads).to_i
42
44
 
43
45
  job_performer = GoodJob::JobPerformer.new(queue_string)
44
- GoodJob::Scheduler.new(job_performer, max_threads: max_threads)
46
+ GoodJob::Scheduler.new(
47
+ job_performer,
48
+ max_threads: max_threads,
49
+ max_cache: configuration.max_cache,
50
+ warm_cache_on_initialize: warm_cache_on_initialize
51
+ )
45
52
  end
46
53
 
47
54
  if schedulers.size > 1
@@ -53,74 +60,108 @@ module GoodJob # :nodoc:
53
60
 
54
61
  # @param performer [GoodJob::JobPerformer]
55
62
  # @param max_threads [Numeric, nil] number of seconds between polls for jobs
56
- def initialize(performer, max_threads: nil)
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)
57
66
  raise ArgumentError, "Performer argument must implement #next" unless performer.respond_to?(:next)
58
67
 
59
68
  self.class.instances << self
60
69
 
61
70
  @performer = performer
62
71
 
63
- @pool_options = DEFAULT_POOL_OPTIONS.dup
64
- @pool_options[:max_threads] = max_threads if max_threads.present?
65
- @pool_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@pool_options[:max_threads]})"
72
+ @max_cache = max_cache || 0
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]})"
66
79
 
67
- create_pool
80
+ create_executor
81
+ warm_cache if warm_cache_on_initialize
68
82
  end
69
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
+
70
92
  # Shut down the scheduler.
71
- # This stops all threads in the pool.
72
- # If +wait+ is +true+, the scheduler will wait for any active tasks to finish.
73
- # 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.
74
94
  # Use {#shutdown?} to determine whether threads have stopped.
75
- # @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
+ #
97
+ # * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
98
+ # * +-1+, the scheduler will wait until the shutdown is complete.
99
+ # * +0+, the scheduler will immediately shutdown and stop any active tasks.
100
+ # * A positive number will wait that many seconds before stopping any remaining active tasks.
76
101
  # @return [void]
77
- def shutdown(wait: true)
78
- return unless @pool&.running?
79
-
80
- instrument("scheduler_shutdown_start", { wait: wait })
81
- instrument("scheduler_shutdown", { wait: wait }) do
82
- @pool.shutdown
83
- @pool.wait_for_termination if wait
84
- # TODO: Should be killed if wait is not true
85
- end
86
- end
102
+ def shutdown(timeout: -1)
103
+ return if executor.nil? || executor.shutdown?
87
104
 
88
- # Tests whether the scheduler is shutdown.
89
- # @return [true, false, nil]
90
- def shutdown?
91
- !@pool&.running?
105
+ instrument("scheduler_shutdown_start", { timeout: timeout })
106
+ instrument("scheduler_shutdown", { timeout: timeout }) do
107
+ if executor.running?
108
+ @timer_set.shutdown
109
+ executor.shutdown
110
+ end
111
+
112
+ if executor.shuttingdown? && timeout
113
+ executor_wait = timeout.negative? ? nil : timeout
114
+ executor.kill unless executor.wait_for_termination(executor_wait)
115
+ end
116
+ end
92
117
  end
93
118
 
94
119
  # Restart the Scheduler.
95
120
  # When shutdown, start; or shutdown and start.
96
- # @param wait [Boolean] Wait for actively executing jobs to finish
121
+ # @param timeout [nil, Numeric] Seconds to wait for actively executing jobs to finish; shares same values as {#shutdown}.
97
122
  # @return [void]
98
- def restart(wait: true)
123
+ def restart(timeout: -1)
99
124
  instrument("scheduler_restart_pools") do
100
- shutdown(wait: wait) unless shutdown?
101
- create_pool
125
+ shutdown(timeout: timeout) if running?
126
+ create_executor
127
+ warm_cache
102
128
  end
103
129
  end
104
130
 
105
131
  # Wakes a thread to allow the performer to execute a task.
106
- # @param state [nil, Object] Contextual information for the performer. See {Performer#next?}.
132
+ # @param state [nil, Object] Contextual information for the performer. See {JobPerformer#next?}.
107
133
  # @return [nil, Boolean] Whether work was started.
108
- # Returns +nil+ if the scheduler is unable to take new work, for example if the thread pool is shut down or at capacity.
109
- # Returns +true+ if the performer started executing work.
110
- # Returns +false+ if the performer decides not to attempt to execute a task based on the +state+ that is passed to it.
134
+ #
135
+ # * +nil+ if the scheduler is unable to take new work, for example if the thread pool is shut down or at capacity.
136
+ # * +true+ if the performer started executing work.
137
+ # * +false+ if the performer decides not to attempt to execute a task based on the +state+ that is passed to it.
111
138
  def create_thread(state = nil)
112
- return nil unless @pool.running? && @pool.ready_worker_count.positive?
113
- return false if state && !@performer.next?(state)
139
+ return nil unless executor.running?
114
140
 
115
- future = Concurrent::Future.new(args: [@performer], executor: @pool) do |performer|
116
- output = nil
117
- Rails.application.executor.wrap { output = performer.next }
118
- output
141
+ if state
142
+ return false unless performer.next?(state)
143
+
144
+ if state[:scheduled_at]
145
+ scheduled_at = if state[:scheduled_at].is_a? String
146
+ Time.zone.parse state[:scheduled_at]
147
+ else
148
+ state[:scheduled_at]
149
+ end
150
+ delay = [(scheduled_at - Time.current).to_f, 0].max
151
+ end
152
+ end
153
+
154
+ delay ||= 0
155
+ run_now = delay <= 0.01
156
+ if run_now
157
+ return nil unless executor.ready_worker_count.positive?
158
+ elsif @max_cache.positive?
159
+ return nil unless remaining_cache_count.positive?
119
160
  end
120
- future.add_observer(self, :task_observer)
121
- future.execute
122
161
 
123
- true
162
+ create_task(delay)
163
+
164
+ run_now ? true : nil
124
165
  end
125
166
 
126
167
  # Invoked on completion of ThreadPoolExecutor task
@@ -129,17 +170,57 @@ module GoodJob # :nodoc:
129
170
  def task_observer(time, output, thread_error)
130
171
  GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
131
172
  instrument("finished_job_task", { result: output, error: thread_error, time: time })
132
- create_thread if output
173
+ create_task if output
174
+ end
175
+
176
+ # Information about the Scheduler
177
+ # @return [Hash]
178
+ def stats
179
+ {
180
+ name: performer.name,
181
+ max_threads: @executor_options[:max_threads],
182
+ active_threads: @executor_options[:max_threads] - executor.ready_worker_count,
183
+ available_threads: executor.ready_worker_count,
184
+ max_cache: @max_cache,
185
+ active_cache: cache_count,
186
+ available_cache: remaining_cache_count,
187
+ }
188
+ end
189
+
190
+ # Preload existing runnable and future-scheduled jobs
191
+ # @return [void]
192
+ def warm_cache
193
+ return if @max_cache.zero?
194
+
195
+ performer.next_at(
196
+ limit: @max_cache,
197
+ now_limit: @executor_options[:max_threads]
198
+ ).each do |scheduled_at|
199
+ create_thread({ scheduled_at: scheduled_at })
200
+ end
133
201
  end
134
202
 
135
203
  private
136
204
 
137
- def create_pool
138
- instrument("scheduler_create_pool", { performer_name: @performer.name, max_threads: @pool_options[:max_threads] }) do
139
- @pool = ThreadPoolExecutor.new(@pool_options)
205
+ attr_reader :performer, :executor, :timer_set
206
+
207
+ def create_executor
208
+ instrument("scheduler_create_pool", { performer_name: performer.name, max_threads: @executor_options[:max_threads] }) do
209
+ @timer_set = TimerSet.new
210
+ @executor = ThreadPoolExecutor.new(@executor_options)
140
211
  end
141
212
  end
142
213
 
214
+ def create_task(delay = 0)
215
+ future = Concurrent::ScheduledTask.new(delay, args: [performer], executor: executor, timer_set: timer_set) do |thr_performer|
216
+ output = nil
217
+ Rails.application.executor.wrap { output = thr_performer.next }
218
+ output
219
+ end
220
+ future.add_observer(self, :task_observer)
221
+ future.execute
222
+ end
223
+
143
224
  def instrument(name, payload = {}, &block)
144
225
  payload = payload.reverse_merge({
145
226
  scheduler: self,
@@ -150,6 +231,14 @@ module GoodJob # :nodoc:
150
231
  ActiveSupport::Notifications.instrument("#{name}.good_job", payload, &block)
151
232
  end
152
233
 
234
+ def cache_count
235
+ timer_set.length
236
+ end
237
+
238
+ def remaining_cache_count
239
+ @max_cache - cache_count
240
+ end
241
+
153
242
  # Custom sub-class of +Concurrent::ThreadPoolExecutor+ to add additional worker status.
154
243
  # @private
155
244
  class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
@@ -168,5 +257,21 @@ module GoodJob # :nodoc:
168
257
  end
169
258
  end
170
259
  end
260
+
261
+ # Custom sub-class of +Concurrent::TimerSet+ for additional behavior.
262
+ # @private
263
+ class TimerSet < Concurrent::TimerSet
264
+ # Number of scheduled jobs in the queue
265
+ # @return [Integer]
266
+ def length
267
+ @queue.length
268
+ end
269
+
270
+ # Clear the queue
271
+ # @return [void]
272
+ def reset
273
+ synchronize { @queue.clear }
274
+ end
275
+ end
171
276
  end
172
277
  end
@@ -1,4 +1,4 @@
1
1
  module GoodJob
2
2
  # GoodJob gem version.
3
- VERSION = '1.6.0'.freeze
3
+ VERSION = '1.9.1'.freeze
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-01-22 00:00:00.000000000 Z
11
+ date: 2021-04-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -94,20 +94,6 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: '2.0'
97
- - !ruby/object:Gem::Dependency
98
- name: appraisal
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - ">="
102
- - !ruby/object:Gem::Version
103
- version: '0'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - ">="
109
- - !ruby/object:Gem::Version
110
- version: '0'
111
97
  - !ruby/object:Gem::Dependency
112
98
  name: capybara
113
99
  requirement: !ruby/object:Gem::Requirement
@@ -400,7 +386,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
400
386
  - !ruby/object:Gem::Version
401
387
  version: '0'
402
388
  requirements: []
403
- rubygems_version: 3.2.4
389
+ rubygems_version: 3.2.13
404
390
  signing_key:
405
391
  specification_version: 4
406
392
  summary: A multithreaded, Postgres-based ActiveJob backend for Ruby on Rails