good_job 1.0.3 → 1.1.4

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.
@@ -0,0 +1,34 @@
1
+ module GoodJob
2
+ class MultiScheduler
3
+ attr_reader :schedulers
4
+
5
+ def initialize(schedulers)
6
+ @schedulers = schedulers
7
+ end
8
+
9
+ def shutdown(wait: true)
10
+ schedulers.each { |s| s.shutdown(wait: wait) }
11
+ end
12
+
13
+ def shutdown?
14
+ schedulers.all?(&:shutdown?)
15
+ end
16
+
17
+ def restart(wait: true)
18
+ schedulers.each { |s| s.restart(wait: wait) }
19
+ end
20
+
21
+ def create_thread(state = nil)
22
+ results = []
23
+ any_true = schedulers.any? do |scheduler|
24
+ scheduler.create_thread(state).tap { |result| results << result }
25
+ end
26
+
27
+ if any_true
28
+ true
29
+ else
30
+ results.any? { |result| result == false } ? false : nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,116 @@
1
+ require 'concurrent/atomic/atomic_boolean'
2
+
3
+ module GoodJob # :nodoc:
4
+ #
5
+ # Wrapper for Postgres LISTEN/NOTIFY
6
+ #
7
+ class Notifier
8
+ CHANNEL = 'good_job'.freeze
9
+ POOL_OPTIONS = {
10
+ min_threads: 0,
11
+ max_threads: 1,
12
+ auto_terminate: true,
13
+ idletime: 60,
14
+ max_queue: 1,
15
+ fallback_policy: :discard,
16
+ }.freeze
17
+ WAIT_INTERVAL = 1
18
+
19
+ # @!attribute [r] instances
20
+ # @!scope class
21
+ # @return [array<GoodJob:Adapter>] the instances of +GoodJob::Notifier+
22
+ cattr_reader :instances, default: [], instance_reader: false
23
+
24
+ def self.notify(message)
25
+ connection = ActiveRecord::Base.connection
26
+ connection.exec_query <<~SQL
27
+ NOTIFY #{CHANNEL}, #{connection.quote(message.to_json)}
28
+ SQL
29
+ end
30
+
31
+ attr_reader :recipients
32
+
33
+ def initialize(*recipients)
34
+ @recipients = Concurrent::Array.new(recipients)
35
+ @listening = Concurrent::AtomicBoolean.new(false)
36
+
37
+ self.class.instances << self
38
+
39
+ create_pool
40
+ listen
41
+ end
42
+
43
+ def listening?
44
+ @listening.true?
45
+ end
46
+
47
+ def restart(wait: true)
48
+ shutdown(wait: wait)
49
+ create_pool
50
+ listen
51
+ end
52
+
53
+ def shutdown(wait: true)
54
+ return unless @pool.running?
55
+
56
+ @pool.shutdown
57
+ @pool.wait_for_termination if wait
58
+ end
59
+
60
+ def shutdown?
61
+ !@pool.running?
62
+ end
63
+
64
+ private
65
+
66
+ def create_pool
67
+ @pool = Concurrent::ThreadPoolExecutor.new(POOL_OPTIONS)
68
+ end
69
+
70
+ def listen
71
+ future = Concurrent::Future.new(args: [@recipients, @pool, @listening], executor: @pool) do |recipients, pool, listening|
72
+ Rails.application.reloader.wrap do
73
+ conn = ActiveRecord::Base.connection.raw_connection
74
+ ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
75
+ conn.async_exec "LISTEN #{CHANNEL}"
76
+ end
77
+
78
+ begin
79
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
80
+ while pool.running?
81
+ listening.make_true
82
+ conn.wait_for_notify(WAIT_INTERVAL) do |channel, _pid, payload|
83
+ listening.make_false
84
+ next unless channel == CHANNEL
85
+
86
+ ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
87
+ parsed_payload = JSON.parse(payload, symbolize_names: true)
88
+ recipients.each do |recipient|
89
+ target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
90
+ target.send(method_name, parsed_payload)
91
+ end
92
+ end
93
+ listening.make_false
94
+ end
95
+ end
96
+ rescue StandardError => e
97
+ ActiveSupport::Notifications.instrument("notifier_notify_error.good_job", { error: e })
98
+ raise
99
+ ensure
100
+ @listening.make_false
101
+ ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
102
+ conn.async_exec "UNLISTEN *"
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ future.add_observer(self, :listen_observer)
109
+ future.execute
110
+ end
111
+
112
+ def listen_observer(_time, _result, _thread_error)
113
+ listen unless shutdown?
114
+ end
115
+ end
116
+ end
@@ -1,12 +1,22 @@
1
1
  module GoodJob
