MongoDBトランザクションにおける 'MongoError: Cannot use a session that has ended' の解決方法

intermediate🍃 MongoDB2026-04-10| Node.js (v14+), MongoDB (v4.0+ Replica Set), Mongoose (v5.x/v6.x/v7.x)

Error Message

MongoError: Cannot use a session that has ended
#mongodb#session#transaction#mongoose#nodejs

エラーの分析

MongoDBのトランザクションを使用したデータ整合性の管理は強力ですが、しばしば「MongoError: Cannot use a session that has ended」という、特定の、そしてフラストレーションの溜まる障害に直面することがあります。このエラーは、Node.jsアプリケーションが、すでにsession.endSession()によって閉じられたClientSessionを使用してクエリを実行しようとしたときに発生します。本質的には、すでに片付けられた道具を使おうとしているようなものです。

以下のようなスタックトレースが表示されることがあります。

MongoError: Cannot use a session that has ended
    at ClientSession.applySession (/node_modules/mongodb/lib/core/sessions.js:145:12)
    at Collection.insertOne (/node_modules/mongodb/lib/collection.js:456:15)
    at /app/services/orderService.js:42:24

主な原因

私は、なぜこれらのセッションが途中で終了してしまうのかを突き止めるために、本番環境のログを何時間も調査してきました。ほとんどの場合、問題は非同期イベントループがセッションのライフサイクルとどのように相互作用するかという点に集約されます。よくある原因は以下の通りです。

1. 「await」忘れによるプロミスの競合

JavaScriptは、トランザクション中だからといってデータベースの処理を待ってくれるわけではありません。トランザクションブロック内でawaitを1つでも忘れると、エンジンはそのままfinallyブロックへとスキップしてしまいます。データベースの操作がネットワーク経由で20〜50ミリ秒かかっている間に、コードは1ミリ秒足らずでsession.endSession()に到達してしまう可能性があります。その結果、セッションが終了し、後から届いたクエリに対してドライバーがエラーをスローします。

2. 共通ロジック内での早すぎるクリーンアップ

手動でのセッション管理は脆弱です。独自のtry/finallyクリーンアップ処理を持つユーティリティ関数にセッションを渡すと、その子関数が親関数の完了前にセッションを終了させてしまうことがあります。これにより、最初に完了した関数がチェーン全体を壊してしまうという「所有権の共有」の悪夢が生じます。

3. アボート後のセッションの再利用

トランザクションをアボート(中止)すると、セッションの内部状態が変化します。ロジックがエラーをキャッチした後、トランザクションを再開せずに同じセッションオブジェクトを使用してクリーンアップクエリを実行しようとすると、MongoDBはそれを無効として拒否します。

エラーの解決方法

方法 1: withTransaction ヘルパーを使用する(推奨)

withTransactionは、MongoDBにおける「安全第一」モードだと考えてください。Mongoose v5.2.0以降、このヘルパーはライフサイクルのバグを回避するための標準となっています。開始、コミット、アボートのロジックを自動的に処理します。最も重要なのは、内部のすべてのプロミスが完全に解決された後にのみセッションが終了するように保証される点です。

const mongoose = require('mongoose');

async function updateInventoryAndCreateOrder(orderData) {
  const session = await mongoose.startSession();
  
  try {
    // withTransaction handles the lifecycle and retries transient errors
    await session.withTransaction(async () => {
      const product = await mongoose.model('Product')
        .findOne({ _id: orderData.productId })
        .session(session);

      if (product.stock < orderData.quantity) {
        throw new Error('Out of stock');
      }

      product.stock -= orderData.quantity;
      await product.save({ session });

      await mongoose.model('Order').create([orderData], { session });
    });
  } catch (err) {
    console.error('Transaction aborted:', err.message);
  } finally {
    session.endSession();
  }
}

方法 2: 手動トランザクションの徹底

ワークフローが複雑すぎてwithTransactionが使えない場合は、awaitキーワードを極めて慎重に扱う必要があります。ログ出力や軽微な更新など、あらゆる操作をクリーンアップブロックに到達する前にawaitしなければなりません。

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

try {
  await User.updateOne({ _id: userId }, { $inc: { balance: -10 } }, { session });
  
  const logEntry = new Log({ action: 'purchase', userId });
  // DANGER: If you forget 'await' here, the session ends before the log saves
  await logEntry.save({ session }); 

  await session.commitTransaction();
} catch (error) {
  await session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

方法 3: 並行操作の調整

並行クエリは高速ですが、セッション終了エラーの最も一般的な原因でもあります。Promise.allを使用する場合は、すべての呼び出しにセッションが渡されていること、および親関数がendSession()を呼び出す前に配列全体が解決されるのを待機していることを確認してください。

// Wait for all operations to finish before moving to the 'finally' block
await Promise.all([
  User.updateOne({ _id: u1 }, { $set: { status: 'active' } }, { session }),
  User.updateOne({ _id: u2 }, { $set: { status: 'active' } }, { session })
]);

確認手順

修正を確認するには、単にエラーが消えるのを見るだけでは不十分です。以下の3つのチェックを行い、データベースが健全であることを確認してください。

  • セッションメトリクスの監視: Mongoシェルでdb.serverStatus().logicalSessionRecordCache.activeSessionsCountを実行してください。この数値が無制限に上昇し続ける場合、エラーを修正するためにendSession()を削除した結果、メモリリークが発生している可能性があります。
  • ネットワーク遅延のシミュレート: ローカル開発中に、ミドルウェアを使用してDB呼び出しに100ミリ秒の遅延を注入してください。セッション管理に不備がある場合、この遅延によって競合状態が発生し、即座にエラーが表面化します。
  • データベースログの監査: abortTransactionの後にendSessionsが続いている箇所を検索してください。健全なライフサイクルでは、これらのコマンドがエラーの重複なく、明確に順序立てて表示されます。

予防のためのベストプラクティス

  • 原則として withTransaction を使用する: ヘルパーが機能しないような特定のアーキテクチャ上の制約がある場合にのみ、手動のstartTransactionを使用してください。
  • ESLintのセキュリティ設定を有効にする: no-floating-promisesルールを使用してください。これにより、awaitが欠落しているデータベース呼び出しにフラグが立てられ、本番環境に混入する前にこのエラーの最も一般的な原因を阻止できます。
  • セッションの所有権を中央集約化する: startSessionを呼び出した関数のみがendSessionを呼び出せるようにします。セッションは借りた道具のように扱い、使い終わったら所有者に返却し、自分で勝手に捨てないようにしましょう。
  • Mongooseをアップグレードする: まだv5.xを使用している場合は、v6またはv7に移行することで、どの操作が失敗したかを正確に特定できる、より詳細なエラーメッセージが得られます。

Related Error Notes