Skip to content

Snapshot and reset document modified state for custom transaction wrappers #14268

Closed

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

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:

  1. ModelA.save({session}) results in an 'insert' command
  2. ModelB.save({session}) results in an 'update' command
  3. The call to database_session.commitTransaction() fails due to write conflict
  4. 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:

  1. ModelA.save({session}) results in an 'insert' command
  2. ModelB.save({session}) results in a 'findOne' command
  3. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Assignees

Labels

new featureThis change adds new functionality, like a new method or class

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions