diff --git a/docs/guide.pug b/docs/guide.pug
index c4f501f4cdd..39c021d6682 100644
--- a/docs/guide.pug
+++ b/docs/guide.pug
@@ -434,6 +434,7 @@ block content
- [useNestedStrict](#useNestedStrict)
- [validateBeforeSave](#validateBeforeSave)
- [versionKey](#versionKey)
+ - [optimisticConcurrency](#optimisticConcurrency)
- [collation](#collation)
- [selectPopulatedPaths](#selectPopulatedPaths)
- [skipVersioning](#skipVersioning)
@@ -891,9 +892,8 @@ block content
thing.save(); // { _somethingElse: 0, name: 'mongoose v3' }
```
- Note that Mongoose versioning is **not** a full [optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control)
- solution. Use [mongoose-update-if-current](https://github.com/eoin-obrien/mongoose-update-if-current)
- for OCC support. Mongoose versioning only operates on arrays:
+ Note that Mongoose's default versioning is **not** a full [optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control)
+ solution. Mongoose's default versioning only operates on arrays as shown below.
```javascript
// 2 copies of the same document
@@ -911,6 +911,8 @@ block content
await doc2.save();
```
+ If you need optimistic concurrency support for `save()`, you can set the [`optimisticConcurrency` option](#optimisticConcurrency)
+
Document versioning can also be disabled by setting the `versionKey` to
`false`.
_DO NOT disable versioning unless you [know what you are doing](http://aaronheckmann.blogspot.com/2012/06/mongoose-v3-part-1-versioning.html)._
@@ -946,6 +948,70 @@ block content
});
```
+
+
+ [Optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) is a strategy to ensure
+ the document you're updating didn't change between when you loaded it using `find()` or `findOne()`, and when
+ you update it using `save()`.
+
+ For example, suppose you have a `House` model that contains a list of `photos`, and a `status` that represents
+ whether this house shows up in searches. Suppose that a house that has status `'APPROVED'` must have at least
+ two `photos`. You might implement the logic of approving a house document as shown below:
+
+ ```javascript
+ async function markApproved(id) {
+ const house = await House.findOne({ _id });
+ if (house.photos.length < 2) {
+ throw new Error('House must have at least two photos!');
+ }
+
+ house.status = 'APPROVED';
+ await house.save();
+ }
+ ```
+
+ The `markApproved()` function looks right in isolation, but there might be a potential issue: what if another
+ function removes the house's photos between the `findOne()` call and the `save()` call? For example, the below
+ code will succeed:
+
+ ```javascript
+ const house = await House.findOne({ _id });
+ if (house.photos.length < 2) {
+ throw new Error('House must have at least two photos!');
+ }
+
+ const house2 = await House.findOne({ _id });
+ house2.photos = [];
+ await house2.save();
+
+ // Marks the house as 'APPROVED' even though it has 0 photos!
+ house.status = 'APPROVED';
+ await house.save();
+ ```
+
+ If you set the `optimisticConcurrency` option on the `House` model's schema, the above script will throw an
+ error.
+
+ ```javascript
+ const House = mongoose.model('House', Schema({
+ status: String,
+ photos: [String]
+ }, { optimisticConcurrency: true }));
+
+ const house = await House.findOne({ _id });
+ if (house.photos.length < 2) {
+ throw new Error('House must have at least two photos!');
+ }
+
+ const house2 = await House.findOne({ _id });
+ house2.photos = [];
+ await house2.save();
+
+ // Throws 'VersionError: No matching document found for id "..." version 0'
+ house.status = 'APPROVED';
+ await house.save();
+ ```
+
Sets a default [collation](https://docs.mongodb.com/manual/reference/collation/)
diff --git a/lib/model.js b/lib/model.js
index b6633d7ad80..da175b20e38 100644
--- a/lib/model.js
+++ b/lib/model.js
@@ -542,6 +542,11 @@ function operand(self, where, delta, data, val, op) {
// already marked for versioning?
if (VERSION_ALL === (VERSION_ALL & self.$__.version)) return;
+ if (self.schema.options.optimisticConcurrency) {
+ self.$__.version = VERSION_ALL;
+ return;
+ }
+
switch (op) {
case '$set':
case '$unset':
@@ -2950,10 +2955,10 @@ Model.findByIdAndRemove = function(id, options, callback) {
*
* // Insert one new `Character` document
* await Character.create({ name: 'Jean-Luc Picard' });
- *
+ *
* // Insert multiple new `Character` documents
* await Character.create([{ name: 'Will Riker' }, { name: 'Geordi LaForge' }]);
- *
+ *
* // Create a new character within a transaction. Note that you **must**
* // pass an array as the first parameter to `create()` if you want to
* // specify options.
diff --git a/lib/schema.js b/lib/schema.js
index c64e829b848..b7099cb8bac 100644
--- a/lib/schema.js
+++ b/lib/schema.js
@@ -69,7 +69,7 @@ let id = 0;
* - [typePojoToMixed](/docs/guide.html#typePojoToMixed) - boolean - defaults to true. Determines whether a type set to a POJO becomes a Mixed path or a Subdocument
* - [useNestedStrict](/docs/guide.html#useNestedStrict) - boolean - defaults to false
* - [validateBeforeSave](/docs/guide.html#validateBeforeSave) - bool - defaults to `true`
- * - [versionKey](/docs/guide.html#versionKey): string - defaults to "__v"
+ * - [versionKey](/docs/guide.html#versionKey): string or object - defaults to "__v"
* - [collation](/docs/guide.html#collation): object - defaults to null (which means use no collation)
* - [selectPopulatedPaths](/docs/guide.html#selectPopulatedPaths): boolean - defaults to `true`
* - [skipVersioning](/docs/guide.html#skipVersioning): object - paths to exclude from versioning
@@ -404,6 +404,7 @@ Schema.prototype.defaultOptions = function(options) {
bufferCommands: true,
capped: false, // { size, max, autoIndexId }
versionKey: '__v',
+ optimisticConcurrency: false,
discriminatorKey: '__t',
minimize: true,
autoIndex: null,
@@ -423,6 +424,10 @@ Schema.prototype.defaultOptions = function(options) {
options.read = readPref(options.read);
}
+ if (options.optimisticConcurrency && !options.versionKey) {
+ throw new MongooseError('Must set `versionKey` if using `optimisticConcurrency`');
+ }
+
return options;
};
diff --git a/test/versioning.test.js b/test/versioning.test.js
index ec3cd6ea64d..538e49c177c 100644
--- a/test/versioning.test.js
+++ b/test/versioning.test.js
@@ -593,4 +593,26 @@ describe('versioning', function() {
}).
catch(done);
});
+
+ it('optimistic concurrency (gh-9001) (gh-5424)', function() {
+ const schema = new Schema({ name: String }, { optimisticConcurrency: true });
+ const M = db.model('Test', schema);
+
+ const doc = new M({ name: 'foo' });
+
+ return co(function*() {
+ yield doc.save();
+
+ const d1 = yield M.findOne();
+ const d2 = yield M.findOne();
+
+ d1.name = 'bar';
+ yield d1.save();
+
+ d2.name = 'qux';
+ const err = yield d2.save().then(() => null, err => err);
+ assert.ok(err);
+ assert.equal(err.name, 'VersionError');
+ });
+ });
});