mongoid-locker 1.0.1 → 2.0.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 +4 -4
- data/.gitignore +2 -7
- data/.rspec +1 -2
- data/.rubocop_todo.yml +39 -23
- data/.travis.yml +27 -41
- data/CHANGELOG.md +6 -0
- data/Dangerfile +2 -0
- data/Gemfile +10 -10
- data/Guardfile +2 -0
- data/LICENSE.txt +1 -1
- data/README.md +88 -36
- data/Rakefile +2 -0
- data/UPGRADING.md +20 -1
- data/lib/config/locales/en.yml +9 -0
- data/lib/mongoid-locker.rb +10 -1
- data/lib/mongoid/locker.rb +243 -142
- data/lib/mongoid/locker/errors.rb +46 -0
- data/lib/mongoid/locker/version.rb +3 -1
- data/lib/mongoid/locker/wrapper.rb +120 -2
- data/mongoid-locker.gemspec +16 -14
- metadata +7 -10
- data/.document +0 -5
- data/lib/mongoid/locker/wrapper4.rb +0 -22
- data/lib/mongoid/locker/wrapper5.rb +0 -27
- data/lib/mongoid/locker/wrapper6.rb +0 -29
- data/lib/mongoid/locker/wrapper7.rb +0 -2
data/Rakefile
CHANGED
data/UPGRADING.md
CHANGED
@@ -1,6 +1,25 @@
|
|
1
|
-
## Upgrading Mongoid
|
1
|
+
## Upgrading Mongoid-Locker
|
2
|
+
|
3
|
+
## Upgrading to 2.0.0
|
4
|
+
|
5
|
+
Mongoid-Locker supports only `5`, `6` and `7` versions of Mongoid.
|
6
|
+
Since this version `Mongoid::Locker` uses unique name of locking and time is set by MongoDB. `Mongoid::Locker` no longer uses `locked_until` field and this field may be deleted with `User.all.unset(:locked_until)`. You must define new `locking_name` field of `String` type.
|
7
|
+
|
8
|
+
```ruby
|
9
|
+
class User
|
10
|
+
include Mongoid::Document
|
11
|
+
include Mongoid::Locker
|
12
|
+
|
13
|
+
field :locking_name, type: String
|
14
|
+
field :locked_at, type: Time
|
15
|
+
end
|
16
|
+
```
|
17
|
+
|
18
|
+
The options `:timeout` and `retry_sleep` of `#with_lock` method was deprecated and have no effect. For details see [RubyDoc.info](https://www.rubydoc.info/gems/mongoid-locker/2.0.0/Mongoid/Locker#with_lock-instance_method).
|
19
|
+
If you handle `Mongoid::Locker::LockError` error then this error should be renamed to `Mongoid::Locker::Errors::DocumentCouldNotGetLock`.
|
2
20
|
|
3
21
|
### Upgrading to 1.0.0
|
22
|
+
|
4
23
|
`Mongoid::Locker` no longer defines `locked_at` and `locked_until` fields when included. You must define these fields manually.
|
5
24
|
|
6
25
|
```ruby
|
data/lib/mongoid-locker.rb
CHANGED
@@ -1,2 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'mongoid'
|
2
|
-
|
4
|
+
|
5
|
+
require 'mongoid/locker'
|
6
|
+
require 'mongoid/locker/version'
|
7
|
+
require 'mongoid/locker/wrapper'
|
8
|
+
require 'mongoid/locker/errors'
|
9
|
+
|
10
|
+
# Load english locale by default.
|
11
|
+
I18n.load_path << File.join(File.dirname(__FILE__), 'config/locales', 'en.yml')
|
data/lib/mongoid/locker.rb
CHANGED
@@ -1,209 +1,310 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
3
4
|
|
4
5
|
module Mongoid
|
5
6
|
module Locker
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
7
|
+
class << self
|
8
|
+
# Available parameters for +Mongoid::Locker+ module, a class where the module is included and it's instances.
|
9
|
+
MODULE_METHODS = %i[
|
10
|
+
locking_name_field
|
11
|
+
locked_at_field
|
12
|
+
maximum_backoff
|
13
|
+
lock_timeout
|
14
|
+
locker_write_concern
|
15
|
+
backoff_algorithm
|
16
|
+
locking_name_generator
|
17
|
+
].freeze
|
18
|
+
|
19
|
+
attr_accessor(*MODULE_METHODS)
|
20
|
+
|
21
|
+
# Generates secure random string of +name#attempt+ format.
|
15
22
|
#
|
16
|
-
# @
|
17
|
-
|
18
|
-
|
19
|
-
end
|
20
|
-
|
21
|
-
# A scope to retrieve all unlocked documents in the collection.
|
23
|
+
# @example
|
24
|
+
# Mongoid::Locker.secure_locking_name(doc, { attempt: 1 })
|
25
|
+
# #=> "zLmulhOy9yn_NE886OWNYw#1"
|
22
26
|
#
|
23
|
-
# @
|
24
|
-
|
25
|
-
|
27
|
+
# @param doc [Mongoid::Document]
|
28
|
+
# @param opts [Hash] (see #with_lock)
|
29
|
+
# @return [String]
|
30
|
+
def secure_locking_name(_doc, opts)
|
31
|
+
"#{SecureRandom.urlsafe_base64}##{opts[:attempt]}"
|
26
32
|
end
|
27
33
|
|
28
|
-
#
|
34
|
+
# Returns random number of seconds depend on passed options.
|
35
|
+
#
|
36
|
+
# @example
|
37
|
+
# Mongoid::Locker.exponential_backoff(doc, { attempt: 0 })
|
38
|
+
# #=> 1.2280675023095662
|
39
|
+
# Mongoid::Locker.exponential_backoff(doc, { attempt: 1 })
|
40
|
+
# #=> 2.901641863236713
|
41
|
+
# Mongoid::Locker.exponential_backoff(doc, { attempt: 2 })
|
42
|
+
# #=> 4.375030664612267
|
29
43
|
#
|
30
|
-
# @param [
|
31
|
-
# @
|
32
|
-
|
33
|
-
|
44
|
+
# @param _doc [Mongoid::Document]
|
45
|
+
# @param opts [Hash] (see #with_lock)
|
46
|
+
# @return [Float]
|
47
|
+
def exponential_backoff(_doc, opts)
|
48
|
+
2**opts[:attempt] + rand
|
34
49
|
end
|
35
50
|
|
36
|
-
#
|
51
|
+
# Returns time in seconds remaining to complete the lock of the provided document. Makes requests to the database.
|
37
52
|
#
|
38
|
-
# @
|
39
|
-
|
40
|
-
|
41
|
-
|
53
|
+
# @example
|
54
|
+
# Mongoid::Locker.locked_at_backoff(doc, opts)
|
55
|
+
# #=> 2.32422359
|
56
|
+
#
|
57
|
+
# @param doc [Mongoid::Document]
|
58
|
+
# @param opts [Hash] (see #with_lock)
|
59
|
+
# @return [Float | Integer]
|
60
|
+
# @return [0] if the provided document is not locked
|
61
|
+
def locked_at_backoff(doc, opts)
|
62
|
+
return doc.maximum_backoff if opts[:attempt] * doc.lock_timeout >= doc.maximum_backoff
|
63
|
+
|
64
|
+
locked_at = Wrapper.locked_at(doc).to_f
|
65
|
+
return 0 unless locked_at > 0
|
66
|
+
|
67
|
+
current_time = Wrapper.current_mongodb_time(doc.class).to_f
|
68
|
+
delay = doc.lock_timeout - (current_time - locked_at)
|
69
|
+
|
70
|
+
delay < 0 ? 0 : delay + rand
|
42
71
|
end
|
43
72
|
|
44
|
-
#
|
45
|
-
|
46
|
-
|
47
|
-
|
73
|
+
# Sets configuration using a block.
|
74
|
+
#
|
75
|
+
# @example
|
76
|
+
# Mongoid::Locker.configure do |config|
|
77
|
+
# config.locking_name_field = :locking_name
|
78
|
+
# config.locked_at_field = :locked_at
|
79
|
+
# config.lock_timeout = 5
|
80
|
+
# config.locker_write_concern = { w: 1 }
|
81
|
+
# config.maximum_backoff = 60.0
|
82
|
+
# config.backoff_algorithm = :exponential_backoff
|
83
|
+
# config.locking_name_generator = :secure_locking_name
|
84
|
+
# end
|
85
|
+
def configure
|
86
|
+
yield(self) if block_given?
|
48
87
|
end
|
49
88
|
|
50
|
-
#
|
51
|
-
|
52
|
-
|
89
|
+
# Resets to default configuration.
|
90
|
+
#
|
91
|
+
# @example
|
92
|
+
# Mongoid::Locker.reset!
|
93
|
+
def reset!
|
94
|
+
# The parameters used by default.
|
95
|
+
self.locking_name_field = :locking_name
|
96
|
+
self.locked_at_field = :locked_at
|
97
|
+
self.lock_timeout = 5
|
98
|
+
self.locker_write_concern = { w: 1 }
|
99
|
+
self.maximum_backoff = 60.0
|
100
|
+
self.backoff_algorithm = :exponential_backoff
|
101
|
+
self.locking_name_generator = :secure_locking_name
|
53
102
|
end
|
54
103
|
|
55
|
-
#
|
56
|
-
def
|
57
|
-
|
104
|
+
# @api private
|
105
|
+
def included(klass)
|
106
|
+
klass.extend ClassMethods
|
107
|
+
klass.singleton_class.instance_eval { attr_accessor(*MODULE_METHODS) }
|
108
|
+
|
109
|
+
klass.locking_name_field = locking_name_field
|
110
|
+
klass.locked_at_field = locked_at_field
|
111
|
+
klass.lock_timeout = lock_timeout
|
112
|
+
klass.locker_write_concern = locker_write_concern
|
113
|
+
klass.maximum_backoff = maximum_backoff
|
114
|
+
klass.backoff_algorithm = backoff_algorithm
|
115
|
+
klass.locking_name_generator = locking_name_generator
|
116
|
+
|
117
|
+
klass.delegate(*MODULE_METHODS, to: :class)
|
118
|
+
klass.singleton_class.delegate(*(methods(false) - MODULE_METHODS.flat_map { |method| [method, "#{method}=".to_sym] } - %i[included reset! configure]), to: self)
|
58
119
|
end
|
59
120
|
end
|
60
121
|
|
61
|
-
|
62
|
-
attr_accessor :locked_at_field, :locked_until_field
|
63
|
-
|
64
|
-
# @api private
|
65
|
-
def included(mod)
|
66
|
-
mod.extend ClassMethods
|
122
|
+
reset!
|
67
123
|
|
68
|
-
|
69
|
-
|
124
|
+
module ClassMethods
|
125
|
+
# A scope to retrieve all locked documents in the collection.
|
126
|
+
#
|
127
|
+
# @example
|
128
|
+
# Account.count
|
129
|
+
# #=> 1717
|
130
|
+
# Account.locked.count
|
131
|
+
# #=> 17
|
132
|
+
#
|
133
|
+
# @return [Mongoid::Criteria]
|
134
|
+
def locked
|
135
|
+
where(
|
136
|
+
'$and': [
|
137
|
+
{ locking_name_field => { '$exists': true, '$ne': nil } },
|
138
|
+
{ locked_at_field => { '$exists': true, '$ne': nil } },
|
139
|
+
{ '$where': "new Date() - this.#{locked_at_field} < #{lock_timeout * 1000}" }
|
140
|
+
]
|
141
|
+
)
|
142
|
+
end
|
70
143
|
|
71
|
-
|
72
|
-
|
144
|
+
# A scope to retrieve all unlocked documents in the collection.
|
145
|
+
#
|
146
|
+
# @example
|
147
|
+
# Account.count
|
148
|
+
# #=> 1717
|
149
|
+
# Account.unlocked.count
|
150
|
+
# #=> 1700
|
151
|
+
#
|
152
|
+
# @return [Mongoid::Criteria]
|
153
|
+
def unlocked
|
154
|
+
where(
|
155
|
+
'$or': [
|
156
|
+
{
|
157
|
+
'$or': [
|
158
|
+
{ locking_name_field => { '$exists': false } },
|
159
|
+
{ locked_at_field => { '$exists': false } }
|
160
|
+
]
|
161
|
+
},
|
162
|
+
{
|
163
|
+
'$or': [
|
164
|
+
{ locking_name_field => { '$eq': nil } },
|
165
|
+
{ locked_at_field => { '$eq': nil } }
|
166
|
+
]
|
167
|
+
},
|
168
|
+
{
|
169
|
+
'$where': "new Date() - this.#{locked_at_field} >= #{lock_timeout * 1000}"
|
170
|
+
}
|
171
|
+
]
|
172
|
+
)
|
73
173
|
end
|
74
174
|
|
75
|
-
# Sets
|
175
|
+
# Unlock all locked documents in the collection. Sets locking_name_field and locked_at_field fields to nil. Returns number of unlocked documents.
|
76
176
|
#
|
77
|
-
#
|
78
|
-
#
|
79
|
-
#
|
80
|
-
#
|
81
|
-
|
82
|
-
|
177
|
+
# @example
|
178
|
+
# Account.unlock_all
|
179
|
+
# #=> 17
|
180
|
+
# Account.locked.unlock_all
|
181
|
+
# #=> 0
|
182
|
+
#
|
183
|
+
# @return [Integer]
|
184
|
+
def unlock_all
|
185
|
+
update_all('$set': { locking_name_field => nil, locked_at_field => nil }).modified_count
|
83
186
|
end
|
84
187
|
|
85
|
-
#
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
188
|
+
# Sets configuration for this class.
|
189
|
+
#
|
190
|
+
# @example
|
191
|
+
# locker locking_name_field: :locker_locking_name,
|
192
|
+
# locked_at_field: :locker_locked_at,
|
193
|
+
# lock_timeout: 3,
|
194
|
+
# locker_write_concern: { w: 1 },
|
195
|
+
# maximum_backoff: 30.0,
|
196
|
+
# backoff_algorithm: :locked_at_backoff,
|
197
|
+
# locking_name_generator: :custom_locking_name
|
198
|
+
#
|
199
|
+
# @param locking_name_field [Symbol]
|
200
|
+
# @param locked_at_field [Symbol]
|
201
|
+
# @param maximum_backoff [Float, Integer]
|
202
|
+
# @param lock_timeout [Float, Integer]
|
203
|
+
# @param locker_write_concern [Hash]
|
204
|
+
# @param backoff_algorithm [Symbol]
|
205
|
+
# @param locking_name_generator [Symbol]
|
206
|
+
def locker(**params)
|
207
|
+
invalid_parameters = params.keys - Mongoid::Locker.singleton_class.const_get('MODULE_METHODS')
|
208
|
+
raise Mongoid::Locker::Errors::InvalidParameter.new(self.class, invalid_parameters.first) unless invalid_parameters.empty?
|
209
|
+
|
210
|
+
params.each_pair do |key, value|
|
211
|
+
send("#{key}=", value)
|
212
|
+
end
|
90
213
|
end
|
91
214
|
end
|
92
215
|
|
93
|
-
# Returns whether the document is currently locked or not.
|
216
|
+
# Returns whether the document is currently locked in the database or not.
|
217
|
+
#
|
218
|
+
# @example
|
219
|
+
# document.locked?
|
220
|
+
# #=> false
|
94
221
|
#
|
95
222
|
# @return [Boolean] true if locked, false otherwise
|
96
223
|
def locked?
|
97
|
-
|
224
|
+
persisted? && self.class.where(_id: id).locked.limit(1).count == 1
|
98
225
|
end
|
99
226
|
|
100
227
|
# Returns whether the current instance has the lock or not.
|
101
228
|
#
|
229
|
+
# @example
|
230
|
+
# document.has_lock?
|
231
|
+
# #=> false
|
232
|
+
#
|
102
233
|
# @return [Boolean] true if locked, false otherwise
|
103
234
|
def has_lock?
|
104
|
-
|
235
|
+
@has_lock || false
|
105
236
|
end
|
106
237
|
|
107
|
-
#
|
238
|
+
# Executes the provided code once the document has been successfully locked. Otherwise, raises error after the number of retries to lock the document is exhausted or it is reached {ClassMethods#maximum_backoff} limit (depending what comes first).
|
239
|
+
#
|
240
|
+
# @example
|
241
|
+
# document.with_lock(reload: true, retries: 3) do
|
242
|
+
# document.quantity = 17
|
243
|
+
# document.save!
|
244
|
+
# end
|
108
245
|
#
|
109
246
|
# @param [Hash] opts for the locking mechanism
|
110
|
-
# @option opts [Fixnum] :
|
111
|
-
# @option opts [
|
112
|
-
# @option opts [
|
113
|
-
# @option opts [
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
if opts[:wait]
|
119
|
-
opts[:retries] = 1
|
120
|
-
warn 'WARN: `:wait` option for Mongoid::Locker is deprecated - use `retries: 1` instead.'
|
121
|
-
end
|
247
|
+
# @option opts [Fixnum] :retries (INFINITY) If the document is currently locked, the number of times to retry
|
248
|
+
# @option opts [Boolean] :reload (true) After acquiring the lock, reload the document
|
249
|
+
# @option opts [Integer] :attempt (0) Increment with each retry (not accepted by the method)
|
250
|
+
# @option opts [String] :locking_name Generate with each retry (not accepted by the method)
|
251
|
+
def with_lock(**opts)
|
252
|
+
opts = opts.dup
|
253
|
+
opts[:retries] ||= Float::INFINITY
|
254
|
+
opts[:reload] = opts[:reload] != false
|
122
255
|
|
123
|
-
|
124
|
-
end
|
256
|
+
acquire_lock(opts) if persisted? && (had_lock = !has_lock?)
|
125
257
|
|
126
258
|
begin
|
127
259
|
yield
|
128
260
|
ensure
|
129
|
-
unlock if
|
261
|
+
unlock!(opts) if had_lock
|
130
262
|
end
|
131
263
|
end
|
132
264
|
|
133
265
|
protected
|
134
266
|
|
135
|
-
def acquire_lock(opts
|
136
|
-
|
137
|
-
timeout = opts[:timeout] || self.class.lock_timeout
|
138
|
-
expiration = time + timeout
|
139
|
-
|
140
|
-
# lock the document atomically in the DB without persisting entire doc
|
141
|
-
locked = Mongoid::Locker::Wrapper.update(
|
142
|
-
self.class,
|
143
|
-
{
|
144
|
-
:_id => id,
|
145
|
-
'$or' => [
|
146
|
-
# not locked
|
147
|
-
{ locked_until_field => nil },
|
148
|
-
# expired
|
149
|
-
{ locked_until_field => { '$lte' => time } }
|
150
|
-
]
|
151
|
-
},
|
152
|
-
'$set' => {
|
153
|
-
locked_at_field => time,
|
154
|
-
locked_until_field => expiration
|
155
|
-
}
|
156
|
-
)
|
157
|
-
|
158
|
-
if locked
|
159
|
-
# document successfully updated, meaning it was locked
|
160
|
-
self[locked_at_field] = time
|
161
|
-
self[locked_until_field] = expiration
|
162
|
-
reload unless opts[:reload] == false
|
163
|
-
@has_lock = true
|
164
|
-
else
|
165
|
-
@has_lock = false
|
166
|
-
end
|
167
|
-
end
|
168
|
-
|
169
|
-
def lock(opts = {})
|
170
|
-
opts = { retries: 0 }.merge(opts)
|
171
|
-
|
172
|
-
attempts_left = opts[:retries] + 1
|
173
|
-
retry_sleep = opts[:retry_sleep]
|
267
|
+
def acquire_lock(opts)
|
268
|
+
opts[:attempt] = 0
|
174
269
|
|
175
270
|
loop do
|
176
|
-
|
271
|
+
opts[:locking_name] = self.class.send(locking_name_generator, self, opts)
|
272
|
+
return if lock!(opts)
|
177
273
|
|
178
|
-
|
274
|
+
opts[:attempt] += 1
|
275
|
+
delay = self.class.send(backoff_algorithm, self, opts)
|
179
276
|
|
180
|
-
raise
|
277
|
+
raise Errors::DocumentCouldNotGetLock.new(self.class, id) if delay >= maximum_backoff || opts[:attempt] >= opts[:retries]
|
181
278
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
279
|
+
sleep delay
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
def lock!(opts)
|
284
|
+
result = Mongoid::Locker::Wrapper.find_and_lock(self, opts)
|
187
285
|
|
188
|
-
|
286
|
+
if result
|
287
|
+
if opts[:reload]
|
288
|
+
reload
|
289
|
+
else
|
290
|
+
self[locking_name_field] = result[locking_name_field.to_s]
|
291
|
+
self[locked_at_field] = result[locked_at_field.to_s]
|
189
292
|
end
|
190
293
|
|
191
|
-
|
294
|
+
@has_lock = true
|
295
|
+
else
|
296
|
+
@has_lock = false
|
192
297
|
end
|
193
298
|
end
|
194
299
|
|
195
|
-
def unlock
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
}
|
204
|
-
)
|
205
|
-
|
206
|
-
self.attributes = { locked_at_field => nil, locked_until_field => nil } unless destroyed?
|
300
|
+
def unlock!(opts)
|
301
|
+
Mongoid::Locker::Wrapper.find_and_unlock(self, opts)
|
302
|
+
|
303
|
+
unless destroyed?
|
304
|
+
self[locking_name_field] = nil
|
305
|
+
self[locked_at_field] = nil
|
306
|
+
end
|
307
|
+
|
207
308
|
@has_lock = false
|
208
309
|
end
|
209
310
|
end
|