good_job 1.9.4 → 1.11.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 +82 -0
- data/README.md +47 -0
- data/engine/app/assets/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- data/engine/app/assets/vendor/bootstrap/bootstrap.min.css +7 -0
- data/engine/app/controllers/good_job/assets_controller.rb +29 -0
- data/engine/app/controllers/good_job/dashboards_controller.rb +1 -1
- data/engine/app/controllers/good_job/jobs_controller.rb +9 -0
- data/engine/app/views/good_job/dashboards/index.html.erb +1 -1
- data/engine/app/views/layouts/good_job/base.html.erb +21 -12
- data/engine/app/views/shared/_jobs_table.erb +13 -1
- data/engine/app/views/shared/icons/_check.html.erb +4 -0
- data/engine/app/views/shared/icons/_exclamation.html.erb +4 -0
- data/engine/app/views/shared/icons/_trash.html.erb +5 -0
- data/engine/config/routes.rb +10 -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/active_job_extensions.rb +4 -0
- data/lib/good_job/active_job_extensions/concurrency.rb +68 -0
- data/lib/good_job/current_execution.rb +12 -5
- data/lib/good_job/job.rb +43 -6
- data/lib/good_job/lockable.rb +114 -57
- data/lib/good_job/poller.rb +7 -3
- data/lib/good_job/version.rb +1 -1
- metadata +30 -5
- data/engine/app/assets/vendor/bootstrap/bootstrap-native.js +0 -1662
- data/engine/app/assets/vendor/bootstrap/bootstrap.css +0 -10258
@@ -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
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module GoodJob
|
2
|
+
module ActiveJobExtensions
|
3
|
+
module Concurrency
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
ConcurrencyExceededError = Class.new(StandardError)
|
7
|
+
|
8
|
+
included do
|
9
|
+
class_attribute :good_job_concurrency_config, instance_accessor: false, default: {}
|
10
|
+
|
11
|
+
before_enqueue do |job|
|
12
|
+
# Always allow jobs to be retried because the current job's execution will complete momentarily
|
13
|
+
next if CurrentExecution.active_job_id == job.job_id
|
14
|
+
|
15
|
+
limit = job.class.good_job_concurrency_config.fetch(:enqueue_limit, Float::INFINITY)
|
16
|
+
next if limit.blank? || (0...Float::INFINITY).exclude?(limit)
|
17
|
+
|
18
|
+
key = job.good_job_concurrency_key
|
19
|
+
next if key.blank?
|
20
|
+
|
21
|
+
GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
|
22
|
+
# TODO: Why is `unscoped` necessary? Nested scope is bleeding into subsequent query?
|
23
|
+
enqueue_concurrency = GoodJob::Job.unscoped.where(concurrency_key: key).unfinished.count
|
24
|
+
# The job has not yet been enqueued, so check if adding it will go over the limit
|
25
|
+
throw :abort if enqueue_concurrency + 1 > limit
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
retry_on(
|
30
|
+
GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError,
|
31
|
+
attempts: Float::INFINITY,
|
32
|
+
wait: :exponentially_longer
|
33
|
+
)
|
34
|
+
|
35
|
+
before_perform do |job|
|
36
|
+
limit = job.class.good_job_concurrency_config.fetch(:perform_limit, Float::INFINITY)
|
37
|
+
next if limit.blank? || (0...Float::INFINITY).exclude?(limit)
|
38
|
+
|
39
|
+
key = job.good_job_concurrency_key
|
40
|
+
next if key.blank?
|
41
|
+
|
42
|
+
GoodJob::Job.new.with_advisory_lock(key: key, function: "pg_advisory_lock") do
|
43
|
+
perform_concurrency = GoodJob::Job.unscoped.where(concurrency_key: key).advisory_locked.count
|
44
|
+
# The current job has already been locked and will appear in the previous query
|
45
|
+
raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError if perform_concurrency > limit
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class_methods do
|
51
|
+
def good_job_control_concurrency_with(config)
|
52
|
+
self.good_job_concurrency_config = config
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def good_job_concurrency_key
|
57
|
+
key = self.class.good_job_concurrency_config[:key]
|
58
|
+
return if key.blank?
|
59
|
+
|
60
|
+
if key.respond_to? :call
|
61
|
+
instance_exec(&key)
|
62
|
+
else
|
63
|
+
key
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -4,11 +4,11 @@ module GoodJob
|
|
4
4
|
# Thread-local attributes for passing values from Instrumentation.
|
5
5
|
# (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
|
6
6
|
module CurrentExecution
|
7
|
-
# @!attribute [rw]
|
7
|
+
# @!attribute [rw] active_job_id
|
8
8
|
# @!scope class
|
9
|
-
#
|
10
|
-
# @return [
|
11
|
-
thread_mattr_accessor :
|
9
|
+
# ActiveJob ID
|
10
|
+
# @return [String, nil]
|
11
|
+
thread_mattr_accessor :active_job_id
|
12
12
|
|
13
13
|
# @!attribute [rw] error_on_discard
|
14
14
|
# @!scope class
|
@@ -16,11 +16,18 @@ module GoodJob
|
|
16
16
|
# @return [Exception, nil]
|
17
17
|
thread_mattr_accessor :error_on_discard
|
18
18
|
|
19
|
+
# @!attribute [rw] error_on_retry
|
20
|
+
# @!scope class
|
21
|
+
# Error captured by retry_on
|
22
|
+
# @return [Exception, nil]
|
23
|
+
thread_mattr_accessor :error_on_retry
|
24
|
+
|
19
25
|
# Resets attributes
|
20
26
|
# @return [void]
|
21
27
|
def self.reset
|
22
|
-
self.
|
28
|
+
self.active_job_id = nil
|
23
29
|
self.error_on_discard = nil
|
30
|
+
self.error_on_retry = nil
|
24
31
|
end
|
25
32
|
|
26
33
|
# @return [Integer] Current process ID
|
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,45 @@ 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
|
+
if column_names.include?('concurrency_key')
|
223
|
+
good_job_args[:concurrency_key] = active_job.good_job_concurrency_key if active_job.respond_to?(:good_job_concurrency_key)
|
224
|
+
else
|
225
|
+
ActiveSupport::Deprecation.warn(<<~DEPRECATION)
|
226
|
+
GoodJob has pending database migrations. To create the migration files, run:
|
227
|
+
|
228
|
+
rails generate good_job:update
|
229
|
+
|
230
|
+
To apply the migration files, run:
|
231
|
+
|
232
|
+
rails db:migrate
|
233
|
+
|
234
|
+
DEPRECATION
|
235
|
+
end
|
236
|
+
|
237
|
+
good_job = GoodJob::Job.new(**good_job_args)
|
206
238
|
|
207
239
|
instrument_payload[:good_job] = good_job
|
208
240
|
|
@@ -247,6 +279,10 @@ module GoodJob
|
|
247
279
|
self.class.unscoped.unfinished.owns_advisory_locked.exists?(id: id)
|
248
280
|
end
|
249
281
|
|
282
|
+
def active_job_id
|
283
|
+
super || serialized_params['job_id']
|
284
|
+
end
|
285
|
+
|
250
286
|
private
|
251
287
|
|
252
288
|
# @return [ExecutionResult]
|
@@ -256,6 +292,7 @@ module GoodJob
|
|
256
292
|
)
|
257
293
|
|
258
294
|
GoodJob::CurrentExecution.reset
|
295
|
+
GoodJob::CurrentExecution.active_job_id = active_job_id
|
259
296
|
ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
|
260
297
|
value = ActiveJob::Base.execute(params)
|
261
298
|
|
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
|
@@ -40,10 +48,9 @@ module GoodJob
|
|
40
48
|
end
|
41
49
|
|
42
50
|
composed_cte = Arel::Nodes::As.new(cte_table, Arel::Nodes::SqlLiteral.new([cte_type, "(", cte_query.to_sql, ")"].join(' ')))
|
43
|
-
|
44
51
|
query = cte_table.project(cte_table[:id])
|
45
52
|
.with(composed_cte)
|
46
|
-
.where(Arel.sql(sanitize_sql_for_conditions(["
|
53
|
+
.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 }])))
|
47
54
|
|
48
55
|
limit = original_query.arel.ast.limit
|
49
56
|
query.limit = limit.value if limit.present?
|
@@ -57,40 +64,44 @@ module GoodJob
|
|
57
64
|
#
|
58
65
|
# For details on +pg_locks+, see
|
59
66
|
# {https://www.postgresql.org/docs/current/view-pg-locks.html}.
|
60
|
-
# @!method joins_advisory_locks
|
67
|
+
# @!method joins_advisory_locks(column: advisory_lockable_column)
|
61
68
|
# @!scope class
|
69
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
62
70
|
# @return [ActiveRecord::Relation]
|
63
71
|
# @example Get the records that have a session awaiting a lock:
|
64
72
|
# MyLockableRecord.joins_advisory_locks.where("pg_locks.granted = ?", false)
|
65
|
-
scope :joins_advisory_locks, (lambda do
|
73
|
+
scope :joins_advisory_locks, (lambda do |column: advisory_lockable_column|
|
66
74
|
join_sql = <<~SQL.squish
|
67
75
|
LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'
|
68
76
|
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}.#{
|
77
|
+
AND pg_locks.classid = ('x' || substr(md5(:table_name || #{quoted_table_name}.#{connection.quote_column_name(column)}::text), 1, 16))::bit(32)::int
|
78
|
+
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
79
|
SQL
|
72
80
|
|
73
81
|
joins(sanitize_sql_for_conditions([join_sql, { table_name: table_name }]))
|
74
82
|
end)
|
75
83
|
|
76
84
|
# Find records that do not have an advisory lock on them.
|
77
|
-
# @!method advisory_unlocked
|
85
|
+
# @!method advisory_unlocked(column: advisory_lockable_column)
|
78
86
|
# @!scope class
|
87
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
79
88
|
# @return [ActiveRecord::Relation]
|
80
|
-
scope :advisory_unlocked, -> { joins_advisory_locks.where(pg_locks: { locktype: nil }) }
|
89
|
+
scope :advisory_unlocked, ->(column: advisory_lockable_column) { joins_advisory_locks(column: column).where(pg_locks: { locktype: nil }) }
|
81
90
|
|
82
91
|
# Find records that have an advisory lock on them.
|
83
|
-
# @!method advisory_locked
|
92
|
+
# @!method advisory_locked(column: advisory_lockable_column)
|
84
93
|
# @!scope class
|
94
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
85
95
|
# @return [ActiveRecord::Relation]
|
86
|
-
scope :advisory_locked, -> { joins_advisory_locks.where.not(pg_locks: { locktype: nil }) }
|
96
|
+
scope :advisory_locked, ->(column: advisory_lockable_column) { joins_advisory_locks(column: column).where.not(pg_locks: { locktype: nil }) }
|
87
97
|
|
88
98
|
# Find records with advisory locks owned by the current Postgres
|
89
99
|
# session/connection.
|
90
|
-
# @!method advisory_locked
|
100
|
+
# @!method advisory_locked(column: advisory_lockable_column)
|
91
101
|
# @!scope class
|
102
|
+
# @param column [String, Symbol] column values to Advisory Lock against
|
92
103
|
# @return [ActiveRecord::Relation]
|
93
|
-
scope :owns_advisory_locked, -> { joins_advisory_locks.where('"pg_locks"."pid" = pg_backend_pid()') }
|
104
|
+
scope :owns_advisory_locked, ->(column: advisory_lockable_column) { joins_advisory_locks(column: column).where('"pg_locks"."pid" = pg_backend_pid()') }
|
94
105
|
|
95
106
|
# Whether an advisory lock should be acquired in the same transaction
|
96
107
|
# that created the record.
|
@@ -122,6 +133,9 @@ module GoodJob
|
|
122
133
|
# can (as in {Lockable.advisory_lock}) and only pass those that could be
|
123
134
|
# locked to the block.
|
124
135
|
#
|
136
|
+
# @param column [String, Symbol] name of advisory lock or unlock function
|
137
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
138
|
+
# @param unlock_session [Boolean] Whether to unlock all advisory locks in the session afterwards
|
125
139
|
# @yield [Array<Lockable>] the records that were successfully locked.
|
126
140
|
# @return [Object] the result of the block.
|
127
141
|
#
|
@@ -129,14 +143,21 @@ module GoodJob
|
|
129
143
|
# MyLockableRecord.order(created_at: :asc).limit(2).with_advisory_lock do |record|
|
130
144
|
# do_something_with record
|
131
145
|
# end
|
132
|
-
def with_advisory_lock
|
146
|
+
def with_advisory_lock(column: advisory_lockable_column, function: advisory_lockable_function, unlock_session: false)
|
133
147
|
raise ArgumentError, "Must provide a block" unless block_given?
|
134
148
|
|
135
|
-
records = advisory_lock.to_a
|
149
|
+
records = advisory_lock(column: column, function: function).to_a
|
136
150
|
begin
|
137
151
|
yield(records)
|
138
152
|
ensure
|
139
|
-
|
153
|
+
if unlock_session
|
154
|
+
advisory_unlock_session
|
155
|
+
else
|
156
|
+
records.each do |record|
|
157
|
+
key = [table_name, record[advisory_lockable_column]].join
|
158
|
+
record.advisory_unlock(key: key, function: advisory_unlockable_function(function))
|
159
|
+
end
|
160
|
+
end
|
140
161
|
end
|
141
162
|
end
|
142
163
|
|
@@ -145,49 +166,86 @@ module GoodJob
|
|
145
166
|
|
146
167
|
@_supports_cte_materialization_specifiers = connection.postgresql_version >= 120000
|
147
168
|
end
|
169
|
+
|
170
|
+
# Postgres advisory unlocking function for the class
|
171
|
+
# @param function [String, Symbol] name of advisory lock or unlock function
|
172
|
+
# @return [Boolean]
|
173
|
+
def advisory_unlockable_function(function = advisory_lockable_function)
|
174
|
+
function.to_s.sub("_lock", "_unlock").sub("_try_", "_")
|
175
|
+
end
|
176
|
+
|
177
|
+
# Unlocks all advisory locks active in the current database session/connection
|
178
|
+
# @return [void]
|
179
|
+
def advisory_unlock_session
|
180
|
+
connection.exec_query("SELECT pg_advisory_unlock_all()::text AS unlocked", 'GoodJob::Lockable Unlock Session').first[:unlocked]
|
181
|
+
end
|
182
|
+
|
183
|
+
# Converts SQL query strings between PG-compatible and JDBC-compatible syntax
|
184
|
+
# @param query [String]
|
185
|
+
# @return [Boolean]
|
186
|
+
def pg_or_jdbc_query(query)
|
187
|
+
if Concurrent.on_jruby?
|
188
|
+
# Replace $1 bind parameters with ?
|
189
|
+
query.gsub(/\$\d*/, '?')
|
190
|
+
else
|
191
|
+
query
|
192
|
+
end
|
193
|
+
end
|
148
194
|
end
|
149
195
|
|
150
196
|
# Acquires an advisory lock on this record if it is not already locked by
|
151
197
|
# another database session. Be careful to ensure you release the lock when
|
152
198
|
# you are done with {#advisory_unlock} (or {#advisory_unlock!} to release
|
153
199
|
# all remaining locks).
|
200
|
+
# @param key [String, Symbol] Key to Advisory Lock against
|
201
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
154
202
|
# @return [Boolean] whether the lock was acquired.
|
155
|
-
def advisory_lock
|
156
|
-
query =
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
203
|
+
def advisory_lock(key: lockable_key, function: advisory_lockable_function)
|
204
|
+
query = if function.include? "_try_"
|
205
|
+
<<~SQL.squish
|
206
|
+
SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint) AS locked
|
207
|
+
SQL
|
208
|
+
else
|
209
|
+
<<~SQL.squish
|
210
|
+
SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint)::text AS locked
|
211
|
+
SQL
|
212
|
+
end
|
213
|
+
|
214
|
+
binds = [[nil, key]]
|
215
|
+
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).first['locked']
|
162
216
|
end
|
163
217
|
|
164
218
|
# Releases an advisory lock on this record if it is locked by this database
|
165
219
|
# session. Note that advisory locks stack, so you must call
|
166
220
|
# {#advisory_unlock} and {#advisory_lock} the same number of times.
|
221
|
+
# @param key [String, Symbol] Key to lock against
|
222
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
167
223
|
# @return [Boolean] whether the lock was released.
|
168
|
-
def advisory_unlock
|
224
|
+
def advisory_unlock(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function))
|
169
225
|
query = <<~SQL.squish
|
170
|
-
SELECT 1 AS
|
171
|
-
WHERE pg_advisory_unlock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
|
226
|
+
SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint) AS unlocked
|
172
227
|
SQL
|
173
|
-
binds = [[nil,
|
174
|
-
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Unlock', binds).
|
228
|
+
binds = [[nil, key]]
|
229
|
+
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Unlock', binds).first['unlocked']
|
175
230
|
end
|
176
231
|
|
177
232
|
# Acquires an advisory lock on this record or raises
|
178
233
|
# {RecordAlreadyAdvisoryLockedError} if it is already locked by another
|
179
234
|
# database session.
|
235
|
+
# @param key [String, Symbol] Key to lock against
|
236
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
180
237
|
# @raise [RecordAlreadyAdvisoryLockedError]
|
181
238
|
# @return [Boolean] +true+
|
182
|
-
def advisory_lock!
|
183
|
-
result = advisory_lock
|
239
|
+
def advisory_lock!(key: lockable_key, function: advisory_lockable_function)
|
240
|
+
result = advisory_lock(key: key, function: function)
|
184
241
|
result || raise(RecordAlreadyAdvisoryLockedError)
|
185
242
|
end
|
186
243
|
|
187
244
|
# Acquires an advisory lock on this record and safely releases it after the
|
188
245
|
# passed block is completed. If the record is locked by another database
|
189
246
|
# session, this raises {RecordAlreadyAdvisoryLockedError}.
|
190
|
-
#
|
247
|
+
# @param key [String, Symbol] Key to lock against
|
248
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
191
249
|
# @yield Nothing
|
192
250
|
# @return [Object] The result of the block.
|
193
251
|
#
|
@@ -196,64 +254,63 @@ module GoodJob
|
|
196
254
|
# record.with_advisory_lock do
|
197
255
|
# do_something_with record
|
198
256
|
# end
|
199
|
-
def with_advisory_lock
|
257
|
+
def with_advisory_lock(key: lockable_key, function: advisory_lockable_function)
|
200
258
|
raise ArgumentError, "Must provide a block" unless block_given?
|
201
259
|
|
202
|
-
advisory_lock!
|
260
|
+
advisory_lock!(key: key, function: function)
|
203
261
|
yield
|
204
262
|
ensure
|
205
|
-
advisory_unlock unless $ERROR_INFO.is_a? RecordAlreadyAdvisoryLockedError
|
263
|
+
advisory_unlock(key: key, function: self.class.advisory_unlockable_function(function)) unless $ERROR_INFO.is_a? RecordAlreadyAdvisoryLockedError
|
206
264
|
end
|
207
265
|
|
208
266
|
# Tests whether this record has an advisory lock on it.
|
267
|
+
# @param key [String, Symbol] Key to test lock against
|
209
268
|
# @return [Boolean]
|
210
|
-
def advisory_locked?
|
269
|
+
def advisory_locked?(key: lockable_key)
|
211
270
|
query = <<~SQL.squish
|
212
271
|
SELECT 1 AS one
|
213
272
|
FROM pg_locks
|
214
273
|
WHERE pg_locks.locktype = 'advisory'
|
215
274
|
AND pg_locks.objsubid = 1
|
216
|
-
AND pg_locks.classid = ('x' || substr(md5($1
|
217
|
-
AND pg_locks.objid = (('x' || substr(md5($
|
275
|
+
AND pg_locks.classid = ('x' || substr(md5($1::text), 1, 16))::bit(32)::int
|
276
|
+
AND pg_locks.objid = (('x' || substr(md5($2::text), 1, 16))::bit(64) << 32)::bit(32)::int
|
218
277
|
SQL
|
219
|
-
binds = [[nil,
|
278
|
+
binds = [[nil, key], [nil, key]]
|
220
279
|
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Locked?', binds).any?
|
221
280
|
end
|
222
281
|
|
223
282
|
# Tests whether this record is locked by the current database session.
|
283
|
+
# @param key [String, Symbol] Key to test lock against
|
224
284
|
# @return [Boolean]
|
225
|
-
def owns_advisory_lock?
|
285
|
+
def owns_advisory_lock?(key: lockable_key)
|
226
286
|
query = <<~SQL.squish
|
227
287
|
SELECT 1 AS one
|
228
288
|
FROM pg_locks
|
229
289
|
WHERE pg_locks.locktype = 'advisory'
|
230
290
|
AND pg_locks.objsubid = 1
|
231
|
-
AND pg_locks.classid = ('x' || substr(md5($1
|
232
|
-
AND pg_locks.objid = (('x' || substr(md5($
|
291
|
+
AND pg_locks.classid = ('x' || substr(md5($1::text), 1, 16))::bit(32)::int
|
292
|
+
AND pg_locks.objid = (('x' || substr(md5($2::text), 1, 16))::bit(64) << 32)::bit(32)::int
|
233
293
|
AND pg_locks.pid = pg_backend_pid()
|
234
294
|
SQL
|
235
|
-
binds = [[nil,
|
295
|
+
binds = [[nil, key], [nil, key]]
|
236
296
|
self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Owns Advisory Lock?', binds).any?
|
237
297
|
end
|
238
298
|
|
239
299
|
# Releases all advisory locks on the record that are held by the current
|
240
300
|
# database session.
|
301
|
+
# @param key [String, Symbol] Key to lock against
|
302
|
+
# @param function [String, Symbol] Postgres Advisory Lock function name to use
|
241
303
|
# @return [void]
|
242
|
-
def advisory_unlock!
|
243
|
-
advisory_unlock while advisory_locked?
|
304
|
+
def advisory_unlock!(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function))
|
305
|
+
advisory_unlock(key: key, function: function) while advisory_locked?
|
244
306
|
end
|
245
307
|
|
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
|
308
|
+
# Default Advisory Lock key
|
309
|
+
# @return [String]
|
310
|
+
def lockable_key
|
311
|
+
[self.class.table_name, self[self.class.advisory_lockable_column]].join
|
257
312
|
end
|
313
|
+
|
314
|
+
delegate :pg_or_jdbc_query, to: :class
|
258
315
|
end
|
259
316
|
end
|