2
2
  class Performer
3
- def initialize(target, method_name)
3
+ attr_reader :name
4
+
5
+ def initialize(target, method_name, name: nil, filter: nil)
4
6
  @target = target
5
7
  @method_name = method_name
8
+ @name = name
9
+ @filter = filter
6
10
  end
7
11
 
8
12
  def next
9
13
  @target.public_send(@method_name)
10
14
  end
15
+
16
+ def next?(state = {})
17
+ return true unless @filter.respond_to?(:call)
18
+
19
+ @filter.call(state)
20
+ end
11
21
  end
12
22
  end
@@ -2,6 +2,17 @@ module GoodJob
2
2
  class Railtie < ::Rails::Railtie
3
3
  initializer "good_job.logger" do
4
4
  ActiveSupport.on_load(:good_job) { self.logger = ::Rails.logger }
5
+ GoodJob::LogSubscriber.attach_to :good_job
6
+ end
7
+
8
+ initializer "good_job.active_job_notifications" do
9
+ ActiveSupport::Notifications.subscribe "enqueue_retry.active_job" do |event|
10
+ GoodJob::CurrentExecution.error_on_retry = event.payload[:error]
11
+ end
12
+
13
+ ActiveSupport::Notifications.subscribe "discard.active_job" do |event|
14
+ GoodJob::CurrentExecution.error_on_discard = event.payload[:error]
15
+ end
5
16
  end
6
17
  end
7
18
  end
@@ -2,16 +2,25 @@ require "concurrent/executor/thread_pool_executor"
2
2
  require "concurrent/timer_task"
3
3
  require "concurrent/utility/processor_counter"
4
4
 
5
- module GoodJob
5
+ module GoodJob # :nodoc:
6
+ #
7
+ # Schedulers are generic thread execution pools that are responsible for
8
+ # periodically checking for available execution tasks, executing tasks in a
9
+ # bounded thread-pool, and efficiently scaling execution threads.
10
+ #
11
+ # Schedulers are "generic" in the sense that they delegate task execution
12
+ # details to a "Performer" object that responds to #next.
13
+ #
6
14
  class Scheduler
15
+ # Defaults for instance of Concurrent::TimerTask
7
16
  DEFAULT_TIMER_OPTIONS = {
8
17
  execution_interval: 1,
9
18
  timeout_interval: 1,
10
19
  run_now: true,
11
20
  }.freeze
12
21
 
22
+ # Defaults for instance of Concurrent::ThreadPoolExecutor
13
23
  DEFAULT_POOL_OPTIONS = {
14
- name: 'good_job',
15
24
  min_threads: 0,
16
25
  max_threads: Concurrent.processor_count,
17
26
  auto_terminate: true,
@@ -20,44 +29,113 @@ module GoodJob
20
29
  fallback_policy: :discard,
21
30
  }.freeze
22
31
 
