good_job 0.2.2 → 0.7.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: 4f7dda29494df3fc05a199d6bf0efbc825a47653c3af507a0d1ceb92456d4307
4
+ data.tar.gz: 881ad046bab6b17c5d532d035fddef54420639288a0a4bd8f4f07b980c1fa239
5
5
  SHA512:
6
- metadata.gz: 9ce51a00e0aeabbd2be39e796168343d0daf96b368cb75ae8011f05bec891fcab820323189c43c79b97ae5a4c2586a7ec4996cd68717a83bb66613b7db523a95
7
- data.tar.gz: d0cb6bc52ba43a1e86a4ba12a8a09204d90f3b393de0d0dc54de64de37c85290f8228362e76f8c023a0d78f28d63706b4c8f2b04626134bc4240d9ad055544db
6
+ metadata.gz: c0cfc3c4d61666844a6bd7c532670e0b1be40fd6f805eff63919d3600d06933b8029f0febbc81efbf52b1f3d459317caeb522affe20dde9d3f4d7672622fd708
7
+ data.tar.gz: c4c9a9fddb2108769e3672baeaeaca2f7c966ee708f95867ed57970e688debcf1ce41a3eacd65d88c0192700f1b293cd4a9ad63fee9e4af93d44934b0876120f
@@ -1,8 +1,67 @@
1
1
  # Changelog
2
2
 
