good_job 1.4.1 → 1.8.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.
@@ -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.
@@ -81,7 +90,9 @@ module GoodJob # :nodoc:
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
@@ -1,8 +1,12 @@
1
1
  module GoodJob
2
2
  # Ruby on Rails integration.
3
3
  class Railtie < ::Rails::Railtie
4
- initializer "good_job.logger" do
5
- ActiveSupport.on_load(:good_job) { self.logger = ::Rails.logger }
4
+ config.good_job = ActiveSupport::OrderedOptions.new
5
+
6
+ initializer "good_job.logger" do |_app|
7
+ ActiveSupport.on_load(:good_job) do
8
+ self.logger = ::Rails.logger
9
+ end
6
10
  GoodJob::LogSubscriber.attach_to :good_job
7
11
  end
8
12
 
@@ -15,5 +19,9 @@ module GoodJob
15
19
  GoodJob::CurrentExecution.error_on_discard = event.payload[:error]
16
20
  end
17
21
  end
22
+
23
+ config.after_initialize do
24
+ GoodJob::Scheduler.instances.each(&:warm_cache)
25
+ end
18
26
  end
19
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:
@@ -8,52 +9,46 @@ module GoodJob # :nodoc:
8
9
  # periodically checking for available tasks, executing tasks within a thread,
9
10
  # and efficiently scaling active threads.
10
11
  #
11
- # Every scheduler has a single {Performer} that will execute tasks.
12
+ # Every scheduler has a single {JobPerformer} that will execute tasks.
12
13
  # The scheduler is responsible for calling its performer efficiently across threads managed by an instance of +Concurrent::ThreadPoolExecutor+.
13
14
  # If a performer does not have work, the thread will go to sleep.
14
15
  # The scheduler maintains an instance of +Concurrent::TimerTask+, which wakes sleeping threads and causes them to check whether the performer has new work.
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
- job_query = GoodJob::Job.queue_string(queue_string)
44
- parsed = GoodJob::Job.queue_parser(queue_string)
45
- job_filter = proc do |state|
46
- if parsed[:exclude]
47
- parsed[:exclude].exclude?(state[:queue_name])
48
- elsif parsed[:include]
49
- parsed[:include].include? state[:queue_name]
50
- else
51
- true
52
- end
53
- end
54
- job_performer = GoodJob::Performer.new(job_query, :perform_with_advisory_lock, name: queue_string, filter: job_filter)
55
-
56
- GoodJob::Scheduler.new(job_performer, max_threads: max_threads)
45
+ job_performer = GoodJob::JobPerformer.new(queue_string)
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
+ )
57
52
  end
58
53
 
59
54
  if schedulers.size > 1
@@ -63,76 +58,110 @@ module GoodJob # :nodoc:
63
58
  end
64
59
  end
65
60
 
66
- # @param performer [GoodJob::Performer]
61
+ # @param performer [GoodJob::JobPerformer]
67
62
  # @param max_threads [Numeric, nil] number of seconds between polls for jobs
68
- 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)
69
66
  raise ArgumentError, "Performer argument must implement #next" unless performer.respond_to?(:next)
70
67
 
71
68
  self.class.instances << self
72
69
 
73
70
  @performer = performer
74
71
 
75
- @pool_options = DEFAULT_POOL_OPTIONS.dup
76
- @pool_options[:max_threads] = max_threads if max_threads.present?
77
- @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]})"
78
79
 
79
- create_pool
80
+ create_executor
81
+ warm_cache if warm_cache_on_initialize
80
82
  end
81
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
+
82
92
  # Shut down the scheduler.
83
- # This stops all threads in the pool.
84
- # If +wait+ is +true+, the scheduler will wait for any active tasks to finish.
85
- # 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.
86
94
  # Use {#shutdown?} to determine whether threads have stopped.
87
- # @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.
88
101
  # @return [void]
89
- def shutdown(wait: true)
90
- return unless @pool&.running?
91
-
92
- instrument("scheduler_shutdown_start", { wait: wait })
93
- instrument("scheduler_shutdown", { wait: wait }) do
94
- @pool.shutdown
95
- @pool.wait_for_termination if wait
96
- # TODO: Should be killed if wait is not true
97
- end
98
- end
102
+ def shutdown(timeout: -1)
103
+ return if executor.nil? || executor.shutdown?
99
104
 
100
- # Tests whether the scheduler is shutdown.
101
- # @return [true, false, nil]
102
- def shutdown?
103
- !@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
104
117
  end
105
118
 
106
119
  # Restart the Scheduler.
107
120
  # When shutdown, start; or shutdown and start.
108
- # @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}.
109
122
  # @return [void]
110
- def restart(wait: true)
123
+ def restart(timeout: -1)
111
124
  instrument("scheduler_restart_pools") do
112
- shutdown(wait: wait) unless shutdown?
113
- create_pool
125
+ shutdown(timeout: timeout) if running?
126
+ create_executor
127
+ warm_cache
114
128
  end