32
+ # @!attribute [r] instances
33
+ # @!scope class
34
+ # All instantiated Schedulers in the current process.
35
+ # @return [array<GoodJob:Scheduler>]
36
+ cattr_reader :instances, default: [], instance_reader: false
37
+
38
+ # Creates GoodJob::Scheduler(s) and Performers from a GoodJob::Configuration instance.
39
+ # @param configuration [GoodJob::Configuration]
40
+ # @return [GoodJob::Scheduler, GoodJob::MultiScheduler]
41
+ def self.from_configuration(configuration)
42
+ schedulers = configuration.queue_string.split(';').map do |queue_string_and_max_threads|
43
+ queue_string, max_threads = queue_string_and_max_threads.split(':')
44
+ max_threads = (max_threads || configuration.max_threads).to_i
45
+
46
+ job_query = GoodJob::Job.queue_string(queue_string)
47
+ parsed = GoodJob::Job.queue_parser(queue_string)
48
+ job_filter = proc do |state|
49
+ if parsed[:exclude]
50
+ !parsed[:exclude].include? state[:queue_name]
51
+ elsif parsed[:include]
52
+ parsed[:include].include? state[:queue_name]
53
+ else
54
+ true
55
+ end
56
+ end
57
+ job_performer = GoodJob::Performer.new(job_query, :perform_with_advisory_lock, name: queue_string, filter: job_filter)
58
+
59
+ timer_options = {}
60
+ timer_options[:execution_interval] = configuration.poll_interval
61
+
62
+ pool_options = {
63
+ max_threads: max_threads,
64
+ }
65
+
66
+ GoodJob::Scheduler.new(job_performer, timer_options: timer_options, pool_options: pool_options)
67
+ end
68
+
69
+ if schedulers.size > 1
70
+ GoodJob::MultiScheduler.new(schedulers)
71
+ else
72
+ schedulers.first
73
+ end
74
+ end
75
+
76
+ # @param performer [GoodJob::Performer]
77
+ # @param timer_options [Hash] Options to instantiate a Concurrent::TimerTask
78
+ # @param pool_options [Hash] Options to instantiate a Concurrent::ThreadPoolExecutor
23
79
  def initialize(performer, timer_options: {}, pool_options: {})
24
80
  raise ArgumentError, "Performer argument must implement #next" unless performer.respond_to?(:next)
25
81
 
82
+ self.class.instances << self
83
+
26
84
  @performer = performer
27
- @pool = ThreadPoolExecutor.new(DEFAULT_POOL_OPTIONS.merge(pool_options))
28
- @timer = Concurrent::TimerTask.new(DEFAULT_TIMER_OPTIONS.merge(timer_options)) do
29
- create_thread
30
- end
31
- @timer.add_observer(self, :timer_observer)
32
- @timer.execute
33
- end
85
+ @pool_options = DEFAULT_POOL_OPTIONS.merge(pool_options)
86
+ @timer_options = DEFAULT_TIMER_OPTIONS.merge(timer_options)
34
87
 
35
- def execute
88
+ @pool_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@pool_options[:max_threads]} poll_interval=#{@timer_options[:execution_interval]})"
89
+
90
+ create_pools
36
91
  end
37
92
 
93
+ # Shut down the Scheduler.
94
+ # @param wait [Boolean] Wait for actively executing jobs to finish
95
+ # @return [void]
38
96
  def shutdown(wait: true)
39
97
  @_shutdown = true
40
98
 
41
- ActiveSupport::Notifications.instrument("scheduler_start_shutdown.good_job", { wait: wait })
42
- ActiveSupport::Notifications.instrument("scheduler_shutdown.good_job", { wait: wait }) do
43
- if @timer.running?
99
+ instrument("scheduler_shutdown_start", { wait: wait })
100
+ instrument("scheduler_shutdown", { wait: wait }) do
101
+ if @timer&.running?
44
102
  @timer.shutdown
45
103
  @timer.wait_for_termination if wait
46
104
  end
47
105
 
48
- if @pool.running?
106
+ if @pool&.running?
49
107
  @pool.shutdown
50
108
  @pool.wait_for_termination if wait
51
109
  end
52
110
  end
53
111
  end
54
112
 
113
+ # True when the Scheduler is shutdown.
114
+ # @return [true, false, nil]
55
115
  def shutdown?
56
116
  @_shutdown
