Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add transactionAsyncLocalStorage option to opt in to automatically setting session on all transactions #14742

Merged
merged 5 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions docs/transactions.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Transactions in Mongoose

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

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

<h2 id="asynclocalstorage"><a href="#asynclocalstorage">Using AsyncLocalStorage</a></h2>

One major pain point with transactions in Mongoose is that you need to remember to set the `session` option on every operation.
If you don't, your operation will execute outside of the transaction.
Mongoose 7.8 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).
Set the `transactionAsyncLocalStorage` option using `mongoose.set('transactionAsyncLocalStorage', true)` to enable this feature.

```javascript
mongoose.set('transactionAsyncLocalStorage', true);

const Test = mongoose.model('Test', mongoose.Schema({ name: String }));

const doc = new Test({ name: 'test' });

// Save a new doc in a transaction that aborts
await connection.transaction(async() => {
await doc.save(); // Notice no session here
throw new Error('Oops');
}).catch(() => {});

// false, `save()` was rolled back
await Test.exists({ _id: doc._id });
```

With `transactionAsyncLocalStorage`, you no longer need to pass sessions to every operation.
Mongoose will add the session by default under the hood.

<h2 id="advanced-usage"><a href="#advanced-usage">Advanced Usage</a></h2>

Advanced users who want more fine-grained control over when they commit or abort transactions
Expand Down
5 changes: 5 additions & 0 deletions lib/aggregate.js
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,11 @@ Aggregate.prototype.exec = async function exec() {
applyGlobalMaxTimeMS(this.options, model);
applyGlobalDiskUse(this.options, model);

const asyncLocalStorage = this.model()?.db?.base.transactionAsyncLocalStorage?.getStore();
if (!this.options.hasOwnProperty('session') && asyncLocalStorage?.session != null) {
this.options.session = asyncLocalStorage.session;
}

if (this.options && this.options.cursor) {
return new AggregationCursor(this);
}
Expand Down
13 changes: 10 additions & 3 deletions lib/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ Connection.prototype.startSession = async function startSession(options) {
Connection.prototype.transaction = function transaction(fn, options) {
return this.startSession().then(session => {
session[sessionNewDocuments] = new Map();
return session.withTransaction(() => _wrapUserTransaction(fn, session), options).
return session.withTransaction(() => _wrapUserTransaction(fn, session, this.base), options).
then(res => {
delete session[sessionNewDocuments];
return res;
Expand All @@ -536,9 +536,16 @@ Connection.prototype.transaction = function transaction(fn, options) {
* Reset document state in between transaction retries re: gh-13698
*/

async function _wrapUserTransaction(fn, session) {
async function _wrapUserTransaction(fn, session, mongoose) {
try {
const res = await fn(session);
const res = mongoose.transactionAsyncLocalStorage == null
? await fn(session)
: await new Promise(resolve => {
mongoose.transactionAsyncLocalStorage.run(
{ session },
() => resolve(fn(session))
);
});
return res;
} catch (err) {
_resetSessionDocuments(session);
Expand Down
16 changes: 14 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ require('./helpers/printJestWarning');

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

const { AsyncLocalStorage } = require('node:async_hooks');

/**
* Mongoose constructor.
*
Expand Down Expand Up @@ -102,6 +104,10 @@ function Mongoose(options) {
}
this.Schema.prototype.base = this;

if (options?.transactionAsyncLocalStorage) {
this.transactionAsyncLocalStorage = new AsyncLocalStorage();
}

Object.defineProperty(this, 'plugins', {
configurable: false,
enumerable: true,
Expand Down Expand Up @@ -258,15 +264,21 @@ Mongoose.prototype.set = function(key, value) {

if (optionKey === 'objectIdGetter') {
if (optionValue) {
Object.defineProperty(mongoose.Types.ObjectId.prototype, '_id', {
Object.defineProperty(_mongoose.Types.ObjectId.prototype, '_id', {
enumerable: false,
configurable: true,
get: function() {
return this;
}
});
} else {
delete mongoose.Types.ObjectId.prototype._id;
delete _mongoose.Types.ObjectId.prototype._id;
}
} else if (optionKey === 'transactionAsyncLocalStorage') {
if (optionValue && !_mongoose.transactionAsyncLocalStorage) {
_mongoose.transactionAsyncLocalStorage = new AsyncLocalStorage();
} else if (!optionValue && _mongoose.transactionAsyncLocalStorage) {
delete _mongoose.transactionAsyncLocalStorage;
}
}
}
Expand Down
15 changes: 14 additions & 1 deletion lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,12 @@ Model.prototype.$__handleSave = function(options, callback) {
}

const session = this.$session();
if (!saveOptions.hasOwnProperty('session') && session != null) {
const asyncLocalStorage = this[modelDbSymbol].base.transactionAsyncLocalStorage?.getStore();
if (session != null) {
saveOptions.session = session;
} else if (!options.hasOwnProperty('session') && asyncLocalStorage?.session != null) {
// Only set session from asyncLocalStorage if `session` option wasn't originally passed in options
saveOptions.session = asyncLocalStorage.session;
}

if (this.$isNew) {
Expand Down Expand Up @@ -3110,6 +3114,11 @@ Model.$__insertMany = function(arr, options, callback) {
const throwOnValidationError = typeof options.throwOnValidationError === 'boolean' ? options.throwOnValidationError : false;
const lean = !!options.lean;

const asyncLocalStorage = this.db.base.transactionAsyncLocalStorage?.getStore();
if ((!options || !options.hasOwnProperty('session')) && asyncLocalStorage?.session != null) {
options = { ...options, session: asyncLocalStorage.session };
}

if (!Array.isArray(arr)) {
arr = [arr];
}
Expand Down Expand Up @@ -3463,6 +3472,10 @@ Model.bulkWrite = async function bulkWrite(ops, options) {
const ordered = options.ordered == null ? true : options.ordered;

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

return new Promise((resolve, reject) => {
if (ordered) {
Expand Down
5 changes: 5 additions & 0 deletions lib/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,11 @@ Query.prototype._optionsForExec = function(model) {
// Apply schema-level `writeConcern` option
applyWriteConcern(model.schema, options);

const asyncLocalStorage = this.model?.db?.base.transactionAsyncLocalStorage?.getStore();
if (!this.options.hasOwnProperty('session') && asyncLocalStorage?.session != null) {
options.session = asyncLocalStorage.session;
}

const readPreference = model &&
model.schema &&
model.schema.options &&
Expand Down
1 change: 1 addition & 0 deletions lib/validoptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const VALID_OPTIONS = Object.freeze([
'strictQuery',
'toJSON',
'toObject',
'transactionAsyncLocalStorage',
'translateAliases'
]);

Expand Down
65 changes: 65 additions & 0 deletions test/docs/transactions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -421,4 +421,69 @@ describe('transactions', function() {

assert.equal(i, 3);
});

describe('transactionAsyncLocalStorage option', function() {
let m;
before(async function() {
m = new mongoose.Mongoose();
m.set('transactionAsyncLocalStorage', true);

await m.connect(start.uri);
});

after(async function() {
await m.disconnect();
});

it('transaction() sets `session` by default if transactionAsyncLocalStorage option is set', async function() {
const Test = m.model('Test', m.Schema({ name: String }));

await Test.createCollection();
await Test.deleteMany({});

let doc = new Test({ name: 'test_transactionAsyncLocalStorage' });
await assert.rejects(
() => m.connection.transaction(async() => {
await doc.save();

await Test.updateOne({ name: 'foo' }, { name: 'foo' }, { upsert: true });

let docs = await Test.aggregate([{ $match: { _id: doc._id } }]);
assert.equal(docs.length, 1);

docs = await Test.find({ _id: doc._id });
assert.equal(docs.length, 1);

docs = await async function test() {
return await Test.findOne({ _id: doc._id });
}();
assert.equal(doc.name, 'test_transactionAsyncLocalStorage');

await Test.insertMany([{ name: 'bar' }]);

throw new Error('Oops!');
}),
/Oops!/
);
let exists = await Test.exists({ _id: doc._id });
assert.ok(!exists);

exists = await Test.exists({ name: 'foo' });
assert.ok(!exists);

exists = await Test.exists({ name: 'bar' });
assert.ok(!exists);

doc = new Test({ name: 'test_transactionAsyncLocalStorage' });
await assert.rejects(
() => m.connection.transaction(async() => {
await doc.save({ session: null });
throw new Error('Oops!');
}),
/Oops!/
);
exists = await Test.exists({ _id: doc._id });
assert.ok(exists);
});
});
});
7 changes: 7 additions & 0 deletions types/mongooseoptions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ declare module 'mongoose' {
*/
toObject?: ToObjectOptions;

/**
* Set to true to make Mongoose use Node.js' built-in AsyncLocalStorage (Added in: v13.10.0, v12.17.0; Stable since 16.4.0)
* to set `session` option on all operations within a `connection.transaction(fn)` call
* by default. Defaults to false.
*/
transactionAsyncLocalStorage?: boolean;

/**
* If `true`, convert any aliases in filter, projection, update, and distinct
* to their database property names. Defaults to false.
Expand Down
Loading