From 819fde3fa01f278d2c315939a80ce1e999958ecc Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Fri, 10 May 2024 17:54:33 -0400 Subject: [PATCH 1/5] feat: add `transactionAsyncLocalStorage` option to opt in to automatically setting `session` on all transactions Fix #13889 --- docs/transactions.md | 31 ++++++++++++++++++-- lib/aggregate.js | 5 ++++ lib/connection.js | 13 ++++++-- lib/model.js | 7 +++++ lib/mongoose.js | 16 ++++++++-- lib/query.js | 5 ++++ lib/validOptions.js | 1 + test/docs/transactions.test.js | 44 ++++++++++++++++++++++++++++ test/model.findByIdAndUpdate.test.js | 3 -- types/mongooseoptions.d.ts | 7 +++++ 10 files changed, 121 insertions(+), 11 deletions(-) diff --git a/docs/transactions.md b/docs/transactions.md index 901282dac44..dfe9610529b 100644 --- a/docs/transactions.md +++ b/docs/transactions.md @@ -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.

Getting Started with Transactions

@@ -86,6 +84,33 @@ Below is an example of executing an aggregation within a transaction. [require:transactions.*aggregate] ``` +

Using AsyncLocalStorage

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

Advanced Usage

Advanced users who want more fine-grained control over when they commit or abort transactions diff --git a/lib/aggregate.js b/lib/aggregate.js index 827f1642a60..0bdf65b995a 100644 --- a/lib/aggregate.js +++ b/lib/aggregate.js @@ -1022,6 +1022,11 @@ Aggregate.prototype.exec = async function exec() { applyGlobalMaxTimeMS(this.options, model.db.options, model.base.options); applyGlobalDiskUse(this.options, model.db.options, model.base.options); + 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); } diff --git a/lib/connection.js b/lib/connection.js index 05ff52461b0..b3e224702a6 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -539,7 +539,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; @@ -558,9 +558,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); diff --git a/lib/model.js b/lib/model.js index 5e0a105c479..d2a5db1f495 100644 --- a/lib/model.js +++ b/lib/model.js @@ -296,8 +296,11 @@ Model.prototype.$__handleSave = function(options, callback) { } const session = this.$session(); + const asyncLocalStorage = this.db.base.transactionAsyncLocalStorage?.getStore(); if (!saveOptions.hasOwnProperty('session') && session != null) { saveOptions.session = session; + } else if (asyncLocalStorage?.session != null) { + saveOptions.session = asyncLocalStorage.session; } if (this.$isNew) { // send entire doc @@ -3533,6 +3536,10 @@ Model.bulkWrite = async function bulkWrite(ops, options) { } const validations = ops.map(op => castBulkWrite(this, op, options)); + const asyncLocalStorage = this.db.base.transactionAsyncLocalStorage?.getStore(); + if (!options.hasOwnProperty('session') && asyncLocalStorage.session != null) { + options = { ...options, session: asyncLocalStorage.session }; + } let res = null; if (ordered) { diff --git a/lib/mongoose.js b/lib/mongoose.js index 915720b59f7..687f5162685 100644 --- a/lib/mongoose.js +++ b/lib/mongoose.js @@ -38,6 +38,8 @@ require('./helpers/printJestWarning'); const objectIdHexRegexp = /^[0-9A-Fa-f]{24}$/; +const { AsyncLocalStorage } = require('node:async_hooks'); + /** * Mongoose constructor. * @@ -101,6 +103,10 @@ function Mongoose(options) { } this.Schema.prototype.base = this; + if (options?.transactionAsyncLocalStorage) { + this.transactionAsyncLocalStorage = new AsyncLocalStorage(); + } + Object.defineProperty(this, 'plugins', { configurable: false, enumerable: true, @@ -267,7 +273,7 @@ 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() { @@ -275,7 +281,13 @@ Mongoose.prototype.set = function(key, value) { } }); } 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; } } } diff --git a/lib/query.js b/lib/query.js index 22956fb818f..e5ec16618be 100644 --- a/lib/query.js +++ b/lib/query.js @@ -1947,6 +1947,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 && diff --git a/lib/validOptions.js b/lib/validOptions.js index c9968237595..2654a7521ed 100644 --- a/lib/validOptions.js +++ b/lib/validOptions.js @@ -32,6 +32,7 @@ const VALID_OPTIONS = Object.freeze([ 'strictQuery', 'toJSON', 'toObject', + 'transactionAsyncLocalStorage', 'translateAliases' ]); diff --git a/test/docs/transactions.test.js b/test/docs/transactions.test.js index 8b883e3388c..6302daee0e4 100644 --- a/test/docs/transactions.test.js +++ b/test/docs/transactions.test.js @@ -351,6 +351,50 @@ describe('transactions', function() { await session.endSession(); }); + 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({}); + + const doc = new Test({ name: 'test' }); + 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); + + const docs = await Test.find({ _id: doc._id }); + assert.equal(docs.length, 1); + + 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); + }); + }); + it('transaction() resets $isNew on error', async function() { db.deleteModel(/Test/); const Test = db.model('Test', Schema({ name: String })); diff --git a/test/model.findByIdAndUpdate.test.js b/test/model.findByIdAndUpdate.test.js index cbd953f5606..9db1b39d228 100644 --- a/test/model.findByIdAndUpdate.test.js +++ b/test/model.findByIdAndUpdate.test.js @@ -53,9 +53,6 @@ describe('model: findByIdAndUpdate:', function() { 'shape.side': 4, 'shape.color': 'white' }, { new: true }); - console.log('doc'); - console.log(doc); - console.log('doc'); assert.equal(doc.shape.kind, 'gh8378_Square'); assert.equal(doc.shape.name, 'after'); diff --git a/types/mongooseoptions.d.ts b/types/mongooseoptions.d.ts index 7fec10b208f..9c35ab8222b 100644 --- a/types/mongooseoptions.d.ts +++ b/types/mongooseoptions.d.ts @@ -203,6 +203,13 @@ declare module 'mongoose' { */ toObject?: ToObjectOptions; + /** + * Set to true to make Mongoose use Node.js' built-in AsyncLocalStorage (Node >= 16.0.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. From 2df8f37b67af817f4c1425d24172f473c5cd8028 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 11 May 2024 11:19:38 -0400 Subject: [PATCH 2/5] fix lint and add extra test showing session propagates down to async functions --- test/docs/transactions.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/docs/transactions.test.js b/test/docs/transactions.test.js index 6302daee0e4..29be35d0b56 100644 --- a/test/docs/transactions.test.js +++ b/test/docs/transactions.test.js @@ -380,7 +380,12 @@ describe('transactions', function() { let docs = await Test.aggregate([{ $match: { _id: doc._id } }]); assert.equal(docs.length, 1); - const docs = await Test.find({ _id: doc._id }); + docs = await Test.find({ _id: doc._id }); + assert.equal(docs.length, 1); + + docs = await Promise.all([async() => { + return await Test.findOne({ _id: doc._id }); + }]).then(res => res[0]); assert.equal(docs.length, 1); throw new Error('Oops!'); From f498cc558b959b954566fbda04645ae0b0ce5edc Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 11 May 2024 11:29:33 -0400 Subject: [PATCH 3/5] fix tests --- lib/model.js | 4 ++-- lib/query.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/model.js b/lib/model.js index d2a5db1f495..5cd6179adcc 100644 --- a/lib/model.js +++ b/lib/model.js @@ -296,7 +296,7 @@ Model.prototype.$__handleSave = function(options, callback) { } const session = this.$session(); - const asyncLocalStorage = this.db.base.transactionAsyncLocalStorage?.getStore(); + const asyncLocalStorage = this[modelDbSymbol].base.transactionAsyncLocalStorage?.getStore(); if (!saveOptions.hasOwnProperty('session') && session != null) { saveOptions.session = session; } else if (asyncLocalStorage?.session != null) { @@ -3537,7 +3537,7 @@ Model.bulkWrite = async function bulkWrite(ops, options) { const validations = ops.map(op => castBulkWrite(this, op, options)); const asyncLocalStorage = this.db.base.transactionAsyncLocalStorage?.getStore(); - if (!options.hasOwnProperty('session') && asyncLocalStorage.session != null) { + if ((!options || !options.hasOwnProperty('session')) && asyncLocalStorage?.session != null) { options = { ...options, session: asyncLocalStorage.session }; } diff --git a/lib/query.js b/lib/query.js index e5ec16618be..6ebd3d1eeb1 100644 --- a/lib/query.js +++ b/lib/query.js @@ -1947,7 +1947,7 @@ Query.prototype._optionsForExec = function(model) { // Apply schema-level `writeConcern` option applyWriteConcern(model.schema, options); - const asyncLocalStorage = this.model.db.base.transactionAsyncLocalStorage?.getStore(); + const asyncLocalStorage = this.model?.db?.base.transactionAsyncLocalStorage?.getStore(); if (!this.options.hasOwnProperty('session') && asyncLocalStorage?.session != null) { options.session = asyncLocalStorage.session; } From 3c74f46c19839496a7c89a5ae1d68a06db6f9d78 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 13 May 2024 10:30:11 -0400 Subject: [PATCH 4/5] fix tests --- lib/aggregate.js | 2 +- test/docs/transactions.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/aggregate.js b/lib/aggregate.js index 0bdf65b995a..35c32c480a9 100644 --- a/lib/aggregate.js +++ b/lib/aggregate.js @@ -1022,7 +1022,7 @@ Aggregate.prototype.exec = async function exec() { applyGlobalMaxTimeMS(this.options, model.db.options, model.base.options); applyGlobalDiskUse(this.options, model.db.options, model.base.options); - const asyncLocalStorage = this.model?.db?.base.transactionAsyncLocalStorage?.getStore(); + const asyncLocalStorage = this.model()?.db?.base.transactionAsyncLocalStorage?.getStore(); if (!this.options.hasOwnProperty('session') && asyncLocalStorage?.session != null) { this.options.session = asyncLocalStorage.session; } diff --git a/test/docs/transactions.test.js b/test/docs/transactions.test.js index 29be35d0b56..e21639331b9 100644 --- a/test/docs/transactions.test.js +++ b/test/docs/transactions.test.js @@ -370,7 +370,7 @@ describe('transactions', function() { await Test.createCollection(); await Test.deleteMany({}); - const doc = new Test({ name: 'test' }); + const doc = new Test({ name: 'test_transactionAsyncLocalStorage' }); await assert.rejects( () => m.connection.transaction(async() => { await doc.save(); @@ -383,10 +383,10 @@ describe('transactions', function() { docs = await Test.find({ _id: doc._id }); assert.equal(docs.length, 1); - docs = await Promise.all([async() => { + docs = await async function test() { return await Test.findOne({ _id: doc._id }); - }]).then(res => res[0]); - assert.equal(docs.length, 1); + }(); + assert.equal(doc.name, 'test_transactionAsyncLocalStorage'); throw new Error('Oops!'); }), From 5e9977bd6c12f0e39c8be8934afb4fb7d36514c5 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Mon, 13 May 2024 10:34:01 -0400 Subject: [PATCH 5/5] Update docs/transactions.md Co-authored-by: hasezoey --- docs/transactions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/transactions.md b/docs/transactions.md index dfe9610529b..4251cd5d017 100644 --- a/docs/transactions.md +++ b/docs/transactions.md @@ -102,7 +102,7 @@ const doc = new Test({ name: 'test' }); 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 });