active_record_mutex 2.5.1 → 3.1.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.
@@ -1,129 +1,212 @@
1
- require 'base64'
2
- require 'tins/xt/string_version'
1
+ require 'digest/md5'
3
2
 
4
3
  module ActiveRecord
5
4
  module DatabaseMutex
6
5
  class Implementation
7
6
 
8
7
  class << self
8
+ # The db method returns an instance of ActiveRecord::Base.connection
9
9
  def db
10
10
  ActiveRecord::Base.connection
11
11
  end
12
-
13
- def check_size?
14
- if defined? @check_size
15
- @check_size
16
- else
17
- version = db.execute("SHOW VARIABLES LIKE 'version'").first.last.
18
- delete('^0-9.').version
19
- @check_size = version >= '5.7'.version
20
- end
21
- end
22
12
  end
23
13
 
24
- # Creates a mutex with the name given with the option :name.
14
+ # The initialize method initializes an instance of the DatabaseMutex
15
+ # class by setting its name and internal_name attributes.
16
+ #
17
+ # @param opts [ Hash ] options hash containing the **name** key
18
+ #
19
+ # @option opts name [ String ] name for the mutex, required.
20
+ #
21
+ # @raise [ ArgumentError ] if no **name** option is provided in the options hash.
25
22
  def initialize(opts = {})
26
23
  @name = opts[:name] or raise ArgumentError, "mutex requires a :name argument"
27
- counter or raise ArgumentError, 'argument :name is too long'
24
+ internal_name # create/check internal_name
28
25
  end
29
26
 
30
- # Returns the name of this mutex as given as a constructor argument.
27
+ # Returns the name of this mutex as given via the constructor argument.
31
28
  attr_reader :name
32
29
 
33
- # Locks the mutex if it isn't already locked via another database
34
- # connection and yields to the given block. After executing the block's
35
- # content the mutex is unlocked (only if it was locked by this
36
- # synchronize method before).
30
+ # The internal_name method generates an encoded name for this mutex
31
+ # instance based on its class and {name} attributes and memoizes it.
37
32
  #
