good_job 0.2.2 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a808beca38372ee8dcd23871600c9ed9b0181aa7756656c46e0382e9eef927b0
4
- data.tar.gz: 7fa59a1e31b4e720149ed19009f91691e840096570d38e519d37eb9b930cb655
3
+ metadata.gz: 3fb26ee806945cc3f179365b97150d570d9054c7907a94044f28a2afdd46d389
4
+ data.tar.gz: ab7d3247d78ad394e729c4b4787157ea3b60b0eeb01d0a31a2c93694a3320a62
5
5
  SHA512:
6
- metadata.gz: 9ce51a00e0aeabbd2be39e796168343d0daf96b368cb75ae8011f05bec891fcab820323189c43c79b97ae5a4c2586a7ec4996cd68717a83bb66613b7db523a95
7
- data.tar.gz: d0cb6bc52ba43a1e86a4ba12a8a09204d90f3b393de0d0dc54de64de37c85290f8228362e76f8c023a0d78f28d63706b4c8f2b04626134bc4240d9ad055544db
6
+ metadata.gz: 8065515ccdf0533d6069688a2afba7ffdb71a2ba9ad45660fda62b3c6c8347eff8e7d6a0a735bfcd974176a1e1e2e1168efd5dc74aa6fbe155326cb33f7700c3
7
+ data.tar.gz: 6e8877daa20867a5748b62a3e40130ee8a270c9c2db3ba1a8a460c76d33b9a48e6d8f127e779fe0808d245cc75276d36492adf7e70850640e729adbe2863e715
data/CHANGELOG.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## [0.2.2](https://github.com/bensheldon/good_job/tree/0.2.2) (2020-03-07)
3
+ ## [0.3.0](https://github.com/bensheldon/good_job/tree/0.3.0) (2020-03-22)
4
4
 
5
- [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.2.1...0.2.2)
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.2.1...0.3.0)
6
6
 
7
7
  **Merged pull requests:**
8
8
 