115
129
  end
116
130
 
117
131
  # Wakes a thread to allow the performer to execute a task.
118
- # @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?}.
119
133
  # @return [nil, Boolean] Whether work was started.
120
- # Returns +nil+ if the scheduler is unable to take new work, for example if the thread pool is shut down or at capacity.
121
- # Returns +true+ if the performer started executing work.
122
- # 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.
123
138
  def create_thread(state = nil)
124
- return nil unless @pool.running? && @pool.ready_worker_count.positive?
125
- return false if state && !@performer.next?(state)
139
+ return nil unless executor.running?
126
140
 
127
- future = Concurrent::Future.new(args: [@performer], executor: @pool) do |performer|
128
- output = nil
129
- Rails.application.executor.wrap { output = performer.next }
130
- 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?
131
160
  end
132
- future.add_observer(self, :task_observer)
133
- future.execute
134
161
 
135
- true
162
+ create_task(delay)
163
+
164
+ run_now ? true : nil
136
165
  end
137
166
 
138
167
  # Invoked on completion of ThreadPoolExecutor task
@@ -141,17 +170,55 @@ module GoodJob # :nodoc:
141
170
  def task_observer(time, output, thread_error)
142
171
  GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
143
172
  instrument("finished_job_task", { result: output, error: thread_error, time: time })
144
- 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
+ def warm_cache
191
+ return if @max_cache.zero?
192
+
193
+ performer.next_at(
194
+ limit: @max_cache,
195
+ now_limit: @executor_options[:max_threads]
196
+ ).each do |scheduled_at|
197
+ create_thread({ scheduled_at: scheduled_at })
198
+ end
145
199
  end
146
200
 
147
201
  private
148
202
 
149
- def create_pool
150
- instrument("scheduler_create_pool", { performer_name: @performer.name, max_threads: @pool_options[:max_threads] }) do
151
- @pool = ThreadPoolExecutor.new(@pool_options)
203
+ attr_reader :performer, :executor, :timer_set
204
+
205
+ def create_executor
206
+ instrument("scheduler_create_pool", { performer_name: performer.name, max_threads: @executor_options[:max_threads] }) do
207
+ @timer_set = Concurrent::TimerSet.new
208
+ @executor = ThreadPoolExecutor.new(@executor_options)
152
209
  end
153
210
  end
154
211
 
212
+ def create_task(delay = 0)
213
+ future = Concurrent::ScheduledTask.new(delay, args: [performer], executor: executor, timer_set: timer_set) do |thr_performer|
214
+ output = nil
215
+ Rails.application.executor.wrap { output = thr_performer.next }
216
+ output
217
+ end
218
+ future.add_observer(self, :task_observer)
219
+ future.execute
220
+ end
221
+
155
222
  def instrument(name, payload = {}, &block)
156
223
  payload = payload.reverse_merge({
157
224
  scheduler: self,
@@ -162,6 +229,14 @@ module GoodJob # :nodoc:
162
229
  ActiveSupport::Notifications.instrument("#{name}.good_job", payload, &block)
163
230
  end
164
231
 
232
+ def cache_count
233
+ timer_set.instance_variable_get(:@queue).length
234
+ end
235
+
236
+ def remaining_cache_count
237
+ @max_cache - cache_count
238
+ end
239
+
165
240
  # Custom sub-class of +Concurrent::ThreadPoolExecutor+ to add additional worker status.
166
241
  # @private
167
242
  class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
@@ -1,4 +1,4 @@
1
1
  module GoodJob
2
2
  # GoodJob gem version.
3
- VERSION = '1.4.1'.freeze
3
+ VERSION = '1.8.0'.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.4.1
4
+ version: 1.8.0
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-09 00:00:00.000000000 Z
11
+ date: 2021-03-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -357,12 +357,13 @@ files:
357
357
  - lib/good_job/cli.rb
358
358
  - lib/good_job/configuration.rb
359
359
  - lib/good_job/current_execution.rb
360
+ - lib/good_job/daemon.rb
360
361
  - lib/good_job/job.rb
362
+ - lib/good_job/job_performer.rb
361
363
  - lib/good_job/lockable.rb
362
364
  - lib/good_job/log_subscriber.rb
363
365
  - lib/good_job/multi_scheduler.rb
364
366
  - lib/good_job/notifier.rb
365
- - lib/good_job/performer.rb
366
367
  - lib/good_job/poller.rb
367
368
  - lib/good_job/railtie.rb
368
369
  - lib/good_job/scheduler.rb
@@ -399,7 +400,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
399
400
  - !ruby/object:Gem::Version
400
401
  version: '0'
401
402
  requirements: []
402
- rubygems_version: 3.2.4
403
+ rubygems_version: 3.1.4
403
404
  signing_key:
404
405
  specification_version: 4
405
406
  summary: A multithreaded, Postgres-based ActiveJob backend for Ruby on Rails