38
- # If the mutex was already locked by another database connection the
39
- # method blocks until it could aquire the lock and only then the block's
40
- # content is executed. If the mutex was already locked by the current database
41
- # connection then the block's content is run and the the mutex isn't
42
- # unlocked afterwards.
43
- #
44
- # If a value in seconds is passed to the :timeout option the blocking
45
- # ends after that many seconds and the method returns immediately if the
46
- # lock couldn't be aquired during that time.
33
+ # @return [ String ] the encoded name of length <= 64 characters
34
+ def internal_name
35
+ @internal_name and return @internal_name
36
+ encoded_name = ?$ + Digest::MD5.base64digest([ self.class.name, name ] * ?#).
37
+ delete('^A-Za-z0-9+/').gsub(/[+\/]/, ?+ => ?_, ?/ => ?.)
38
+ if encoded_name.size <= 64
39
+ @internal_name = encoded_name
40
+ else
41
+ # This should never happen:
42
+ raise MutexInvalidState, "internal_name #{encoded_name} too long: >64 characters"
43
+ end
44
+ end
45
+
46
+ # The synchronize method attempts to acquire a mutex lock for the given name
47
+ # and executes the block passed to it. If the lock is already held by another
48
+ # database connection, this method will return nil instead of raising an
49
+ # exception and not execute the block. #
50
+ #
51
+ # This method provides a convenient way to ensure that critical sections of code
52
+ # are executed while holding the mutex lock. It attempts to acquire the lock using
53
+ # the underlying locking mechanisms (such as {lock} and {unlock}) and executes
54
+ # the block passed to it.
55
+ #
56
+ # The **block** and **timeout** options are passed to the {lock} method
57
+ # and configure the way the lock is acquired.
58
+ #
59
+ # The **force** option is passed to the {unlock} method, which will force the
60
+ # lock to open if true.
61
+ #
62
+ # @example
63
+ # foo.mutex.synchronize { do_something_with foo } # wait forever and never give up
64
+ #
65
+ # @example
66
+ # foo.mutex.synchronize(timeout: 5) { do_something_with foo } # wait 5s and give up
67
+ #
68
+ # @example
69
+ # unless foo.mutex.synchronize(block: false) { do_something_with foo }
70
+ # # try again later
71
+ # end
72
+ #
73
+ # @param opts [ Hash ] Options hash containing the **block**, **timeout**, or **force** keys
74
+ #
75
+ # @yield [ Result ] The block to be executed while holding the mutex lock
76
+ #
77
+ # @return [ Nil or result of yielded block ] depending on whether the lock was acquired
47
78
  def synchronize(opts = {})
48
- locked = lock(opts) or return
79
+ locked = lock(opts.slice(:block, :timeout)) or return
49
80
  yield
50
81
  rescue ActiveRecord::DatabaseMutex::MutexLocked
51
82
  return nil
52
83
  ensure
53
- locked and unlock
84
+ locked and unlock opts.slice(:force)
54
85
  end
55
86
 
56
- # Locks the mutex and returns true if successful. If the mutex is
57
- # already locked and the timeout in seconds is given as the :timeout
58
- # option, this method raises a MutexLocked exception after that many
59
- # seconds. If the :timeout option wasn't given, this method blocks until
60
- # the lock could be aquired.
87
+ # The lock method attempts to acquire the mutex lock for the configured
88
+ # name and returns true if successful, that means #{locked?} and
89
+ # #{owned?} will be true. Note that you can lock the mutex n-times, but
90
+ # it has to be unlocked n-times to be released as well.
91
+ #
92
+ # If the **block** option was given as false, it returns false instead of
93
+ # raising MutexLocked exception when unable to acquire lock without blocking.
94
+ #
95
+ # If a **timeout** option with the (nonnegative) timeout in seconds was
96
+ # given, a MutexLocked exception is raised after this time, otherwise the
97
+ # method blocks forever.
98
+ #
99
+ # If the **raise** option is given as false, no MutexLocked exception is raised,
100
+ # but false is returned.
101
+ #
102
+ # @param opts [ Hash ] the options hash
103
+ #
104
+ # @option opts [ true, false ] block, defaults to true
105
+ # @option opts [ true, false ] raise, defaults to true
106
+ # @option opts [ Integer, nil ] timeout, defaults to nil, which means wait forever
107
+ #
108
+ # @return [ true, false ] depending on whether lock was acquired
61
109
  def lock(opts = {})
62
- if opts[:nonblock] # XXX document
63
- begin
64
- lock_with_timeout :timeout => 0
65
- rescue MutexLocked
66
- end
67
- elsif opts[:timeout]
68
- lock_with_timeout opts
110
+ opts = { block: true, raise: true }.merge(opts)
111
+ if opts[:block]
112
+ timeout = opts[:timeout] || -1
113
+ lock_with_timeout timeout:
69
114
  else
70
- spin_timeout = opts[:spin_timeout] || 1 # XXX document
71
115
  begin
72
- lock_with_timeout :timeout => spin_timeout
116
+ lock_with_timeout timeout: 0
73
117
  rescue MutexLocked
74
- retry
118
+ false # If non-blocking and unable to acquire lock, return false.
75
119
  end
76
120
  end
121
+ rescue MutexLocked
122
+ if opts[:raise]
123
+ raise
124
+ else
125
+ return false
126
+ end
77
127
  end
78
128
 
79
- # Unlocks the mutex and returns true if successful. Otherwise this method
80
- # raises a MutexLocked exception.
81
- def unlock(*)
82
- if aquired_lock?
83
- decrease_counter
129
+ # The unlock method releases the mutex lock for the given name and
130
+ # returns true if successful. If the lock doesn't belong to this
131
+ # connection raises a MutexUnlockFailed exception.
132
+ #
133
+ # @param opts [ Hash ] the options hash
134
+ #
135
+ # @option opts [ true, false ] raise if false won't raise MutexUnlockFailed, defaults to true
136
+ # @option opts [ true, false ] force if true will force the lock to open, defaults to false
137
+ #
138
+ # @raise [ MutexUnlockFailed ] if unlocking failed and raise was true
139
+ #
140
+ # @return [ true, false ] true if unlocking was successful, false otherwise
141
+ def unlock(opts = {})
142
+ opts = { raise: true, force: false }.merge(opts)
143
+ if owned?
144
+ if opts[:force]
145
+ reset_counter
146
+ else
147
+ decrement_counter
148
+ end
84
149
  if counter_zero?
85
- case query("SELECT RELEASE_LOCK(#{quote(name)})")
150
+ case query("SELECT RELEASE_LOCK(#{quote(internal_name)})")
86
151
  when 1
87
152
  true
88
153
  when 0, nil
89
154
  raise MutexUnlockFailed, "unlocking of mutex '#{name}' failed"
90
155
  end
156
+ else
157
+ false
91
158
  end
92
159
  else
93
160
  raise MutexUnlockFailed, "unlocking of mutex '#{name}' failed"
94
161
  end
162
+ rescue MutexUnlockFailed
163
+ if opts[:raise]
164
+ raise
165
+ else
166
+ return false
167
+ end
95
168
  end
96
169
 
97
- # Unlock this mutex and return self if successful, otherwise (the mutex
98
- # was not locked) nil is returned.
99
- def unlock?(*a)
100
- unlock(*a)
101
- self
102
- rescue MutexUnlockFailed
103
- nil
170
+ # The unlock? method returns self if the mutex could successfully
171
+ # unlocked, otherwise it returns nil.
172
+ #
173
+ # @return [self, nil] self if the mutex was unlocked, nil otherwise
174
+ def unlock?(opts = {})
175
+ opts = { raise: false }.merge(opts)
176
+ self if unlock(opts)
104
177
  end
105
178
 
106
- # Returns true if this mutex is unlocked at the moment.
179
+ # The unlocked? method checks whether the mutex is currently free and not
180
+ # locked by any database connection.
181
+ #
182
+ # @return [ true, false ] true if the mutex is unlocked, false otherwise
107
183
  def unlocked?
108
- query("SELECT IS_FREE_LOCK(#{quote(name)})") == 1
184
+ query("SELECT IS_FREE_LOCK(#{quote(internal_name)})") == 1
109
185
  end
110
186
 
111
- # Returns true if this mutex is locked at the moment.
187
+ # The locked? method returns true if this mutex is currently locked by
188
+ # any database connection, the opposite of {unlocked?}.
189
+ #
190
+ # @return [ true, false ] true if the mutex is locked, false otherwise
112
191
  def locked?
113
192
  not unlocked?
114
193
  end
115
194
 
116
- # Returns true if this mutex is locked by this database connection.
117
- def aquired_lock?
118
- query("SELECT CONNECTION_ID() = IS_USED_LOCK(#{quote(name)})") == 1
195
+ # Returns true if the mutex is was acquired on this database connection.
196
+ def owned?
197
+ query("SELECT CONNECTION_ID() = IS_USED_LOCK(#{quote(internal_name)})") == 1
119
198
  end
120
199
 
121
- # Returns true if this mutex is not locked by this database connection.
122
- def not_aquired_lock?
123
- not aquired_lock?
200
+ # Returns true if this mutex was not acquired on this database connection,
201
+ # the opposite of {owned?}.
202
+ def not_owned?
203
+ not owned?
124
204
  end
125
205
 
126
- # Returns a string representation of this DatabaseMutex instance.
206
+ # The to_s method returns a string representation of this DatabaseMutex
207
+ # instance.
208
+ #
209
+ # @return [ String ] the string representation of this DatabaseMutex instance
127
210
  def to_s
128
211
  "#<#{self.class} #{name}>"
129
212
  end
@@ -132,43 +215,84 @@ module ActiveRecord
132
215
 
133
216
  private
134
217
 
218
+ # The quote method returns a string that is suitable for inclusion in an
219
+ # SQL query as the value of a parameter.
220
+ #
221
+ # @param value [ Object ] the object to be quoted
222
+ #
223
+ # @return [ String ] the quoted string
135
224
  def quote(value)
136
225
  ActiveRecord::Base.connection.quote(value)
137
226
  end
138
227
 
228
+ # The counter method generates a unique name for the mutex's internal
229
+ # counter variable. This name is used as part of the SQL query to set and
230
+ # retrieve the counter value.
231
+ #
232
+ # @return [String] the unique name for the mutex's internal counter variable.
139
233
  def counter
140
- encoded_name = ?$ + Base64.encode64(name).delete('^A-Za-z0-9+/').
141
- gsub(/[+\/]/, ?+ => ?_, ?/ => ?.)
142
- if !self.class.check_size? || encoded_name.size <= 64 # mysql 5.7 only allows size <=64 variable names
143
- "@#{encoded_name}"
144
- end
234
+ "@#{internal_name}"
145
235
  end
146
236
 
147
- def increase_counter
237
+ # The increment_counter method increments the internal counter value for
238
+ # this mutex instance.
239
+ def increment_counter
148
240
  query("SET #{counter} = IF(#{counter} IS NULL OR #{counter} = 0, 1, #{counter} + 1)")
149
241
  end
150
242
 
151
- def decrease_counter
243
+ # The decrement_counter method decrements the internal counter value for #
244
+ # this mutex instance.
245
+ def decrement_counter
152
246
  query("SET #{counter} = #{counter} - 1")
153
247
  end
154
248
 
249
+ # The reset_counter method resets the internal counter value for this
250
+ # mutex instance to zero.
251
+ def reset_counter
252
+ query("SET #{counter} = 0")
253
+ end
254
+
255
+ # The counter_value method returns the current value of the internal
256
+ # counter variable for this mutex instance as an integer number.
257
+ #
258
+ # @return [ Integer ] the current value of the internal counter variable
155
259
  def counter_value
156
260
  query("SELECT #{counter}").to_i
157
261
  end
158
262
 
263
+ # The counter_zero? method checks whether the internal counter value for
264
+ # this mutex instance is zero.
265
+ #
266
+ # @return [ true, false ] true if the counter value is zero, false otherwise
159
267
  def counter_zero?
160
268
  counter_value.zero?
161
269
  end
162
270
 
271
+ # The lock_with_timeout method attempts to acquire the mutex lock for the
272
+ # given name and returns true if successful.
273
+ #
274
+ # If the :timeout option was given as a nonnegative value of seconds, it
275
+ # raises a MutexLocked exception if unable to acquire lock within that
276
+ # time period, otherwise the method blocks forever.
277
+ #
278
+ # @param opts [ Hash ] options hash containing the :timeout key
279
+ #
280
+ # @option opts [ Integer ] timeout, defaults to nil, but is required
281
+ #
282
+ # @raise [ ArgumentError ] if no :timeout option is provided in the options hash
283
+ # @raise [ MutexLocked ] if the mutex is already locked in another database connection
284
+ # @raise [ MutexSystemError ] if a system error occured
285
+ #
286
+ # @return [ true, false ] depending on whether lock was acquired
163
287
  def lock_with_timeout(opts = {})
164
- if aquired_lock?
165
- increase_counter
288
+ timeout = opts.fetch(:timeout) { raise ArgumentError, 'require :timeout argument' }
289
+ if owned?
290
+ increment_counter
166
291
  true
167
292
  else
168
- timeout = opts[:timeout] || 1
169
- case query("SELECT GET_LOCK(#{quote(name)}, #{timeout})")
293
+ case query("SELECT GET_LOCK(#{quote(internal_name)}, #{timeout})")
170
294
  when 1
171
- increase_counter
295
+ increment_counter
172
296
  true
173
297
  when 0
174
298
  raise MutexLocked, "mutex '#{name}' is already locked"
@@ -178,6 +302,12 @@ module ActiveRecord
178
302
  end
179
303
  end
180
304
 
305
+ # The query method executes an SQL statement and returns the result.
306
+ #
307
+ # @param sql [ String ] the SQL statement to be executed
308
+ #
309
+ # @return [ Integer, nil ] the result of the SQL execution or nil if it
310
+ # failed
181
311
  def query(sql)
182
312
  if result = self.class.db.execute(sql)
183
313
  result = result.first.first.to_i
@@ -190,4 +320,3 @@ module ActiveRecord
190
320
  end
191
321
  end
192
322
  end
193
-
@@ -1,6 +1,6 @@
1
1
  module ActiveRecord::DatabaseMutex
2
2
  # ActiveRecord::DatabaseMutex version
3
- VERSION = '2.5.1'
3
+ VERSION = '3.1.0'
4
4
  VERSION_ARRAY = VERSION.split('.').map(&:to_i) # :nodoc:
5
5
  VERSION_MAJOR = VERSION_ARRAY[0] # :nodoc:
6
6
  VERSION_MINOR = VERSION_ARRAY[1] # :nodoc:
@@ -1,6 +1,7 @@
1
1
  require 'active_record'
2
2
  require 'active_record/database_mutex/version'
3
3
  require 'active_record/database_mutex/implementation'
4
+ require 'ostruct'
4
5
 
5
6
  module ActiveRecord
6
7
  # This module is mixed into ActiveRecord::Base to provide the mutex methods
@@ -9,42 +10,85 @@ module ActiveRecord
9
10
  # This is the base exception of all mutex exceptions.
10
11
  class MutexError < ActiveRecordError; end
11
12
 
12
- # This exception is raised if a mutex of the given name isn't locked at the
13
- # moment and unlock was called.
13
+ # This exception is raised when an attempt to unlock a mutex fails,
14
+ # typically due to the lock not being held by the current database
15
+ # connection or other system errors.
14
16
  class MutexUnlockFailed < MutexError; end
15
17
 
16
- # This exception is raised if a mutex of the given name is locked at the
17
- # moment and lock was called again.
18
+ # This exception is raised when attempting to acquire a lock that is
19
+ # already held by another database connection.
18
20
  class MutexLocked < MutexError; end
19
21
 
22
+ # This exception raised when an unexpected situation occurs while managing
23
+ # the mutex lock, such as incorrect encoding or handling of internal mutex
24
+ # names.
20
25
  class MutexInvalidState < MutexError; end
21
26
 
27
+ # This exception is raised when an unexpected system-related issue prevents
28
+ # the mutex (lock) from being acquired or managed properly, often due to
29
+ # disk I/O errors, database connection issues, resource limitations, or
30
+ # lock file permissions problems.
22
31
  class MutexSystemError < MutexError; end
23
32
 
33
+ # The MutexInfo class is a subclass of OpenStruct, serving as a wrapper for
34
+ # information related to database mutexes. It allows dynamic attribute
35
+ # access.
36
+ MutexInfo = Class.new OpenStruct
37
+
24
38
  def self.included(modul)
25
39
  modul.instance_eval do
26
40
  extend ClassMethods
27
41
  end
28
42
  end
29
43
 
30
- # Return a mutex implementation for the mutex named +name+.
44
+ # The for method returns an instance of
45
+ # ActiveRecord::DatabaseMutex::Implementation that is initialized with the
46
+ # given name.
47
+ #
48
+ # @param name [ String ] the mutex name
49
+ #
50
+ # @return [ ActiveRecord::DatabaseMutex::Implementation ]
31
51
  def self.for(name)
32
- Implementation.new(:name => name)
52
+ Implementation.new(name: name)
33
53
  end
34
54
 
35
55
  module ClassMethods
36
- # Returns a mutex instance for this ActiveRecord subclass.
56
+ def mutex_name
57
+ @mutex_name ||= [ name, defined?(Rails) ? Rails.env : ENV['RAILS_ENV'] ].compact * ?@
58
+ end
59
+
60
+ # The mutex method returns an instance of
61
+ # ActiveRecord::DatabaseMutex::Implementation that is initialized with
62
+ # the name given by the class and environment variables.
63
+ #
64
+ # @return [ActiveRecord::DatabaseMutex::Implementation] the mutex instance
37
65
  def mutex
38
- @mutex ||= Implementation.new(
39
- :name => [ name, defined?(Rails) ? Rails.env : ENV['RAILS_ENV'] ].compact * ?@
40
- )
66
+ @mutex ||= Implementation.new(name: mutex_name)
67
+ end
68
+
69
+ # The all_mutexes method returns an array of MutexInfo objects
70
+ # representing all mutexes currently held by database connections. The
71
+ # MutexInfo#OBJECT_NAME is the
72
+ # {ActiveRecord::DatabaseMutex::Implementation#internal_name}.
73
+ #
74
+ # @return [Array] An array of MutexInfo objects.
75
+ def all_mutexes
76
+ connection.select_all(<<~EOT).map { MutexInfo.new(_1) }
77
+ SELECT * FROM performance_schema.metadata_locks
78
+ WHERE OBJECT_TYPE = 'USER LEVEL LOCK'
79
+ AND OBJECT_NAME LIKE "$%"
80
+ EOT
41
81
  end
42
82
  end
43
83
 
44
- # Returns a mutex instance for this ActiveRecord instance.
84
+ # The mutex method returns an instance of
85
+ # ActiveRecord::DatabaseMutex::Implementation that is initialized with the
86
+ # name given by the id, the class and environment variables.
87
+ #
88
+ # @return [ActiveRecord::DatabaseMutex::Implementation] the mutex instance
45
89
  def mutex
46
90
  if persisted?
47
- @mutex ||= Implementation.new(:name => "#{id}@#{self.class.name}")
91
+ @mutex ||= Implementation.new(name: "#{id}@#{self.class.mutex_name}")
48
92
  else
49
93
  raise MutexInvalidState, "instance #{inspect} not persisted"
50
94
  end