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.
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