Skip to content

Commit ec61900

Browse files
committed
feat: add transactionAsyncLocalStorage option to opt in to automatically setting session on all transactions
Backport #14583 to 7.x Re: #13889
1 parent 0c65a53 commit ec61900

File tree

9 files changed

+126
-8
lines changed

9 files changed

+126
-8
lines changed

docs/transactions.md

Lines changed: 28 additions & 3 deletions
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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,11 @@ Aggregate.prototype.exec = async function exec() {
10221022
applyGlobalMaxTimeMS(this.options, model);
10231023
applyGlobalDiskUse(this.options, model);
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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ Connection.prototype.startSession = async function startSession(options) {
517517
Connection.prototype.transaction = function transaction(fn, options) {
518518
return this.startSession().then(session => {
519519
session[sessionNewDocuments] = new Map();
520-
return session.withTransaction(() => _wrapUserTransaction(fn, session), options).
520+
return session.withTransaction(() => _wrapUserTransaction(fn, session, this.base), options).
521521
then(res => {
522522
delete session[sessionNewDocuments];
523523
return res;
@@ -536,9 +536,16 @@ Connection.prototype.transaction = function transaction(fn, options) {
536536
* Reset document state in between transaction retries re: gh-13698
537537
*/
538538

539-
async function _wrapUserTransaction(fn, session) {
539+
async function _wrapUserTransaction(fn, session, mongoose) {
540540
try {
541-
const res = await fn(session);
541+
const res = mongoose.transactionAsyncLocalStorage == null
542+
? await fn(session)
543+
: await new Promise(resolve => {
544+
mongoose.transactionAsyncLocalStorage.run(
545+
{ session },
546+
() => resolve(fn(session))
547+
);
548+
});
542549
return res;
543550
} catch (err) {
544551
_resetSessionDocuments(session);

lib/index.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ require('./helpers/printJestWarning');
4040

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

43+
const { AsyncLocalStorage } = require('node:async_hooks');
44+
4345
/**
4446
* Mongoose constructor.
4547
*
@@ -102,6 +104,10 @@ function Mongoose(options) {
102104
}
103105
this.Schema.prototype.base = this;
104106

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

259265
if (optionKey === 'objectIdGetter') {
260266
if (optionValue) {
261-
Object.defineProperty(mongoose.Types.ObjectId.prototype, '_id', {
267+
Object.defineProperty(_mongoose.Types.ObjectId.prototype, '_id', {
262268
enumerable: false,
263269
configurable: true,
264270
get: function() {
265271
return this;
266272
}
267273
});
268274
} else {
269-
delete mongoose.Types.ObjectId.prototype._id;
275+
delete _mongoose.Types.ObjectId.prototype._id;
276+
}
277+
} else if (optionKey === 'transactionAsyncLocalStorage') {
278+
if (optionValue && !_mongoose.transactionAsyncLocalStorage) {
279+
_mongoose.transactionAsyncLocalStorage = new AsyncLocalStorage();
280+
} else if (!optionValue && _mongoose.transactionAsyncLocalStorage) {
281+
delete _mongoose.transactionAsyncLocalStorage;
270282
}
271283
}
272284
}

lib/model.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,11 @@ Model.prototype.$__handleSave = function(options, callback) {
288288
}
289289

290290
const session = this.$session();
291+
const asyncLocalStorage = this[modelDbSymbol].base.transactionAsyncLocalStorage?.getStore();
291292
if (!saveOptions.hasOwnProperty('session') && session != null) {
292293
saveOptions.session = session;
294+
} else if (asyncLocalStorage?.session != null) {
295+
saveOptions.session = asyncLocalStorage.session;
293296
}
294297

295298
if (this.$isNew) {
@@ -3463,6 +3466,10 @@ Model.bulkWrite = async function bulkWrite(ops, options) {
34633466
const ordered = options.ordered == null ? true : options.ordered;
34643467

34653468
const validations = ops.map(op => castBulkWrite(this, op, options));
3469+
const asyncLocalStorage = this.db.base.transactionAsyncLocalStorage?.getStore();
3470+
if ((!options || !options.hasOwnProperty('session')) && asyncLocalStorage?.session != null) {
3471+
options = { ...options, session: asyncLocalStorage.session };
3472+
}
34663473

34673474
return new Promise((resolve, reject) => {
34683475
if (ordered) {

lib/query.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,6 +1980,11 @@ Query.prototype._optionsForExec = function(model) {
19801980
// Apply schema-level `writeConcern` option
19811981
applyWriteConcern(model.schema, options);
19821982

1983+
const asyncLocalStorage = this.model?.db?.base.transactionAsyncLocalStorage?.getStore();
1984+
if (!this.options.hasOwnProperty('session') && asyncLocalStorage?.session != null) {
1985+
options.session = asyncLocalStorage.session;
1986+
}
1987+
19831988
const readPreference = model &&
19841989
model.schema &&
19851990
model.schema.options &&

lib/validoptions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const VALID_OPTIONS = Object.freeze([
3131
'strictQuery',
3232
'toJSON',
3333
'toObject',
34+
'transactionAsyncLocalStorage',
3435
'translateAliases'
3536
]);
3637

test/docs/transactions.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,4 +421,53 @@ describe('transactions', function() {
421421

422422
assert.equal(i, 3);
423423
});
424+
425+
describe('transactionAsyncLocalStorage option', function() {
426+
let m;
427+
before(async function() {
428+
m = new mongoose.Mongoose();
429+
m.set('transactionAsyncLocalStorage', true);
430+
431+
await m.connect(start.uri);
432+
});
433+
434+
after(async function() {
435+
await m.disconnect();
436+
});
437+
438+
it('transaction() sets `session` by default if transactionAsyncLocalStorage option is set', async function() {
439+
const Test = m.model('Test', m.Schema({ name: String }));
440+
441+
await Test.createCollection();
442+
await Test.deleteMany({});
443+
444+
const doc = new Test({ name: 'test_transactionAsyncLocalStorage' });
445+
await assert.rejects(
446+
() => m.connection.transaction(async() => {
447+
await doc.save();
448+
449+
await Test.updateOne({ name: 'foo' }, { name: 'foo' }, { upsert: true });
450+
451+
let docs = await Test.aggregate([{ $match: { _id: doc._id } }]);
452+
assert.equal(docs.length, 1);
453+
454+
docs = await Test.find({ _id: doc._id });
455+
assert.equal(docs.length, 1);
456+
457+
docs = await async function test() {
458+
return await Test.findOne({ _id: doc._id });
459+
}();
460+
assert.equal(doc.name, 'test_transactionAsyncLocalStorage');
461+
462+
throw new Error('Oops!');
463+
}),
464+
/Oops!/
465+
);
466+
let exists = await Test.exists({ _id: doc._id });
467+
assert.ok(!exists);
468+
469+
exists = await Test.exists({ name: 'foo' });
470+
assert.ok(!exists);
471+
});
472+
});
424473
});

types/mongooseoptions.d.ts

Lines changed: 7 additions & 0 deletions
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)