9
+ - Update development Ruby to 2.6.5 [\#22](https://github.com/bensheldon/good_job/pull/22) ([bensheldon](https://github.com/bensheldon))
10
+ - Simplify the internal API, removing JobWrapper and InlineScheduler [\#21](https://github.com/bensheldon/good_job/pull/21) ([bensheldon](https://github.com/bensheldon))
11
+ - Generate a new future for every executed job [\#20](https://github.com/bensheldon/good_job/pull/20) ([bensheldon](https://github.com/bensheldon))
12
+ - Configuration for maximum number of job execution threads [\#18](https://github.com/bensheldon/good_job/pull/18) ([bensheldon](https://github.com/bensheldon))
9
13
  - Gracefully shutdown Scheduler when executable receives TERM or INT [\#17](https://github.com/bensheldon/good_job/pull/17) ([bensheldon](https://github.com/bensheldon))
10
14
  - Update Appraisals [\#16](https://github.com/bensheldon/good_job/pull/16) ([bensheldon](https://github.com/bensheldon))
11
15
 
data/README.md CHANGED
@@ -67,6 +67,17 @@ $ bundle
67
67
  ```bash
68
68
  $ bundle exec good_job
69
69
  ```
70
+
71
+ ### Configuring Job Execution Threads
72
+
73
+ GoodJob executes enqueued jobs using threads. There is a lot than can be said about [multithreaded behavior in Ruby on Rails](https://guides.rubyonrails.org/threading_and_code_execution.html), but briefly:
74
+
75
+ - Each GoodJob execution thread requires its own database connection, which are automatically checked out from Rails’s connection pool. _Allowing GoodJob to schedule more threads than are available in the database connection pool can lead to timeouts and is not recommended._
76
+ - The maximum number of GoodJob threads can be configured, in decreasing precedence:
77
+ 1. `$ bundle exec good_job --max_threads 4`
78
+ 2. `$ GOOD_JOB_MAX_THREADS=4 bundle exec good_job`
79
+ 3. `$ RAILS_MAX_THREADS=4 bundle exec good_job`
80
+ 4. Implicitly via Rails's database connection pool size (`ActiveRecord::Base.connection_pool.size`)
70
81
 
71
82
  ## Development
72
83
 
data/lib/good_job.rb CHANGED
@@ -4,10 +4,9 @@ require 'good_job/railtie'
4
4
  require 'good_job/logging'
5
5
  require 'good_job/lockable'
6
6
  require 'good_job/job'
7
- require 'good_job/inline_scheduler'
8
7
  require "good_job/scheduler"
9
- require "good_job/job_wrapper"
10
8
  require 'good_job/adapter'
9
+ require 'good_job/pg_locks'
11
10
 
12
11
  module GoodJob
13
12
  include Logging
@@ -1,41 +1,36 @@
1
1
  module GoodJob
2
2
  class Adapter
3
- def initialize(options = {})
4
- @options = options
5
- @scheduler = InlineScheduler.new if inline?
3
+ def initialize(inline: false)
4
+ @inline = inline
6
5
  end
7
6
 
8
- def enqueue(job)
9
- enqueue_at(job, nil)
7
+ def enqueue(active_job)
8
+ enqueue_at(active_job, nil)
10
9
  end
11
10
 
12
- def enqueue_at(job, timestamp)
13
- params = {
14
- queue_name: job.queue_name,
15
- priority: job.priority,
16
- serialized_params: job.serialize,
17
- }
18
- params[:scheduled_at] = Time.at(timestamp) if timestamp
11
+ def enqueue_at(active_job, timestamp)
12
+ good_job = GoodJob::Job.enqueue(
13
+ active_job,
14
+ scheduled_at: timestamp ? Time.at(timestamp) : nil,
15
+ create_with_advisory_lock: inline?
16
+ )
19
17
 
20
- good_job = GoodJob::Job.create(params)
21
- job.provider_job_id = good_job.id
22
-
23
- GoodJob.tag_logger do
24
- ActiveSupport::Notifications.instrument("create.good_job", { good_job: good_job, job: job })
25
- @scheduler.enqueue(good_job) if inline?
18
+ if inline?
19
+ good_job.perform
20
+ good_job.advisory_unlock
26
21
  end
27
22
 
28
23
  good_job
29
24
  end
30
25
 
31
- def shutdown(wait: true)
32
- @scheduler&.shutdown(wait: wait)
26
+ def shutdown(wait: true) # rubocop:disable Lint/UnusedMethodArgument
27
+ nil
33
28
  end
34
29
 
35
30
  private
36
31
 
37
32
  def inline?
38
- @options.fetch(:inline, false)
33
+ @inline
39
34
  end
40
35
  end
41
36
  end
data/lib/good_job/cli.rb CHANGED
@@ -5,18 +5,23 @@ module GoodJob
5
5
  RAILS_ENVIRONMENT_RB = File.expand_path("config/environment.rb")
6
6
 
7
7
  desc :start, "Start jobs"
8
+ method_option :max_threads, type: :numeric
8
9
  def start
9
10
  require RAILS_ENVIRONMENT_RB
10
11
 
11
- scheduler = GoodJob::Scheduler.new
12
+ max_threads = options[:max_threads] ||
13
+ ENV['GOOD_JOB_MAX_THREADS'] ||
14
+ ENV['RAILS_MAX_THREADS'] ||
15
+ ActiveRecord::Base.connection_pool.size
16
+
17
+ $stdout.puts "GoodJob starting with max_threads=#{max_threads}"
18
+ scheduler = GoodJob::Scheduler.new(pool_options: { max_threads: max_threads })
12
19
 
13
20
  %w[INT TERM].each do |signal|
14
21
  trap(signal) { @stop_good_job_executable = true }
15
22
  end
16
23
  @stop_good_job_executable = false
17
24
 
18
- $stdout.puts "GoodJob waiting for jobs..."
19
-
20
25
  Kernel.loop do
21
26
  sleep 0.1
22
27
  break if @stop_good_job_executable || scheduler.shutdown?
data/lib/good_job/job.rb CHANGED
@@ -1,6 +1,39 @@
1
1
  module GoodJob
2
2
  class Job < ActiveRecord::Base
3
3
  include Lockable
4
+
4
5
  self.table_name = 'good_jobs'
6
+
7
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
8
+ good_job = nil
9
+ ActiveSupport::Notifications.instrument("enqueue_job.good_job", { active_job: active_job, scheduled_at: scheduled_at, create_with_advisory_lock: create_with_advisory_lock }) do |instrument_payload|
10
+ good_job = GoodJob::Job.new(
11
+ queue_name: active_job.queue_name,
12
+ priority: active_job.priority,
13
+ serialized_params: active_job.serialize,
14
+ scheduled_at: scheduled_at,
15
+ create_with_advisory_lock: create_with_advisory_lock
16
+ )
17
+
18
+ instrument_payload[:good_job] = good_job
19
+
20
+ good_job.save!
21
+ active_job.provider_job_id = good_job.id
22
+ end
23
+
24
+ good_job
25
+ end
26
+
27
+ def perform
28
+ ActiveSupport::Notifications.instrument("before_perform_job.good_job", { good_job: self })
29
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
30
+ params = serialized_params.merge(
31
+ "provider_job_id" => id
32
+ )
33
+ ActiveJob::Base.execute(params)
34
+
35
+ destroy!
36
+ end
37
+ end
5
38
  end
6
39
  end
@@ -15,88 +15,79 @@ module GoodJob
15
15
  end)
16
16
 
17
17
  scope :advisory_unlocked, -> { joins_advisory_locks.where(pg_locks: { locktype: nil }) }
18
- scope :with_advisory_lock, (lambda do
19
- where(<<~SQL)
20
- pg_try_advisory_lock(('x'||substr(md5(id::text), 1, 16))::bit(64)::bigint)
21
- SQL
22
- end)
23
18
 
24
- def self.first_advisory_locked_row(query)
25
- find_by_sql(<<~SQL)
19
+ attr_accessor :create_with_advisory_lock
20
+
21
+ after_create -> { advisory_lock }, if: :create_with_advisory_lock
22
+ end
23
+
24
+ class_methods do
25
+ def first_advisory_locked_row(query)
26
+ find_by_sql(<<~SQL).first
26
27
  WITH rows AS (#{query.to_sql})
27
- SELECT rows.id
28
+ SELECT rows.*
28
29
  FROM rows
29
30
  WHERE pg_try_advisory_lock(('x'||substr(md5(id::text), 1, 16))::bit(64)::bigint)
31
+ LIMIT 1
30
32
  SQL
31
33
  end
32
- # private_class_method :first_advisory_locked_row
33
-
34
- # https://www.postgresql.org/docs/9.6/view-pg-locks.html
35
- # Advisory locks can be acquired on keys consisting of either a single bigint value or two integer values.
36
- # A bigint key is displayed with its high-order half in the classid column, its low-order half in the objid column, and objsubid equal to 1.
37
- # The original bigint value can be reassembled with the expression (classid::bigint << 32) | objid::bigint.
38
- # Integer keys are displayed with the first key in the classid column, the second key in the objid column, and objsubid equal to 2.
39
- # The actual meaning of the keys is up to the user. Advisory locks are local to each database, so the database column is meaningful for an advisory lock.
40
- def self.advisory_lock_details
41
- connection.select("SELECT * FROM pg_locks WHERE locktype = 'advisory' AND objsubid = 1")
42
- end
34
+ end
43
35
 
44
- def advisory_lock
45
- self.class.connection.execute(sanitize_sql_for_conditions(["SELECT 1 as one WHERE pg_try_advisory_lock(('x'||substr(md5(?), 1, 16))::bit(64)::bigint)", id])).ntuples.positive?
46
- end
36
+ def advisory_lock
37
+ self.class.connection.execute(sanitize_sql_for_conditions(["SELECT 1 as one WHERE pg_try_advisory_lock(('x'||substr(md5(?), 1, 16))::bit(64)::bigint)", id])).ntuples.positive?
38
+ end
47
39
 
48
- def advisory_lock!
49
- result = advisory_lock
50
- result || raise(RecordAlreadyAdvisoryLockedError)
51
- end
40
+ def advisory_lock!
41
+ result = advisory_lock
42
+ result || raise(RecordAlreadyAdvisoryLockedError)
43
+ end
52
44
 
53
- def with_advisory_lock
54
- advisory_lock!
55
- yield
56
- rescue StandardError => e
57
- advisory_unlock unless e.is_a? RecordAlreadyAdvisoryLockedError
58
- raise
59
- end
45
+ def with_advisory_lock
46
+ advisory_lock!
47
+ yield
48
+ rescue StandardError => e
49
+ advisory_unlock unless e.is_a? RecordAlreadyAdvisoryLockedError
50
+ raise
51
+ end
60
52
 
61
- def advisory_locked?
62
- self.class.connection.execute(<<~SQL).ntuples.positive?
63
- SELECT 1 as one
64
- FROM pg_locks
65
- WHERE
66
- locktype = 'advisory'
67
- AND objsubid = 1
68
- AND classid = ('x'||substr(md5('#{id}'), 1, 16))::bit(32)::int
69
- AND objid = (('x'||substr(md5('#{id}'), 1, 16))::bit(64) << 32)::bit(32)::int
70
- SQL
71
- end
53
+ def advisory_locked?
54
+ self.class.connection.execute(<<~SQL).ntuples.positive?
55
+ SELECT 1 as one
56
+ FROM pg_locks
57
+ WHERE
58
+ locktype = 'advisory'
59
+ AND objsubid = 1
60
+ AND classid = ('x'||substr(md5('#{id}'), 1, 16))::bit(32)::int
61
+ AND objid = (('x'||substr(md5('#{id}'), 1, 16))::bit(64) << 32)::bit(32)::int
62
+ SQL
63
+ end
72
64
 
73
- def owns_advisory_lock?
74
- self.class.connection.execute(<<~SQL).ntuples.positive?
75
- SELECT 1 as one
76
- FROM pg_locks
77
- WHERE
78
- locktype = 'advisory'
79
- AND objsubid = 1
80
- AND classid = ('x'||substr(md5('#{id}'), 1, 16))::bit(32)::int
81
- AND objid = (('x'||substr(md5('#{id}'), 1, 16))::bit(64) << 32)::bit(32)::int
82
- AND pid = pg_backend_pid()
83
- SQL
84
- end
65
+ def owns_advisory_lock?
66
+ self.class.connection.execute(<<~SQL).ntuples.positive?
67
+ SELECT 1 as one
68
+ FROM pg_locks
69
+ WHERE
70
+ locktype = 'advisory'
71
+ AND objsubid = 1
72
+ AND classid = ('x'||substr(md5('#{id}'), 1, 16))::bit(32)::int
73
+ AND objid = (('x'||substr(md5('#{id}'), 1, 16))::bit(64) << 32)::bit(32)::int
74
+ AND pid = pg_backend_pid()
75
+ SQL
76
+ end
85
77
 
86
- def advisory_unlock
87
- self.class.connection.execute("SELECT pg_advisory_unlock(('x'||substr(md5('#{id}'), 1, 16))::bit(64)::bigint)").first["pg_advisory_unlock"]
88
- end
78
+ def advisory_unlock
79
+ self.class.connection.execute("SELECT pg_advisory_unlock(('x'||substr(md5('#{id}'), 1, 16))::bit(64)::bigint)").first["pg_advisory_unlock"]
80
+ end
89
81
 
90
- def advisory_unlock!
91
- advisory_unlock while advisory_locked?
92
- end
82
+ def advisory_unlock!
83
+ advisory_unlock while advisory_locked?
84
+ end
93
85
 
94
- private
86
+ private
95
87
 
96
- def sanitize_sql_for_conditions(*args)
97
- # Made public in Rails 5.2
98
- self.class.send(:sanitize_sql_for_conditions, *args)
99
- end
88
+ def sanitize_sql_for_conditions(*args)
89
+ # Made public in Rails 5.2
90
+ self.class.send(:sanitize_sql_for_conditions, *args)
100
91
  end
101
92
  end
102
93
  end
@@ -0,0 +1,21 @@
1
+ module GoodJob
2
+ class PgLocks < ActiveRecord::Base
3
+ self.table_name = 'pg_locks'.freeze
4
+
5
+ # https://www.postgresql.org/docs/9.6/view-pg-locks.html
6
+ # Advisory locks can be acquired on keys consisting of either a single bigint value or two integer values.
7
+ # A bigint key is displayed with its high-order half in the classid column, its low-order half in the objid column, and objsubid equal to 1.
8
+ # The original bigint value can be reassembled with the expression (classid::bigint << 32) | objid::bigint.
9
+ # Integer keys are displayed with the first key in the classid column, the second key in the objid column, and objsubid equal to 2.
10
+ # The actual meaning of the keys is up to the user. Advisory locks are local to each database, so the database column is meaningful for an advisory lock.
11
+ def self.advisory_lock_details
12
+ connection.select <<~SQL
13
+ SELECT *
14
+ FROM pg_locks
15
+ WHERE
16
+ locktype = 'advisory' AND
17
+ objsubid = 1
18
+ SQL
19
+ end
20
+ end
21
+ end
@@ -1,11 +1,9 @@
1
- require "concurrent/scheduled_task"
2
1
  require "concurrent/executor/thread_pool_executor"
2
+ require "concurrent/timer_task"
3
3
  require "concurrent/utility/processor_counter"
4
4
 
5
5
  module GoodJob
6
6
  class Scheduler
7
- MAX_THREADS = Concurrent.processor_count
8
-
9
7
  DEFAULT_TIMER_OPTIONS = {
10
8
  execution_interval: 1,
11
9
  timeout_interval: 1,
@@ -15,22 +13,22 @@ module GoodJob
15
13
  DEFAULT_POOL_OPTIONS = {
16
14
  name: 'good_job',
17
15
  min_threads: 0,
18
- max_threads: MAX_THREADS,
16
+ max_threads: Concurrent.processor_count,
19
17
  auto_terminate: true,
20
18
  idletime: 0,
21
19
  max_queue: 0,
22
20
  fallback_policy: :abort, # shouldn't matter -- 0 max queue
23
21
  }.freeze
24
22
 
25
- def initialize(query = GoodJob::Job.all, **_options)
23
+ def initialize(query = GoodJob::Job.all, timer_options: {}, pool_options: {})
26
24
  @query = query
27
25
 
28
- @pool = Concurrent::ThreadPoolExecutor.new(DEFAULT_POOL_OPTIONS)
29
- @timer = Concurrent::TimerTask.new(DEFAULT_TIMER_OPTIONS) do
26
+ @pool = Concurrent::ThreadPoolExecutor.new(DEFAULT_POOL_OPTIONS.merge(pool_options))
27
+ @timer = Concurrent::TimerTask.new(DEFAULT_TIMER_OPTIONS.merge(timer_options)) do
30
28
  idle_threads = @pool.max_length - @pool.length
31
29
  create_thread if idle_threads.positive?
32
30
  end
33
- @timer.add_observer(TimerObserver.new)
31
+ @timer.add_observer(self, :timer_observer)
34
32
  @timer.execute
35
33
  end
36
34
 
@@ -64,35 +62,30 @@ module GoodJob
64
62
 
65
63
  def create_thread
66
64
  future = Concurrent::Future.new(args: [ordered_query], executor: @pool) do |query|
67
- Rails.application.executor.wrap do
68
- loop do
69
- good_job = query.with_advisory_lock.first
70
- break unless good_job
71
-
72
- ActiveSupport::Notifications.instrument("job_started.good_job", { good_job: good_job })
65
+ executed_job = false
73
66
 
74
- JobWrapper.new(good_job).perform
67
+ Rails.application.executor.wrap do
68
+ good_job = GoodJob::Job.first_advisory_locked_row(query)
69
+ break unless good_job
75
70
 
76
- good_job.advisory_unlock
77
- end
71
+ executed_job = true
72
+ good_job.perform
73
+ good_job.advisory_unlock
78
74
  end
79
75
 
80
- true
76
+ executed_job
81
77
  end
82
- future.add_observer(TaskObserver.new)
78
+ future.add_observer(self, :task_observer)
83
79
  future.execute
84
80
  end
85
81
 
86
- class TimerObserver
87
- def update(time, result, error)
88
- ActiveSupport::Notifications.instrument("timer_task_finished.good_job", { result: result, error: error, time: time })
89
- end
82
+ def timer_observer(time, executed_task, error)
83
+ ActiveSupport::Notifications.instrument("finished_timer_task.good_job", { result: executed_task, error: error, time: time })
90
84
  end
91
85
 
92
- class TaskObserver
93
- def update(time, result, error)
94
- ActiveSupport::Notifications.instrument("job_finished.good_job", { result: result, error: error, time: time })
95
- end
86
+ def task_observer(time, executed_task, error)
87
+ ActiveSupport::Notifications.instrument("finished_job_task.good_job", { result: executed_task, error: error, time: time })
88
+ create_thread if executed_task
96
89
  end
97
90
  end
98
91
  end
@@ -1,3 +1,3 @@
1
1
  module GoodJob
2
- VERSION = '0.2.2'.freeze
2
+ VERSION = '0.3.0'.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: 0.2.2
4
+ version: 0.3.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: 2020-03-08 00:00:00.000000000 Z
11
+ date: 2020-03-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - ">="
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: pry
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: rspec-rails
127
141
  requirement: !ruby/object:Gem::Requirement
@@ -182,11 +196,10 @@ files:
182
196
  - lib/good_job.rb
183
197
  - lib/good_job/adapter.rb
184
198
  - lib/good_job/cli.rb
185
- - lib/good_job/inline_scheduler.rb
186
199
  - lib/good_job/job.rb
187
- - lib/good_job/job_wrapper.rb
188
200
  - lib/good_job/lockable.rb
189
201
  - lib/good_job/logging.rb
202
+ - lib/good_job/pg_locks.rb
190
203
  - lib/good_job/railtie.rb
191
204
  - lib/good_job/scheduler.rb
192
205
  - lib/good_job/version.rb
@@ -221,7 +234,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
221
234
  - !ruby/object:Gem::Version
222
235
  version: '0'
223
236
  requirements: []
224
- rubygems_version: 3.1.2
237
+ rubygems_version: 3.0.3
225
238
  signing_key:
226
239
  specification_version: 4
227
240
  summary: A multithreaded, Postgres-based ActiveJob backend for Ruby on Rails
@@ -1,10 +0,0 @@
1
- module GoodJob
2
- class InlineScheduler
3
- def enqueue(good_job)
4
- JobWrapper.new(good_job).perform
5
- end
6
-
7
- def shutdown(wait: true)
8
- end
9
- end
10
- end
@@ -1,16 +0,0 @@
1
- module GoodJob
2
- class JobWrapper
3
- def initialize(good_job)
4
- @good_job = good_job
5
- end
6
-
7
- def perform
8
- serialized_params = @good_job.serialized_params.merge(
9
- "provider_job_id" => @good_job.id
10
- )
11
- ActiveJob::Base.execute(serialized_params)
12
-
13
- @good_job.destroy!
14
- end
15
- end
16
- end