mongoid-locker 1.0.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rubygems'
2
4
  require 'bundler/setup'
3
5
  require 'bundler/gem_tasks'
@@ -1,6 +1,25 @@
1
- ## Upgrading Mongoid Locker
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
@@ -0,0 +1,9 @@
1
+ en:
2
+ mongoid:
3
+ locker:
4
+ errors:
5
+ messages:
6
+ document_could_not_get_lock:
7
+ message: "Document could not get lock for class %{klass} with id %{id}."
8
+ invalid_parameter:
9
+ message: "Invalid parameter :%{parameter} provided for class %{klass}."
@@ -1,2 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'mongoid'
2
- require File.expand_path(File.join(File.dirname(__FILE__), 'mongoid', 'locker'))
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')
@@ -1,209 +1,310 @@
1
- require File.expand_path(File.join(File.dirname(__FILE__), 'locker', 'version'))
2
- require File.expand_path(File.join(File.dirname(__FILE__), 'locker', 'wrapper'))
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
3
4
 
4
5
  module Mongoid
5
6
  module Locker
6
- # The field names used by default.
7
- @locked_at_field = :locked_at
8
- @locked_until_field = :locked_until
9
-
10
- # Error thrown if document could not be successfully locked.
11
- class LockError < RuntimeError; end
12
-
13
- module ClassMethods
14
- # A scope to retrieve all locked documents in the collection.
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
- # @return [Mongoid::Criteria]
17
- def locked
18
- where locked_until_field.gt => Time.now.utc
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
- # @return [Mongoid::Criteria]
24
- def unlocked
25
- any_of({ locked_until_field => nil }, locked_until_field.lte => Time.now.utc)
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
- # Set the default lock timeout for this class. Note this only applies to new locks. Defaults to five seconds.
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 [Fixnum] new_time the default number of seconds until a lock is considered "expired", in seconds
31
- # @return [void]
32
- def timeout_lock_after(new_time)
33
- @lock_timeout = new_time
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
- # Retrieve the lock timeout default for this class.
51
+ # Returns time in seconds remaining to complete the lock of the provided document. Makes requests to the database.
37
52
  #
38
- # @return [Fixnum] the default number of seconds until a lock is considered "expired", in seconds
39
- def lock_timeout
40
- # default timeout of five seconds
41
- @lock_timeout || 5
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
- # Set locked_at_field and locked_until_field names for this class
45
- def locker(locked_at_field: nil, locked_until_field: nil)
46
- class_variable_set(:@@locked_at_field, locked_at_field) if locked_at_field
47
- class_variable_set(:@@locked_until_field, locked_until_field) if locked_until_field
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
- # Returns field name used to set locked at time for this class.
51
- def locked_at_field
52
- class_variable_get(:@@locked_at_field)
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
- # Returns field name used to set locked until time for this class.
56
- def locked_until_field
57
- class_variable_get(:@@locked_until_field)
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
- class << self
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
- mod.class_variable_set(:@@locked_at_field, locked_at_field)
69
- mod.class_variable_set(:@@locked_until_field, locked_until_field)
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
- mod.send(:define_method, :locked_at_field) { mod.class_variable_get(:@@locked_at_field) }
72
- mod.send(:define_method, :locked_until_field) { mod.class_variable_get(:@@locked_until_field) }
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 configuration using a block
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
- # Mongoid::Locker.configure do |config|
78
- # config.locked_at_field = :mongoid_locker_locked_at
79
- # config.locked_until_field = :mongoid_locker_locked_until
80
- # end
81
- def configure
82
- yield(self) if block_given?
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
- # Resets to default configuration.
86
- def reset!
87
- # The field names used by default.
88
- @locked_at_field = :locked_at
89
- @locked_until_field = :locked_until
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
- !!(self[locked_until_field] && self[locked_until_field] > Time.now.utc)
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
- !!(@has_lock && locked?)
235
+ @has_lock || false
105
236
  end
106
237
 
107
- # Primary method of plugin: execute the provided code once the document has been successfully locked.
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] :timeout The number of seconds until the lock is considered "expired" - defaults to the {ClassMethods#lock_timeout}
111
- # @option opts [Fixnum] :retries If the document is currently locked, the number of times to retry - defaults to 0
112
- # @option opts [Float] :retry_sleep How long to sleep between attempts to acquire lock - defaults to time left until lock is available
113
- # @option opts [Boolean] :wait (deprecated) If the document is currently locked, wait until the lock expires and try again - defaults to false. If set, :retries will be ignored
114
- # @option opts [Boolean] :reload After acquiring the lock, reload the document - defaults to true
115
- # @return [void]
116
- def with_lock(opts = {})
117
- unless !persisted? || (had_lock = has_lock?)
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
- lock(opts)
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 !had_lock && locked?
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
- time = Time.now.utc
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
- return if acquire_lock(opts)
271
+ opts[:locking_name] = self.class.send(locking_name_generator, self, opts)
272
+ return if lock!(opts)
177
273
 
178
- attempts_left -= 1
274
+ opts[:attempt] += 1
275
+ delay = self.class.send(backoff_algorithm, self, opts)
179
276
 
180
- raise LockError, 'could not get lock' unless attempts_left > 0
277
+ raise Errors::DocumentCouldNotGetLock.new(self.class, id) if delay >= maximum_backoff || opts[:attempt] >= opts[:retries]
181
278
 
182
- # if not passed a retry_sleep value, we sleep for the remaining life of the lock
183
- unless retry_sleep
184
- locked_until = Mongoid::Locker::Wrapper.locked_until(self)
185
- # the lock might be released since the last check so make another attempt
186
- next unless locked_until
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
- retry_sleep = locked_until - Time.now.utc
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
- sleep retry_sleep if retry_sleep > 0
294
+ @has_lock = true
295
+ else
296
+ @has_lock = false
192
297
  end
193
298
  end
194
299
 
195
- def unlock
196
- # unlock the document in the DB without persisting entire doc
197
- Mongoid::Locker::Wrapper.update(
198
- self.class,
199
- { _id: id },
200
- '$set' => {
201
- locked_at_field => nil,
202
- locked_until_field => nil
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