├── lib ├── mongoid_retry.rb ├── mongoid_retry │ └── version.rb └── mongoid │ └── mongoid_retry.rb ├── gemfiles └── mongoid-2 ├── Gemfile ├── Rakefile ├── .travis.yml ├── .gitignore ├── spec ├── support │ └── models │ │ └── thing.rb ├── spec_helper.rb └── retry_spec.rb ├── LICENSE ├── mongoid_retry.gemspec └── README.md /lib/mongoid_retry.rb: -------------------------------------------------------------------------------- 1 | require "mongoid/mongoid_retry" -------------------------------------------------------------------------------- /lib/mongoid_retry/version.rb: -------------------------------------------------------------------------------- 1 | module MongoidRetry 2 | VERSION = "0.0.3" 3 | end 4 | -------------------------------------------------------------------------------- /gemfiles/mongoid-2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | gem "mongoid", "~> 2.0" -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in mongoid_retry.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1.0 4 | - 2.0.0 5 | - 1.9.3 6 | services: mongodb 7 | gemfile: 8 | - Gemfile 9 | - gemfiles/mongoid-2 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /spec/support/models/thing.rb: -------------------------------------------------------------------------------- 1 | class Thing 2 | include Mongoid::Document 3 | include Mongoid::MongoidRetry 4 | 5 | field :name 6 | field :color 7 | field :shape 8 | 9 | if Mongoid::VERSION < '3' 10 | index :name, unique: true 11 | index ([[:color, Mongo::ASCENDING], [:shape, Mongo::DESCENDING]]), unique: true 12 | else 13 | index({name: 1}, {unique: true}) 14 | index({color: 1, shape: -1}, {unique: true}) 15 | end 16 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | require 'mongoid' 5 | require 'mongoid/mongoid_retry' 6 | require 'database_cleaner' 7 | 8 | require 'rspec' 9 | 10 | Mongoid.configure do |config| 11 | if Mongoid::VERSION >= "3" 12 | config.connect_to('mongoid_retry_test') 13 | else 14 | config.master = Mongo::Connection.new.db('mongoid_retry_test') 15 | config.allow_dynamic_fields = false 16 | end 17 | end 18 | 19 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 20 | 21 | RSpec.configure do |config| 22 | config.mock_with :rspec 23 | config.after :each do 24 | DatabaseCleaner.clean 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Travis Dahlke 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /mongoid_retry.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/mongoid_retry/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Travis Dahlke"] 6 | gem.email = ["travis.dahlke@tstmedia.com"] 7 | gem.description = %q{Provides a 'save_and_retry' method that will attempt to save a document and, if a duplicate key error is thrown by mongodb, will update the existing document instead of failing.} 8 | gem.summary = %q{Catch mongo duplicate key errors and retry} 9 | gem.homepage = "http://www.github.com/travisdahlke/mongoid_retry" 10 | 11 | gem.files = `git ls-files`.split($\) 12 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 13 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 14 | gem.name = "mongoid_retry" 15 | gem.require_paths = ["lib"] 16 | gem.version = MongoidRetry::VERSION 17 | 18 | gem.add_runtime_dependency "mongoid", ['> 2.0'] 19 | gem.add_development_dependency "rspec" 20 | gem.add_development_dependency "database_cleaner" 21 | gem.add_development_dependency('rake', ['>= 0.9.2']) 22 | end 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MongoidRetry [![Build Status](https://travis-ci.org/travisdahlke/mongoid_retry.png?branch=master)](https://travis-ci.org/travisdahlke/mongoid_retry) [![Gem Version](https://badge.fury.io/rb/mongoid_retry.svg)](http://badge.fury.io/rb/mongoid_retry) 2 | 3 | Overcome duplicate key errors in MongoDB by catching the exception, finding the existing document, and updating it instead. 4 | Compatible with Mongoid 2 and 3. 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | gem 'mongoid_retry' 11 | 12 | And then execute: 13 | 14 | $ bundle 15 | 16 | Or install it yourself as: 17 | 18 | $ gem install mongoid_retry 19 | 20 | ## Usage 21 | 22 | ``` 23 | class Fruit 24 | include Mongoid::Document 25 | include Mongoid::MongoidRetry 26 | 27 | field :name 28 | index :type, unique: true 29 | end 30 | ``` 31 | 32 | Fruit.new(type: 'apple').save_and_retry 33 | 34 | `#save_and_retry` takes a hash of options: 35 | 36 | - `:retries` Specifies the number of times to retry before failing. Defaults to 3. 37 | - `:allow_delete` If true, this will delete any conflicting documents if a duplicate key error is encountered. 38 | 39 | 40 | ## Contributing 41 | 42 | 1. Fork it 43 | 2. Create your feature branch (`git checkout -b my-new-feature`) 44 | 3. Commit your changes (`git commit -am 'Added some feature'`) 45 | 4. Push to the branch (`git push origin my-new-feature`) 46 | 5. Create new Pull Request 47 | -------------------------------------------------------------------------------- /lib/mongoid/mongoid_retry.rb: -------------------------------------------------------------------------------- 1 | require "mongoid_retry/version" 2 | 3 | module Mongoid 4 | module MongoidRetry 5 | 6 | DUPLICATE_KEY_ERROR_CODES = [11000,11001] 7 | MAX_RETRIES = 3 8 | 9 | def self.is_a_duplicate_key_error?(exception) 10 | DUPLICATE_KEY_ERROR_CODES.include?(exception.code) 11 | end 12 | 13 | # Catch a duplicate key error 14 | def save_and_retry(options = {}) 15 | begin 16 | result = with(safe: true).save! 17 | result 18 | rescue Mongo::Error::OperationFailure => e 19 | result = retry_if_duplicate_key_error(e, options) 20 | result 21 | end 22 | end 23 | 24 | def retry_if_duplicate_key_error(e, options) 25 | retries = options.fetch(:retries, MAX_RETRIES) 26 | if ::Mongoid::MongoidRetry.is_a_duplicate_key_error?(e) && retries > 0 27 | keys = duplicate_key(e) 28 | if (duplicate = find_duplicate(keys)) 29 | if options[:allow_delete] 30 | duplicate.delete 31 | save_and_retry(options) 32 | else 33 | update_document!(duplicate, options.merge(retries: retries - 1)) 34 | self.attributes = duplicate.attributes.except(:_id) 35 | end 36 | end 37 | else 38 | raise e 39 | end 40 | end 41 | 42 | private 43 | 44 | def find_duplicate(keys) 45 | self.class.where(keys).first 46 | end 47 | 48 | # [11000]: E11000 duplicate key error collection: sn_test_master.subseason_player_stats index: stat_module_id_1_subseason_id_1_team_id_1_player_id_1 dup key: { stat_module_id: ObjectId('65e5fb893349e7d9482bc2cf'), subseason_id: 2, team_id: 1, player_id: 5 } (on localhost:27017, legacy retry, attempt 1) 49 | def duplicate_key(exception) 50 | str = exception.message[/\{[^{}]+\}/,0] 51 | if str 52 | str.gsub!('ObjectId', 'BSON::ObjectId').gsub!('null', 'nil') 53 | eval(str) 54 | end 55 | end 56 | 57 | def update_document!(duplicate, options = {}) 58 | attributes.except("_id").each_pair do |key, value| 59 | duplicate[key] = value 60 | end 61 | duplicate.save_and_retry(options) 62 | end 63 | 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/retry_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::MongoidRetry do 4 | 5 | before(:all) do 6 | Thing.create_indexes 7 | end 8 | 9 | describe "#save_and_retry" do 10 | describe "when a document with the same key exists" do 11 | before(:each) do 12 | Thing.create(name: 'apple', color: 'red') 13 | end 14 | 15 | subject { Thing.new(name: 'apple', color: 'green') } 16 | 17 | it "should not raise an exception" do 18 | subject.save_and_retry 19 | end 20 | 21 | it "should find and update the document" do 22 | subject.save_and_retry 23 | Thing.all.last.color.should == 'green' 24 | end 25 | 26 | it "should not create a duplicate document" do 27 | subject.save_and_retry 28 | Thing.count.should == 1 29 | end 30 | end 31 | 32 | describe "with compound indexes" do 33 | before(:each) do 34 | Thing.create(name: 'apple', color: 'red', shape: 'round') 35 | end 36 | 37 | subject { Thing.new(name: 'grapefruit', color: 'red', shape: 'round') } 38 | 39 | it "should not raise an exception" do 40 | subject.save_and_retry 41 | end 42 | 43 | it "should find and update the document" do 44 | subject.save_and_retry 45 | Thing.all.last.name == 'block' 46 | end 47 | 48 | it "should not create a duplicate document" do 49 | subject.save_and_retry 50 | Thing.count.should == 1 51 | end 52 | end 53 | 54 | describe "with conflicting documents" do 55 | before(:each) do 56 | Thing.create(name: 'banana', color: 'yellow') 57 | Thing.create(name: 'apple', color: 'red') 58 | end 59 | 60 | subject { Thing.new(name: 'banana', color: 'red') } 61 | 62 | it "should raise error" do 63 | expect { subject.save_and_retry }.to raise_error 64 | end 65 | 66 | it "should delete conflicting document" do 67 | subject.save_and_retry(allow_delete: true) 68 | expect(Thing.count).to eq(1) 69 | end 70 | 71 | it "should save the new document" do 72 | subject.save_and_retry(allow_delete: true) 73 | expect(Thing.all.last.name).to eq('banana') 74 | expect(Thing.all.last.color).to eq('red') 75 | end 76 | 77 | end 78 | 79 | end 80 | 81 | end 82 | --------------------------------------------------------------------------------