From 9cb4e79cc2b849d0b17345a1ba54dea03594c421 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Sat, 11 Aug 2018 17:05:36 +0300 Subject: [PATCH] Customizable :locked_at and :locked_until fields --- .rubocop_todo.yml | 85 ++-- CHANGELOG.md | 3 +- Dangerfile | 2 +- Gemfile | 8 +- README.md | 53 ++- Rakefile | 2 - UPGRADING.md | 18 + lib/mongoid/locker.rb | 83 ++-- lib/mongoid/locker/version.rb | 2 +- lib/mongoid/locker/wrapper.rb | 2 +- lib/mongoid/locker/wrapper2.rb | 6 +- lib/mongoid/locker/wrapper3.rb | 6 +- lib/mongoid/locker/wrapper4.rb | 6 +- lib/mongoid/locker/wrapper5.rb | 6 +- lib/mongoid/locker/wrapper6.rb | 6 +- mongoid-locker.gemspec | 3 +- spec/mongoid-locker_spec.rb | 693 +++++++++++++++++++++++---------- spec/spec_helper.rb | 2 + 18 files changed, 694 insertions(+), 292 deletions(-) create mode 100644 UPGRADING.md diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 49611ef..558b3f9 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,58 +1,79 @@ -# This configuration was generated by `rubocop --auto-gen-config` -# on 2015-10-21 11:03:44 -0400 using RuboCop version 0.29.1. +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2018-08-22 18:00:04 +0300 using RuboCop version 0.58.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 1 +# Offense count: 6 +# Configuration parameters: Include. +# Include: **/*.gemfile, **/Gemfile, **/gems.rb +Bundler/DuplicatedGem: + Exclude: + - 'Gemfile' + +# Offense count: 2 Metrics/AbcSize: - Max: 18 + Max: 20 -# Offense count: 37 -# Configuration parameters: AllowURI, URISchemes. -Metrics/LineLength: - Max: 184 +# Offense count: 5 +# Configuration parameters: CountComments, ExcludedMethods. +# ExcludedMethods: refine +Metrics/BlockLength: + Max: 478 -# Offense count: 3 +# Offense count: 2 # Configuration parameters: CountComments. Metrics/MethodLength: Max: 25 -# Offense count: 6 -# Cop supports --auto-correct. -Style/Blocks: - Enabled: false +# Offense count: 1 +# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist, MethodDefinitionMacros. +# NamePrefix: is_, has_, have_ +# NamePrefixBlacklist: is_, has_, have_ +# NameWhitelist: is_a? +# MethodDefinitionMacros: define_method, define_singleton_method +Naming/PredicateName: + Exclude: + - 'spec/**/*' + - 'lib/mongoid/locker.rb' -# Offense count: 3 +# Offense count: 2 Style/Documentation: - Enabled: false + Exclude: + - 'spec/**/*' + - 'test/**/*' + - 'lib/mongoid/locker.rb' # Offense count: 2 Style/DoubleNegation: - Enabled: false - -# Offense count: 2 -# Configuration parameters: Exclude. -Style/FileName: - Enabled: false + Exclude: + - 'lib/mongoid/locker.rb' # Offense count: 1 -# Configuration parameters: NamePrefix, NamePrefixBlacklist. -Style/PredicateName: - Enabled: false +# Configuration parameters: MinBodyLength. +Style/GuardClause: + Exclude: + - 'lib/mongoid/locker.rb' # Offense count: 1 -# Configuration parameters: SupportedStyles. +# Cop supports --auto-correct. +# Configuration parameters: . +# SupportedStyles: compact, exploded Style/RaiseArgs: EnforcedStyle: compact -# Offense count: 2 -Style/RegexpLiteral: - MaxSlashes: 0 - # Offense count: 1 # Cop supports --auto-correct. -# Configuration parameters: ExactNameMatch, AllowPredicates, AllowDSLWriters, Whitelist. -Style/TrivialAccessors: - Enabled: false +# Configuration parameters: . +# SupportedStyles: percent, brackets +Style/SymbolArray: + EnforcedStyle: percent + MinSize: 3 + +# Offense count: 63 +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. +# URISchemes: http, https +Metrics/LineLength: + Max: 184 diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9674b..9c3205f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ ## Changelog -### 0.3.7 (Next) +### 1.0.0 (Next) * Your contribution here. +* [#55](https://github.com/mongoid/mongoid-locker/pull/55): Customizable :locked_at and :locked_until fields - [@dks17](https://github.com/dks17). ### 0.3.6 (4/18/2018) diff --git a/Dangerfile b/Dangerfile index 67c103c..6248c90 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1 +1 @@ -danger.import_dangerfile(gem: "mongoid-danger") +danger.import_dangerfile(gem: 'mongoid-danger') diff --git a/Gemfile b/Gemfile index 8401493..74870ae 100644 --- a/Gemfile +++ b/Gemfile @@ -27,10 +27,12 @@ group :development do end group :development, :test do + gem 'byebug', platforms: %i[mri mingw x64_mingw] gem 'mongoid-compatibility' + gem 'mongoid-danger', '~> 0.1.1' + gem 'pry-byebug' gem 'rack', '~> 1.5' - gem 'rspec', '~> 3.0' gem 'rake', '11.3.0' - gem 'rubocop', '0.29.1' - gem 'mongoid-danger', '~> 0.1.1' + gem 'rspec', '~> 3.0' + gem 'rubocop' end diff --git a/README.md b/README.md index 13ee24a..44341e5 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ [![Build Status](https://secure.travis-ci.org/mongoid/mongoid-locker.svg?branch=master)](http://travis-ci.org/mongoid/mongoid-locker) [![Code Climate](https://codeclimate.com/github/mongoid/mongoid-locker.svg)](https://codeclimate.com/github/mongoid/mongoid-locker) -Document-level locking for MongoDB via Mongoid. The need arose at [Jux](https://jux.com) from multiple processes on multiple servers trying to act upon the same document and stepping on each other's toes. Mongoid-Locker is an easy way to ensure only one process can perform a certain operation on a document at a time. +Document-level locking for MongoDB via Mongoid. The need arose at [Jux](https://jux.com) from multiple processes on multiple servers trying to act upon the same document and stepping on each other's toes. Mongoid-Locker is an easy way to ensure only one process can perform a certain operation on a document at a time. [Tested](http://travis-ci.org/mongoid/mongoid-locker) against: -- MRI: `2.3.6`, `2.4.3`, `2.5.0` +- MRI: `2.3.6`, `2.4.3`, `2.5.0` - Mongoid: `2`, `3`, `4`, `5`, `6`, `7` See [.travis.yml](.travis.yml) for the latest test matrix. @@ -19,14 +19,17 @@ Add to your `Gemfile`: gem 'mongoid-locker' ``` -and run `bundle install`. In the model you wish to lock, include `Mongoid::Locker` after `Mongoid::Document`. For example: +and run `bundle install`. In the model you wish to lock, include `Mongoid::Locker` after `Mongoid::Document`. For example: ```ruby class QueueItem include Mongoid::Document include Mongoid::Locker - field :completed_at, :type => Time + field :locked_at, type: Time + field :locked_until, type: Time + + field :completed_at, type: Time end ``` @@ -57,6 +60,48 @@ Note that these locks are only enforced when using `#with_lock`, not at the data More in-depth method documentation can be found at [rdoc.info](http://rdoc.info/github/mongoid/mongoid-locker/frames). +### Customizable :locked_at and :locked_until field names +By default, Locker uses fields with `:locked_at` and `:locked_until` names which should be defined in a model. +```ruby +class User + include Mongoid::Document + include Mongoid::Locker + + field :locked_at, type: Time + field :locked_until, type: Time +end +``` + +Use `Mongoid::Locker.configure` to setup field names which used by Locker for all models where it's included. +```ruby +Mongoid::Locker.configure do |config| + config.locked_at_field = :global_locked_at + config.locked_until_field = :global_locked_until +end + +class User + include Mongoid::Document + include Mongoid::Locker + + field :global_locked_at, type: Time + field :global_locked_until, type: Time +end +``` + +The `locker` method in your model accepts `:locked_at_field` and `:locked_until_field` options to setup field names which used by Locker for the model. This can be useful when another library uses the same field for different purposes. +```ruby +class User + include Mongoid::Document + include Mongoid::Locker + + field :locker_locked_at, type: Time + field :locker_locked_until, type: Time + + locker locked_at_field: :locker_locked_at, + locked_until_field: :locker_locked_until +end +``` + ## Copyright & License Copyright (c) 2012-2018 Aidan Feldman & Contributors diff --git a/Rakefile b/Rakefile index 551c22e..57e1fb5 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,3 @@ -# encoding: utf-8 - require 'rubygems' require 'bundler/setup' require 'bundler/gem_tasks' diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..b924900 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,18 @@ +## Upgrading Mongoid Locker + +### Upgrading to 1.0.0 +`Mongoid::Locker` no longer defines `locked_at` and `locked_until` fields when included. You must define these fields manually. + +```ruby +class User + include Mongoid::Document + include Mongoid::Locker + + field :locked_at, type: Time + field :locked_until, type: Time +end +``` + +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. + +See [#55](https://github.com/mongoid/mongoid-locker/pull/55) for more information. diff --git a/lib/mongoid/locker.rb b/lib/mongoid/locker.rb index 07a0f35..b88da8c 100644 --- a/lib/mongoid/locker.rb +++ b/lib/mongoid/locker.rb @@ -3,22 +3,26 @@ module Mongoid module Locker + # The field names used by default. + @locked_at_field = :locked_at + @locked_until_field = :locked_until + # Error thrown if document could not be successfully locked. - class LockError < Exception; end + class LockError < RuntimeError; end module ClassMethods # A scope to retrieve all locked documents in the collection. # # @return [Mongoid::Criteria] def locked - where :locked_until.gt => Time.now + where locked_until_field.gt => Time.now end # A scope to retrieve all unlocked documents in the collection. # # @return [Mongoid::Criteria] def unlocked - any_of({ locked_until: nil }, :locked_until.lte => Time.now) + any_of({ locked_until_field => nil }, locked_until_field.lte => Time.now) end # Set the default lock timeout for this class. Note this only applies to new locks. Defaults to five seconds. @@ -36,28 +40,61 @@ def lock_timeout # default timeout of five seconds @lock_timeout || 5 end + + # Set locked_at_field and locked_until_field names for this class + def locker(locked_at_field: nil, locked_until_field: nil) + class_variable_set(:@@locked_at_field, locked_at_field) if locked_at_field + class_variable_set(:@@locked_until_field, locked_until_field) if locked_until_field + end + + # Returns field name used to set locked at time for this class. + def locked_at_field + class_variable_get(:@@locked_at_field) + end + + # Returns field name used to set locked until time for this class. + def locked_until_field + class_variable_get(:@@locked_until_field) + end end - # @api private - def self.included(mod) - mod.extend ClassMethods + class << self + attr_accessor :locked_at_field, :locked_until_field + + # @api private + def included(mod) + mod.extend ClassMethods + + mod.class_variable_set(:@@locked_at_field, locked_at_field) + mod.class_variable_set(:@@locked_until_field, locked_until_field) - mod.field :locked_at, type: Time - mod.field :locked_until, type: Time + mod.send(:define_method, :locked_at_field) { mod.class_variable_get(:@@locked_at_field) } + mod.send(:define_method, :locked_until_field) { mod.class_variable_get(:@@locked_until_field) } + end + + # Sets configuration using a block + # + # Mongoid::Locker.configure do |config| + # config.locked_at_field = :mongoid_locker_locked_at + # config.locked_until_field = :mongoid_locker_locked_until + # end + def configure + yield(self) if block_given? + end end # Returns whether the document is currently locked or not. # # @return [Boolean] true if locked, false otherwise def locked? - !!(locked_until && locked_until > Time.now) + !!(self[locked_until_field] && self[locked_until_field] > Time.now) end # Returns whether the current instance has the lock or not. # # @return [Boolean] true if locked, false otherwise def has_lock? - !!(@has_lock && self.locked?) + !!(@has_lock && locked?) end # Primary method of plugin: execute the provided code once the document has been successfully locked. @@ -70,7 +107,7 @@ def has_lock? # @option opts [Boolean] :reload After acquiring the lock, reload the document - defaults to true # @return [void] def with_lock(opts = {}) - had_lock = self.has_lock? + had_lock = has_lock? unless had_lock opts[:retries] = 1 if opts[:wait] @@ -98,23 +135,21 @@ def acquire_lock(opts = {}) :_id => id, '$or' => [ # not locked - { locked_until: nil }, + { locked_until_field => nil }, # expired - { locked_until: { '$lte' => time } } + { locked_until_field => { '$lte' => time } } ] }, - '$set' => { - locked_at: time, - locked_until: expiration + locked_at_field => time, + locked_until_field => expiration } - ) if locked # document successfully updated, meaning it was locked - self.locked_at = time - self.locked_until = expiration + self[locked_at_field] = time + self[locked_until_field] = expiration reload unless opts[:reload] == false @has_lock = true else @@ -144,7 +179,7 @@ def lock(opts = {}) sleep retry_sleep if retry_sleep > 0 else - fail LockError.new('could not get lock') + raise LockError.new('could not get lock') end end end @@ -154,15 +189,13 @@ def unlock Mongoid::Locker::Wrapper.update( self.class, { _id: id }, - '$set' => { - locked_at: nil, - locked_until: nil + locked_at_field => nil, + locked_until_field => nil } - ) - self.attributes = { locked_at: nil, locked_until: nil } unless destroyed? + self.attributes = { locked_at_field => nil, locked_until_field => nil } unless destroyed? @has_lock = false end end diff --git a/lib/mongoid/locker/version.rb b/lib/mongoid/locker/version.rb index 9bce13e..437255c 100644 --- a/lib/mongoid/locker/version.rb +++ b/lib/mongoid/locker/version.rb @@ -1,5 +1,5 @@ module Mongoid module Locker - VERSION = '0.3.7' + VERSION = '1.0.0'.freeze end end diff --git a/lib/mongoid/locker/wrapper.rb b/lib/mongoid/locker/wrapper.rb index 0d11044..87ca76f 100644 --- a/lib/mongoid/locker/wrapper.rb +++ b/lib/mongoid/locker/wrapper.rb @@ -13,5 +13,5 @@ elsif Mongoid::Compatibility::Version.mongoid2? require 'mongoid/locker/wrapper2' else - fail 'incompatible Mongoid version' + raise 'incompatible Mongoid version' end diff --git a/lib/mongoid/locker/wrapper2.rb b/lib/mongoid/locker/wrapper2.rb index 6889d69..48f3c92 100644 --- a/lib/mongoid/locker/wrapper2.rb +++ b/lib/mongoid/locker/wrapper2.rb @@ -17,9 +17,9 @@ def self.update(klass, query, setter) # @param [Class] The model instance # @return [Time] The timestamp of when the document is locked until, nil if not locked. def self.locked_until(doc) - existing_query = { _id: doc.id, locked_until: { '$exists' => true } } - existing = doc.class.collection.find_one(existing_query, fields: { locked_until: 1 }) - existing ? existing['locked_until'] : nil + existing_query = { _id: doc.id, doc.locked_until_field => { '$exists' => true } } + existing = doc.class.collection.find_one(existing_query, fields: { doc.locked_until_field => 1 }) + existing ? existing[doc.locked_until_field] : nil end end end diff --git a/lib/mongoid/locker/wrapper3.rb b/lib/mongoid/locker/wrapper3.rb index d232263..de235c0 100644 --- a/lib/mongoid/locker/wrapper3.rb +++ b/lib/mongoid/locker/wrapper3.rb @@ -17,9 +17,9 @@ def self.update(klass, query, setter) # @param [Class] The model instance # @return [Time] The timestamp of when the document is locked until, nil if not locked. def self.locked_until(doc) - existing_query = { _id: doc.id, locked_until: { '$exists' => true } } - existing = doc.class.where(existing_query).limit(1).only(:locked_until).first - existing ? existing.locked_until : nil + existing_query = { _id: doc.id, doc.locked_until_field => { '$exists' => true } } + existing = doc.class.where(existing_query).limit(1).only(doc.locked_until_field).first + existing ? existing[doc.locked_until_field] : nil end end end diff --git a/lib/mongoid/locker/wrapper4.rb b/lib/mongoid/locker/wrapper4.rb index 619a8fd..e6879f2 100644 --- a/lib/mongoid/locker/wrapper4.rb +++ b/lib/mongoid/locker/wrapper4.rb @@ -13,9 +13,9 @@ def self.update(klass, query, setter) end def self.locked_until(doc) - existing_query = { _id: doc.id, locked_until: { '$exists' => true } } - existing = doc.class.where(existing_query).limit(1).only(:locked_until).first - existing ? existing.locked_until : nil + existing_query = { _id: doc.id, doc.locked_until_field => { '$exists' => true } } + existing = doc.class.where(existing_query).limit(1).only(doc.locked_until_field).first + existing ? existing[doc.locked_until_field] : nil end end end diff --git a/lib/mongoid/locker/wrapper5.rb b/lib/mongoid/locker/wrapper5.rb index 4f1d8fb..a7ed8f0 100644 --- a/lib/mongoid/locker/wrapper5.rb +++ b/lib/mongoid/locker/wrapper5.rb @@ -18,9 +18,9 @@ def self.update(klass, query, setter) # @param [Class] The model instance # @return [Time] The timestamp of when the document is locked until, nil if not locked. def self.locked_until(doc) - existing_query = { _id: doc.id, locked_until: { '$exists' => true } } - existing = doc.class.where(existing_query).limit(1).only(:locked_until).first - existing ? existing.locked_until : nil + existing_query = { _id: doc.id, doc.locked_until_field => { '$exists' => true } } + existing = doc.class.where(existing_query).limit(1).only(doc.locked_until_field).first + existing ? existing[doc.locked_until_field] : nil end end end diff --git a/lib/mongoid/locker/wrapper6.rb b/lib/mongoid/locker/wrapper6.rb index 6cb3883..873672a 100644 --- a/lib/mongoid/locker/wrapper6.rb +++ b/lib/mongoid/locker/wrapper6.rb @@ -20,9 +20,9 @@ def self.update(klass, query, setter) # @param [Class] The model instance # @return [Time] The timestamp of when the document is locked until, nil if not locked. def self.locked_until(doc) - existing_query = { _id: doc.id, locked_until: { '$exists' => true } } - existing = doc.class.where(existing_query).limit(1).only(:locked_until).first - existing ? existing.locked_until : nil + existing_query = { _id: doc.id, doc.locked_until_field => { '$exists' => true } } + existing = doc.class.where(existing_query).limit(1).only(doc.locked_until_field).first + existing ? existing[doc.locked_until_field] : nil end end end diff --git a/mongoid-locker.gemspec b/mongoid-locker.gemspec index c41e7c0..c11aa6b 100644 --- a/mongoid-locker.gemspec +++ b/mongoid-locker.gemspec @@ -1,5 +1,4 @@ -# coding: utf-8 -lib = File.expand_path('../lib', __FILE__) +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'mongoid/locker/version' diff --git a/spec/mongoid-locker_spec.rb b/spec/mongoid-locker_spec.rb index 2cc8118..7c37b0e 100644 --- a/spec/mongoid-locker_spec.rb +++ b/spec/mongoid-locker_spec.rb @@ -5,332 +5,615 @@ def remove_class(klass) Object.send :remove_const, klass.to_s.to_sym end - before do - # recreate the class for each spec - class User - include Mongoid::Document - include Mongoid::Locker + shared_examples 'Mongoid::Locker is included' do + describe '#locked?' do + it "shouldn't be locked when created" do + expect(@user.locked?).to be false + end - field :account_balance, type: Integer # easier to test than Float - end + it 'should be true when locked' do + @user.with_lock do + expect(@user.locked?).to be true + end + end - @user = User.create! account_balance: 20 - end + it 'should respect the expiration' do + User.timeout_lock_after 1 - after do - User.delete_all - remove_class User - end + @user.with_lock do + sleep 2 + expect(@user.locked?).to be false + end + end - describe '#locked?' do - it "shouldn't be locked when created" do - expect(@user.locked?).to be false + it 'should be true for a different instance' do + @user.with_lock do + expect(User.first.locked?).to be true + end + end end - it 'should be true when locked' do - @user.with_lock do - expect(@user.locked?).to be true + describe '#has_lock?' do + it "shouldn't be has_lock when created" do + expect(@user.has_lock?).to be false end - end - it 'should respect the expiration' do - User.timeout_lock_after 1 + it 'should be true when has_lock' do + @user.with_lock do + expect(@user.has_lock?).to be true + end + end - @user.with_lock do - sleep 2 - expect(@user.locked?).to be false + it 'should respect the expiration' do + User.timeout_lock_after 1 + + @user.with_lock do + sleep 2 + expect(@user.has_lock?).to be false + end end - end - it 'should be true for a different instance' do - @user.with_lock do - expect(User.first.locked?).to be true + it 'should be false for a different instance' do + @user.with_lock do + expect(User.first.has_lock?).to be false + end end end - end - describe '#has_lock?' do - it "shouldn't be has_lock when created" do - expect(@user.has_lock?).to be false - end + describe '#with_lock' do + it 'should lock and unlock the user' do + @user.with_lock do + expect(@user).to be_locked + expect(User.first).to be_locked + end - it 'should be true when has_lock' do - @user.with_lock do - expect(@user.has_lock?).to be true + expect(@user).to_not be_locked + expect(@user.reload).to_not be_locked end - end - it 'should respect the expiration' do - User.timeout_lock_after 1 + it "shouldn't save the full document" do + @user.with_lock do + @user.account_balance = 10 + end - @user.with_lock do - sleep 2 - expect(@user.has_lock?).to be false + expect(@user.account_balance).to eq(10) + expect(User.first.account_balance).to eq(20) end - end - it 'should be false for a different instance' do - @user.with_lock do - expect(User.first.has_lock?).to be false + it 'should handle errors gracefully' do + expect do + @user.with_lock do + raise 'booyah!' + end + end.to raise_error 'booyah!' + + expect(@user.reload).to_not be_locked end - end - end - describe '#with_lock' do - it 'should lock and unlock the user' do - @user.with_lock do - expect(@user).to be_locked - expect(User.first).to be_locked + it 'should complain if trying to lock locked doc' do + @user.with_lock do + user_dup = User.first + + expect do + user_dup.with_lock do + raise "shouldn't get the lock" + end + end.to raise_error(Mongoid::Locker::LockError) + end end - expect(@user).to_not be_locked - expect(@user.reload).to_not be_locked - end + it 'should handle recursive calls' do + @user.with_lock do + @user.with_lock do + @user.account_balance = 10 + end + end - it "shouldn't save the full document" do - @user.with_lock do - @user.account_balance = 10 + expect(@user.account_balance).to eq(10) end - expect(@user.account_balance).to eq(10) - expect(User.first.account_balance).to eq(20) - end + it 'should wait until the lock times out, if desired' do + User.timeout_lock_after 1 - it 'should handle errors gracefully' do - expect { @user.with_lock do - fail 'booyah!' + user_dup = User.first + + user_dup.with_lock wait: true do + user_dup.account_balance = 10 + user_dup.save! + end end - }.to raise_error 'booyah!' - expect(@user.reload).to_not be_locked - end + expect(@user.reload.account_balance).to eq(10) + end - it 'should complain if trying to lock locked doc' do - @user.with_lock do - user_dup = User.first + it 'should, by default, reload the row after acquiring the lock' do + expect(@user).to receive(:reload) + @user.with_lock do + # no-op + end + end + + it 'should allow override of the default reload behavior' do + expect(@user).to_not receive(:reload) + @user.with_lock reload: false do + # no-op + end + end + + it 'should, by default, not retry' do + expect(@user).to receive(:acquire_lock).once.and_return(true) + @user.with_lock do + user_dup = User.first - expect { user_dup.with_lock do - fail "shouldn't get the lock" + # no-op end - }.to raise_error(Mongoid::Locker::LockError) + end end - end - it 'should handle recursive calls' do - @user.with_lock do + it 'should retry the number of times given, if desired' do + allow(@user).to receive(:acquire_lock).and_return(false) + allow(Mongoid::Locker::Wrapper).to receive(:locked_until).and_return(Time.now) + + expect(@user).to receive(:acquire_lock).exactly(6).times + expect do + @user.with_lock retries: 5 do + # no-op + end + end.to raise_error(Mongoid::Locker::LockError) + end + + it 'does not fail if the lock has been released between check and sleep time calculation' do + allow(@user).to receive(:acquire_lock).and_return(false) + allow(Mongoid::Locker::Wrapper).to receive(:locked_until).and_return(nil) + + expect(@user).to receive(:acquire_lock).exactly(2).times + expect do + @user.with_lock retries: 1 do + # no-op + end + end.to raise_error(Mongoid::Locker::LockError) + end + + it 'should, by default, when retrying, sleep until the lock expires' do + allow(@user).to receive(:acquire_lock).and_return(false) + allow(Mongoid::Locker::Wrapper).to receive(:locked_until).and_return(Time.now + 5.seconds) + allow(@user).to receive(:sleep) { |time| expect(time).to be_within(0.1).of(5) } + + expect do + @user.with_lock retries: 1 do + # no-op + end + end.to raise_error(Mongoid::Locker::LockError) + end + + it 'should sleep for the time given, if desired' do + allow(@user).to receive(:acquire_lock).and_return(false) + allow(@user).to receive(:sleep) { |time| expect(time).to be_within(0.1).of(3) } + + expect do + @user.with_lock(retries: 1, retry_sleep: 3) do + # no-op + end + end.to raise_error(Mongoid::Locker::LockError) + end + + it 'should override the default timeout' do + User.timeout_lock_after 1 + + expiration = (Time.now + 3).to_i + @user.with_lock timeout: 3 do + expect(@user[@user.locked_until_field].to_i).to eq(expiration) + end + end + + it 'should reload the document if it needs to wait for a lock' do + User.timeout_lock_after 1 + @user.with_lock do + user_dup = User.first + @user.account_balance = 10 + @user.save! + + expect(user_dup.account_balance).to eq(20) + user_dup.with_lock wait: true do + expect(user_dup.account_balance).to eq(10) + end end end - expect(@user.account_balance).to eq(10) - end + it 'should succeed for subclasses' do + class Admin < User + end - it 'should wait until the lock times out, if desired' do - User.timeout_lock_after 1 + admin = Admin.create! - @user.with_lock do - user_dup = User.first + admin.with_lock do + expect(admin).to be_locked + expect(Admin.first).to be_locked + end + + expect(admin).to_not be_locked + expect(admin.reload).to_not be_locked + + remove_class Admin + end - user_dup.with_lock wait: true do - user_dup.account_balance = 10 - user_dup.save! + context 'when a lock has timed out' do + before do + User.timeout_lock_after 1 + @user.with_lock do + expect(@user).to be_locked + expect(User.first).to be_locked + sleep 2 + end + end + it 'should remain unlocked' do + expect(@user).to_not receive(:unlock) + expect(@user).to_not be_locked + expect(@user.reload).to_not be_locked end end - expect(@user.reload.account_balance).to eq(10) + context 'when fields are not defined' do + before do + class Admin + include Mongoid::Document + include Mongoid::Locker + end + end + + after do + Admin.delete_all + remove_class Admin + end + + it 'should return error if locked_at field is not defined' do + Admin.field(:locked_until, type: Time) + admin = Admin.create! + + expect do + admin.with_lock do + # no-op + end + end.to raise_error(Mongoid::Errors::UnknownAttribute) + end + + it 'should return error if locked_until field is not defined' do + Admin.field(:locked_at, type: Time) + admin = Admin.create! + + expect do + admin.with_lock do + # no-op + end + end.to raise_error(Mongoid::Errors::UnknownAttribute) + end + end end - it 'should, by default, reload the row after acquiring the lock' do - expect(@user).to receive(:reload) - @user.with_lock do - # no-op + describe '#locked_at_field' do + it 'should be defined' do + expect(User.public_method_defined?(:locked_at_field)).to be_truthy + end + + it 'should return @@locked_at_field variable value' do + expect(@user.locked_at_field).to eq(User.class_variable_get(:@@locked_at_field)) end end - it 'should allow override of the default reload behavior' do - expect(@user).to_not receive(:reload) - @user.with_lock reload: false do - # no-op + describe '#locked_until_field' do + it 'should be defined' do + expect(User.public_method_defined?(:locked_until_field)).to be_truthy + end + + it 'should return @@locked_until_field variable value' do + expect(@user.locked_until_field).to eq(User.class_variable_get(:@@locked_until_field)) end end - it 'should, by default, not retry' do - expect(@user).to receive(:acquire_lock).once.and_return(true) - @user.with_lock do - user_dup = User.first + describe '.timeout_lock_after' do + it 'should ignore the lock if it has timed out' do + User.timeout_lock_after 1 - user_dup.with_lock do - # no-op + @user.with_lock do + user_dup = User.first + sleep 2 + + user_dup.with_lock do + user_dup.account_balance = 10 + user_dup.save! + end end + + expect(@user.reload.account_balance).to eq(10) + end + + it 'should be independent for different classes' do + class Account + include Mongoid::Document + include Mongoid::Locker + end + + User.timeout_lock_after 1 + Account.timeout_lock_after 2 + + expect(User.lock_timeout).to eq(1) + + remove_class Account end end - it 'should retry the number of times given, if desired' do - allow(@user).to receive(:acquire_lock).and_return(false) - allow(Mongoid::Locker::Wrapper).to receive(:locked_until).and_return(Time.now) + describe '.locked' do + it 'should return the locked documents' do + User.create! - expect(@user).to receive(:acquire_lock).exactly(6).times - expect { - @user.with_lock retries: 5 do - # no-op + @user.with_lock do + expect(User.locked.to_a).to eq([@user]) end - }.to raise_error(Mongoid::Locker::LockError) - end + end - it 'does not fail if the lock has been released between check and sleep time calculation' do - allow(@user).to receive(:acquire_lock).and_return(false) - allow(Mongoid::Locker::Wrapper).to receive(:locked_until).and_return(nil) + it 'shouldnt throw error while unlocking destroyed object' do + User.create! - expect(@user).to receive(:acquire_lock).exactly(2).times - expect { - @user.with_lock retries: 1 do - # no-op + @user.with_lock do + @user.destroy end - }.to raise_error(Mongoid::Locker::LockError) + end end - it 'should, by default, when retrying, sleep until the lock expires' do - allow(@user).to receive(:acquire_lock).and_return(false) - allow(Mongoid::Locker::Wrapper).to receive(:locked_until).and_return(Time.now + 5.seconds) - allow(@user).to receive(:sleep) { |time| expect(time).to be_within(0.1).of(5) } + describe '.unlocked' do + it 'should return the unlocked documents' do + user2 = User.create! - expect { - @user.with_lock retries: 1 do - # no-op + @user.with_lock do + expect(User.unlocked.to_a).to eq([user2]) end - }.to raise_error(Mongoid::Locker::LockError) + end end - it 'should sleep for the time given, if desired' do - allow(@user).to receive(:acquire_lock).and_return(false) - allow(@user).to receive(:sleep) { |time| expect(time).to be_within(0.1).of(3) } + describe '.locked_at_field' do + it 'should be defined' do + expect(User.singleton_methods).to include(:locked_at_field) + end - expect { - @user.with_lock(retries: 1, retry_sleep: 3) do - # no-op - end - }.to raise_error(Mongoid::Locker::LockError) + it 'should return @@locked_at_field variable value' do + expect(User.locked_at_field).to eq(User.class_variable_get(:@@locked_at_field)) + end end - it 'should override the default timeout' do - User.timeout_lock_after 1 + describe '.locked_until_field' do + it 'should be defined' do + expect(User.singleton_methods).to include(:locked_until_field) + end - expiration = (Time.now + 3).to_i - @user.with_lock timeout: 3 do - expect(@user.locked_until.to_i).to eq(expiration) + it 'should return @@locked_until_field variable value' do + expect(User.locked_until_field).to eq(User.class_variable_get(:@@locked_until_field)) end end - it 'should reload the document if it needs to wait for a lock' do - User.timeout_lock_after 1 + describe '.locker' do + it 'should set locked_at_field name' do + User.locker(locked_at_field: :locker_locked_at) - @user.with_lock do - user_dup = User.first + expect(User.locked_at_field).to eq(:locker_locked_at) + expect(User.locked_at_field).to_not eq(Mongoid::Locker.locked_at_field) + end - @user.account_balance = 10 - @user.save! + it 'should set locked_until_field name' do + User.locker(locked_until_field: :locker_locked_until) - expect(user_dup.account_balance).to eq(20) - user_dup.with_lock wait: true do - expect(user_dup.account_balance).to eq(10) - end + expect(User.locked_until_field).to eq(:locker_locked_until) + expect(User.locked_until_field).to_not eq(Mongoid::Locker.locked_until_field) end end - it 'should succeed for subclasses' do - class Admin < User + describe '::locked_at_field' do + it 'should be defined' do + expect(Mongoid::Locker.singleton_methods).to include(:locked_at_field) end - admin = Admin.create! + it '@locked_at_field variable should be defined' do + expect(Mongoid::Locker.instance_variable_defined?(:@locked_at_field)).to be_truthy + end + end - admin.with_lock do - expect(admin).to be_locked - expect(Admin.first).to be_locked + describe '::locked_at_field=' do + before do + @field_name = Mongoid::Locker.locked_at_field end - expect(admin).to_not be_locked - expect(admin.reload).to_not be_locked + after do + Mongoid::Locker.locked_at_field = @field_name + end - remove_class Admin + it 'should be defined' do + expect(Mongoid::Locker.singleton_methods).to include(:locked_at_field=) + end + + it 'should assign the value' do + Mongoid::Locker.locked_at_field = :dks17_locked_at + + expect(Mongoid::Locker.locked_at_field).to eq(:dks17_locked_at) + expect(Mongoid::Locker.locked_at_field).to eq(Mongoid::Locker.instance_variable_get(:@locked_at_field)) + end end - context 'when a lock has timed out' do - before do - User.timeout_lock_after 1 - @user.with_lock do - expect(@user).to be_locked - expect(User.first).to be_locked - sleep 2 - end + describe '::locked_until_field' do + it 'should be defined' do + expect(Mongoid::Locker.singleton_methods).to include(:locked_until_field) end - it 'should remain unlocked' do - expect(@user).to_not receive(:unlock) - expect(@user).to_not be_locked - expect(@user.reload).to_not be_locked + + it '@locked_until_field variable should be defined' do + expect(Mongoid::Locker.instance_variable_defined?(:@locked_until_field)).to be_truthy end end - end - describe '.timeout_lock_after' do - it 'should ignore the lock if it has timed out' do - User.timeout_lock_after 1 + describe '::locked_until_field=' do + before do + @field_name = Mongoid::Locker.locked_until_field + end - @user.with_lock do - user_dup = User.first - sleep 2 + after do + Mongoid::Locker.locked_until_field = @field_name + end - user_dup.with_lock do - user_dup.account_balance = 10 - user_dup.save! - end + it 'should be defined' do + expect(Mongoid::Locker.singleton_methods).to include(:locked_until_field=) end - expect(@user.reload.account_balance).to eq(10) + it 'should assign the value' do + Mongoid::Locker.locked_until_field = :dks17_locked_until + + expect(Mongoid::Locker.locked_until_field).to eq(:dks17_locked_until) + expect(Mongoid::Locker.locked_until_field).to eq(Mongoid::Locker.instance_variable_get(:@locked_until_field)) + end end - it 'should be independent for different classes' do - class Account + describe '::configure' do + it 'should be defined' do + expect(Mongoid::Locker.singleton_methods).to include(:configure) + end + + it 'should pass module name into block' do + Mongoid::Locker.configure do |config| + expect(config).to eq(Mongoid::Locker) + end + end + end + end + + context 'with default configuration' do + before do + # recreate the class for each spec + class User include Mongoid::Document include Mongoid::Locker - end - User.timeout_lock_after 1 - Account.timeout_lock_after 2 + field :locked_at, type: Time + field :locked_until, type: Time + field :account_balance, type: Integer # easier to test than Float + end - expect(User.lock_timeout).to eq(1) + @user = User.create! account_balance: 20 + end - remove_class Account + after do + User.delete_all + remove_class User end + + it_behaves_like 'Mongoid::Locker is included' end - describe '.locked' do - it 'should return the locked documents' do - User.create! + context 'with global configuration' do + before do + Mongoid::Locker.configure do |config| + config.locked_at_field = :global_locked_at + config.locked_until_field = :global_locked_until + end - @user.with_lock do - expect(User.locked.to_a).to eq([@user]) + class User + include Mongoid::Document + include Mongoid::Locker + + field :global_locked_at, type: Time + field :global_locked_until, type: Time + field :account_balance, type: Integer # easier to test than Float end + + @user = User.create! account_balance: 20 end - it 'shouldnt throw error while unlocking destroyed object' do - User.create! + after do + User.delete_all + remove_class User + end - @user.with_lock do - @user.destroy - end + it_behaves_like 'Mongoid::Locker is included' + + it '.locked_at_field should return global value' do + expect(User.locked_at_field).to eq(Mongoid::Locker.locked_at_field) + end + + it '.locked_until_field should return global value' do + expect(User.locked_until_field).to eq(Mongoid::Locker.locked_until_field) end end - describe '.unlocked' do - it 'should return the unlocked documents' do - user2 = User.create! + context 'with locker configuration' do + before do + Mongoid::Locker.configure do |config| + config.locked_at_field = :global_locked_at + config.locked_until_field = :global_locked_until + end + + class User + include Mongoid::Document + include Mongoid::Locker + + field :locker_locked_at, type: Time + field :locker_locked_until, type: Time + field :account_balance, type: Integer # easier to test than Float + + locker locked_at_field: :locker_locked_at, + locked_until_field: :locker_locked_until + end + + class Item + include Mongoid::Document + include Mongoid::Locker - @user.with_lock do - expect(User.unlocked.to_a).to eq([user2]) + field :global_locked_at, type: Time + field :global_locked_until, type: Time end + + @user = User.create! account_balance: 20 + @item = Item.create! + end + + after do + User.delete_all + remove_class User + + Item.delete_all + remove_class Item + end + + it_behaves_like 'Mongoid::Locker is included' + + it '.locked_at_field should return locker value' do + expect(User.locked_at_field).to eq(:locker_locked_at) + end + + it '.locked_until_field should return locker value' do + expect(User.locked_until_field).to eq(:locker_locked_until) + end + + it '#locked_at_field should return locker value' do + expect(@user.locked_at_field).to eq(:locker_locked_at) + end + + it '#locked_until_field should return locker value' do + expect(@user.locked_until_field).to eq(:locker_locked_until) + end + + it '.locked_at_field should return global value for other class' do + expect(Item.locked_at_field).to eq(Mongoid::Locker.locked_at_field) + end + + it '.locked_until_field should return global value for other class' do + expect(Item.locked_until_field).to eq(Mongoid::Locker.locked_until_field) + end + + it '#locked_at_field should return global value for other class' do + expect(@item.locked_at_field).to eq(Mongoid::Locker.locked_at_field) + end + + it '#locked_until_field should return global value for other class' do + expect(@item.locked_until_field).to eq(Mongoid::Locker.locked_until_field) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a48752a..6aa447e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,6 +4,8 @@ require 'rspec' require 'mongoid-locker' require 'mongoid/compatibility' +require 'byebug' +require 'pry-byebug' ENV['RACK_ENV'] = 'test'