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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +47 -0
  4. data/engine/app/assets/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  5. data/engine/app/assets/vendor/bootstrap/bootstrap.min.css +7 -0
  6. data/engine/app/controllers/good_job/assets_controller.rb +29 -0
  7. data/engine/app/controllers/good_job/dashboards_controller.rb +1 -1
  8. data/engine/app/controllers/good_job/jobs_controller.rb +9 -0
  9. data/engine/app/views/good_job/dashboards/index.html.erb +1 -1
  10. data/engine/app/views/layouts/good_job/base.html.erb +21 -12
  11. data/engine/app/views/shared/_jobs_table.erb +13 -1
  12. data/engine/app/views/shared/icons/_check.html.erb +4 -0
  13. data/engine/app/views/shared/icons/_exclamation.html.erb +4 -0
  14. data/engine/app/views/shared/icons/_trash.html.erb +5 -0
  15. data/engine/config/routes.rb +10 -1
  16. data/lib/generators/good_job/install_generator.rb +5 -15
  17. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +27 -0
  18. data/lib/generators/good_job/templates/{migration.rb.erb → update/migrations/01_create_good_jobs.rb} +3 -3
  19. data/lib/generators/good_job/templates/update/migrations/02_add_active_job_id_concurrency_key_cron_key_to_good_jobs.rb +15 -0
  20. data/lib/generators/good_job/templates/update/migrations/03_add_active_job_id_index_and_concurrency_key_index_to_good_jobs.rb +32 -0
  21. data/lib/generators/good_job/update_generator.rb +29 -0
  22. data/lib/good_job/active_job_extensions.rb +4 -0
  23. data/lib/good_job/active_job_extensions/concurrency.rb +68 -0
  24. data/lib/good_job/current_execution.rb +12 -5
  25. data/lib/good_job/job.rb +43 -6
  26. data/lib/good_job/lockable.rb +114 -57
  27. data/lib/good_job/poller.rb +7 -3
  28. data/lib/good_job/version.rb +1 -1
  29. metadata +30 -5
  30. data/engine/app/assets/vendor/bootstrap/bootstrap-native.js +0 -1662
  31. 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,4 @@
1
+ module GoodJob
2
+ module ActiveJobExtensions
3
+ end
4
+ 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] error_on_retry
7
+ # @!attribute [rw] active_job_id
8
8
  # @!scope class
9
- # Error captured by retry_on
10
- # @return [Exception, nil]
11
- thread_mattr_accessor :error_on_retry
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.error_on_retry = nil
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
- # 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,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
- 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
+ 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
 
@@ -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(["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 }])))
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}.#{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
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
- records.each(&:advisory_unlock)
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 = <<~SQL.squish
157
- SELECT 1 AS one
158
- WHERE pg_try_advisory_lock(('x'||substr(md5($1 || $2::text), 1, 16))::bit(64)::bigint)
159
- 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?
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 one
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, 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?
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 || $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
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, self.class.table_name], [nil, send(self.class.primary_key)], [nil, self.class.table_name], [nil, send(self.class.primary_key)]]
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 || $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
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, self.class.table_name], [nil, send(self.class.primary_key)], [nil, self.class.table_name], [nil, send(self.class.primary_key)]]
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
- 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
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