57
117
  end
58
118
 
59
- def create_thread
60
- return false unless @pool.ready_worker_count.positive?
119
+ # Restart the Scheduler. When shutdown, start; or shutdown and start.
120
+ # @param wait [Boolean] Wait for actively executing jobs to finish
121
+ # @return [void]
122
+ def restart(wait: true)
123
+ instrument("scheduler_restart_pools") do
124
+ shutdown(wait: wait) unless shutdown?
125
+ create_pools
126
+ @_shutdown = false
127
+ end
128
+ end
129
+
130
+ # Triggers a Performer execution, if an execution thread is available.
131
+ # @param state [nil, Object] Allows Performer#next? to accept or reject the execution
132
+ # @return [nil, Boolean] if the thread was created
133
+ def create_thread(state = nil)
134
+ return nil unless @pool.running? && @pool.ready_worker_count.positive?
135
+
136
+ if state
137
+ return false unless @performer.next?(state)
138
+ end
61
139
 
62
140
  future = Concurrent::Future.new(args: [@performer], executor: @pool) do |performer|
63
141
  output = nil
@@ -66,26 +144,63 @@ module GoodJob
66
144
  end
67
145
  future.add_observer(self, :task_observer)
68
146
  future.execute
147
+
148
+ true
69
149
  end
70
150
 
151
+ # Invoked on completion of TimerTask task.
152
+ # @!visibility private
153
+ # @return [void]
71
154
  def timer_observer(time, executed_task, thread_error)
72
- ActiveSupport::Notifications.instrument("finished_timer_task.good_job", { result: executed_task, error: thread_error, time: time })
155
+ GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
156
+ instrument("finished_timer_task", { result: executed_task, error: thread_error, time: time })
73
157
  end
74
158
 
159
+ # Invoked on completion of ThreadPoolExecutor task
160
+ # @!visibility private
161
+ # @return [void]
75
162
  def task_observer(time, output, thread_error)
76
- ActiveSupport::Notifications.instrument("finished_job_task.good_job", { result: output, error: thread_error, time: time })
163
+ GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
164
+ instrument("finished_job_task", { result: output, error: thread_error, time: time })
77
165
  create_thread if output
78
166
  end
79
167
 
80
- class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
81
- # https://github.com/ruby-concurrency/concurrent-ruby/issues/684#issuecomment-427594437
82
- def ready_worker_count
83
- synchronize do
84
- workers_still_to_be_created = @max_length - @pool.length
85
- workers_created_but_waiting = @ready.length
168
+ private
86
169
 
87
- workers_still_to_be_created + workers_created_but_waiting
88
- end
170
+ # @return [void]
171
+ def create_pools
172
+ instrument("scheduler_create_pools", { performer_name: @performer.name, max_threads: @pool_options[:max_threads], poll_interval: @timer_options[:execution_interval] }) do
173
+ @pool = ThreadPoolExecutor.new(@pool_options)
174
+ next unless @timer_options[:execution_interval].positive?
175
+
176
+ @timer = Concurrent::TimerTask.new(@timer_options) { create_thread }
177
+ @timer.add_observer(self, :timer_observer)
178
+ @timer.execute
179
+ end
180
+ end
181
+
182
+ def instrument(name, payload = {}, &block)
183
+ payload = payload.reverse_merge({
184
+ scheduler: self,
185
+ process_id: GoodJob::CurrentExecution.process_id,
186
+ thread_name: GoodJob::CurrentExecution.thread_name,
187
+ })
188
+
189
+ ActiveSupport::Notifications.instrument("#{name}.good_job", payload, &block)
190
+ end
191
+ end
192
+
193
+ # Slightly customized sub-class of Concurrent::ThreadPoolExecutor
194
+ class ThreadPoolExecutor < Concurrent::ThreadPoolExecutor
195
+ # Number of idle or potential threads available to execute tasks
196
+ # https://github.com/ruby-concurrency/concurrent-ruby/issues/684#issuecomment-427594437
197
+ # @return [Integer]
198
+ def ready_worker_count
199
+ synchronize do
200
+ workers_still_to_be_created = @max_length - @pool.length
201
+ workers_created_but_waiting = @ready.length
202
+
203
+ workers_still_to_be_created + workers_created_but_waiting
89
204
  end
