good_job 1.6.0 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8f0f56838ed9baa984ad7b70ece1c72009369149bb45522aa140298ff727d44
4
- data.tar.gz: a076e6f1315911e9fcacc8be7be4e87d176f6a63fd4cf8e297b8985d928074cf
3
+ metadata.gz: ba54d04d3afa9fea7af913fb6e04f3bdbc104f47a57b629001071da7fcd4ed55
4
+ data.tar.gz: 2bd4dd5be31a15c43d58f0ab7cd33830834e1e2bcd0506445258aa75d4cc98e5
5
5
  SHA512:
6
- metadata.gz: 6eae147e6a6f22da09c87b246735d0844e09dbaaea2e9b2cc92664ad86efef95feb8572ddafa83964009572d5c55db5eefc9f589d63820fb77fc59ec8ac4f110
7
- data.tar.gz: 3f884573f31e65c3d63c2b864a820b9e9dd5db2a71405f3514ffdd774458bb89c7b5a834878aba2a57b988cce02443fc2df8e986d09815f98784bdaa9de6b62f
6
+ metadata.gz: 8ade9720ef3918e10d129e886411b819e8fd96614a299552d68287ed43fcab2997057b8c63ee26b35ac321cf7d58a055c7cf5b14f74f3c887b14f59934537be8
7
+ data.tar.gz: 9f81f5e7faacbe1b6a4999fa82afa6eb03675472c0237616f6f570c235f72c8c925fbea0ff526726bb6542c795ef47feaa61b5e9dd00520d6a38fbfa4b7463a5
@@ -1,6 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## [v1.6.0](https://github.com/bensheldon/good_job/tree/v1.6.0) (2021-01-21)
3
+ ## [v1.7.0](https://github.com/bensheldon/good_job/tree/v1.7.0) (2021-01-25)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.6.0...v1.7.0)
6
+
7
+ **Merged pull requests:**
8
+
9
+ - Cache scheduled jobs in memory so they can be executed without polling [\#205](https://github.com/bensheldon/good_job/pull/205) ([bensheldon](https://github.com/bensheldon))
10
+
11
+ ## [v1.6.0](https://github.com/bensheldon/good_job/tree/v1.6.0) (2021-01-22)
4
12
 
5
13
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.5.0...v1.6.0)
6
14
 
data/README.md CHANGED
@@ -152,6 +152,7 @@ Options:
152
152
  [--max-threads=COUNT] # Maximum number of threads to use for working jobs. (env var: GOOD_JOB_MAX_THREADS, default: 5)
153
153
  [--queues=QUEUE_LIST] # Queues to work from. (env var: GOOD_JOB_QUEUES, default: *)
154
154
  [--poll-interval=SECONDS] # Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 1)
155
+ [--max-cache=COUNT] # Maximum number of scheduled jobs to cache in memory (env var: GOOD_JOB_MAX_CACHE, default: 10000)
155
156
  [--daemonize] # Run as a background daemon (default: false)
156
157
  [--pidfile=PIDFILE] # Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)
157
158
 
@@ -225,6 +226,7 @@ Available configuration options are:
225
226
  - `max_threads` (integer) sets the maximum number of threads to use when `execution_mode` is set to `:async`. You can also set this with the environment variable `GOOD_JOB_MAX_THREADS`.
226
227
  - `queues` (string) determines which queues to execute jobs from when `execution_mode` is set to `:async`. See the description of `good_job start` for more details on the format of this string. You can also set this with the environment variable `GOOD_JOB_QUEUES`.
