Skip to content

Commit 09bdf11

Browse files
committed
Merge branch '8.4' into vkarpov15/gh-13772
2 parents 792bcac + 5103366 commit 09bdf11

12 files changed

+133
-12
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
8.3.5 / 2024-05-15
2+
==================
3+
* fix(query): shallow clone $or, $and if merging onto empty query filter #14580 #14567
4+
* types(model+query): pass TInstanceMethods to QueryWithHelpers so populated docs have methods #14581 #14574
5+
* docs(typescript): clarify that setting THydratedDocumentType on schemas is necessary for correct method context #14575 #14573
6+
17
8.3.4 / 2024-05-06
28
==================
39
* perf(document): avoid cloning options using spread operator for perf reasons #14565 #14394

docs/transactions.md

+28-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# Transactions in Mongoose
22

3-
[Transactions](https://www.mongodb.com/transactions) are new in MongoDB
4-
4.0 and Mongoose 5.2.0. Transactions let you execute multiple operations
5-
in isolation and potentially undo all the operations if one of them fails.
3+
[Transactions](https://www.mongodb.com/transactions) let you execute multiple operations in isolation and potentially undo all the operations if one of them fails.
64
This guide will get you started using transactions with Mongoose.
75

86
<h2 id="getting-started-with-transactions"><a href="#getting-started-with-transactions">Getting Started with Transactions</a></h2>
@@ -86,6 +84,33 @@ Below is an example of executing an aggregation within a transaction.
8684
[require:transactions.*aggregate]
8785
```
8886

87+
<h2 id="asynclocalstorage"><a href="#asynclocalstorage">Using AsyncLocalStorage</a></h2>
88+
89+
One major pain point with transactions in Mongoose is that you need to remember to set the `session` option on every operation.
90+
If you don't, your operation will execute outside of the transaction.
91+
Mongoose 8.4 is able to set the `session` operation on all operations within a `Connection.prototype.transaction()` executor function using Node's [AsyncLocalStorage API](https://nodejs.org/api/async_context.html#class-asynclocalstorage).
92+
Set the `transactionAsyncLocalStorage` option using `mongoose.set('transactionAsyncLocalStorage', true)` to enable this feature.
93+
94+
```javascript
95+
mongoose.set('transactionAsyncLocalStorage', true);
96+
97+
const Test = mongoose.model('Test', mongoose.Schema({ name: String }));
98+
99+
const doc = new Test({ name: 'test' });
100+
101+
// Save a new doc in a transaction that aborts
102+
await connection.transaction(async() => {
103+
await doc.save(); // Notice no session here
104+
throw new Error('Oops');
105+
}).catch(() => {});
106+
107+
// false, `save()` was rolled back
108+
await Test.exists({ _id: doc._id });
109+
```
110+
111+
With `transactionAsyncLocalStorage`, you no longer need to pass sessions to every operation.
112+
Mongoose will add the session by default under the hood.
113+
89114
<h2 id="advanced-usage"><a href="#advanced-usage">Advanced Usage</a></h2>
90115

91116
Advanced users who want more fine-grained control over when they commit or abort transactions

lib/aggregate.js

+5
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,11 @@ Aggregate.prototype.exec = async function exec() {
10221022
applyGlobalMaxTimeMS(this.options, model.db.options, model.base.options);
10231023
applyGlobalDiskUse(this.options, model.db.options, model.base.options);
10241024

1025+
const asyncLocalStorage = this.model()?.db?.base.transactionAsyncLocalStorage?.getStore();
1026+
if (!this.options.hasOwnProperty('session') && asyncLocalStorage?.session != null) {
1027+
this.options.session = asyncLocalStorage.session;
1028+
}
1029+
10251030
if (this.options && this.options.cursor) {
10261031
return new AggregationCursor(this);
10271032
}

lib/connection.js

+10-3
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@ Connection.prototype.startSession = async function startSession(options) {
531531
Connection.prototype.transaction = function transaction(fn, options) {
532532
return this.startSession().then(session => {
533533
session[sessionNewDocuments] = new Map();
534-
return session.withTransaction(() => _wrapUserTransaction(fn, session), options).
534+
return session.withTransaction(() => _wrapUserTransaction(fn, session, this.base), options).
535535
then(res => {
536536
delete session[sessionNewDocuments];
537537
return res;
@@ -550,9 +550,16 @@ Connection.prototype.transaction = function transaction(fn, options) {
550550
* Reset document state in between transaction retries re: gh-13698
551551
*/
552552

553-
async function _wrapUserTransaction(fn, session) {
553+
async function _wrapUserTransaction(fn, session, mongoose) {
554554
try {
555-
const res = await fn(session);
555+
const res = mongoose.transactionAsyncLocalStorage == null
556+
? await fn(session)
557+
: await new Promise(resolve => {
558+
mongoose.transactionAsyncLocalStorage.run(
559+
{ session },
560+
() => resolve(fn(session))
561+
);
562+
});
556563
return res;
557564
} catch (err) {
558565
_resetSessionDocuments(session);

lib/model.js

+7
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,11 @@ Model.prototype.$__handleSave = function(options, callback) {
297297
}
298298

299299
const session = this.$session();
300+
const asyncLocalStorage = this[modelDbSymbol].base.transactionAsyncLocalStorage?.getStore();
300301
if (!saveOptions.hasOwnProperty('session') && session != null) {
301302
saveOptions.session = session;
303+
} else if (asyncLocalStorage?.session != null) {
304+
saveOptions.session = asyncLocalStorage.session;
302305
}
303306
if (this.$isNew) {
304307
// send entire doc
@@ -3561,6 +3564,10 @@ Model.bulkWrite = async function bulkWrite(ops, options) {
35613564
}
35623565

35633566
const validations = ops.map(op => castBulkWrite(this, op, options));
3567+
const asyncLocalStorage = this.db.base.transactionAsyncLocalStorage?.getStore();
3568+
if ((!options || !options.hasOwnProperty('session')) && asyncLocalStorage?.session != null) {
3569+
options = { ...options, session: asyncLocalStorage.session };
3570+
}
35643571

35653572
let res = null;
35663573
if (ordered) {

lib/mongoose.js

+14-2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ require('./helpers/printJestWarning');
3838

3939
const objectIdHexRegexp = /^[0-9A-Fa-f]{24}$/;
4040

41+
const { AsyncLocalStorage } = require('node:async_hooks');
42+
4143
/**
4244
* Mongoose constructor.
4345
*
@@ -101,6 +103,10 @@ function Mongoose(options) {
101103
}
102104
this.Schema.prototype.base = this;
103105

106+
if (options?.transactionAsyncLocalStorage) {
107+
this.transactionAsyncLocalStorage = new AsyncLocalStorage();
108+
}
109+
104110
Object.defineProperty(this, 'plugins', {
105111
configurable: false,
106112
enumerable: true,
@@ -270,15 +276,21 @@ Mongoose.prototype.set = function(key, value) {
270276

271277
if (optionKey === 'objectIdGetter') {
272278
if (optionValue) {
273-
Object.defineProperty(mongoose.Types.ObjectId.prototype, '_id', {
279+
Object.defineProperty(_mongoose.Types.ObjectId.prototype, '_id', {
274280
enumerable: false,
275281
configurable: true,
276282
get: function() {
277283
return this;
278284
}
279285
});
280286
} else {
281-
delete mongoose.Types.ObjectId.prototype._id;
287+
delete _mongoose.Types.ObjectId.prototype._id;
288+
}
289+
} else if (optionKey === 'transactionAsyncLocalStorage') {
290+
if (optionValue && !_mongoose.transactionAsyncLocalStorage) {
291+
_mongoose.transactionAsyncLocalStorage = new AsyncLocalStorage();
292+
} else if (!optionValue && _mongoose.transactionAsyncLocalStorage) {
293+
delete _mongoose.transactionAsyncLocalStorage;
282294
}
283295
}
284296
}

lib/query.js

+5
Original file line numberDiff line numberDiff line change
@@ -1949,6 +1949,11 @@ Query.prototype._optionsForExec = function(model) {
19491949
// Apply schema-level `writeConcern` option
19501950
applyWriteConcern(model.schema, options);
19511951

1952+
const asyncLocalStorage = this.model?.db?.base.transactionAsyncLocalStorage?.getStore();
1953+
if (!this.options.hasOwnProperty('session') && asyncLocalStorage?.session != null) {
1954+
options.session = asyncLocalStorage.session;
1955+
}
1956+
19521957
const readPreference = model &&
19531958
model.schema &&
19541959
model.schema.options &&

lib/validOptions.js

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const VALID_OPTIONS = Object.freeze([
3232
'strictQuery',
3333
'toJSON',
3434
'toObject',
35+
'transactionAsyncLocalStorage',
3536
'translateAliases'
3637
]);
3738

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "mongoose",
33
"description": "Mongoose MongoDB ODM",
4-
"version": "8.3.4",
4+
"version": "8.3.5",
55
"author": "Guillermo Rauch <[email protected]>",
66
"keywords": [
77
"mongodb",

test/docs/transactions.test.js

+49
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,55 @@ describe('transactions', function() {
351351
await session.endSession();
352352
});
353353

354+
describe('transactionAsyncLocalStorage option', function() {
355+
let m;
356+
before(async function() {
357+
m = new mongoose.Mongoose();
358+
m.set('transactionAsyncLocalStorage', true);
359+
360+
await m.connect(start.uri);
361+
});
362+
363+
after(async function() {
364+
await m.disconnect();
365+
});
366+
367+
it('transaction() sets `session` by default if transactionAsyncLocalStorage option is set', async function() {
368+
const Test = m.model('Test', m.Schema({ name: String }));
369+
370+
await Test.createCollection();
371+
await Test.deleteMany({});
372+
373+
const doc = new Test({ name: 'test_transactionAsyncLocalStorage' });
374+
await assert.rejects(
375+
() => m.connection.transaction(async() => {
376+
await doc.save();
377+
378+
await Test.updateOne({ name: 'foo' }, { name: 'foo' }, { upsert: true });
379+
380+
let docs = await Test.aggregate([{ $match: { _id: doc._id } }]);
381+
assert.equal(docs.length, 1);
382+
383+
docs = await Test.find({ _id: doc._id });
384+
assert.equal(docs.length, 1);
385+
386+
docs = await async function test() {
387+
return await Test.findOne({ _id: doc._id });
388+
}();
389+
assert.equal(doc.name, 'test_transactionAsyncLocalStorage');
390+
391+
throw new Error('Oops!');
392+
}),
393+
/Oops!/
394+
);
395+
let exists = await Test.exists({ _id: doc._id });
396+
assert.ok(!exists);
397+
398+
exists = await Test.exists({ name: 'foo' });
399+
assert.ok(!exists);
400+
});
401+
});
402+
354403
it('transaction() resets $isNew on error', async function() {
355404
db.deleteModel(/Test/);
356405
const Test = db.model('Test', Schema({ name: String }));

test/model.findByIdAndUpdate.test.js

-3
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,6 @@ describe('model: findByIdAndUpdate:', function() {
5353
'shape.side': 4,
5454
'shape.color': 'white'
5555
}, { new: true });
56-
console.log('doc');
57-
console.log(doc);
58-
console.log('doc');
5956

6057
assert.equal(doc.shape.kind, 'gh8378_Square');
6158
assert.equal(doc.shape.name, 'after');

types/mongooseoptions.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ declare module 'mongoose' {
203203
*/
204204
toObject?: ToObjectOptions;
205205

206+
/**
207+
* Set to true to make Mongoose use Node.js' built-in AsyncLocalStorage (Node >= 16.0.0)
208+
* to set `session` option on all operations within a `connection.transaction(fn)` call
209+
* by default. Defaults to false.
210+
*/
211+
transactionAsyncLocalStorage?: boolean;
212+
206213
/**
207214
* If `true`, convert any aliases in filter, projection, update, and distinct
208215
* to their database property names. Defaults to false.

0 commit comments

Comments
 (0)