Mongooseにおけるトランザクション

トランザクションを使用すると、複数の操作を隔離して実行し、いずれかの操作が失敗した場合にすべての操作を元に戻すことができます。このガイドでは、Mongooseでトランザクションを使用する方法について説明します。

トランザクション入門

まだインポートしていない場合は、mongooseをインポートしてください。

import mongoose from 'mongoose';

トランザクションを作成するには、まずMongoose#startSessionまたはConnection#startSession()を使用してセッションを作成する必要があります。

// Using Mongoose's default connection
const session = await mongoose.startSession();

// Using custom connection
const db = await mongoose.createConnection(mongodbUri).asPromise();
const session = await db.startSession();

実際には、session.withTransaction()ヘルパーまたはMongooseのConnection#transaction()関数を使用してトランザクションを実行する必要があります。 session.withTransaction()ヘルパーは、以下の処理を行います。

  • トランザクションの作成
  • 成功した場合のトランザクションのコミット
  • 操作がスローされた場合のトランザクションの中止
  • 一時的なトランザクションエラーが発生した場合の再試行
let session = null;
return Customer.createCollection().
  then(() => Customer.startSession()).
  // The `withTransaction()` function's first parameter is a function
  // that returns a promise.
  then(_session => {
    session = _session;
    return session.withTransaction(() => {
      return Customer.create([{ name: 'Test' }], { session: session });
    });
  }).
  then(() => Customer.countDocuments()).
  then(count => assert.strictEqual(count, 1)).
  then(() => session.endSession());

ClientSession#withTransaction()関数の詳細については、MongoDB Node.jsドライバのドキュメントを参照してください。

MongooseのConnection#transaction()関数は、withTransaction()のラッパーであり、Mongooseの変更追跡をトランザクションと統合します。たとえば、後で失敗するトランザクションでドキュメントをsave()するとします。そのドキュメントの変更はMongoDBに永続化されません。 Connection#transaction()関数は、save()がロールバックされたことをMongooseの変更追跡に通知し、トランザクションで変更されたすべてのフィールドを修正済みとしてマークします。

const doc = new Person({ name: 'Will Riker' });

await db.transaction(async function setRank(session) {
  doc.name = 'Captain';
  await doc.save({ session });
  doc.isNew; // false

  // Throw an error to abort the transaction
  throw new Error('Oops!');
}, { readPreference: 'primary' }).catch(() => {});

// true, `transaction()` reset the document's state because the
// transaction was aborted.
doc.isNew;

トランザクションにおける並列処理に関する注意

トランザクション中の並列操作の実行は**サポートされていません**。トランザクション内で操作を並列化するためにPromise.allPromise.allSettledPromise.raceなどを使用することは未定義の動作であり、避ける必要があります。

Mongooseドキュメントとsave()を使用する場合

セッションを使用してfindOne()またはfind()からMongooseドキュメントを取得した場合、ドキュメントはセッションへの参照を保持し、save()にそのセッションを使用します。

特定のドキュメントに関連付けられたセッションを取得/設定するには、doc.$session()を使用します。

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

let session = null;
return User.createCollection().
  then(() => db.startSession()).
  then(_session => {
    session = _session;
    return User.create({ name: 'foo' });
  }).
  then(() => {
    session.startTransaction();
    return User.findOne({ name: 'foo' }).session(session);
  }).
  then(user => {
    // Getter/setter for the session associated with this document.
    assert.ok(user.$session());
    user.name = 'bar';
    // By default, `save()` uses the associated session
    return user.save();
  }).
  then(() => User.findOne({ name: 'bar' })).
  // Won't find the doc because `save()` is part of an uncommitted transaction
  then(doc => assert.ok(!doc)).
  then(() => session.commitTransaction()).
  then(() => session.endSession()).
  then(() => User.findOne({ name: 'bar' })).
  then(doc => assert.ok(doc));

集約フレームワークを使用する場合

Model.aggregate()関数もトランザクションをサポートしています。 Mongooseの集約には、sessionオプションを設定するsession()ヘルパーがあります。以下は、トランザクション内で集約を実行する例です。

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

let session = null;
return Event.createCollection().
  then(() => db.startSession()).
  then(_session => {
    session = _session;
    session.startTransaction();
    return 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 });
  }).
  then(() => Event.aggregate([
    {
      $group: {
        _id: {
          month: { $month: '$createdAt' },
          year: { $year: '$createdAt' }
        },
        count: { $sum: 1 }
      }
    },
    { $sort: { count: -1, '_id.year': -1, '_id.month': -1 } }
  ]).session(session)).
  then(res => assert.deepEqual(res, [
    { _id: { month: 6, year: 2018 }, count: 2 },
    { _id: { month: 6, year: 2017 }, count: 1 },
    { _id: { month: 5, year: 2017 }, count: 1 }
  ])).
  then(() => session.commitTransaction()).
  then(() => session.endSession());

AsyncLocalStorageの使用

Mongooseのトランザクションにおける大きな問題点の1つは、すべての操作でsessionオプションを設定する必要があることです。設定しないと、操作はトランザクション外で実行されます。 Mongoose 8.4は、NodeのAsyncLocalStorage APIを使用して、Connection.prototype.transaction() executor関数内のすべての操作にsession操作を設定できます。 mongoose.set('transactionAsyncLocalStorage', true)を使用してtransactionAsyncLocalStorageオプションを設定し、この機能を有効にします。

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 });

transactionAsyncLocalStorageを使用すると、すべての操作にセッションを渡す必要がなくなります。 Mongooseは、内部的にデフォルトでセッションを追加します.

高度な使用方法

トランザクションをコミットまたは中止するタイミングをより細かく制御したい上級ユーザーは、`session.startTransaction()`を使用してトランザクションを開始できます。

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

let session = null;
return Customer.createCollection().
  then(() => db.startSession()).
  then(_session => {
    session = _session;
    // Start a transaction
    session.startTransaction();
    // This `create()` is part of the transaction because of the `session`
    // option.
    return 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.
  then(() => Customer.findOne({ name: 'Test' })).
  then(doc => assert.ok(!doc)).
  // This `findOne()` will return the doc, because passing the `session`
  // means this `findOne()` will run as part of the transaction.
  then(() => Customer.findOne({ name: 'Test' }).session(session)).
  then(doc => assert.ok(doc)).
  // Once the transaction is committed, the write operation becomes
  // visible outside of the transaction.
  then(() => session.commitTransaction()).
  then(() => Customer.findOne({ name: 'Test' })).
  then(doc => assert.ok(doc)).
  then(() => session.endSession());

また、`session.abortTransaction()`を使用してトランザクションを中止することもできます。

let session = null;
return Customer.createCollection().
  then(() => Customer.startSession()).
  then(_session => {
    session = _session;
    session.startTransaction();
    return Customer.create([{ name: 'Test' }], { session: session });
  }).
  then(() => Customer.create([{ name: 'Test2' }], { session: session })).
  then(() => session.abortTransaction()).
  then(() => Customer.countDocuments()).
  then(count => assert.strictEqual(count, 0)).
  then(() => session.endSession());