227
228
  - `poll_interval` (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`.
229
+ - `max_cache` (integer) sets the maximum number of scheduled jobs that will be stored in memory to reduce execution latency when also polling for scheduled jobs. Caching 10,000 scheduled jobs uses approximately 20MB of memory. You can also set this with the environment variable `GOOD_JOB_MAX_CACHE`.
228
230
 
229
231
  By default, GoodJob configures the following execution modes per environment:
230
232
 
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'good_job/cli'
3
+ GOOD_JOB_WITHIN_CLI = true
3
4
  GOOD_JOB_LOG_TO_STDOUT = true
4
5
  GoodJob::CLI.start(ARGV)
@@ -50,10 +50,10 @@ module GoodJob
50
50
  @execution_mode = configuration.execution_mode
51
51
  raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(@execution_mode)
52
52
 
53
- if @execution_mode == :async # rubocop:disable Style/GuardClause
53
+ if execute_async? # rubocop:disable Style/GuardClause
54
54
  @notifier = GoodJob::Notifier.new
55
55
  @poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
56
- @scheduler = GoodJob::Scheduler.from_configuration(configuration)
56
+ @scheduler = GoodJob::Scheduler.from_configuration(configuration, warm_cache_on_initialize: Rails.application.initialized?)
57
57
  @notifier.recipients << [@scheduler, :create_thread]
58
58
  @poller.recipients << [@scheduler, :create_thread]
59
59
  end
@@ -85,10 +85,13 @@ module GoodJob
85
85
  ensure
86
86
  good_job.advisory_unlock
87
87
  end
88
- end
88
+ else
89
+ job_state = { queue_name: good_job.queue_name }
90
+ job_state[:scheduled_at] = good_job.scheduled_at if good_job.scheduled_at
89
91
 
90
- executed_locally = execute_async? && @scheduler.create_thread(queue_name: good_job.queue_name)
91
- Notifier.notify(queue_name: good_job.queue_name) unless executed_locally
92
+ executed_locally = execute_async? && @scheduler.create_thread(job_state)
93
+ Notifier.notify(job_state) unless executed_locally
94
+ end
92
95
 
93
96
  good_job
94
97
  end
@@ -49,6 +49,10 @@ module GoodJob
49
49
  type: :numeric,
50
50
  banner: 'SECONDS',
51
51
  desc: "Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 5)"
52
+ method_option :max_cache,
53
+ type: :numeric,
54
+ banner: 'COUNT',
55
+ desc: "Maximum number of scheduled jobs to cache in memory (env var: GOOD_JOB_MAX_CACHE, default: 10000)"
52
56
  method_option :daemonize,
53
57
  type: :boolean,
54
58
  desc: "Run as a background daemon (default: false)"
@@ -8,7 +8,9 @@ module GoodJob
8
8
  # Default number of threads to use per {Scheduler}
9
9
  DEFAULT_MAX_THREADS = 5
10
10
  # Default number of seconds between polls for jobs
11
- DEFAULT_POLL_INTERVAL = 5
11
+ DEFAULT_POLL_INTERVAL = 10
12
+ # Default number of threads to use per {Scheduler}
13
+ DEFAULT_MAX_CACHE = 10000
12
14
  # Default number of seconds to preserve jobs for {CLI#cleanup_preserved_jobs}
13
15
  DEFAULT_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO = 24 * 60 * 60
14
16
 
@@ -42,7 +44,9 @@ module GoodJob
42
44
  # Value to use if none was specified in the configuration.
43
45
  # @return [Symbol]
44
46
  def execution_mode(default: :external)
45
- if options[:execution_mode]
47
+ if defined?(GOOD_JOB_WITHIN_CLI) && GOOD_JOB_WITHIN_CLI
48
+ :external
49
+ elsif options[:execution_mode]
46
50
  options[:execution_mode]
47
51
  elsif rails_config[:execution_mode]
48
52
  rails_config[:execution_mode]
@@ -105,6 +109,19 @@ module GoodJob
105
109
  ).to_i
106
110
  end
107
111
 
112
+ # The maximum number of future-scheduled jobs to store in memory.
113
+ # Storing future-scheduled jobs in memory reduces execution latency
114
+ # at the cost of increased memory usage. 10,000 stored jobs = ~20MB.
115
+ # @return [Integer]
116
+ def max_cache
117
+ (
118
+ options[:max_cache] ||
119
+ rails_config[:max_cache] ||
120
+ env['GOOD_JOB_MAX_CACHE'] ||
121
+ DEFAULT_MAX_CACHE
122
+ ).to_i
123
+ end
124
+
108
125
  # Number of seconds to preserve jobs when using the +good_job cleanup_preserved_jobs+ CLI command.
109
126
  # This configuration is only used when {GoodJob.preserve_job_records} is +true+.
110
127
  # @return [Integer]
@@ -72,6 +72,12 @@ module GoodJob
72
72
  # @return [ActiveRecord::Relation]
73
73
  scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
74
74
 
75
+ # Order jobs by scheduled (unscheduled or soonest first).
76
+ # @!method schedule_ordered
77
+ # @!scope class
78
+ # @return [ActiveRecord::Relation]
79
+ scope :schedule_ordered, -> { order(Arel.sql('COALESCE(scheduled_at, created_at) ASC')) }
80
+
75
81
  # Get Jobs were completed before the given timestamp. If no timestamp is
76
82
  # provided, get all jobs that have been completed. By default, GoodJob
77
83
  # deletes jobs after they are completed and this will find no jobs.
@@ -147,6 +153,23 @@ module GoodJob
147
153
  [good_job, result, error] if good_job
148
154
  end
149
155
 
156
+ # Fetches the scheduled execution time of the next eligible Job(s).
157
+ # @return [Array<(DateTime)>]
158
+ def self.next_scheduled_at(after: nil, limit: 100, now_limit: nil)
159
+ query = advisory_unlocked.unfinished.schedule_ordered
160
+
161
+ after ||= Time.current
162
+ after_query = query.where('scheduled_at > ?', after).or query.where(scheduled_at: nil).where('created_at > ?', after)
163
+ after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
164
+
165
+ if now_limit&.positive?
166
+ now_query = query.where('scheduled_at < ?', Time.current).or query.where(scheduled_at: nil)
167
+ now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
168
+ end
169
+
170
+ Array(now_at) + after_at
171
+ end
172
+
150
173
  # Places an ActiveJob job on a queue by creating a new {Job} record.
151
174
  # @param active_job [ActiveJob::Base]
152
175
  # The job to enqueue.
@@ -39,6 +39,8 @@ module GoodJob
39
39
  # @return [Boolean] whether the performer's {#next} method should be
40
40
  # called in the current state.
41
41
  def next?(state = {})
42
+ return true unless state[:queue_name]
43
+
42
44
  if parsed_queues[:exclude]
43
45
  parsed_queues[:exclude].exclude?(state[:queue_name])
44
46
  elsif parsed_queues[:include]
@@ -48,6 +50,14 @@ module GoodJob
48
50
  end
49
51
  end
50
52
 
53
+ # The Returns timestamps of when next tasks may be available.
54
+ # @param count [Integer] number of timestamps to return
55
+ # @param count [DateTime, Time, nil] jobs scheduled after this time
56
+ # @return [Array<(Time, Timestamp)>, nil]
57
+ def next_at(after: nil, limit: nil, now_limit: nil)
58
+ job_query.next_scheduled_at(after: after, limit: limit, now_limit: now_limit)
59
+ end
60
+
51
61
  private
52
62
 
53
63
  attr_reader :queue_string
@@ -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:
@@ -22,7 +23,7 @@ module GoodJob # :nodoc:
22
23
  max_threads: Configuration::DEFAULT_MAX_THREADS,
23
24
  auto_terminate: true,
24
25
  idletime: 60,
25
- max_queue: 0,
26
+ max_queue: 1, # ideally zero, but 0 == infinite
26
27
  fallback_policy: :discard,
27
28
  }.freeze
28
29
 
@@ -34,14 +35,20 @@ module GoodJob # :nodoc:
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,18 +60,22 @@ 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
 
72
+ @max_cache = max_cache || 0
63
73
  @pool_options = DEFAULT_POOL_OPTIONS.dup
64
74
  @pool_options[:max_threads] = max_threads if max_threads.present?
65
75
  @pool_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@pool_options[:max_threads]})"
66
76
 
67
77
  create_pool
78
+ warm_cache if warm_cache_on_initialize
68
79
  end
69
80
 
70
81
  # Shut down the scheduler.
@@ -79,6 +90,8 @@ module GoodJob # :nodoc:
79
90
 
80
91
  instrument("scheduler_shutdown_start", { wait: wait })
81
92
  instrument("scheduler_shutdown", { wait: wait }) do
93
+ @timer_set.shutdown
94
+
82
95
  @pool.shutdown
83
96
  @pool.wait_for_termination if wait
84
97
  # TODO: Should be killed if wait is not true
@@ -99,6 +112,7 @@ module GoodJob # :nodoc:
99
112
  instrument("scheduler_restart_pools") do
100
113
  shutdown(wait: wait) unless shutdown?
101
114
  create_pool
115
+ warm_cache
102
116
  end
103
117
  end
104
118
 
@@ -109,10 +123,30 @@ module GoodJob # :nodoc:
109
123
  # Returns +true+ if the performer started executing work.
110
124
  # Returns +false+ if the performer decides not to attempt to execute a task based on the +state+ that is passed to it.
111
125
  def create_thread(state = nil)
112
- return nil unless @pool.running? && @pool.ready_worker_count.positive?
113
- return false if state && !@performer.next?(state)
126
+ return nil unless @pool.running?
127
+
128
+ if state
129
+ return false unless @performer.next?(state)
130
+
131
+ if state[:scheduled_at]
132
+ scheduled_at = if state[:scheduled_at].is_a? String
133
+ Time.zone.parse state[:scheduled_at]
134
+ else
135
+ state[:scheduled_at]
136
+ end
137
+ delay = [(scheduled_at - Time.current).to_f, 0].max
138
+ end
139
+ end
140
+
141
+ delay ||= 0
142
+ run_now = delay <= 0.01
143
+ if run_now
144
+ return nil unless @pool.ready_worker_count.positive?
145
+ elsif @max_cache.positive?
146
+ return nil unless remaining_cache_count.positive?
147
+ end
114
148
 
115
- future = Concurrent::Future.new(args: [@performer], executor: @pool) do |performer|
149
+ future = Concurrent::ScheduledTask.new(delay, args: [@performer], executor: @pool, timer_set: timer_set) do |performer|
116
150
  output = nil
117
151
  Rails.application.executor.wrap { output = performer.next }
118
152
  output
@@ -120,7 +154,7 @@ module GoodJob # :nodoc:
120
154
  future.add_observer(self, :task_observer)
121
155
  future.execute
122
156
 
123
- true
157
+ run_now ? true : nil
124
158
  end
125
159
 
126
160
  # Invoked on completion of ThreadPoolExecutor task
@@ -132,10 +166,36 @@ module GoodJob # :nodoc:
132
166
  create_thread if output
133
167
  end
134
168
 
169
+ def warm_cache
170
+ return if @max_cache.zero?
171
+
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 })
177
+ end
178
+ end
179
+
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
+ }
190
+ end
191
+
135
192
  private
136
193
 
194
+ attr_reader :timer_set
195
+
137
196
  def create_pool
138
197
  instrument("scheduler_create_pool", { performer_name: @performer.name, max_threads: @pool_options[:max_threads] }) do
198
+ @timer_set = Concurrent::TimerSet.new
139
199
  @pool = ThreadPoolExecutor.new(@pool_options)
140
200
  end
141
201
  end
@@ -150,6 +210,14 @@ module GoodJob # :nodoc:
150
210
  ActiveSupport::Notifications.instrument("#{name}.good_job", payload, &block)
151
211
  end
152
212
 
213
+ def cache_count
214
+ timer_set.instance_variable_get(:@queue).length
215
+ end
216
+
217
+ def remaining_cache_count
218
+ @max_cache - cache_count
219
+ end
220
+
153
221
  # Custom sub-class of +Concurrent::ThreadPoolExecutor+ to add additional worker status.
154
222
  # @private
155
223
  class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
@@ -1,4 +1,4 @@
1
1
  module GoodJob
2
2
  # GoodJob gem version.
3
- VERSION = '1.6.0'.freeze
3
+ VERSION = '1.7.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.6.0
4
+ version: 1.7.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-22 00:00:00.000000000 Z
11
+ date: 2021-01-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob