mongoid-locker 0.3.5 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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