module GoodJob::Lockable

Adds Postgres advisory locking capabilities to an ActiveRecord record. For details on advisory locks, see the Postgres documentation:

@example Add this concern to a MyRecord class:

class MyRecord < ActiveRecord::Base
  include Lockable

  def my_method
    ...
  end
end

Constants

RecordAlreadyAdvisoryLockedError

Indicates an advisory lock is already held on a record by another database session.

Public Instance Methods

_advisory_lockable_column() click to toggle source

Allow advisory_lockable_column to be a `Concurrent::Delay`

    # File lib/good_job/lockable.rb
165 def _advisory_lockable_column
166   column = advisory_lockable_column
167   column.respond_to?(:value) ? column.value : column
168 end
advisory_lock(key: lockable_key, function: advisory_lockable_function) click to toggle source

Acquires an advisory lock on this record if it is not already locked by another database session. Be careful to ensure you release the lock when you are done with {#advisory_unlock} (or {#advisory_unlock!} to release all remaining locks). @param key [String, Symbol] Key to Advisory Lock against @param function [String, Symbol] Postgres Advisory Lock function name to use @return [Boolean] whether the lock was acquired.

    # File lib/good_job/lockable.rb
209     def advisory_lock(key: lockable_key, function: advisory_lockable_function)
210       query = if function.include? "_try_"
211                 <<~SQL.squish
212                   SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint) AS locked
213                 SQL
214               else
215                 <<~SQL.squish
216                   SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint)::text AS locked
217                 SQL
218               end
219 
220       binds = [[nil, key]]
221       self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Lock', binds).first['locked']
222     end
advisory_lock!(key: lockable_key, function: advisory_lockable_function) click to toggle source

Acquires an advisory lock on this record or raises {RecordAlreadyAdvisoryLockedError} if it is already locked by another database session. @param key [String, Symbol] Key to lock against @param function [String, Symbol] Postgres Advisory Lock function name to use @raise [RecordAlreadyAdvisoryLockedError] @return [Boolean] true

    # File lib/good_job/lockable.rb
245 def advisory_lock!(key: lockable_key, function: advisory_lockable_function)
246   result = advisory_lock(key: key, function: function)
247   result || raise(RecordAlreadyAdvisoryLockedError)
248 end
advisory_locked?(key: lockable_key) click to toggle source

Tests whether this record has an advisory lock on it. @param key [String, Symbol] Key to test lock against @return [Boolean]

    # File lib/good_job/lockable.rb
275     def advisory_locked?(key: lockable_key)
276       query = <<~SQL.squish
277         SELECT 1 AS one
278         FROM pg_locks
279         WHERE pg_locks.locktype = 'advisory'
280           AND pg_locks.objsubid = 1
281           AND pg_locks.classid = ('x' || substr(md5($1::text), 1, 16))::bit(32)::int
282           AND pg_locks.objid = (('x' || substr(md5($2::text), 1, 16))::bit(64) << 32)::bit(32)::int
283       SQL
284       binds = [[nil, key], [nil, key]]
285       self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Locked?', binds).any?
286     end
advisory_unlock(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function)) click to toggle source

Releases an advisory lock on this record if it is locked by this database session. Note that advisory locks stack, so you must call {#advisory_unlock} and {#advisory_lock} the same number of times. @param key [String, Symbol] Key to lock against @param function [String, Symbol] Postgres Advisory Lock function name to use @return [Boolean] whether the lock was released.

    # File lib/good_job/lockable.rb
230     def advisory_unlock(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function))
231       query = <<~SQL.squish
232         SELECT #{function}(('x'||substr(md5($1::text), 1, 16))::bit(64)::bigint) AS unlocked
233       SQL
234       binds = [[nil, key]]
235       self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Advisory Unlock', binds).first['unlocked']
236     end
advisory_unlock!(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function)) click to toggle source

Releases all advisory locks on the record that are held by the current database session. @param key [String, Symbol] Key to lock against @param function [String, Symbol] Postgres Advisory Lock function name to use @return [void]

    # File lib/good_job/lockable.rb
317 def advisory_unlock!(key: lockable_key, function: self.class.advisory_unlockable_function(advisory_lockable_function))
318   advisory_unlock(key: key, function: function) while advisory_locked?
319 end
advisory_unlock_session() click to toggle source

