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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db147d840e981330790b1b3254d9aa40d0c9e6e9284612336e3815928c0efa38
4
- data.tar.gz: 22d1b91b0edc67538ded7b8efd9dbdf36e4d1a06717c8e96ed09adb50188310a
3
+ metadata.gz: 96c16ec6a03de5eaf957327ad3f9161d19665eeef2e8bc300caa758a84cfe58f
4
+ data.tar.gz: 22be95e4ae3d2dc29a8b242cb37889d21d2b19f119184a5a33d07f04e9a3b453
5
5
  SHA512:
6
- metadata.gz: 352ddf2bbe0dbe7c79a650a32f37c070855d3330ecb444a79b33afbb253108bac567794959cd2a2111f10c7c2623f08a341f17211ee70c13841c783f0611ed04
7
- data.tar.gz: 5ecbac7d33b5a6db0d8ad2f5964dadd9bc6f394eab3d6935584c5b4ab3633dadc1ed2249e99de8e532f8cd1c4cd4268a76cf7104c21f3b00bc924e8fd12ee64b
6
+ metadata.gz: a59d51fdaf7e08a551e6401bdc6fb59d56e8397bc15b7f6ead69ce5eb45da653c149ebad6583af739cdcb619c2d827fb19f50becf4e4b3780ef4d31dc9be4a50
7
+ data.tar.gz: a9c3c96e76c19270b436d03fcff64e2e873cb6f14b784cbe8b2dd20e2deee455ce2ae6e1869a67b812f414880a7e264a50afd2951d80b41f4ddb9acdab8a0764
data/CHANGELOG.md CHANGED
@@ -1,23 +1,53 @@
1
1
  # Changelog
2
2
 
3
- ## [v1.9.6](https://github.com/bensheldon/good_job/tree/v1.9.6) (2021-06-04)
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...v1.9.6)
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
- - Pause jobs during migration / maintenance? [\#257](https://github.com/bensheldon/good_job/issues/257)
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
- # Implements the Rails generator used for setting up GoodJob in a Rails
7
- # application. Run it with +bin/rails g good_job:install+ in your console.
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 the actual migration file and places it on disk.
17
+ # Generates monolithic migration file that contains all database changes.
22
18
  def create_migration_file
23
- migration_template 'migration.rb.erb', 'db/migrate/create_good_jobs.rb', migration_version: migration_version
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
@@ -1,4 +1,4 @@
1
- class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
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
- # TODO: Determine why some records are fetched without an advisory lock at all
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
- good_job = GoodJob::Job.new(
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
 
@@ -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(["pg_try_advisory_lock(('x' || substr(md5(:table_name || #{connection.quote_table_name(cte_table.name)}.#{quoted_primary_key}::text), 1, 16))::bit(64)::bigint)", { table_name: table_name }])))
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}.#{quoted_primary_key}::text), 1, 16))::bit(32)::int
70
- AND pg_locks.objid = (('x' || substr(md5(:table_name || #{quoted_table_name}.#{quoted_primary_key}::text), 1, 16))::bit(64) << 32)::bit(32)::int
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
- records.each(&:advisory_unlock)
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 one
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, self.class.table_name], [nil, send(self.class.primary_key)]]
161
- self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).any?
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 one
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, self.class.table_name], [nil, send(self.class.primary_key)]]
174
- self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Unlock', binds).any?
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 || $2::text), 1, 16))::bit(32)::int
217
- AND pg_locks.objid = (('x' || substr(md5($3 || $4::text), 1, 16))::bit(64) << 32)::bit(32)::int
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, self.class.table_name], [nil, send(self.class.primary_key)], [nil, self.class.table_name], [nil, send(self.class.primary_key)]]
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 || $2::text), 1, 16))::bit(32)::int
232
- AND pg_locks.objid = (('x' || substr(md5($3 || $4::text), 1, 16))::bit(64) << 32)::bit(32)::int
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, self.class.table_name], [nil, send(self.class.primary_key)], [nil, self.class.table_name], [nil, send(self.class.primary_key)]]
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
- private
247
-
248
- # @param query [String]
249
- # @return [Boolean]
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
@@ -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: 1,
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
- delegate :shutdown?, to: :timer, allow_nil: true
54
+ def shutdown?
55
+ timer ? timer.shutdown? : true
56
+ end
53
57
 
54
- # Shut down the notifier.
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.
@@ -1,4 +1,4 @@
1
1
  module GoodJob
2
2
  # GoodJob gem version.
3
- VERSION = '1.9.6'.freeze
3
+ VERSION = '1.10.0'.freeze
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.6
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-04 00:00:00.000000000 Z
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/migration.rb.erb
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