90
205
  end
91
206
  end
@@ -1,3 +1,3 @@
1
1
  module GoodJob
2
- VERSION = '1.0.3'.freeze
2
+ VERSION = '1.1.4'.freeze
3
3
  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.0.3
4
+ version: 1.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-07-26 00:00:00.000000000 Z
11
+ date: 2020-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: dotenv
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'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: foreman
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -137,7 +151,35 @@ dependencies:
137
151
  - !ruby/object:Gem::Version
138
152
  version: '0'
139
153
  - !ruby/object:Gem::Dependency
140
- name: pry
154
+ name: pry-rails
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: puma
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: rbtrace
141
183
  requirement: !ruby/object:Gem::Requirement
142
184
  requirements:
143
185
  - - ">="
@@ -220,6 +262,34 @@ dependencies:
220
262
  - - ">="
221
263
  - !ruby/object:Gem::Version
222
264
  version: '0'
265
+ - !ruby/object:Gem::Dependency
266
+ name: sigdump
267
+ requirement: !ruby/object:Gem::Requirement
268
+ requirements:
269
+ - - ">="
270
+ - !ruby/object:Gem::Version
271
+ version: '0'
272
+ type: :development
273
+ prerelease: false
274
+ version_requirements: !ruby/object:Gem::Requirement
275
+ requirements:
276
+ - - ">="
277
+ - !ruby/object:Gem::Version
278
+ version: '0'
279
+ - !ruby/object:Gem::Dependency
280
+ name: yard
281
+ requirement: !ruby/object:Gem::Requirement
282
+ requirements:
283
+ - - ">="
284
+ - !ruby/object:Gem::Version
285
+ version: '0'
286
+ type: :development
287
+ prerelease: false
288
+ version_requirements: !ruby/object:Gem::Requirement
289
+ requirements:
290
+ - - ">="
291
+ - !ruby/object:Gem::Version
292
+ version: '0'
223
293
  description: A multithreaded, Postgres-based ActiveJob backend for Ruby on Rails
224
294
  email:
225
295
  - bensheldon@gmail.com
@@ -241,9 +311,13 @@ files:
241
311
  - lib/good_job.rb
242
312
  - lib/good_job/adapter.rb
243
313
  - lib/good_job/cli.rb
314
+ - lib/good_job/configuration.rb
315
+ - lib/good_job/current_execution.rb
244
316
  - lib/good_job/job.rb
245
317
  - lib/good_job/lockable.rb
246
- - lib/good_job/logging.rb
318
+ - lib/good_job/log_subscriber.rb
319
+ - lib/good_job/multi_scheduler.rb
320
+ - lib/good_job/notifier.rb
247
321
  - lib/good_job/performer.rb
248
322
  - lib/good_job/pg_locks.rb
249
323
  - lib/good_job/railtie.rb
@@ -258,7 +332,7 @@ metadata:
258
332
  documentation_uri: https://rdoc.info/github/bensheldon/good_job
259
333
  homepage_uri: https://github.com/bensheldon/good_job
260
334
  source_code_uri: https://github.com/bensheldon/good_job
261
- post_install_message:
335
+ post_install_message:
262
336
  rdoc_options:
263
337
  - "--title"
264
338
  - GoodJob - a multithreaded, Postgres-based ActiveJob backend for Ruby on Rails
@@ -281,7 +355,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
281
355
  version: '0'
282
356
  requirements: []
283
357
  rubygems_version: 3.0.3
284
- signing_key:
358
+ signing_key:
285
359
  specification_version: 4
286
360
  summary: A multithreaded, Postgres-based ActiveJob backend for Ruby on Rails
287
361
  test_files: []