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.
- checksums.yaml +5 -5
- data/.envrc +1 -0
- data/CHANGES.md +151 -0
- data/Gemfile +0 -2
- data/README.md +105 -15
- data/Rakefile +8 -5
- data/active_record_mutex.gemspec +14 -33
- data/docker-compose.yml +13 -0
- data/examples/process1.rb +31 -0
- data/examples/process2.rb +28 -0
- data/lib/active_record/database_mutex/implementation.rb +210 -81
- data/lib/active_record/database_mutex/version.rb +1 -1
- data/lib/active_record/database_mutex.rb +56 -12
- data/test/database_mutex_test.rb +133 -9
- data/test/test_helper.rb +8 -9
- metadata +33 -24
- data/.gitignore +0 -6
- data/.travis.yml +0 -20
- data/VERSION +0 -1
@@ -1,129 +1,212 @@
|
|
1
|
-
require '
|
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
|
-
#
|
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
|
-
|
24
|
+
internal_name # create/check internal_name
|
28
25
|
end
|
29
26
|
|
30
|
-
# Returns the name of this mutex as given
|
27
|
+
# Returns the name of this mutex as given via the constructor argument.
|
31
28
|
attr_reader :name
|
32
29
|
|
33
|
-
#
|
34
|
-
#
|
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
|
-
#
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
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
|
-
#
|
57
|
-
#
|
58
|
-
#
|
59
|
-
#
|
60
|
-
#
|
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
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
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 :
|
116
|
+
lock_with_timeout timeout: 0
|
73
117
|
rescue MutexLocked
|
74
|
-
|
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
|
-
#
|
80
|
-
#
|
81
|
-
|
82
|
-
|
83
|
-
|
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(
|
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
|
-
#
|
98
|
-
#
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
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
|
-
#
|
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(
|
184
|
+
query("SELECT IS_FREE_LOCK(#{quote(internal_name)})") == 1
|
109
185
|
end
|
110
186
|
|
111
|
-
#
|
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
|
117
|
-
def
|
118
|
-
query("SELECT CONNECTION_ID() = IS_USED_LOCK(#{quote(
|
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
|
122
|
-
|
123
|
-
|
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
|
-
#
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
165
|
-
|
288
|
+
timeout = opts.fetch(:timeout) { raise ArgumentError, 'require :timeout argument' }
|
289
|
+
if owned?
|
290
|
+
increment_counter
|
166
291
|
true
|
167
292
|
else
|
168
|
-
|
169
|
-
case query("SELECT GET_LOCK(#{quote(name)}, #{timeout})")
|
293
|
+
case query("SELECT GET_LOCK(#{quote(internal_name)}, #{timeout})")
|
170
294
|
when 1
|
171
|
-
|
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,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
|
13
|
-
#
|
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
|
17
|
-
#
|
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
|
-
#
|
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(:
|
52
|
+
Implementation.new(name: name)
|
33
53
|
end
|
34
54
|
|
35
55
|
module ClassMethods
|
36
|
-
|
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
|
-
|
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
|
-
#
|
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(:
|
91
|
+
@mutex ||= Implementation.new(name: "#{id}@#{self.class.mutex_name}")
|
48
92
|
else
|
49
93
|
raise MutexInvalidState, "instance #{inspect} not persisted"
|
50
94
|
end
|