mongoid-locker 0.3.5 → 2.0.1

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.
@@ -0,0 +1,68 @@
1
+ Releasing Mongoid::Locker
2
+ =========================
3
+
4
+ There're no particular rules about when to release mongoid-locker. Release bug fixes frequently, features not so frequently and breaking API changes rarely.
5
+
6
+ ### Release
7
+
8
+ Run tests, check that all tests succeed locally.
9
+
10
+ ```
11
+ bundle install
12
+ bundle exec rake
13
+ ```
14
+
15
+ Check that the last build succeeded in [Travis CI](https://travis-ci.org/mongoid/mongoid-locker) for all supported platforms.
16
+
17
+ Check the version, if needed modify [lib/mongoid/locker/version.rb](lib/mongoid/locker/version.rb).
18
+
19
+ * Increment the third number if the release has bug fixes and/or very minor features, only (eg. change `0.5.1` to `0.5.2`).
20
+ * Increment the second number if the release contains major features or breaking API changes (eg. change `0.5.1` to `0.4.0`).
21
+
22
+ Change "Next Release" in [CHANGELOG.md](CHANGELOG.md) to the new version.
23
+
24
+ ```
25
+ ### 0.4.0 (2014-01-27)
26
+ ```
27
+
28
+ Remove the line with "Your contribution here.", since there will be no more contributions to this release.
29
+
30
+ Commit your changes.
31
+
32
+ ```
33
+ git add CHANGELOG.md lib/mongoid-locker/version.rb
34
+ git commit -m "Preparing for release, 0.4.0."
35
+ git push origin master
36
+ ```
37
+
38
+ Release.
39
+
40
+ ```
41
+ $ bundle exec rake release
42
+
43
+ mongoid-locker 0.4.0 built to pkg/mongoid-locker-0.4.0.gem.
44
+ Tagged v0.4.0.
45
+ Pushed git commits and tags.
46
+ Pushed mongoid-locker 0.4.0 to rubygems.org.
47
+ ```
48
+
49
+ ### Prepare for the Next Version
50
+
51
+ Add the next release to [CHANGELOG.md](CHANGELOG.md).
52
+
53
+ ```
54
+ Next Release
55
+ ============
56
+
57
+ * Your contribution here.
58
+ ```
59
+
60
+ Increment the minor version, modify [lib/mongoid-locker/version.rb](lib/mongoid-locker/version.rb).
61
+
62
+ Commit your changes.
63
+
64
+ ```
65
+ git add CHANGELOG.md lib/mongoid-locker/version.rb
66
+ git commit -m "Preparing for next release, 0.4.1."
67
+ git push origin master
68
+ ```
data/Rakefile CHANGED
@@ -1,4 +1,4 @@
1
- # encoding: utf-8
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'rubygems'
4
4
  require 'bundler/setup'
@@ -14,4 +14,4 @@ end
14
14
  require 'rubocop/rake_task'
15
15
  RuboCop::RakeTask.new(:rubocop)
16
16
 
17
- task default: [:rubocop, :spec]
17
+ task default: %i[rubocop spec]
@@ -0,0 +1,37 @@
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`.
20
+
21
+ ### Upgrading to 1.0.0
22
+
23
+ `Mongoid::Locker` no longer defines `locked_at` and `locked_until` fields when included. You must define these fields manually.
24
+
25
+ ```ruby
26
+ class User
27
+ include Mongoid::Document
28
+ include Mongoid::Locker
29
+
30
+ field :locked_at, type: Time
31
+ field :locked_until, type: Time
32
+ end
33
+ ```
34
+
35
+ You can customize the fields used with a `locker` class method or via a global `configure`. See [Customizable :locked_at and :locked_until field names](https://github.com/mongoid/mongoid-locker#customizable-locked_at-and-locked_until-field-names) for more information.
36
+
37
+ See [#55](https://github.com/mongoid/mongoid-locker/pull/55) for more information.
@@ -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,168 +1,313 @@
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 'forwardable'
4
+ require 'securerandom'
3
5
 
4
6
  module Mongoid
5
7
  module Locker
6
- # Error thrown if document could not be successfully locked.
7
- class LockError < Exception; end
8
+ class << self
9
+ # Available parameters for +Mongoid::Locker+ module, a class where the module is included and it's instances.
10
+ MODULE_METHODS = %i[
11
+ locking_name_field
12
+ locked_at_field
13
+ maximum_backoff
14
+ lock_timeout
15
+ locker_write_concern
16
+ backoff_algorithm
17
+ locking_name_generator
18
+ ].freeze
19
+
20
+ attr_accessor(*MODULE_METHODS)
21
+
22
+ # Generates secure random string of +name#attempt+ format.
23
+ #
24
+ # @example
25
+ # Mongoid::Locker.secure_locking_name(doc, { attempt: 1 })
26
+ # #=> "zLmulhOy9yn_NE886OWNYw#1"
27
+ #
28
+ # @param doc [Mongoid::Document]
29
+ # @param opts [Hash] (see #with_lock)
30
+ # @return [String]
31
+ def secure_locking_name(_doc, opts)
32
+ "#{SecureRandom.urlsafe_base64}##{opts[:attempt]}"
33
+ end
34
+
35
+ # Returns random number of seconds depend on passed options.
36
+ #
37
+ # @example
38
+ # Mongoid::Locker.exponential_backoff(doc, { attempt: 0 })
39
+ # #=> 1.2280675023095662
40
+ # Mongoid::Locker.exponential_backoff(doc, { attempt: 1 })
41
+ # #=> 2.901641863236713
42
+ # Mongoid::Locker.exponential_backoff(doc, { attempt: 2 })
43
+ # #=> 4.375030664612267
44
+ #
45
+ # @param _doc [Mongoid::Document]
46
+ # @param opts [Hash] (see #with_lock)
47
+ # @return [Float]
48
+ def exponential_backoff(_doc, opts)
49
+ 2**opts[:attempt] + rand
50
+ end
51
+
52
+ # Returns time in seconds remaining to complete the lock of the provided document. Makes requests to the database.
53
+ #
54
+ # @example
55
+ # Mongoid::Locker.locked_at_backoff(doc, opts)
56
+ # #=> 2.32422359
57
+ #
58
+ # @param doc [Mongoid::Document]
59
+ # @param opts [Hash] (see #with_lock)
60
+ # @return [Float | Integer]
61
+ # @return [0] if the provided document is not locked
62
+ def locked_at_backoff(doc, opts)
63
+ return doc.maximum_backoff if opts[:attempt] * doc.lock_timeout >= doc.maximum_backoff
64
+
65
+ locked_at = Wrapper.locked_at(doc).to_f
66
+ return 0 unless locked_at > 0
67
+
68
+ current_time = Wrapper.current_mongodb_time(doc.class).to_f
69
+ delay = doc.lock_timeout - (current_time - locked_at)
70
+
71
+ delay < 0 ? 0 : delay + rand
72
+ end
73
+
74
+ # Sets configuration using a block.
75
+ #
76
+ # @example
77
+ # Mongoid::Locker.configure do |config|
78
+ # config.locking_name_field = :locking_name
79
+ # config.locked_at_field = :locked_at
80
+ # config.lock_timeout = 5
81
+ # config.locker_write_concern = { w: 1 }
82
+ # config.maximum_backoff = 60.0
83
+ # config.backoff_algorithm = :exponential_backoff
84
+ # config.locking_name_generator = :secure_locking_name
85
+ # end
86
+ def configure
87
+ yield(self) if block_given?
88
+ end
89
+
90
+ # Resets to default configuration.
91
+ #
92
+ # @example
93
+ # Mongoid::Locker.reset!
94
+ def reset!
95
+ # The parameters used by default.
96
+ self.locking_name_field = :locking_name
97
+ self.locked_at_field = :locked_at
98
+ self.lock_timeout = 5
99
+ self.locker_write_concern = { w: 1 }
100
+ self.maximum_backoff = 60.0
101
+ self.backoff_algorithm = :exponential_backoff
102
+ self.locking_name_generator = :secure_locking_name
103
+ end
104
+
105
+ # @api private
106
+ def included(klass)
107
+ klass.extend(Forwardable) unless klass.ancestors.include?(Forwardable)
108
+
109
+ klass.extend ClassMethods
110
+ klass.singleton_class.instance_eval { attr_accessor(*MODULE_METHODS) }
111
+
112
+ klass.locking_name_field = locking_name_field
113
+ klass.locked_at_field = locked_at_field
114
+ klass.lock_timeout = lock_timeout
115
+ klass.locker_write_concern = locker_write_concern
116
+ klass.maximum_backoff = maximum_backoff
117
+ klass.backoff_algorithm = backoff_algorithm
118
+ klass.locking_name_generator = locking_name_generator
119
+
120
+ klass.def_delegators(klass, *MODULE_METHODS)
121
+ klass.singleton_class.delegate(*(methods(false) - MODULE_METHODS.flat_map { |method| [method, "#{method}=".to_sym] } - %i[included reset! configure]), to: self)
122
+ end
123
+ end
124
+
125
+ reset!
8
126
 
9
127
  module ClassMethods
10
128
  # A scope to retrieve all locked documents in the collection.
11
129
  #
130
+ # @example
131
+ # Account.count
132
+ # #=> 1717
133
+ # Account.locked.count
134
+ # #=> 17
135
+ #
12
136
  # @return [Mongoid::Criteria]
13
137
  def locked
14
- where :locked_until.gt => Time.now
138
+ where(
139
+ '$and': [
140
+ { locking_name_field => { '$exists': true, '$ne': nil } },
141
+ { locked_at_field => { '$exists': true, '$ne': nil } },
142
+ { '$where': "new Date() - this.#{locked_at_field} < #{lock_timeout * 1000}" }
143
+ ]
144
+ )
15
145
  end
16
146
 
17
147
  # A scope to retrieve all unlocked documents in the collection.
18
148
  #
149
+ # @example
150
+ # Account.count
151
+ # #=> 1717
152
+ # Account.unlocked.count
153
+ # #=> 1700
154
+ #
19
155
  # @return [Mongoid::Criteria]
20
156
  def unlocked
21
- any_of({ locked_until: nil }, :locked_until.lte => Time.now)
157
+ where(
158
+ '$or': [
159
+ {
160
+ '$or': [
161
+ { locking_name_field => { '$exists': false } },
162
+ { locked_at_field => { '$exists': false } }
163
+ ]
164
+ },
165
+ {
166
+ '$or': [
167
+ { locking_name_field => { '$eq': nil } },
168
+ { locked_at_field => { '$eq': nil } }
169
+ ]
170
+ },
171
+ {
172
+ '$where': "new Date() - this.#{locked_at_field} >= #{lock_timeout * 1000}"
173
+ }
174
+ ]
175
+ )
22
176
  end
23
177
 
24
- # Set the default lock timeout for this class. Note this only applies to new locks. Defaults to five seconds.
178
+ # Unlock all locked documents in the collection. Sets locking_name_field and locked_at_field fields to nil. Returns number of unlocked documents.
25
179
  #
26
- # @param [Fixnum] new_time the default number of seconds until a lock is considered "expired", in seconds
27
- # @return [void]
28
- def timeout_lock_after(new_time)
29
- @lock_timeout = new_time
30
- end
31
-
32
- # Retrieve the lock timeout default for this class.
180
+ # @example
181
+ # Account.unlock_all
182
+ # #=> 17
183
+ # Account.locked.unlock_all
184
+ # #=> 0
33
185
  #
34
- # @return [Fixnum] the default number of seconds until a lock is considered "expired", in seconds
35
- def lock_timeout
36
- # default timeout of five seconds
37
- @lock_timeout || 5
186
+ # @return [Integer]
187
+ def unlock_all
188
+ update_all('$set': { locking_name_field => nil, locked_at_field => nil }).modified_count
38
189
  end
39
- end
40
190
 
41
- # @api private
42
- def self.included(mod)
43
- mod.extend ClassMethods
191
+ # Sets configuration for this class.
192
+ #
193
+ # @example
194
+ # locker locking_name_field: :locker_locking_name,
195
+ # locked_at_field: :locker_locked_at,
196
+ # lock_timeout: 3,
197
+ # locker_write_concern: { w: 1 },
198
+ # maximum_backoff: 30.0,
199
+ # backoff_algorithm: :locked_at_backoff,
200
+ # locking_name_generator: :custom_locking_name
201
+ #
202
+ # @param locking_name_field [Symbol]
203
+ # @param locked_at_field [Symbol]
204
+ # @param maximum_backoff [Float, Integer]
205
+ # @param lock_timeout [Float, Integer]
206
+ # @param locker_write_concern [Hash]
207
+ # @param backoff_algorithm [Symbol]
208
+ # @param locking_name_generator [Symbol]
209
+ def locker(**params)
210
+ invalid_parameters = params.keys - Mongoid::Locker.singleton_class.const_get('MODULE_METHODS')
211
+ raise Mongoid::Locker::Errors::InvalidParameter.new(self.class, invalid_parameters.first) unless invalid_parameters.empty?
44
212
 
45
- mod.field :locked_at, type: Time
46
- mod.field :locked_until, type: Time
213
+ params.each_pair do |key, value|
214
+ send("#{key}=", value)
215
+ end
216
+ end
47
217
  end
48
218
 
49
- # Returns whether the document is currently locked or not.
219
+ # Returns whether the document is currently locked in the database or not.
220
+ #
221
+ # @example
222
+ # document.locked?
223
+ # #=> false
50
224
  #
51
225
  # @return [Boolean] true if locked, false otherwise
52
226
  def locked?
53
- !!(locked_until && locked_until > Time.now)
227
+ persisted? && self.class.where(_id: id).locked.limit(1).count == 1
54
228
  end
55
229
 
56
230
  # Returns whether the current instance has the lock or not.
57
231
  #
232
+ # @example
233
+ # document.has_lock?
234
+ # #=> false
235
+ #
58
236
  # @return [Boolean] true if locked, false otherwise
59
237
  def has_lock?
60
- !!(@has_lock && self.locked?)
238
+ @has_lock || false
61
239
  end
62
240
 
63
- # Primary method of plugin: execute the provided code once the document has been successfully locked.
241
+ # 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).
242
+ #
243
+ # @example
244
+ # document.with_lock(reload: true, retries: 3) do
245
+ # document.quantity = 17
246
+ # document.save!
247
+ # end
64
248
  #
65
249
  # @param [Hash] opts for the locking mechanism
66
- # @option opts [Fixnum] :timeout The number of seconds until the lock is considered "expired" - defaults to the {ClassMethods#lock_timeout}
67
- # @option opts [Fixnum] :retries If the document is currently locked, the number of times to retry. Defaults to 0 (note: setting this to 1 is the equivalent of using :wait => true)
68
- # @option opts [Float] :retry_sleep How long to sleep between attempts to acquire lock - defaults to time left until lock is available
69
- # @option opts [Boolean] :wait If the document is currently locked, wait until the lock expires and try again - defaults to false. If set, :retries will be ignored
70
- # @option opts [Boolean] :reload After acquiring the lock, reload the document - defaults to true
71
- # @return [void]
72
- def with_lock(opts = {})
73
- had_lock = self.has_lock?
74
-
75
- unless had_lock
76
- opts[:retries] = 1 if opts[:wait]
77
- lock(opts)
78
- end
250
+ # @option opts [Fixnum] :retries (INFINITY) If the document is currently locked, the number of times to retry
251
+ # @option opts [Boolean] :reload (true) After acquiring the lock, reload the document
252
+ # @option opts [Integer] :attempt (0) Increment with each retry (not accepted by the method)
253
+ # @option opts [String] :locking_name Generate with each retry (not accepted by the method)
254
+ def with_lock(**opts)
255
+ opts = opts.dup
256
+ opts[:retries] ||= Float::INFINITY
257
+ opts[:reload] = opts[:reload] != false
258
+
259
+ acquire_lock(opts) if persisted? && (had_lock = !has_lock?)
79
260
 
80
261
  begin
81
262
  yield
82
263
  ensure
83
- unlock if locked? && !had_lock
264
+ unlock!(opts) if had_lock
84
265
  end
85
266
  end
86
267
 
87
268
  protected
88
269
 
89
- def acquire_lock(opts = {})
90
- time = Time.now
91
- timeout = opts[:timeout] || self.class.lock_timeout
92
- expiration = time + timeout
93
-
94
- # lock the document atomically in the DB without persisting entire doc
95
- locked = Mongoid::Locker::Wrapper.update(
96
- self.class,
97
- {
98
- :_id => id,
99
- '$or' => [
100
- # not locked
101
- { locked_until: nil },
102
- # expired
103
- { locked_until: { '$lte' => time } }
104
- ]
105
- },
270
+ def acquire_lock(opts)
271
+ opts[:attempt] = 0
106
272
 
107
- '$set' => {
108
- locked_at: time,
109
- locked_until: expiration
110
- }
273
+ loop do
274
+ opts[:locking_name] = self.class.send(locking_name_generator, self, opts)
275
+ return if lock!(opts)
111
276
 
112
- )
277
+ opts[:attempt] += 1
278
+ delay = self.class.send(backoff_algorithm, self, opts)
113
279
 
114
- if locked
115
- # document successfully updated, meaning it was locked
116
- self.locked_at = time
117
- self.locked_until = expiration
118
- reload unless opts[:reload] == false
119
- @has_lock = true
120
- else
121
- @has_lock = false
280
+ raise Errors::DocumentCouldNotGetLock.new(self.class, id) if delay >= maximum_backoff || opts[:attempt] >= opts[:retries]
281
+
282
+ sleep delay
122
283
  end
123
284
  end
124
285
 
125
- def lock(opts = {})
126
- opts = { retries: 0 }.merge(opts)
127
-
128
- attempts_left = opts[:retries] + 1
129
- retry_sleep = opts[:retry_sleep]
130
-
131
- loop do
132
- return if acquire_lock(opts)
133
-
134
- attempts_left -= 1
286
+ def lock!(opts)
287
+ result = Mongoid::Locker::Wrapper.find_and_lock(self, opts)
135
288
 
136
- if attempts_left > 0
137
- # if not passed a retry_sleep value, we sleep for the remaining life of the lock
138
- unless opts[:retry_sleep]
139
- locked_until = Mongoid::Locker::Wrapper.locked_until(self)
140
- # the lock might be released since the last check so make another attempt
141
- next unless locked_until
142
- retry_sleep = locked_until - Time.now
143
- end
144
-
145
- sleep retry_sleep if retry_sleep > 0
289
+ if result
290
+ if opts[:reload]
291
+ reload
146
292
  else
147
- fail LockError.new('could not get lock')
293
+ self[locking_name_field] = result[locking_name_field.to_s]
294
+ self[locked_at_field] = result[locked_at_field.to_s]
148
295
  end
296
+
297
+ @has_lock = true
298
+ else
299
+ @has_lock = false
149
300
  end
150
301
  end
151
302
 
152
- def unlock
153
- # unlock the document in the DB without persisting entire doc
154
- Mongoid::Locker::Wrapper.update(
155
- self.class,
156
- { _id: id },
157
-
158
- '$set' => {
159
- locked_at: nil,
160
- locked_until: nil
161
- }
303
+ def unlock!(opts)
304
+ Mongoid::Locker::Wrapper.find_and_unlock(self, opts)
162
305
 
163
- )
306
+ unless destroyed?
307
+ self[locking_name_field] = nil
308
+ self[locked_at_field] = nil
309
+ end
164
310
 
165
- self.attributes = { locked_at: nil, locked_until: nil } unless destroyed?
166
311
  @has_lock = false
167
312
  end
168
313
  end