Skip to content

Connection#transaction() loses writes when the driver retries the callback after a transient commitTransaction() error #16639

Open

Description

@IbrahimHafez1

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the bug has not already been reported

Mongoose version

9.11.4 (also current master)

Node.js version

22.23.4

MongoDB server version

8.2.3 (single-node replica set)

Typescript version (if applicable)

No response

Description

Connection#transaction() restores tracked document state ($isNew, modified paths, version key, array atomics) between retries only when the callback throws (gh-13698 and follow-ups). But session.withTransaction() also re-runs the callback when commitTransaction() fails with a TransientTransactionError (transactions spec, step 10.2), for example after a primary step-down right before the commit. In that case the callback did not throw, so nothing is restored: documents saved in the first attempt are no longer new or modified when the callback runs again.

The result is silent data loss for updates: the retried transaction commits without the change. For new documents, the retried save() treats the document as existing and throws DocumentNotFoundError.

Steps to Reproduce

The script simulates the transient commit failure by making the first commitTransaction() abort the transaction and throw a TransientTransactionError, which is what the driver sees after a step-down.

'use strict';

// Requires a replica set, e.g. MONGODB_URI=mongodb://127.0.0.1:27017/test?replicaSet=rs0
const mongoose = require('mongoose');

let failNextCommit = false;
function patchCommit(connection) {
  const startSession = connection.startSession.bind(connection);
  connection.startSession = async function() {
    const session = await startSession();
    const commitTransaction = session.commitTransaction.bind(session);
    session.commitTransaction = async function() {
      if (failNextCommit) {
        failNextCommit = false;
        await session.abortTransaction();
        throw new mongoose.mongo.MongoServerError({
          message: 'simulated transient commit failure',
          errorLabels: ['TransientTransactionError']
        });
      }
      return commitTransaction();
    };
    return session;
  };
}

async function run() {
  await mongoose.connect(process.env.MONGODB_URI);
  const Test = mongoose.model('Test', new mongoose.Schema({ name: String }));
  await Test.createCollection();
  await Test.deleteMany({});
  patchCommit(mongoose.connection);

  // 1. Modify an existing document
  const { _id } = await Test.create({ name: 'initial' });
  const doc = await Test.findById(_id).orFail();
  failNextCommit = true;
  let attempts = 0;
  await mongoose.connection.transaction(async(session) => {
    ++attempts;
    doc.name = 'updated';
    await doc.save({ session });
  });
  const fromDb = await Test.findById(_id).orFail();
  console.log('modify: attempts=%d, name in db=%j (expected "updated")', attempts, fromDb.name);

  // 2. Insert a new document
  failNextCommit = true;
  const fresh = new Test({ name: 'fresh' });
  try {
    await mongoose.connection.transaction(async(session) => {
      await fresh.save({ session });
    });
    console.log('insert: ok, count=%d', await Test.countDocuments({ name: 'fresh' }));
  } catch (err) {
    console.log('insert: %s: %s', err.name, err.message);
  }

  await mongoose.disconnect();
}

run().catch(err => {
  console.error(err);
  process.exit(1);
});

Output:

modify: attempts=2, name in db="initial" (expected "updated")
insert: DocumentNotFoundError: No document found for query "{ _id: new ObjectId('6addc205c4dce131a3c4dcb9') }" on model "Test"

Expected Behavior

The retried callback should see the same document state as the first attempt, as it does when the callback itself throws:

modify: attempts=2, name in db="updated" (expected "updated")
insert: ok, count=1

I have a fix ready (restore the tracked state before each attempt in _wrapUserTransaction()), with tests, and will open a PR referencing this issue.

Activity

  1. added a commit that references this issue on Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions