Skip to content

About

Document-level locking for MongoDB via Mongoid

Resources

Contributing

Stars

78 stars

Watchers

6 watching

Forks

Repository files navigation

Mongoid-Locker

Gem Version CI Coverage Status

Document-level optimistic locking for MongoDB via Mongoid. Mongoid-Locker is an easy way to ensure only one process can perform a certain operation on a document at a time.

NOTE: Since version 2 Mongoid-Locker relies on MongoDB server time and not current client time, and does not perform any time calculation to get lock or release it. The basis of the current version are unique name of locking and time is set by MongoDB.

NOTE: Please refer to 1-x-stable branch for 1.x.x documentation. See the UPGRADING guide and CHANGELOG for an overview of the changes.

Tested against:

  • MRI: 3.x, 4.x
  • JRuby 9.1.17.0, 9.2.11.1
  • Mongoid: 5, 6, 7, 8, 9

See .github/workflows/test.yml for the latest test matrix.

Usage

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:

class User
  include Mongoid::Document
  include Mongoid::Locker

  field :locking_name, type: String
  field :locked_at, type: Time

  field :age, type: Integer
end

Then, execute any code you like in a block like so:

user.with_lock do
  user.age = 17
  user.save!
end

The #with_lock function takes an optional handful of options, so make sure to take a look.

Note that these locks are only enforced when using #with_lock, not at the database level. It's useful for transactional operations, where you can make atomic modification of the document with checks. For example, you could deduct a purchase from a user's balance ... unless they are broke.

More in-depth method documentation can be found at RubyDoc.info.

Updating Embedded Documents

Lock the root document and retrieve embedded documents inside the with_lock block. By default, acquiring a lock reloads the root document before yielding, replacing its cached embedded documents. An embedded document reference obtained before locking is therefore stale: changing it and saving the reloaded parent does not persist that child's changes. Unsaved changes made before acquiring the lock can also be discarded by the reload.

parent.with_lock do
  child = parent.children.first
  child.status = 2
  parent.option = 4
  parent.save!
end

If you select a particular child before locking, keep its ID and retrieve it again inside the block with parent.children.find(child_id). This preserves the default reload, so the update works with the current persisted state.

Alternatively, parent.with_lock(reload: false) preserves existing in-memory embedded references, but does not refresh the parent or its children after acquiring the lock. Use it only when you deliberately want to work with the state already loaded; acquiring the lock does not make that state current.

Customizable :locking_name and :locked_at field names

By default, Locker uses fields with :locking_name and :locked_at names which should be defined in a model.

class User
  include Mongoid::Document
  include Mongoid::Locker

  field :locking_name, type: String
  field :locked_at, type: Time
end

Use Mongoid::Locker.configure to setup parameters which used by Locker for all models where it's included.

Mongoid::Locker.configure do |config|
  config.locking_name_field = :global_locking_name
  config.locked_at_field    = :global_locked_at
end

class User
  include Mongoid::Document
  include Mongoid::Locker

  field :global_locking_name, type: String
  field :global_locked_at, type: Time
end

The locker method in your model accepts options to setup parameters for the model.

class User
  include Mongoid::Document
  include Mongoid::Locker

  field :locker_locking_name, type: String
  field :locker_locked_at, type: Time

  locker locked_at_field: :locker_locking_name,
         locked_at_field: :locker_locked_at
end

Available parameters for Mongoid::Locker, a class where it's included

parameter default options description
locking_name_field :locking_name any field name field where name of locking is storing, must be of type String
locked_at_field :locked_at any field name field where it is storing the time of beginning a lock of a document, must be of type Time
lock_timeout 5 within this time (in seconds) a document is considered as locked
locker_write_concern { w: 1 } see MongoDB Write Concern a write concern only used for lock and unlock operations
maximum_backoff 60.0 the highest timeout (in seconds) between retires to lock a document, reaching that value #with_lock method raises Mongoid::Locker::Errors::DocumentCouldNotGetLock
backoff_algorithm :exponential_backoff :locked_at_backoff or custom algorithm algorithm used for timeout calculating between retries to lock a document
locking_name_generator :secure_locking_name custom generator generator used to generate unique name of a lock

For instances of a class where Mongoid::Locker is included, all parameters of a class are available for reading.

  document.lock_timeout
  #=> 5

Custom :backoff_algorithm and :locking_name_generator

A method which is defined in Mongoid::Locker are available in a class where it is included.

Method #with_lock passes to the methods a document to which apply #with_lock and a hash of options. The hash may look like this:

  { retries: Infinity, reload: true, attempt: 0, locking_name: "71c1ccd4-72d9-4a83-bbed-adf65803bd5d" }

A custom backoff algorithmoff must return a value more or equal maximum_backoff value to force #with_lock quit trying to lock a document, otherwise #with_lock will be trying to lock a document INFINITY times.

Mongoid::Locker.configure do |config|
  config.backoff_algorithm = :custom_backoff
end

module Mongoid
  module Locker
    def self.custom_backoff(doc, _opts)
      rand > 0.5 ? 5 : doc.maximum_backoff
    end
  end
end

A custom locking name generator must return a string to secure uniqueness name of locking.

class User
  include Mongoid::Document
  include Mongoid::Locker

  locker locking_name_generator: :custom_locking_name

  field :locker_locking_name, type: String
  field :locker_locked_at, type: Time

  def self.custom_locking_name(_doc, _opts)
    SecureRandom.uuid
  end

Testing with RSpec

Please see examples in test_examples_spec.rb file.

Sharded Collections

Locking is integration-tested through a real mongos router with collections sharded by ranged and hashed _id keys. These tests cover acquiring and releasing locks, contention, expired locks, and exceptions. They use a single-shard cluster; they do not test chunk migrations or arbitrary non-_id shard keys.

To run the sharding tests locally, start the disposable cluster and wait until its shard is registered:

docker run -d --name locker-sharding -p 127.0.0.1:27028:27017 \
  -v "$PWD/spec/integration/sharding/start.sh:/start.sh:ro" \
  mongo:8.0 bash /start.sh
docker exec locker-sharding mongosh --quiet --eval 'db.adminCommand({listShards:1})'
SHARDED_MONGODB_URI=mongodb://localhost:27028/mongoid_locker_sharding_test \
  bundle exec rspec spec/integration/sharding
docker stop locker-sharding
docker rm locker-sharding

Use a dedicated test database: these tests drop and recreate their collection. Without SHARDED_MONGODB_URI, they are skipped by the normal suite. CI starts the cluster and runs them separately on MongoDB 6.0 and 8.0.

Copyright & License

Copyright (c) 2012-2020 Aidan Feldman & Contributors

MIT License, see LICENSE for more information.

About

Document-level locking for MongoDB via Mongoid

Resources

Contributing

Stars

78 stars

Watchers

6 watching

Forks

Releases

Packages

Used by

Contributors

Languages