Repository navigation
Connection#transaction() loses writes when the driver retries the callback after a transient commitTransaction() error #16639
Description
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
test: reference Automatticgh-16639 in the transient commit error tests