good_job 0.2.2 → 0.3.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: 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