Description
openedon Jan 18, 2024
Prerequisites
- I have written a descriptive issue title
- I have searched existing issues to ensure the bug has not already been reported
Mongoose version
7.6.4
Node.js version
18.17
MongoDB server version
5.9.1
Typescript version (if applicable)
No response
Description
While running a transaction that uses model.save({session}) to create one document and update another, we catch a transaction error due to a write conflict. After aborting the transaction and ending the session, we attempt to rerun the entire transaction and noticed some unexpected behavior.
Upon inspecting the server logs for the first pass of the transaction, here is what we see:
- ModelA.save({session}) results in an 'insert' command
- ModelB.save({session}) results in an 'update' command
- The call to database_session.commitTransaction() fails due to write conflict
- We abort the transaction, end the session, and retry everything with a new session after a short wait period
From the server logs, the second pass of the transaction behaves differently:
- ModelA.save({session}) results in an 'insert' command
- ModelB.save({session}) results in a 'findOne' command
- The transaction succeeds, but the updates to ModelB are not persisted.
Looking through the docs, it doesn't seem like Model.save({session}) should ever resolve to a findOne() operation, but that appears to be what's happening. Is there something that I'm missing with how Model.save() is expected to behave after retrying a full transaction? How can we guarantee that ModelB.save({session}) will always attempt to update the target document?
Steps to Reproduce
Code for the Transaction Wrapper:
function wait(ms) {
console.log(`Waiting for ${ms} milliseconds...`);
return new Promise((resolve) => setTimeout(resolve, ms));
}
class TransactionBuilder {
constructor(conn = null, max_retries = 3) {
this.conn = conn;
this.operations = [];
this.max_retries = max_retries;
this.retry = 0;
}
static async create({ mongoose_connection }) {
return new TransactionBuilder(mongoose_connection);
}
addOperation(callback) {
this.operations.push(callback);
}
async run(
transaction_options = {
writeConcern: {
w: WRITE_CONCERN_LEVEL.MAJORITY,
j: true,
},
readConcern: READ_CONCERN_LEVEL.SNAPSHOT,
}
) {
const database_session = await this.conn.startSession();
database_session.startTransaction(transaction_options);
for (const operation of this.operations) {
try {
loggerUtils
.getLogger()
.info(`TransactionBuilder: About to run operation...${operation}`);
const result = await operation(database_session);
loggerUtils
.getLogger()
.info(`Result TransactionBuilder: ${JSON.stringify(result)}`);
} catch (error) {
loggerUtils
.getLogger()
.error(
`Error executing operation in TransactionBuilder: ${JSON.stringify(
error
)}`
);
await database_session.abortTransaction();
await database_session.endSession();
throw error;
}
}
try {
await database_session.commitTransaction();
console.log('Transaction committed successfully');
await database_session.endSession();
} catch (error) {
loggerUtils
.getLogger()
.error(
`Unable to commit transaction on attempt ${
this.retry + 1
}: ${JSON.stringify(error)}`
);
await database_session.endSession();
if (this.retry < this.max_retries) {
this.retry++;
const delay = this.retry ** Math.random(0, 1).toFixed(4);
await wait(delay * 1000);
console.log('Retrying transaction...');
try {
await this.run();
} catch (error) {
console.log(`Failed retry: ${error}`);
}
console.log('Transaction complete');
} else {
loggerUtils
.getLogger()
.error(
`Failed to commit transaction after ${
this.retry + 1
} attempts: ${JSON.stringify(error)}`
);
throw error;
}
}
}
}
The two operations for the transaction that are retried:
const transaction_builder = await TransactionBuilder.create({
mongoose_connection: mongoose.connection,
});
transaction_builder.addOperation(async (session) => {
const audit_log = new AuditLog(new_company_audit_log);
await audit_log.save({ session });
});
transaction_builder.addOperation(async (session) => {
await company.save({ session });
});
await transaction_builder.run();
Expected Behavior
During each attempt at running the transaction, the calls to ModelB.save({session}) should trigger updates to the target document and persist after the transaction is complete.