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 +4 -4
- data/CHANGELOG.md +61 -2
- data/README.md +88 -15
- data/lib/active_job/queue_adapters/good_job_adapter.rb +13 -0
- data/lib/good_job.rb +3 -2
- data/lib/good_job/adapter.rb +20 -24
- data/lib/good_job/cli.rb +46 -5
- data/lib/good_job/job.rb +60 -1
- data/lib/good_job/lockable.rb +78 -74
- data/lib/good_job/pg_locks.rb +21 -0
- data/lib/good_job/scheduler.rb +18 -37
- data/lib/good_job/version.rb +1 -1
- metadata +61 -5
- data/lib/good_job/inline_scheduler.rb +0 -10
- data/lib/good_job/job_wrapper.rb +0 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4f7dda29494df3fc05a199d6bf0efbc825a47653c3af507a0d1ceb92456d4307
|
4
|
+
data.tar.gz: 881ad046bab6b17c5d532d035fddef54420639288a0a4bd8f4f07b980c1fa239
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c0cfc3c4d61666844a6bd7c532670e0b1be40fd6f805eff63919d3600d06933b8029f0febbc81efbf52b1f3d459317caeb522affe20dde9d3f4d7672622fd708
|
7
|
+
data.tar.gz: c4c9a9fddb2108769e3672baeaeaca2f7c966ee708f95867ed57970e688debcf1ce41a3eacd65d88c0192700f1b293cd4a9ad63fee9e4af93d44934b0876120f
|
data/CHANGELOG.md
CHANGED
@@ -1,8 +1,67 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
## [
|
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.
|
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'
|
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
|
-
|
63
|
-
|
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
|
-
#
|
107
|
-
$
|
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
|
-
#
|
116
|
-
$ gem release
|
189
|
+
# ..and follow subsequent directions.
|
117
190
|
```
|
118
191
|
|
119
192
|
## Contributing
|
data/lib/good_job.rb
CHANGED
@@ -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
|
data/lib/good_job/adapter.rb
CHANGED
@@ -1,41 +1,37 @@
|
|
1
1
|
module GoodJob
|
2
2
|
class Adapter
|
3
|
-
def initialize(
|
4
|
-
@
|
5
|
-
@scheduler = InlineScheduler.new if inline?
|
3
|
+
def initialize(inline: false)
|
4
|
+
@inline = inline
|
6
5
|
end
|
7
6
|
|
8
|
-
def enqueue(
|
9
|
-
enqueue_at(
|
7
|
+
def enqueue(active_job)
|
8
|
+
enqueue_at(active_job, nil)
|
10
9
|
end
|
11
10
|
|
12
|
-
def enqueue_at(
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
29
|
+
def shutdown(wait: true) # rubocop:disable Lint/UnusedMethodArgument
|
30
|
+
nil
|
33
31
|
end
|
34
32
|
|
35
|
-
private
|
36
|
-
|
37
33
|
def inline?
|
38
|
-
@
|
34
|
+
@inline
|
39
35
|
end
|
40
36
|
end
|
41
37
|
end
|
data/lib/good_job/cli.rb
CHANGED
@@ -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
|
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
|
-
|
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
|
data/lib/good_job/job.rb
CHANGED
@@ -1,6 +1,65 @@
|
|
1
1
|
module GoodJob
|
2
2
|
class Job < ActiveRecord::Base
|
3
3
|
include Lockable
|
4
|
-
|
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
|
data/lib/good_job/lockable.rb
CHANGED
@@ -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
|
-
|
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(
|
13
|
-
AND pg_locks.objid = (('x'||substr(md5(
|
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
|
-
|
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
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
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
|
-
|
49
|
-
|
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
|
-
|
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
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
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
|
-
|
87
|
-
|
88
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
73
|
+
def advisory_lock!
|
74
|
+
result = advisory_lock
|
75
|
+
result || raise(RecordAlreadyAdvisoryLockedError)
|
76
|
+
end
|
93
77
|
|
94
|
-
|
78
|
+
def with_advisory_lock
|
79
|
+
raise ArgumentError, "Must provide a block" unless block_given?
|
95
80
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
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
|
data/lib/good_job/scheduler.rb
CHANGED
@@ -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:
|
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(
|
26
|
-
|
23
|
+
def initialize(performer, timer_options: {}, pool_options: {})
|
24
|
+
raise ArgumentError, "Performer argument must implement #next" unless performer.respond_to?(:next)
|
27
25
|
|
28
|
-
@
|
29
|
-
@
|
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(
|
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: [
|
67
|
-
|
68
|
-
|
69
|
-
|
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(
|
66
|
+
future.add_observer(self, :task_observer)
|
83
67
|
future.execute
|
84
68
|
end
|
85
69
|
|
86
|
-
|
87
|
-
|
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
|
-
|
93
|
-
|
94
|
-
|
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
|
data/lib/good_job/version.rb
CHANGED
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.
|
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-
|
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.
|
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
|
data/lib/good_job/job_wrapper.rb
DELETED
@@ -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
|