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 }); ``` +

option: optimisticConcurrency

+ + [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(); + ``` +

option: collation

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'); + }); + }); });