good_job 1.9.6 → 1.10.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 +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
|