Skip to content

Commit

Permalink
feat(schema+model): add optimisticConcurrency option to use OCC for…
Browse files Browse the repository at this point in the history
… `save()`

Fix #9001 #5424
  • Loading branch information
vkarpov15 committed Jul 27, 2020
1 parent 75ba615 commit 6a7b275
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 6 deletions.
72 changes: 69 additions & 3 deletions docs/guide.pug
Expand Up @@ -434,6 +434,7 @@ block content
- [useNestedStrict](#useNestedStrict)
- [validateBeforeSave](#validateBeforeSave)
- [versionKey](#versionKey)
- [optimisticConcurrency](#optimisticConcurrency)
- [collation](#collation)
- [selectPopulatedPaths](#selectPopulatedPaths)
- [skipVersioning](#skipVersioning)
Expand Down Expand Up @@ -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
Expand All @@ -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)._
Expand Down Expand Up @@ -946,6 +948,70 @@ block content
});
```

<h3 id="optimisticConcurrency"><a href="#optimisticConcurrency">option: optimisticConcurrency</a></h3>

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

<h3 id="collation"><a href="#collation">option: collation</a></h3>

Sets a default [collation](https://docs.mongodb.com/manual/reference/collation/)
Expand Down
9 changes: 7 additions & 2 deletions lib/model.js
Expand Up @@ -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':
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion lib/schema.js
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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;
};

Expand Down
22 changes: 22 additions & 0 deletions test/versioning.test.js
Expand Up @@ -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');
});
});
});

0 comments on commit 6a7b275

Please sign in to comment.