active_record_mutex 2.5.1 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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