Transactions in Mongoose

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. This guide will get you started using transactions with Mongoose.

Your First Transaction

MongoDB currently only supports transactions on replica sets, not standalone servers. To run a local replica set for development on macOS, Linux or Windows, use npm to install run-rs globally and run run-rs --version 4.0.0. Run-rs will download MongoDB 4.0.0 for you.

To use transactions with Mongoose, you should use Mongoose >= 5.2.0. To check your current version of Mongoose, run npm list | grep "mongoose" or check the mongoose.version property.

Transactions are built on MongoDB sessions. To start a transaction, you first need to call startSession() and then call the session's startTransaction() function. To execute an operation in a transaction, you need to pass the session as an option.

const Customer = db.model('Customer', new Schema({ name: String }));

const session = await db.startSession();
session.startTransaction();

// This `create()` is part of the transaction because of the `session`
// option.
await Customer.create([{ name: 'Test' }], { session: session });

// Transactions execute in isolation, so unless you pass a `session`
// to `findOne()` you won't see the document until the transaction
// is committed.
let doc = await Customer.findOne({ name: 'Test' });
assert.ok(!doc);

// This `findOne()` will return the doc, because passing the `session`
// means this `findOne()` will run as part of the transaction.
doc = await Customer.findOne({ name: 'Test' }).session(session);
assert.ok(doc);

// Once the transaction is committed, the write operation becomes
// visible outside of the transaction.
await session.commitTransaction();
doc = await Customer.findOne({ name: 'Test' });
assert.ok(doc);

session.endSession();

In the above example, session is an instance of the MongoDB Node.js driver's ClientSession class. Please refer to the MongoDB driver docs for more information on what methods session has.

Aborting a Transaction

The most important feature of transactions is the ability to roll back all operations in the transaction using the abortTransaction() function.

Think about modeling a bank account in Mongoose. To transfer money from account A to account B, you would decrement A's balance and increment B's balance. However, if A only has a balance of $5 and you try to transfer $10, you want to abort the transaction and undo incrementing B's balance.

const session = await Customer.startSession();
session.startTransaction();

await Customer.create([{ name: 'Test' }], { session: session });
await Customer.create([{ name: 'Test2' }], { session: session });

await session.abortTransaction();

const count = await Customer.countDocuments();
assert.strictEqual(count, 0);

session.endSession();

The withTransaction() Helper

The previous examples explicitly create a transaction and commits it. In practice, you'll want to use the session.withTransaction() helper instead. The session.withTransaction() helper handles:

  • Creating a transaction
  • Committing the transaction if it succeeds
  • Aborting the transaction if your operation throws
  • Retrying in the event of a transient transaction error.

const session = await Customer.startSession();

// The `withTransaction()` function's first parameter is a function
// that returns a promise.
await session.withTransaction(() => {
  return Customer.create([{ name: 'Test' }], { session: session })
});

const count = await Customer.countDocuments();
assert.strictEqual(count, 1);

session.endSession();

For more information on the ClientSession#withTransaction() function, please see the MongoDB Node.js driver docs.

With Mongoose Documents and save()

If you get a Mongoose document from findOne() or find() using a session, the document will keep a reference to the session and use that session for save().

To get/set the session associated with a given document, use doc.$session().

const User = db.model('User', new Schema({ name: String }));
const session = await db.startSession();
session.startTransaction();

await User.create({ name: 'foo' });

const user = await User.findOne({ name: 'foo' }).session(session);
// Getter/setter for the session associated with this document.
assert.ok(user.$session());
user.name = 'bar';
// By default, `save()` uses the associated session
await user.save();

// Won't find the doc because `save()` is part of an uncommitted transaction
let doc = await User.findOne({ name: 'bar' });
assert.ok(!doc);

await session.commitTransaction();
session.endSession();

doc = await User.findOne({ name: 'bar' });
assert.ok(doc);

With the Aggregation Framework

The Model.aggregate() function also supports transactions. Mongoose aggregations have a session() helper that sets the session option. Below is an example of executing an aggregation within a transaction.

const Event = db.model('Event', new Schema({ createdAt: Date }), 'Event');
const session = await db.startSession();
session.startTransaction();

await Event.insertMany([
  { createdAt: new Date('2018-06-01') },
  { createdAt: new Date('2018-06-02') },
  { createdAt: new Date('2017-06-01') },
  { createdAt: new Date('2017-05-31') }
], { session: session });

const res = await Event.aggregate([
  {
    $group: {
      _id: {
        month: { $month: '$createdAt' },
        year: { $year: '$createdAt' }
      },
      count: { $sum: 1 }
    }
  },
  { $sort: { count: -1, '_id.year': -1, '_id.month': -1 } }
]).session(session);

assert.deepEqual(res, [
  { _id: { month: 6, year: 2018 }, count: 2 },
  { _id: { month: 6, year: 2017 }, count: 1 },
  { _id: { month: 5, year: 2017 }, count: 1 }
]);

await session.commitTransaction();
session.endSession();