Unlocks all advisory locks active in the current database session/connection @return [void]

    # File lib/good_job/lockable.rb
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
advisory_unlockable_function(function = advisory_lockable_function) click to toggle source

Postgres advisory unlocking function for the class @param function [String, Symbol] name of advisory lock or unlock function @return [Boolean]

    # File lib/good_job/lockable.rb
179 def advisory_unlockable_function(function = advisory_lockable_function)
180   function.to_s.sub("_lock", "_unlock").sub("_try_", "_")
181 end
advisory_unlocked?(key: lockable_key) click to toggle source

Tests whether this record does not have an advisory lock on it. @param key [String, Symbol] Key to test lock against @return [Boolean]

    # File lib/good_job/lockable.rb
291 def advisory_unlocked?(key: lockable_key)
292   !advisory_locked?(key: key)
293 end
lockable_column_key(column: self.class._advisory_lockable_column) click to toggle source

Default Advisory Lock key for column-based locking @return [String]

    # File lib/good_job/lockable.rb
329 def lockable_column_key(column: self.class._advisory_lockable_column)
330   "#{self.class.table_name}-#{self[column]}"
331 end
lockable_key() click to toggle source

Default Advisory Lock key @return [String]

    # File lib/good_job/lockable.rb
323 def lockable_key
324   lockable_column_key
325 end
owns_advisory_lock?(key: lockable_key) click to toggle source

Tests whether this record is locked by the current database session. @param key [String, Symbol] Key to test lock against @return [Boolean]

    # File lib/good_job/lockable.rb
298     def owns_advisory_lock?(key: lockable_key)
299       query = <<~SQL.squish
300         SELECT 1 AS one
301         FROM pg_locks
302         WHERE pg_locks.locktype = 'advisory'
303           AND pg_locks.objsubid = 1
304           AND pg_locks.classid = ('x' || substr(md5($1::text), 1, 16))::bit(32)::int
305           AND pg_locks.objid = (('x' || substr(md5($2::text), 1, 16))::bit(64) << 32)::bit(32)::int
306           AND pg_locks.pid = pg_backend_pid()
307       SQL
308       binds = [[nil, key], [nil, key]]
309       self.class.connection.exec_query(pg_or_jdbc_query(query), 'GoodJob::Lockable Owns Advisory Lock?', binds).any?
310     end
pg_or_jdbc_query(query) click to toggle source

Converts SQL query strings between PG-compatible and JDBC-compatible syntax @param query [String] @return [Boolean]

    # File lib/good_job/lockable.rb
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
supports_cte_materialization_specifiers?() click to toggle source
    # File lib/good_job/lockable.rb
170 def supports_cte_materialization_specifiers?
171   return @_supports_cte_materialization_specifiers if defined?(@_supports_cte_materialization_specifiers)
172 
173   @_supports_cte_materialization_specifiers = connection.postgresql_version >= 120000
174 end
with_advisory_lock(column: _advisory_lockable_column, function: advisory_lockable_function, unlock_session: false) { |records| ... } click to toggle source

Acquires an advisory lock on the selected record(s) and safely releases it after the passed block is completed. The block will be passed an array of the locked records as its first argument.

Note that this will not block and wait for locks to be acquired. Instead, it will acquire a lock on all the selected records that it can (as in {Lockable.advisory_lock}) and only pass those that could be locked to the block.

@param column [String, Symbol] name of advisory lock or unlock function @param function [String, Symbol] Postgres Advisory Lock function name to use @param unlock_session [Boolean] Whether to unlock all advisory locks in the session afterwards @yield [Array<Lockable>] the records that were successfully locked. @return [Object] the result of the block.

@example Work on the first two MyLockableRecord objects that could be locked:

MyLockableRecord.order(created_at: :asc).limit(2).with_advisory_lock do |record|
  do_something_with record
end
    # File lib/good_job/lockable.rb
147 def with_advisory_lock(column: _advisory_lockable_column, function: advisory_lockable_function, unlock_session: false)
148   raise ArgumentError, "Must provide a block" unless block_given?
149 
150   records = advisory_lock(column: column, function: function).to_a
151   begin
152     yield(records)
153   ensure
154     if unlock_session
155       advisory_unlock_session
156     else
157       records.each do |record|
158         record.advisory_unlock(key: record.lockable_column_key(column: column), function: advisory_unlockable_function(function))
159       end
160     end
161   end
162 end