good_job 1.9.6 → 1.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -5
- data/README.md +17 -0
- data/engine/app/controllers/good_job/assets_controller.rb +1 -1
- data/lib/generators/good_job/install_generator.rb +5 -15
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +27 -0
- data/lib/generators/good_job/templates/{migration.rb.erb → update/migrations/01_create_good_jobs.rb} +3 -3
- data/lib/generators/good_job/templates/update/migrations/02_add_active_job_id_concurrency_key_cron_key_to_good_jobs.rb +15 -0
- data/lib/generators/good_job/templates/update/migrations/03_add_active_job_id_index_and_concurrency_key_index_to_good_jobs.rb +32 -0
- data/lib/generators/good_job/update_generator.rb +29 -0
- data/lib/good_job/job.rb +23 -6
- data/lib/good_job/lockable.rb +110 -54
- data/lib/good_job/poller.rb +7 -3
- data/lib/good_job/version.rb +1 -1
- metadata +21 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 96c16ec6a03de5eaf957327ad3f9161d19665eeef2e8bc300caa758a84cfe58f
|
4
|
+
data.tar.gz: 22be95e4ae3d2dc29a8b242cb37889d21d2b19f119184a5a33d07f04e9a3b453
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a59d51fdaf7e08a551e6401bdc6fb59d56e8397bc15b7f6ead69ce5eb45da653c149ebad6583af739cdcb619c2d827fb19f50becf4e4b3780ef4d31dc9be4a50
|
7
|
+
data.tar.gz: a9c3c96e76c19270b436d03fcff64e2e873cb6f14b784cbe8b2dd20e2deee455ce2ae6e1869a67b812f414880a7e264a50afd2951d80b41f4ddb9acdab8a0764
|
data/CHANGELOG.md
CHANGED
@@ -1,23 +1,53 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
## [v1.
|
3
|
+
## [v1.10.0](https://github.com/bensheldon/good_job/tree/v1.10.0) (2021-06-29)
|
4
4
|
|
5
|
-
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.9.
|
5
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.9.6...v1.10.0)
|
6
|
+
|
7
|
+
**Implemented enhancements:**
|
8
|
+
|
9
|
+
- Add `rails g good_job:update` command to add idempotent migration files, including `active_job_id`, `concurrency_key`, `cron_key` columns [\#266](https://github.com/bensheldon/good_job/pull/266) ([bensheldon](https://github.com/bensheldon))
|
10
|
+
|
11
|
+
**Fixed bugs:**
|
12
|
+
|
13
|
+
- Use `pg_advisory_unlock_all` after each thread's job execution; fix Lockable return values; improve test stability [\#285](https://github.com/bensheldon/good_job/pull/285) ([bensheldon](https://github.com/bensheldon))
|
14
|
+
- Dashboard AssetsController does not raise if verify\_authenticity\_token is not in the callback chain [\#284](https://github.com/bensheldon/good_job/pull/284) ([bensheldon](https://github.com/bensheldon))
|
6
15
|
|
7
16
|
**Closed issues:**
|
8
17
|
|
9
|
-
-
|
10
|
-
- How to properly report errors to error tracker service [\#159](https://github.com/bensheldon/good_job/issues/159)
|
18
|
+
- \[Question\] Dashboard assets not showing [\#282](https://github.com/bensheldon/good_job/issues/282)
|
11
19
|
|
12
20
|
**Merged pull requests:**
|
13
21
|
|
22
|
+
- Separately cache Appraisal gems in GH Action [\#280](https://github.com/bensheldon/good_job/pull/280) ([bensheldon](https://github.com/bensheldon))
|
23
|
+
- Use custom RSpec doc formatter to show spec examples that are running [\#279](https://github.com/bensheldon/good_job/pull/279) ([bensheldon](https://github.com/bensheldon))
|
24
|
+
- Update development dependencies [\#278](https://github.com/bensheldon/good_job/pull/278) ([bensheldon](https://github.com/bensheldon))
|
25
|
+
- Fix Scheduler integration spec to ensure jobs are run in the Scheduler under test [\#276](https://github.com/bensheldon/good_job/pull/276) ([bensheldon](https://github.com/bensheldon))
|
26
|
+
- Add example benchmark for job throughput [\#275](https://github.com/bensheldon/good_job/pull/275) ([bensheldon](https://github.com/bensheldon))
|
27
|
+
- Allow Lockable to be passed custom column, key, and Postgres advisory lock/unlock function [\#273](https://github.com/bensheldon/good_job/pull/273) ([bensheldon](https://github.com/bensheldon))
|
28
|
+
|
29
|
+
## [v1.9.6](https://github.com/bensheldon/good_job/tree/v1.9.6) (2021-06-04)
|
30
|
+
|
31
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.9.5...v1.9.6)
|
32
|
+
|
33
|
+
**Implemented enhancements:**
|
34
|
+
|
14
35
|
- Add deleting jobs from UI. [\#265](https://github.com/bensheldon/good_job/pull/265) ([morgoth](https://github.com/morgoth))
|
15
36
|
- Collapse Dashboard params by default [\#263](https://github.com/bensheldon/good_job/pull/263) ([morgoth](https://github.com/morgoth))
|
16
37
|
|
38
|
+
**Closed issues:**
|
39
|
+
|
40
|
+
- Pause jobs during migration / maintenance? [\#257](https://github.com/bensheldon/good_job/issues/257)
|
41
|
+
- How to properly report errors to error tracker service [\#159](https://github.com/bensheldon/good_job/issues/159)
|
42
|
+
|
17
43
|
## [v1.9.5](https://github.com/bensheldon/good_job/tree/v1.9.5) (2021-05-24)
|
18
44
|
|
19
45
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v1.9.4...v1.9.5)
|
20
46
|
|
47
|
+
**Implemented enhancements:**
|
48
|
+
|
49
|
+
- Update Dashboard to Bootstrap 5 [\#260](https://github.com/bensheldon/good_job/pull/260) ([morgoth](https://github.com/morgoth))
|
50
|
+
|
21
51
|
**Closed issues:**
|
22
52
|
|
23
53
|
- Update from bootstrap 4 to bootstrap 5 [\#258](https://github.com/bensheldon/good_job/issues/258)
|
@@ -26,7 +56,6 @@
|
|
26
56
|
|
27
57
|
- Serve Dashboard assets as discrete paths instead of inlining [\#262](https://github.com/bensheldon/good_job/pull/262) ([bensheldon](https://github.com/bensheldon))
|
28
58
|
- Fix Gemfile.lock's missing JRuby dependencies; fix release script and add check [\#261](https://github.com/bensheldon/good_job/pull/261) ([bensheldon](https://github.com/bensheldon))
|
29
|
-
- Update Dashboard to Bootstrap 5 [\#260](https://github.com/bensheldon/good_job/pull/260) ([morgoth](https://github.com/morgoth))
|
30
59
|
|
31
60
|
## [v1.9.4](https://github.com/bensheldon/good_job/tree/v1.9.4) (2021-05-18)
|
32
61
|
|
data/README.md
CHANGED
@@ -38,6 +38,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
|
|
38
38
|
- [Configuration options](#configuration-options)
|
39
39
|
- [Global options](#global-options)
|
40
40
|
- [Dashboard](#dashboard)
|
41
|
+
- [Updating](#updating)
|
41
42
|
- [Go deeper](#go-deeper)
|
42
43
|
- [Exceptions, retries, and reliability](#exceptions-retries-and-reliability)
|
43
44
|
- [Exceptions](#exceptions)
|
@@ -318,6 +319,22 @@ GoodJob includes a Dashboard as a mountable `Rails::Engine`.
|
|
318
319
|
end
|
319
320
|
```
|
320
321
|
|
322
|
+
### Updating
|
323
|
+
|
324
|
+
GoodJob follows semantic versioning, though updates may be encouraged through deprecation warnings in minor versions.
|
325
|
+
|
326
|
+
To apply updates:
|
327
|
+
|
328
|
+
```bash
|
329
|
+
bin/rails g good_job:update
|
330
|
+
```
|
331
|
+
|
332
|
+
...and run the resulting migration:
|
333
|
+
|
334
|
+
```bash
|
335
|
+
bin/rails db:migrate
|
336
|
+
```
|
337
|
+
|
321
338
|
## Go deeper
|
322
339
|
|
323
340
|
### Exceptions, retries, and reliability
|
@@ -1,6 +1,6 @@
|
|
1
1
|
module GoodJob
|
2
2
|
class AssetsController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
3
|
-
skip_before_action :verify_authenticity_token
|
3
|
+
skip_before_action :verify_authenticity_token, raise: false
|
4
4
|
|
5
5
|
before_action do
|
6
6
|
expires_in 1.year, public: true
|
@@ -1,13 +1,9 @@
|
|
1
1
|
require 'rails/generators'
|
2
2
|
require 'rails/generators/active_record'
|
3
|
-
|
4
3
|
module GoodJob
|
5
4
|
#
|
6
|
-
#
|
7
|
-
#
|
8
|
-
#
|
9
|
-
# This generator is primarily dedicated to stubbing out a migration that adds
|
10
|
-
# a table to hold GoodJob's queued jobs in your database.
|
5
|
+
# Rails generator used for setting up GoodJob in a Rails application.
|
6
|
+
# Run it with +bin/rails g good_job:install+ in your console.
|
11
7
|
#
|
12
8
|
class InstallGenerator < Rails::Generators::Base
|
13
9
|
include Rails::Generators::Migration
|
@@ -16,17 +12,11 @@ module GoodJob
|
|
16
12
|
delegate :next_migration_number, to: ActiveRecord::Generators::Base
|
17
13
|
end
|
18
14
|
|
19
|
-
source_paths << File.join(File.dirname(__FILE__), "templates")
|
15
|
+
source_paths << File.join(File.dirname(__FILE__), "templates/install")
|
20
16
|
|
21
|
-
# Generates
|
17
|
+
# Generates monolithic migration file that contains all database changes.
|
22
18
|
def create_migration_file
|
23
|
-
migration_template '
|
24
|
-
end
|
25
|
-
|
26
|
-
private
|
27
|
-
|
28
|
-
def migration_version
|
29
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
19
|
+
migration_template 'migrations/create_good_jobs.rb.erb', 'db/migrate/create_good_jobs.rb'
|
30
20
|
end
|
31
21
|
end
|
32
22
|
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class CreateGoodJobs < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
enable_extension 'pgcrypto'
|
4
|
+
|
5
|
+
create_table :good_jobs, id: :uuid do |t|
|
6
|
+
t.text :queue_name
|
7
|
+
t.integer :priority
|
8
|
+
t.jsonb :serialized_params
|
9
|
+
t.timestamp :scheduled_at
|
10
|
+
t.timestamp :performed_at
|
11
|
+
t.timestamp :finished_at
|
12
|
+
t.text :error
|
13
|
+
|
14
|
+
t.timestamps
|
15
|
+
|
16
|
+
t.uuid :active_job_id
|
17
|
+
t.text :concurrency_key
|
18
|
+
t.text :cron_key
|
19
|
+
end
|
20
|
+
|
21
|
+
add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: "index_good_jobs_on_scheduled_at"
|
22
|
+
add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at
|
23
|
+
add_index :good_jobs, [:active_job_id, :created_at], name: :index_good_jobs_on_active_job_id_and_created_at
|
24
|
+
add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished
|
25
|
+
add_index :good_jobs, [:cron_key, :created_at], name: :index_good_jobs_on_cron_key_and_created_at
|
26
|
+
end
|
27
|
+
end
|
data/lib/generators/good_job/templates/{migration.rb.erb → update/migrations/01_create_good_jobs.rb}
RENAMED
@@ -1,4 +1,4 @@
|
|
1
|
-
class CreateGoodJobs < ActiveRecord::Migration
|
1
|
+
class CreateGoodJobs < ActiveRecord::Migration[5.2]
|
2
2
|
def change
|
3
3
|
enable_extension 'pgcrypto'
|
4
4
|
|
@@ -14,7 +14,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
|
|
14
14
|
t.timestamps
|
15
15
|
end
|
16
16
|
|
17
|
-
add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)"
|
18
|
-
add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)"
|
17
|
+
add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: "index_good_jobs_on_scheduled_at"
|
18
|
+
add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at
|
19
19
|
end
|
20
20
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class AddActiveJobIdConcurrencyKeyCronKeyToGoodJobs < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
reversible do |dir|
|
4
|
+
dir.up do
|
5
|
+
# Ensure this incremental update migration is idempotent
|
6
|
+
# with monolithic install migration.
|
7
|
+
return if connection.column_exists?(:good_jobs, :active_job_id)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
add_column :good_jobs, :active_job_id, :uuid
|
12
|
+
add_column :good_jobs, :concurrency_key, :text
|
13
|
+
add_column :good_jobs, :cron_key, :text
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
class AddActiveJobIdIndexAndConcurrencyKeyIndexToGoodJobs < ActiveRecord::Migration[5.2]
|
2
|
+
disable_ddl_transaction!
|
3
|
+
|
4
|
+
UPDATE_BATCH_SIZE = 1_000
|
5
|
+
|
6
|
+
class GoodJobJobs < ActiveRecord::Base
|
7
|
+
self.table_name = "good_jobs"
|
8
|
+
end
|
9
|
+
|
10
|
+
def change
|
11
|
+
reversible do |dir|
|
12
|
+
dir.up do
|
13
|
+
# Ensure this incremental update migration is idempotent
|
14
|
+
# with monolithic install migration.
|
15
|
+
return if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id_and_created_at)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
add_index :good_jobs, [:active_job_id, :created_at], algorithm: :concurrently, name: :index_good_jobs_on_active_job_id_and_created_at
|
20
|
+
add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", algorithm: :concurrently, name: :index_good_jobs_on_concurrency_key_when_unfinished
|
21
|
+
add_index :good_jobs, [:cron_key, :created_at], algorithm: :concurrently, name: :index_good_jobs_on_cron_key_and_created_at
|
22
|
+
|
23
|
+
reversible do |dir|
|
24
|
+
dir.up do
|
25
|
+
start_time = Time.current
|
26
|
+
loop do
|
27
|
+
break if GoodJobJobs.where(active_job_id: nil, finished_at: nil).where("created_at < ?", start_time).limit(UPDATE_BATCH_SIZE).update_all("active_job_id = (serialized_params->>'job_id')::uuid").zero?
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/active_record'
|
3
|
+
|
4
|
+
module GoodJob
|
5
|
+
#
|
6
|
+
# Rails generator used for updating GoodJob in a Rails application.
|
7
|
+
# Run it with +bin/rails g good_job:update+ in your console.
|
8
|
+
#
|
9
|
+
class UpdateGenerator < Rails::Generators::Base
|
10
|
+
include Rails::Generators::Migration
|
11
|
+
|
12
|
+
class << self
|
13
|
+
delegate :next_migration_number, to: ActiveRecord::Generators::Base
|
14
|
+
end
|
15
|
+
|
16
|
+
TEMPLATES = File.join(File.dirname(__FILE__), "templates/update")
|
17
|
+
source_paths << TEMPLATES
|
18
|
+
|
19
|
+
# Generates incremental migration files unless they already exist.
|
20
|
+
# All migrations should be idempotent e.g. +add_index+ is guarded with +if_index_exists?+
|
21
|
+
def update_migration_files
|
22
|
+
migration_templates = Dir.children(File.join(TEMPLATES, 'migrations')).sort
|
23
|
+
migration_templates.each do |template_file|
|
24
|
+
destination_file = template_file.match(/^\d*_(.*\.rb)/)[1] # 01_create_good_jobs.rb.erb => create_good_jobs.rb
|
25
|
+
migration_template "migrations/#{template_file}", "db/migrate/#{destination_file}", skip: true
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/good_job/job.rb
CHANGED
@@ -156,10 +156,10 @@ module GoodJob
|
|
156
156
|
# raised, if any (if the job raised, then the second array entry will be
|
157
157
|
# +nil+). If there were no jobs to execute, returns +nil+.
|
158
158
|
def self.perform_with_advisory_lock
|
159
|
-
unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
|
159
|
+
unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock(unlock_session: true) do |good_jobs|
|
160
160
|
good_job = good_jobs.first
|
161
|
-
|
162
|
-
break unless good_job&.executable?
|
161
|
+
break if good_job.blank?
|
162
|
+
break :unlocked unless good_job&.executable?
|
163
163
|
|
164
164
|
good_job.perform
|
165
165
|
end
|
@@ -196,13 +196,30 @@ module GoodJob
|
|
196
196
|
# The new {Job} instance representing the queued ActiveJob job.
|
197
197
|
def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
|
198
198
|
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|
|
199
|
-
|
199
|
+
good_job_args = {
|
200
200
|
queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
|
201
201
|
priority: active_job.priority || DEFAULT_PRIORITY,
|
202
202
|
serialized_params: active_job.serialize,
|
203
203
|
scheduled_at: scheduled_at,
|
204
|
-
create_with_advisory_lock: create_with_advisory_lock
|
205
|
-
|
204
|
+
create_with_advisory_lock: create_with_advisory_lock,
|
205
|
+
}
|
206
|
+
|
207
|
+
if column_names.include?('active_job_id')
|
208
|
+
good_job_args[:active_job_id] = active_job.job_id
|
209
|
+
else
|
210
|
+
ActiveSupport::Deprecation.warn(<<~DEPRECATION)
|
211
|
+
GoodJob has pending database migrations. To create the migration files, run:
|
212
|
+
|
213
|
+
rails generate good_job:update
|
214
|
+
|
215
|
+
To apply the migration files, run:
|
216
|
+
|
217
|
+
rails db:migrate
|
218
|
+
|
219
|
+
DEPRECATION
|
220
|
+
end
|
221
|
+
|
222
|
+
good_job = GoodJob::Job.new(**good_job_args)
|
206
223
|
|
207
224
|
instrument_payload[:good_job] = good_job
|
208
225
|
|
data/lib/good_job/lockable.rb
CHANGED
@@ -22,17 +22,25 @@ module GoodJob
|
|
22
22
|
RecordAlreadyAdvisoryLockedError = Class.new(StandardError)
|
23
23
|
|
24
24
|
included do
|
25
|
+
# Default column to be used when creating Advisory Locks
|
26
|
+
cattr_accessor(:advisory_lockable_column, instance_accessor: false) { primary_key }
|
27
|
+
|
28
|
+
# Default Postgres function to be used for Advisory Locks
|
29
|
+
cattr_accessor(:advisory_lockable_function) { "pg_try_advisory_lock" }
|
30
|
+
|
25
31
|
# Attempt to acquire an advisory lock on the selected records and
|
26
32
|
# return only those records for which a lock could be acquired.
|
27
|
-
# @!method advisory_lock
|
33
|
+
# @!method advisory_lock(column: advisory_lockable_column, function: advisory_lockable_function)
|
28
34
|
# @!scope class
|
35
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
36
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
29
37
|
# @return [ActiveRecord::Relation]
|
30
38
|
# A relation selecting only the records that were locked.
|
31
|
-
scope :advisory_lock, (lambda do
|
39
|
+
scope :advisory_lock, (lambda do |column: advisory_lockable_column, function: advisory_lockable_function|
|
32
40
|
original_query = self
|
33
41
|
|
34
42
|
cte_table = Arel::Table.new(:rows)
|
35
|
-
cte_query = original_query.select(primary_key).except(:limit)
|
43
|
+
cte_query = original_query.select(primary_key, column).except(:limit)
|
36
44
|
cte_type = if supports_cte_materialization_specifiers?
|
37
45
|
'MATERIALIZED'
|
38
46
|
else
|
@@ -41,9 +49,14 @@ module GoodJob
|
|
41
49
|
|
42
50
|
composed_cte = Arel::Nodes::As.new(cte_table, Arel::Nodes::SqlLiteral.new([cte_type, "(", cte_query.to_sql, ")"].join(' ')))
|
43
51
|
|
52
|
+
# In addition to an advisory lock, there is also a FOR UPDATE SKIP LOCKED
|
53
|
+
# because this causes the query to skip jobs that were completed (and deleted)
|
54
|
+
# by another session in the time since the table snapshot was taken.
|
55
|
+
# In rare cases under high concurrency levels, leaving this out can result in double executions.
|
44
56
|
query = cte_table.project(cte_table[:id])
|
45
57
|
.with(composed_cte)
|
46
|
-
.where(Arel.sql(sanitize_sql_for_conditions(["
|
58
|
+
.where(Arel.sql(sanitize_sql_for_conditions(["#{function}(('x' || substr(md5(:table_name || #{connection.quote_table_name(cte_table.name)}.#{connection.quote_column_name(column)}::text), 1, 16))::bit(64)::bigint)", { table_name: table_name }])))
|
59
|
+
.lock(Arel.sql("FOR UPDATE SKIP LOCKED"))
|
47
60
|
|
48
61
|
limit = original_query.arel.ast.limit
|
49
62
|
query.limit = limit.value if limit.present?
|
@@ -57,40 +70,44 @@ module GoodJob
|
|
57
70
|
#
|
58
71
|
# For details on +pg_locks+, see
|
59
72
|
# {https://www.postgresql.org/docs/current/view-pg-locks.html}.
|
60
|
-
# @!method joins_advisory_locks
|
73
|
+
# @!method joins_advisory_locks(column: advisory_lockable_column)
|
61
74
|
# @!scope class
|
75
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
62
76
|
# @return [ActiveRecord::Relation]
|
63
77
|
# @example Get the records that have a session awaiting a lock:
|
64
78
|
# MyLockableRecord.joins_advisory_locks.where("pg_locks.granted = ?", false)
|
65
|
-
scope :joins_advisory_locks, (lambda do
|
79
|
+
scope :joins_advisory_locks, (lambda do |column: advisory_lockable_column|
|
66
80
|
join_sql = <<~SQL.squish
|
67
81
|
LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'
|
68
82
|
AND pg_locks.objsubid = 1
|
69
|
-
AND pg_locks.classid = ('x' || substr(md5(:table_name || #{quoted_table_name}.#{
|
70
|
-
AND pg_locks.objid = (('x' || substr(md5(:table_name || #{quoted_table_name}.#{
|
83
|
+
AND pg_locks.classid = ('x' || substr(md5(:table_name || #{quoted_table_name}.#{connection.quote_column_name(column)}::text), 1, 16))::bit(32)::int
|
84
|
+
AND pg_locks.objid = (('x' || substr(md5(:table_name || #{quoted_table_name}.#{connection.quote_column_name(column)}::text), 1, 16))::bit(64) << 32)::bit(32)::int
|
71
85
|
SQL
|
72
86
|
|
73
87
|
joins(sanitize_sql_for_conditions([join_sql, { table_name: table_name }]))
|
74
88
|
end)
|
75
89
|
|
76
90
|
# Find records that do not have an advisory lock on them.
|
77
|
-
# @!method advisory_unlocked
|
91
|
+
# @!method advisory_unlocked(column: advisory_lockable_column)
|
78
92
|
# @!scope class
|
93
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
79
94
|
# @return [ActiveRecord::Relation]
|
80
|
-
scope :advisory_unlocked, -> { joins_advisory_locks.where(pg_locks: { locktype: nil }) }
|
95
|
+
scope :advisory_unlocked, ->(column: advisory_lockable_column) { joins_advisory_locks(column: column).where(pg_locks: { locktype: nil }) }
|
81
96
|
|
82
97
|
# Find records that have an advisory lock on them.
|
83
|
-
# @!method advisory_locked
|
98
|
+
# @!method advisory_locked(column: advisory_lockable_column)
|
84
99
|
# @!scope class
|
100
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
85
101
|
# @return [ActiveRecord::Relation]
|
86
|
-
scope :advisory_locked, -> { joins_advisory_locks.where.not(pg_locks: { locktype: nil }) }
|
102
|
+
scope :advisory_locked, ->(column: advisory_lockable_column) { joins_advisory_locks(column: column).where.not(pg_locks: { locktype: nil }) }
|
87
103
|
|
88
104
|
# Find records with advisory locks owned by the current Postgres
|
89
105
|
# session/connection.
|
90
|
-
# @!method advisory_locked
|
106
|
+
# @!method advisory_locked(column: advisory_lockable_column)
|
91
107
|
# @!scope class
|
108
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
92
109
|
# @return [ActiveRecord::Relation]
|
93
|
-
scope :owns_advisory_locked, -> { joins_advisory_locks.where('"pg_locks"."pid" = pg_backend_pid()') }
|
110
|
+
scope :owns_advisory_locked, ->(column: advisory_lockable_column) { joins_advisory_locks(column: column).where('"pg_locks"."pid" = pg_backend_pid()') }
|
94
111
|
|
95
112
|
# Whether an advisory lock should be acquired in the same transaction
|
96
113
|
# that created the record.
|
@@ -122,6 +139,9 @@ module GoodJob
|
|
122
139
|
# can (as in {Lockable.advisory_lock}) and only pass those that could be
|
123
140
|
# locked to the block.
|
124
141
|
#
|
142
|
+
# @param column [String, Symbol] name of advisory lock or unlock function
|
143
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
144
|
+
# @param unlock_session [Boolean] Whether to unlock all advisory locks in the session afterwards
|
125
145
|
# @yield [Array<Lockable>] the records that were successfully locked.
|
126
146
|
# @return [Object] the result of the block.
|
127
147
|
#
|
@@ -129,14 +149,21 @@ module GoodJob
|
|
129
149
|
# MyLockableRecord.order(created_at: :asc).limit(2).with_advisory_lock do |record|
|
130
150
|
# do_something_with record
|
131
151
|
# end
|
132
|
-
def with_advisory_lock
|
152
|
+
def with_advisory_lock(column: advisory_lockable_column, function: advisory_lockable_function, unlock_session: false)
|
133
153
|
raise ArgumentError, "Must provide a block" unless block_given?
|
134
154
|
|
135
|
-
records = advisory_lock.to_a
|
155
|
+
records = advisory_lock(column: column, function: function).to_a
|
136
156
|
begin
|
137
157
|
yield(records)
|
138
158
|
ensure
|
139
|
-
|
159
|
+
if unlock_session
|
160
|
+
advisory_unlock_session
|
161
|
+
else
|
162
|
+
records.each do |record|
|
163
|
+
key = [table_name, record[advisory_lockable_column]].join
|
164
|
+
record.advisory_unlock(key: key, function: advisory_unlockable_function(function))
|
165
|
+
end
|
166
|
+
end
|
140
167
|
end
|
141
168
|
end
|
142
169
|
|
@@ -145,49 +172,79 @@ module GoodJob
|
|
145
172
|
|
146
173
|
@_supports_cte_materialization_specifiers = connection.postgresql_version >= 120000
|
147
174
|
end
|
175
|
+
|
176
|
+
# Postgres advisory unlocking function for the class
|
177
|
+
# @param function [String, Symbol] name of advisory lock or unlock function
|
178
|
+
# @return [Boolean]
|
179
|
+
def advisory_unlockable_function(function = advisory_lockable_function)
|
180
|
+
function.to_s.sub("_lock", "_unlock").sub("_try_", "_")
|
181
|
+
end
|
182
|
+
|
183
|
+
# Unlocks all advisory locks active in the current database session/connection
|
184
|
+
# @return [void]
|
185
|
+
def advisory_unlock_session
|
186
|
+
connection.exec_query("SELECT pg_advisory_unlock_all()::text AS unlocked", 'GoodJob::Lockable Unlock Session').first[:unlocked]
|
187
|
+
end
|
188
|
+
|
189
|
+
# Converts SQL query strings between PG-compatible and JDBC-compatible syntax
|
190
|
+
# @param query [String]
|
191
|
+
# @return [Boolean]
|
192
|
+
def pg_or_jdbc_query(query)
|
193
|
+
if Concurrent.on_jruby?
|
194
|
+
# Replace $1 bind parameters with ?
|
195
|
+
query.gsub(/\$\d*/, '?')
|
196
|
+
else
|
197
|
+
query
|
198
|
+
end
|
199
|
+
end
|
148
200
|
end
|
149
201
|
|
150
202
|
# Acquires an advisory lock on this record if it is not already locked by
|
151
203
|
# another database session. Be careful to ensure you release the lock when
|
152
204
|
# you are done with {#advisory_unlock} (or {#advisory_unlock!} to release
|
153
205
|
# all remaining locks).
|
206
|
+
# @param key [String, Symbol] Key to Advisory Lock against
|
207
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
154
208
|
# @return [Boolean] whether the lock was acquired.
|
155
|
-
def advisory_lock
|
209
|
+
def advisory_lock(key: lockable_key, function: advisory_lockable_function)
|
156
210
|
query = <<~SQL.squish
|
157
|
-
SELECT 1 AS
|
158
|
-
WHERE pg_try_advisory_lock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
|
211
|
+
SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint) AS locked
|
159
212
|
SQL
|
160
|
-
binds = [[nil,
|
161
|
-
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).
|
213
|
+
binds = [[nil, key]]
|
214
|
+
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).first['locked']
|
162
215
|
end
|
163
216
|
|
164
217
|
# Releases an advisory lock on this record if it is locked by this database
|
165
218
|
# session. Note that advisory locks stack, so you must call
|
166
219
|
# {#advisory_unlock} and {#advisory_lock} the same number of times.
|
220
|
+
# @param key [String, Symbol] Key to lock against
|
221
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
167
222
|
# @return [Boolean] whether the lock was released.
|
168
|
-
def advisory_unlock
|
223
|
+
def advisory_unlock(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function))
|
169
224
|
query = <<~SQL.squish
|
170
|
-
SELECT 1 AS
|
171
|
-
WHERE pg_advisory_unlock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
|
225
|
+
SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint) AS unlocked
|
172
226
|
SQL
|
173
|
-
binds = [[nil,
|
174
|
-
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Unlock', binds).
|
227
|
+
binds = [[nil, key]]
|
228
|
+
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Unlock', binds).first['unlocked']
|
175
229
|
end
|
176
230
|
|
177
231
|
# Acquires an advisory lock on this record or raises
|
178
232
|
# {RecordAlreadyAdvisoryLockedError} if it is already locked by another
|
179
233
|
# database session.
|
234
|
+
# @param key [String, Symbol] Key to lock against
|
235
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
180
236
|
# @raise [RecordAlreadyAdvisoryLockedError]
|
181
237
|
# @return [Boolean] +true+
|
182
|
-
def advisory_lock!
|
183
|
-
result = advisory_lock
|
238
|
+
def advisory_lock!(key: lockable_key, function: advisory_lockable_function)
|
239
|
+
result = advisory_lock(key: key, function: function)
|
184
240
|
result || raise(RecordAlreadyAdvisoryLockedError)
|
185
241
|
end
|
186
242
|
|
187
243
|
# Acquires an advisory lock on this record and safely releases it after the
|
188
244
|
# passed block is completed. If the record is locked by another database
|
189
245
|
# session, this raises {RecordAlreadyAdvisoryLockedError}.
|
190
|
-
#
|
246
|
+
# @param key [String, Symbol] Key to lock against
|
247
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
191
248
|
# @yield Nothing
|
192
249
|
# @return [Object] The result of the block.
|
193
250
|
#
|
@@ -196,64 +253,63 @@ module GoodJob
|
|
196
253
|
# record.with_advisory_lock do
|
197
254
|
# do_something_with record
|
198
255
|
# end
|
199
|
-
def with_advisory_lock
|
256
|
+
def with_advisory_lock(key: lockable_key, function: advisory_lockable_function)
|
200
257
|
raise ArgumentError, "Must provide a block" unless block_given?
|
201
258
|
|
202
|
-
advisory_lock!
|
259
|
+
advisory_lock!(key: key, function: function)
|
203
260
|
yield
|
204
261
|
ensure
|
205
|
-
advisory_unlock unless $ERROR_INFO.is_a? RecordAlreadyAdvisoryLockedError
|
262
|
+
advisory_unlock(key: key, function: self.class.advisory_unlockable_function(function)) unless $ERROR_INFO.is_a? RecordAlreadyAdvisoryLockedError
|
206
263
|
end
|
207
264
|
|
208
265
|
# Tests whether this record has an advisory lock on it.
|
266
|
+
# @param key [String, Symbol] Key to test lock against
|
209
267
|
# @return [Boolean]
|
210
|
-
def advisory_locked?
|
268
|
+
def advisory_locked?(key: lockable_key)
|
211
269
|
query = <<~SQL.squish
|
212
270
|
SELECT 1 AS one
|
213
271
|
FROM pg_locks
|
214
272
|
WHERE pg_locks.locktype = 'advisory'
|
215
273
|
AND pg_locks.objsubid = 1
|
216
|
-
AND pg_locks.classid = ('x' || substr(md5($1
|
217
|
-
AND pg_locks.objid = (('x' || substr(md5($
|
274
|
+
AND pg_locks.classid = ('x' || substr(md5($1::text), 1, 16))::bit(32)::int
|
275
|
+
AND pg_locks.objid = (('x' || substr(md5($2::text), 1, 16))::bit(64) << 32)::bit(32)::int
|
218
276
|
SQL
|
219
|
-
binds = [[nil,
|
277
|
+
binds = [[nil, key], [nil, key]]
|
220
278
|
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Locked?', binds).any?
|
221
279
|
end
|
222
280
|
|
223
281
|
# Tests whether this record is locked by the current database session.
|
282
|
+
# @param key [String, Symbol] Key to test lock against
|
224
283
|
# @return [Boolean]
|
225
|
-
def owns_advisory_lock?
|
284
|
+
def owns_advisory_lock?(key: lockable_key)
|
226
285
|
query = <<~SQL.squish
|
227
286
|
SELECT 1 AS one
|
228
287
|
FROM pg_locks
|
229
288
|
WHERE pg_locks.locktype = 'advisory'
|
230
289
|
AND pg_locks.objsubid = 1
|
231
|
-
AND pg_locks.classid = ('x' || substr(md5($1
|
232
|
-
AND pg_locks.objid = (('x' || substr(md5($
|
290
|
+
AND pg_locks.classid = ('x' || substr(md5($1::text), 1, 16))::bit(32)::int
|
291
|
+
AND pg_locks.objid = (('x' || substr(md5($2::text), 1, 16))::bit(64) << 32)::bit(32)::int
|
233
292
|
AND pg_locks.pid = pg_backend_pid()
|
234
293
|
SQL
|
235
|
-
binds = [[nil,
|
294
|
+
binds = [[nil, key], [nil, key]]
|
236
295
|
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Owns Advisory Lock?', binds).any?
|
237
296
|
end
|
238
297
|
|
239
298
|
# Releases all advisory locks on the record that are held by the current
|
240
299
|
# database session.
|
300
|
+
# @param key [String, Symbol] Key to lock against
|
301
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
241
302
|
# @return [void]
|
242
|
-
def advisory_unlock!
|
243
|
-
advisory_unlock while advisory_locked?
|
303
|
+
def advisory_unlock!(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function))
|
304
|
+
advisory_unlock(key: key, function: function) while advisory_locked?
|
244
305
|
end
|
245
306
|
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
def pg_or_jdbc_query(query)
|
251
|
-
if Concurrent.on_jruby?
|
252
|
-
# Replace $1 bind parameters with ?
|
253
|
-
query.gsub(/\$\d*/, '?')
|
254
|
-
else
|
255
|
-
query
|
256
|
-
end
|
307
|
+
# Default Advisory Lock key
|
308
|
+
# @return [String]
|
309
|
+
def lockable_key
|
310
|
+
[self.class.table_name, self[self.class.advisory_lockable_column]].join
|
257
311
|
end
|
312
|
+
|
313
|
+
delegate :pg_or_jdbc_query, to: :class
|
258
314
|
end
|
259
315
|
end
|
data/lib/good_job/poller.rb
CHANGED
@@ -5,11 +5,13 @@ module GoodJob # :nodoc:
|
|
5
5
|
# Pollers regularly wake up execution threads to check for new work.
|
6
6
|
#
|
7
7
|
class Poller
|
8
|
+
TIMEOUT_INTERVAL = 5
|
9
|
+
|
8
10
|
# Defaults for instance of Concurrent::TimerTask.
|
9
11
|
# The timer controls how and when sleeping threads check for new work.
|
10
12
|
DEFAULT_TIMER_OPTIONS = {
|
11
13
|
execution_interval: Configuration::DEFAULT_POLL_INTERVAL,
|
12
|
-
timeout_interval:
|
14
|
+
timeout_interval: TIMEOUT_INTERVAL,
|
13
15
|
run_now: true,
|
14
16
|
}.freeze
|
15
17
|
|
@@ -49,9 +51,11 @@ module GoodJob # :nodoc:
|
|
49
51
|
|
50
52
|
# Tests whether the timer is shutdown.
|
51
53
|
# @return [true, false, nil]
|
52
|
-
|
54
|
+
def shutdown?
|
55
|
+
timer ? timer.shutdown? : true
|
56
|
+
end
|
53
57
|
|
54
|
-
# Shut down the
|
58
|
+
# Shut down the poller.
|
55
59
|
# Use {#shutdown?} to determine whether threads have stopped.
|
56
60
|
# @param timeout [nil, Numeric] Seconds to wait for active threads.
|
57
61
|
# * +nil+, the scheduler will trigger a shutdown but not wait for it to complete.
|
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: 1.
|
4
|
+
version: 1.10.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: 2021-06-
|
11
|
+
date: 2021-06-29 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activejob
|
@@ -94,6 +94,20 @@ dependencies:
|
|
94
94
|
- - ">="
|
95
95
|
- !ruby/object:Gem::Version
|
96
96
|
version: '2.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: benchmark-ips
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - '='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 2.8.4
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - '='
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 2.8.4
|
97
111
|
- !ruby/object:Gem::Dependency
|
98
112
|
name: capybara
|
99
113
|
requirement: !ruby/object:Gem::Requirement
|
@@ -342,7 +356,11 @@ files:
|
|
342
356
|
- exe/good_job
|
343
357
|
- lib/active_job/queue_adapters/good_job_adapter.rb
|
344
358
|
- lib/generators/good_job/install_generator.rb
|
345
|
-
- lib/generators/good_job/templates/
|
359
|
+
- lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb
|
360
|
+
- lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb
|
361
|
+
- lib/generators/good_job/templates/update/migrations/02_add_active_job_id_concurrency_key_cron_key_to_good_jobs.rb
|
362
|
+
- lib/generators/good_job/templates/update/migrations/03_add_active_job_id_index_and_concurrency_key_index_to_good_jobs.rb
|
363
|
+
- lib/generators/good_job/update_generator.rb
|
346
364
|
- lib/good_job.rb
|
347
365
|
- lib/good_job/adapter.rb
|
348
366
|
- lib/good_job/cli.rb
|