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 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