3
- ## [0.2.2](https://github.com/bensheldon/good_job/tree/0.2.2) (2020-03-07)
3
+ ## [v0.7.0](https://github.com/bensheldon/good_job/tree/v0.7.0) (2020-07-15)
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.6.0...v0.7.0)
6
+
7
+ **Closed issues:**
8
+
9
+ - Always store a default priority \(0\) and scheduled\_at\(Time.current\) [\#30](https://github.com/bensheldon/good_job/issues/30)
10
+
11
+ **Merged pull requests:**
12
+
13
+ - Add more examples to Readme [\#39](https://github.com/bensheldon/good_job/pull/39) ([bensheldon](https://github.com/bensheldon))
14
+ - Add additional Rubocops and lint [\#38](https://github.com/bensheldon/good_job/pull/38) ([bensheldon](https://github.com/bensheldon))
15
+ - Always store a default queue\_name, priority and scheduled\_at; index by queue\_name and scheduled\_at [\#37](https://github.com/bensheldon/good_job/pull/37) ([bensheldon](https://github.com/bensheldon))
16
+
17
+ ## [v0.6.0](https://github.com/bensheldon/good_job/tree/v0.6.0) (2020-07-15)
18
+
19
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.5.0...v0.6.0)
20
+
21
+ **Closed issues:**
22
+
23
+ - Improve the command line options [\#32](https://github.com/bensheldon/good_job/issues/32)
24
+ - Allow config.active\_job.queue\_adapter = :good\_job to work [\#5](https://github.com/bensheldon/good_job/issues/5)
25
+
26
+ **Merged pull requests:**
27
+
28
+ - Improve generation of changelog [\#36](https://github.com/bensheldon/good_job/pull/36) ([bensheldon](https://github.com/bensheldon))
29
+ - Update Github Action Workflow for Backlog Project Board [\#35](https://github.com/bensheldon/good_job/pull/35) ([bensheldon](https://github.com/bensheldon))
30
+ - Add configuration options to good\_job executable [\#33](https://github.com/bensheldon/good_job/pull/33) ([bensheldon](https://github.com/bensheldon))
31
+ - Extract Job querying behavior out of Scheduler [\#31](https://github.com/bensheldon/good_job/pull/31) ([bensheldon](https://github.com/bensheldon))
32
+ - Allow configuration of Rails queue adapter with `:good\_job` [\#28](https://github.com/bensheldon/good_job/pull/28) ([bensheldon](https://github.com/bensheldon))
33
+
34
+ ## [v0.5.0](https://github.com/bensheldon/good_job/tree/v0.5.0) (2020-07-13)
35
+
36
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.4.0...v0.5.0)
37
+
38
+ **Merged pull requests:**
39
+
40
+ - Update development Ruby to 2.6.6 and gems [\#29](https://github.com/bensheldon/good_job/pull/29) ([bensheldon](https://github.com/bensheldon))
41
+
42
+ ## [v0.4.0](https://github.com/bensheldon/good_job/tree/v0.4.0) (2020-03-31)
43
+
44
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.3.0...v0.4.0)
45
+
46
+ **Merged pull requests:**
47
+
48
+ - Improve ActiveRecord usage for advisory locking [\#24](https://github.com/bensheldon/good_job/pull/24) ([bensheldon](https://github.com/bensheldon))
49
+ - Remove support for Rails 5.1 [\#23](https://github.com/bensheldon/good_job/pull/23) ([bensheldon](https://github.com/bensheldon))
50
+
51
+ ## [v0.3.0](https://github.com/bensheldon/good_job/tree/v0.3.0) (2020-03-22)
52
+
53
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.2.2...v0.3.0)
54
+
55
+ **Merged pull requests:**
56
+
57
+ - Update development Ruby to 2.6.5 [\#22](https://github.com/bensheldon/good_job/pull/22) ([bensheldon](https://github.com/bensheldon))
58
+ - Simplify the internal API, removing JobWrapper and InlineScheduler [\#21](https://github.com/bensheldon/good_job/pull/21) ([bensheldon](https://github.com/bensheldon))
59
+ - Generate a new future for every executed job [\#20](https://github.com/bensheldon/good_job/pull/20) ([bensheldon](https://github.com/bensheldon))
60
+ - Configuration for maximum number of job execution threads [\#18](https://github.com/bensheldon/good_job/pull/18) ([bensheldon](https://github.com/bensheldon))
61
+
62
+ ## [v0.2.2](https://github.com/bensheldon/good_job/tree/v0.2.2) (2020-03-08)
63
+
64
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.2.1...v0.2.2)
6
65
 
7
66
  **Merged pull requests:**
8
67
 
data/README.md CHANGED
@@ -7,19 +7,27 @@ Inspired by [Delayed::Job](https://github.com/collectiveidea/delayed_job) and [Q
7
7
  - Stand on the shoulders of ActiveJob. For example, [exception](https://edgeguides.rubyonrails.org/active_job_basics.html#exceptions) and [retry](https://edgeguides.rubyonrails.org/active_job_basics.html#retrying-or-discarding-failed-jobs) behavior.
8
8
  - Stand on the shoulders of Ruby on Rails. For example, ActiveRecord ORM, connection pools, and [multithreaded support](https://guides.rubyonrails.org/threading_and_code_execution.html) with [Concurrent-Ruby](https://github.com/ruby-concurrency/concurrent-ruby).
9
9
  - Stand on the shoulders of Postgres. For example, Advisory Locks.
10
- - Convention over simplicity over performance.
10
+ - Convention over simplicity over performance.
11
+
12
+ GoodJob supports all ActiveJob functionality:
13
+ - Async. GoodJob has the ability to run the job in a non-blocking manner.
14
+ - Queues. Jobs may set which queue they are run in with queue_as or by using the set method.
15
+ - Delayed. GoodJob will run the job in the future through perform_later.
16
+ - Priorities. The order in which jobs are processed can be configured differently.
17
+ - Timeouts. GoodJob defers to ActiveJob where it can be implemented as an `around` hook. See [Taking advantage of ActiveJob](#taking-advantage-of-activejob).
18
+ - Retries. GoodJob will automatically retry uncompleted jobs immediately. See [Taking advantage of ActiveJob](#taking-advantage-of-activejob).
11
19
 
12
20
  ## Installation
13
21
 
14
22
  Add this line to your application's Gemfile:
15
23
 
16
24
  ```ruby
17
- gem 'good_job', github: 'bensheldon/good_job'
25
+ gem 'good_job'
18
26
  ```
19
27
 
20
28
  And then execute:
21
29
  ```bash
22
- $ bundle
30
+ $ bundle install
23
31
  ```
24
32
 
25
33
  ## Usage
@@ -43,6 +51,9 @@ $ bundle
43
51
  t.integer :priority
44
52
  t.jsonb :serialized_params
45
53
  t.timestamp :scheduled_at
54
+
55
+ t.index :scheduled_at
56
+ t.index [:queue_name, :scheduled_at]
46
57
  end
47
58
  end
48
59
  end
@@ -56,17 +67,75 @@ $ bundle
56
67
 
57
68
  1. Configure the ActiveJob adapter:
58
69
  ```ruby
70
+ # config/application.rb
71
+ config.active_job.queue_adapter = :good_job
72
+ ```
73
+
74
+ By default, using `:good_job` is equivalent to manually configuring the adapter:
75
+
76
+ ```ruby
77
+ # config/environments/development.rb
78
+ config.active_job.queue_adapter = GoodJob::Adapter.new(inline: true)
79
+
80
+ # config/environments/test.rb
81
+ config.active_job.queue_adapter = GoodJob::Adapter.new(inline: true)
82
+
59
83
  # config/environments/production.rb
60
84
  config.active_job.queue_adapter = GoodJob::Adapter.new
85
+ ```
61
86
 
62
- # config/environments/development.rb
63
- config.active_job.queue_adapter = GoodJob::Adapter.new(inline: true)
87
+ 1. Queue your job 🎉:
88
+ ```ruby
89
+ YourJob.set(queue: :some_queue, wait: 5.minutes, priority: 10).perform_later
64
90
  ```
65
91
 
66
92
  1. In production, the scheduler is designed to run in its own process:
67
93
  ```bash
68
94
  $ bundle exec good_job
69
95
  ```
96
+
97
+ Configuration options available with `help`:
98
+ ```bash
99
+ $ bundle exec good_job help start
100
+
101
+ # Usage:
102
+ # good_job start
103
+ #
104
+ # Options:
105
+ # [--max-threads=N] # Maximum number of threads to use for working jobs (default: ActiveRecord::Base.connection_pool.size)
106
+ # [--queues=queue1,queue2] # Queues to work from. Separate multiple queues with commas (default: *)
107
+ # [--poll-interval=N] # Interval between polls for available jobs in seconds (default: 1)
108
+ ```
109
+
110
+ ### Taking advantage of ActiveJob
111
+
112
+ ActiveJob has a rich set of built-in functionality for timeouts, error handling, and retrying. For example:
113
+
114
+ ```ruby
115
+ class ApplicationJob < ActiveJob::Base
116
+ # Retry errors an infinite number of times with exponential back-off
117
+ retry_on StandardError, wait: :exponentially_longer, attempts: Float::INFINITY
118
+
119
+ # Timeout jobs after 10 minutes
120
+ JobTimeoutError = Class.new(StandardError)
121
+ around_perform do |_job, block|
122
+ Timeout.timeout(10.minutes, JobTimeoutError) do
123
+ block.call
124
+ end
125
+ end
126
+ end
127
+ ```
128
+
129
+ ### Configuring Job Execution Threads
130
+
131
+ 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:
132
+
133
+ - 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._
134
+ - The maximum number of GoodJob threads can be configured, in decreasing precedence:
135
+ 1. `$ bundle exec good_job --max_threads 4`
136
+ 2. `$ GOOD_JOB_MAX_THREADS=4 bundle exec good_job`
137
+ 3. `$ RAILS_MAX_THREADS=4 bundle exec good_job`
138
+ 4. Implicitly via Rails's database connection pool size (`ActiveRecord::Base.connection_pool.size`)
70
139
 
71
140
  ## Development
72
141
 
@@ -83,6 +152,17 @@ $ bin/setup_test
83
152
  $ bin/rspec
84
153
  ```
85
154
 
155
+ This gem uses Appraisal to run tests against multiple versions of Rails:
156
+
157
+ ```bash
158
+ # Install Appraisal(s) gemfiles
159
+ $ bundle exec appraisal
160
+
161
+ # Run tests
162
+ $ bundle exec appraisal bin/rspec
163
+
164
+ ```
165
+
86
166
  For developing locally within another Ruby on Rails project:
87
167
 
88
168
  ```bash
@@ -103,17 +183,10 @@ Package maintainers can release this gem with the following [gem-release](https:
103
183
  # Sign into rubygems
104
184
  $ gem signin
105
185
 
106
- # Increase the version number
107
- $ gem bump -v minor --no-commit
108
-
109
- # Update the changelog
110
- $ bundle exec rake changelog
111
-
112
- # Commit the version and changelog to git
113
- $ bundle exec rake commit_version
186
+ # Update version number, changelog, and create git commit:
187
+ $ bundle exec rake commit_version[minor] # major,minor,patch
114
188
 
115
- # Push to rubygems.org
116
- $ gem release
189
+ # ..and follow subsequent directions.
117
190
  ```
118
191
 
119
192
  ## Contributing
@@ -0,0 +1,13 @@
1
+ module ActiveJob
2
+ module QueueAdapters
3
+ class GoodJobAdapter < GoodJob::Adapter
4
+ def initialize
5
+ if Rails.env.development? || Rails.env.test?
6
+ super(inline: true)
7
+ else
8
+ super(inline: false)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -4,10 +4,11 @@ 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'
10
+
11
+ require 'active_job/queue_adapters/good_job_adapter'
11
12
 
12
13
  module GoodJob
13
14
  include Logging
@@ -1,41 +1,37 @@
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
19
-
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?
11
+ def enqueue_at(active_job, timestamp)
12
+ good_job = GoodJob::Job.enqueue(
13
+ active_job,
14
+ scheduled_at: timestamp ? Time.zone.at(timestamp) : nil,
15
+ create_with_advisory_lock: inline?
16
+ )
17
+
18
+ if inline?
19
+ begin
20
+ good_job.perform
21
+ ensure
22
+ good_job.advisory_unlock
23
+ end
26
24
  end
27
25
 
28
26
  good_job
29
27
  end
30
28
 
31
- def shutdown(wait: true)
32
- @scheduler&.shutdown(wait: wait)
29
+ def shutdown(wait: true) # rubocop:disable Lint/UnusedMethodArgument
30
+ nil
33
31
  end
34
32
 
35
- private
36
-
37
33
  def inline?
38
- @options.fetch(:inline, false)
34
+ @inline
39
35
  end
40
36
  end
41
37
  end
@@ -4,18 +4,59 @@ module GoodJob
4
4
  class CLI < Thor
5
5
  RAILS_ENVIRONMENT_RB = File.expand_path("config/environment.rb")
6
6
 
7
- desc :start, "Start jobs"
7
+ desc :start, "Start job worker"
8
+ method_option :max_threads,
9
+ type: :numeric,
10
+ desc: "Maximum number of threads to use for working jobs (default: ActiveRecord::Base.connection_pool.size)"
11
+ method_option :queues,
12
+ type: :string,
13
+ banner: "queue1,queue2",
14
+ desc: "Queues to work from. Separate multiple queues with commas (default: *)"
15
+ method_option :poll_interval,
16
+ type: :numeric,
17
+ desc: "Interval between polls for available jobs in seconds (default: 1)"
8
18
  def start
9
19
  require RAILS_ENVIRONMENT_RB
10
20
 
11
- scheduler = GoodJob::Scheduler.new
21
+ max_threads = (
22
+ options[:max_threads] ||
23
+ ENV['GOOD_JOB_MAX_THREADS'] ||
24
+ ENV['RAILS_MAX_THREADS'] ||
25
+ ActiveRecord::Base.connection_pool.size
26
+ ).to_i
12
27
 
28
+ queue_names = (
29
+ options[:queues] ||
30
+ ENV['GOOD_JOB_QUEUES'] ||
31
+ '*'
32
+ ).split(',').map(&:strip)
33
+
34
+ poll_interval = (
35
+ options[:poll_interval] ||
36
+ ENV['GOOD_JOB_POLL_INTERVAL']
37
+ ).to_i
38
+
39
+ job_query = GoodJob::Job.all
40
+ queue_names_without_all = queue_names.reject { |q| q == '*' }
41
+ job_query = job_query.where(queue_name: queue_names_without_all) unless queue_names_without_all.size.zero?
42
+
43
+ job_performer = job_query.only_scheduled.priority_ordered.to_performer
44
+
45
+ $stdout.puts "GoodJob worker starting with max_threads=#{max_threads} on queues=#{queue_names.join(',')}"
46
+
47
+ timer_options = {}
48
+ timer_options[:execution_interval] = poll_interval if poll_interval.positive?
49
+
50
+ pool_options = {
51
+ max_threads: max_threads,
52
+ }
53
+
54
+ scheduler = GoodJob::Scheduler.new(job_performer, timer_options: timer_options, pool_options: pool_options)
55
+
56
+ @stop_good_job_executable = false
13
57
  %w[INT TERM].each do |signal|
14
58
  trap(signal) { @stop_good_job_executable = true }
15
59
  end
16
- @stop_good_job_executable = false
17
-
18
- $stdout.puts "GoodJob waiting for jobs..."
19
60
 
20
61
  Kernel.loop do
21
62
  sleep 0.1
@@ -1,6 +1,65 @@
1
1
  module GoodJob
2
2
  class Job < ActiveRecord::Base
3
3
  include Lockable
4
- self.table_name = 'good_jobs'
4
+
5
+ DEFAULT_QUEUE_NAME = 'default'.freeze
6
+ DEFAULT_PRIORITY = 0
7
+
8
+ self.table_name = 'good_jobs'.freeze
9
+
10
+ scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(Time.current)).or(where(scheduled_at: nil)) }
11
+ scope :priority_ordered, -> { order(priority: :desc) }
12
+ scope :to_performer, -> { Performer.new(self) }
13
+
14
+ class Performer
15
+ def initialize(query)
16
+ @query = query
17
+ end
18
+
19
+ def next
20
+ good_job = nil
21
+
22
+ @query.limit(1).with_advisory_lock do |good_jobs|
23
+ good_job = good_jobs.first
24
+ break unless good_job
25
+
26
+ good_job.perform
27
+ end
28
+
29
+ good_job
30
+ end
31
+ end
32
+
33
+ def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
34
+ good_job = nil
35
+ 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|
36
+ good_job = GoodJob::Job.new(
37
+ queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
38
+ priority: active_job.priority || DEFAULT_PRIORITY,
39
+ serialized_params: active_job.serialize,
40
+ scheduled_at: scheduled_at || Time.current,
41
+ create_with_advisory_lock: create_with_advisory_lock
42
+ )
43
+
44
+ instrument_payload[:good_job] = good_job
45
+
46
+ good_job.save!
47
+ active_job.provider_job_id = good_job.id
48
+ end
49
+
50
+ good_job
51
+ end
52
+
53
+ def perform
54
+ ActiveSupport::Notifications.instrument("before_perform_job.good_job", { good_job: self })
55
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
56
+ params = serialized_params.merge(
57
+ "provider_job_id" => id
58
+ )
59
+ ActiveJob::Base.execute(params)
60
+
61
+ destroy!
62
+ end
63
+ end
5
64
  end
6
65
  end
@@ -5,98 +5,102 @@ module GoodJob
5
5
  RecordAlreadyAdvisoryLockedError = Class.new(StandardError)
6
6
 
7
7
  included do
8
+ scope :advisory_lock, (lambda do
9
+ original_query = self
10
+
11
+ cte_table = Arel::Table.new(:rows)
12
+ composed_cte = Arel::Nodes::As.new(cte_table, original_query.select(primary_key).except(:limit).arel)
13
+
14
+ query = cte_table.project(cte_table[:id])
15
+ .with(composed_cte)
16
+ .where(Arel.sql(sanitize_sql_for_conditions(["pg_try_advisory_lock(('x'||substr(md5(:table_name || \"#{cte_table.name}\".\"#{primary_key}\"::text), 1, 16))::bit(64)::bigint)", { table_name: table_name }])))
17
+
18
+ limit = original_query.arel.ast.limit
19
+ query.limit = limit.value if limit.present?
20
+
21
+ unscoped.where(arel_table[:id].in(query)).merge(original_query.only(:order))
22
+ end)
23
+
8
24
  scope :joins_advisory_locks, (lambda do
9
- joins(<<~SQL)
25
+ join_sql = <<~SQL
10
26
  LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'
11
27
  AND pg_locks.objsubid = 1
12
- AND pg_locks.classid = ('x'||substr(md5(good_jobs.id::text), 1, 16))::bit(32)::int
13
- AND pg_locks.objid = (('x'||substr(md5(good_jobs.id::text), 1, 16))::bit(64) << 32)::bit(32)::int
28
+ AND pg_locks.classid = ('x'||substr(md5(:table_name || "#{table_name}"."#{primary_key}"::text), 1, 16))::bit(32)::int
29
+ AND pg_locks.objid = (('x'||substr(md5(:table_name || "#{table_name}"."#{primary_key}"::text), 1, 16))::bit(64) << 32)::bit(32)::int
14
30
  SQL
15
- end)
16
31
 
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
32
+ joins(sanitize_sql_for_conditions([join_sql, { table_name: table_name }]))
22
33
  end)
23
34
 
24
- def self.first_advisory_locked_row(query)
25
- find_by_sql(<<~SQL)
26
- WITH rows AS (#{query.to_sql})
27
- SELECT rows.id
28
- FROM rows
29
- WHERE pg_try_advisory_lock(('x'||substr(md5(id::text), 1, 16))::bit(64)::bigint)
30
- SQL
31
- 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
35
+ scope :advisory_unlocked, -> { joins_advisory_locks.where(pg_locks: { locktype: nil }) }
36
+ scope :advisory_locked, -> { joins_advisory_locks.where.not(pg_locks: { locktype: nil }) }
37
+ scope :owns_advisory_locked, -> { joins_advisory_locks.where('"pg_locks"."pid" = pg_backend_pid()') }
43
38
 
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
39
+ attr_accessor :create_with_advisory_lock
47
40
 
48
- def advisory_lock!
49
- result = advisory_lock
50
- result || raise(RecordAlreadyAdvisoryLockedError)
51
- end
41
+ after_create -> { advisory_lock }, if: :create_with_advisory_lock
42
+ end
52
43
 
44
+ class_methods do
53
45
  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
46
+ raise ArgumentError, "Must provide a block" unless block_given?
60
47
 
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
48
+ records = advisory_lock.to_a
49
+ begin
50
+ yield(records)
51
+ ensure
52
+ records.each(&:advisory_unlock)
53
+ end
71
54
  end
55
+ end
72
56
 
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
57
+ def advisory_lock
58
+ query = <<~SQL
59
+ SELECT 1 AS one
60
+ WHERE pg_try_advisory_lock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
61
+ SQL
62
+ self.class.connection.execute(sanitize_sql_for_conditions([query, { table_name: self.class.table_name, id: send(self.class.primary_key) }])).ntuples.positive?
63
+ end
85
64
 
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
65
+ def advisory_unlock
66
+ query = <<~SQL
67
+ SELECT 1 AS one
68
+ WHERE pg_advisory_unlock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
69
+ SQL
70
+ self.class.connection.execute(sanitize_sql_for_conditions([query, { table_name: self.class.table_name, id: send(self.class.primary_key) }])).ntuples.positive?
71
+ end
89
72
 
90
- def advisory_unlock!
91
- advisory_unlock while advisory_locked?
92
- end
73
+ def advisory_lock!
74
+ result = advisory_lock
75
+ result || raise(RecordAlreadyAdvisoryLockedError)
76
+ end
93
77
 
94
- private
78
+ def with_advisory_lock
79
+ raise ArgumentError, "Must provide a block" unless block_given?
95
80
 
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
81
+ advisory_lock!
82
+ yield
83
+ ensure
84
+ advisory_unlock unless $ERROR_INFO.is_a? RecordAlreadyAdvisoryLockedError
85
+ end
86
+
87
+ def advisory_locked?
88
+ self.class.advisory_locked.where(id: send(self.class.primary_key)).any?
89
+ end
90
+
91
+ def owns_advisory_lock?
92
+ self.class.owns_advisory_locked.where(id: send(self.class.primary_key)).any?
93
+ end
94
+
95
+ def advisory_unlock!
96
+ advisory_unlock while advisory_locked?
97
+ end
98
+
99
+ private
100
+
101
+ def sanitize_sql_for_conditions(*args)
102
+ # Made public in Rails 5.2
103
+ self.class.send(:sanitize_sql_for_conditions, *args)
100
104
  end
101
105
  end
102
106
  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,29 +13,26 @@ 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)
26
- @query = query
23
+ def initialize(performer, timer_options: {}, pool_options: {})
24
+ raise ArgumentError, "Performer argument must implement #next" unless performer.respond_to?(:next)
27
25
 
28
- @pool = Concurrent::ThreadPoolExecutor.new(DEFAULT_POOL_OPTIONS)
29
- @timer = Concurrent::TimerTask.new(DEFAULT_TIMER_OPTIONS) do
26
+ @performer = performer
27
+ @pool = Concurrent::ThreadPoolExecutor.new(DEFAULT_POOL_OPTIONS.merge(pool_options))
28
+ @timer = Concurrent::TimerTask.new(DEFAULT_TIMER_OPTIONS.merge(timer_options)) do
30
29
  idle_threads = @pool.max_length - @pool.length
31
30
  create_thread if idle_threads.positive?
32
31
  end
33
- @timer.add_observer(TimerObserver.new)
32
+ @timer.add_observer(self, :timer_observer)
34
33
  @timer.execute
35
34
  end
36
35
 
37
- def ordered_query
38
- @query.where("scheduled_at < ?", Time.current).or(@query.where(scheduled_at: nil)).order(priority: :desc)
39
- end
40
-
41
36
  def execute
42
37
  end
43
38
 
@@ -63,36 +58,22 @@ module GoodJob
63
58
  end
64
59
 
65
60
  def create_thread
66
- 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 })
73
-
74
- JobWrapper.new(good_job).perform
75
-
76
- good_job.advisory_unlock
77
- end
78
- end
79
-
80
- true
61
+ future = Concurrent::Future.new(args: [@performer], executor: @pool) do |performer|
62
+ result = nil
63
+ Rails.application.executor.wrap { result = performer.next }
64
+ result
81
65
  end
82
- future.add_observer(TaskObserver.new)
66
+ future.add_observer(self, :task_observer)
83
67
  future.execute
84
68
  end
85
69
 
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
70
+ def timer_observer(time, executed_task, error)
71
+ ActiveSupport::Notifications.instrument("finished_timer_task.good_job", { result: executed_task, error: error, time: time })
90
72
  end
91
73
 
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
74
+ def task_observer(time, result, error)
75
+ ActiveSupport::Notifications.instrument("finished_job_task.good_job", { result: result, error: error, time: time })
76
+ create_thread if result
96
77
  end
97
78
  end
98
79
  end
@@ -1,3 +1,3 @@
1
1
  module GoodJob
2
- VERSION = '0.2.2'.freeze
2
+ VERSION = '0.7.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.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: 2020-03-08 00:00:00.000000000 Z
11
+ date: 2020-07-16 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: foreman
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: gem-release
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -122,6 +136,20 @@ dependencies:
122
136
  - - ">="
123
137
  - !ruby/object:Gem::Version
124
138
  version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: pry
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
125
153
  - !ruby/object:Gem::Dependency
126
154
  name: rspec-rails
127
155
  requirement: !ruby/object:Gem::Requirement
@@ -150,6 +178,34 @@ dependencies:
150
178
  - - ">="
151
179
  - !ruby/object:Gem::Version
152
180
  version: '0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: rubocop-performance
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ - !ruby/object:Gem::Dependency
196
+ name: rubocop-rails
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: '0'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
153
209
  - !ruby/object:Gem::Dependency
154
210
  name: rubocop-rspec
155
211
  requirement: !ruby/object:Gem::Requirement
@@ -179,14 +235,14 @@ files:
179
235
  - LICENSE.txt
180
236
  - README.md
181
237
  - exe/good_job
238
+ - lib/active_job/queue_adapters/good_job_adapter.rb
182
239
  - lib/good_job.rb
183
240
  - lib/good_job/adapter.rb
184
241
  - lib/good_job/cli.rb
185
- - lib/good_job/inline_scheduler.rb
186
242
  - lib/good_job/job.rb
187
- - lib/good_job/job_wrapper.rb
188
243
  - lib/good_job/lockable.rb
189
244
  - lib/good_job/logging.rb
245
+ - lib/good_job/pg_locks.rb
190
246
  - lib/good_job/railtie.rb
191
247
  - lib/good_job/scheduler.rb
192
248
  - lib/good_job/version.rb
@@ -221,7 +277,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
221
277
  - !ruby/object:Gem::Version
222
278
  version: '0'
223
279
  requirements: []
224
- rubygems_version: 3.1.2
280
+ rubygems_version: 3.0.3
225
281
  signing_key:
226
282
  specification_version: 4
227
283
  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