A Pattern for handling concurrent edits to a MongoDB Document

Lately I’ve been working on converting my space strategy game (Astriarch) to multiplayer using Node.js, Mongodb, and Mongoose.

One problem I faced while making the game multiplayer is handling concurrent edits to the mongo game documents. Here is the scenerio: two players decide to send ships (edit the document) at the same time. If we are lucky, there are no problems, however there is a chance that both players get the latest version of the document one after the other (before either one of them has time to save the document), then concurrently edit and save the document. What this means is that the last person to save the document wins, the first person’s edits are lost.

This problem can be handled in a couple of ways:

  1. Change the model so that anytime there is the possibility of concurrent edits to the document, a new collection (or model) is created for each user’s edits, then at some point in the future those edits are merged into the document when it is guaranteed that only one process is modifying the document.
  2. Use a nonce field in your document to protect against concurrent edits as described here.

The problem with option #1 is that it is more difficult to implement, it means making your model and processing more complicated, and you still need to guarantee that at some point you can safely merge in the changes to your document by ensuring only one thread/process is modifying the document at that time.

The key to option #2 is that a user will only be able to modify the document if the nonce field has not been changed since that user last fetched the document, this is done by providing the nonce within the update command.

models.PostModel.update({"_id":postId, "nonce":doc.nonce}, data, callback);

Depending on your application, if you are not able to update the document because someone else has updated it, you may simply want to return an error for the user with the latest document so that the user may choose to perform the edits and try to save again.

For my application, I wanted to make a generic function for handling concurrent edits to a mongo document, that way I could leverage this function in each context where edits to the document had to be made.

Here is a Github Gist of the “savePostByIdWithConcurrencyProtection” function and a sample of how it is used:

var mongoose = require("mongoose");
var ObjectId = mongoose.Schema.Types.ObjectId;
//create schema for a post
var PostSchema = new mongoose.Schema({
nonce: ObjectId, //this is used for protecting against concurrent edits: http://docs.mongodb.org/ecosystem/use-cases/metadata-and-asset-management/
name: String,
dateCreated: { type: Date, default: Date.now },
dateLastChanged: { type: Date, default: Date.now },
postData: mongoose.Schema.Types.Mixed
});
//compile schema to model
exports.PostModel = db.model('post', PostSchema);
var postService = require('./postService');
exports.AppendPostText = function(postId, text, callback){
saveGameByIdWithConcurrencyProtection(postId, function(doc, cb){
doc.postData.text += text;
cb(null, data);
}, 0, function(err, doc){
callback(err, doc);
});
};
var mongoose = require("mongoose");
var models = require("./documentModel");
/*
This method uses the nonce field in the document to protect against concurrent edits on the post document
http://docs.mongodb.org/ecosystem/use-cases/metadata-and-asset-management/#create-and-edit-content-nodes
transformFunction will be given the post doc and should callback the data to update:
transformFunction(doc, callback)
*/
var savePostByIdWithConcurrencyProtection = function(postId, transformFunction, retries, callback){
FindPostById(postId, function(err, doc){
if(err || !doc){
callback(err || "Unable to find post in savePostByIdWithConcurrencyProtection");
return;
}
transformFunction(doc, function(err, data){
if(err){
callback(err);
return;
}
data.nonce = new mongoose.Types.ObjectId;
//setTimeout(function(){ //setTimeout for testing concurrent edits
models.PostModel.update({"_id":postId, "nonce":doc.nonce}, data, function(err, numberAffected, raw){
if(err){
callback(err);
return;
}
//console.log("savePostByIdWithConcurrencyProtection: ", numberAffected, raw);
if(!numberAffected && retries < 10){
//we weren't able to update the doc because someone else modified it first, retry
console.log("Unable to savePostByIdWithConcurrencyProtection, retrying ", retries);
//retry with a little delay
setTimeout(function(){
savePostByIdWithConcurrencyProtection(postId, transformFunction, (retries + 1), callback);
}, 20);
} else if(retries >= 10){
//there is probably something wrong, just return an error
callback("Couldn't update document after 10 retries in savePostByIdWithConcurrencyProtection");
} else {
FindPostById(postId, callback);
}
});
//}, 5000);
});
});
};
var FindPostById = function(id, callback){
//search for an existing post
models.PostModel.findOne({"_id":id}, function(err, doc){
if(err){
console.error("FindPostById:", err);
} else if(!doc){
var msg = "Could Not FindPostById:" + id;
console.error(msg);
return callback(msg);
}
callback(err, doc);
});
};
view raw postService.js hosted with ❤ by GitHub

Notice how the savePostByIdWithConcurrencyProtection takes a transform function that it will execute to perform the actual work of the edit on the document object, and then handles the rest of the logic of performing the update while ensuring the nonce is used in the update as well as recursively retrying until some limit is reached.

You can see an example of how to use this function in the AppendPostText method where the transform function simply appends the text passed into the function to the document.

This pattern allows reuse of the same complex concurrent edit protection and retry logic in many different areas of my application.

A personal blog by Matt Palmerlee about software development, coding, leadership, and much more. Checkout my resume.)
Feel free to reach out to me using these channels© 2019