module GoodJob::Lockable
Adds Postgres advisory locking capabilities to an ActiveRecord record. For details on advisory locks, see the Postgres documentation:
-
{www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS Advisory Locks Overview}
-
{www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS Advisory Locks Functions}
@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
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
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
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
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
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
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
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
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
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
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
Default Advisory Lock key @return [String]
# File lib/good_job/lockable.rb 323 def lockable_key 324 lockable_column_key 325 end
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
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
# 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
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