Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change tracking is getting lost on transaction retry #14460

Closed
2 tasks done
hardcodet opened this issue Mar 22, 2024 · 1 comment
Closed
2 tasks done

Change tracking is getting lost on transaction retry #14460

hardcodet opened this issue Mar 22, 2024 · 1 comment

Comments

@hardcodet
Copy link

hardcodet commented Mar 22, 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

8.2.3

Node.js version

20.x

MongoDB server version

Atlas 7.x

Typescript version (if applicable)

5.2.2

Description

I didn't want to hijack #14268, but it's probably related, even though our scenario is even simpler, and potentially quite common.

We just discovered this in PROD because our data wasn't updated despite a seemingly successful update in case of transaction errors. Unfortunately, Atlas is hitting us with these retriable transaction errors on a regular basis, so this is a common scenario for us.

Basically, what we're doing on a failed update is a simple rollback (if possible), create a new session and run the update again:
new session -> doc.save() -> rollback -> new session -> doc.save() -> commit (doesn't update anything)

Here's my minimal repro with just one document (not) being updated:

export async function runTest() {

    const ChildSchema = new mongoose.Schema({ foo: Number });
    const ChildModel = mongoose.model("Child", ChildSchema);
    await ChildModel.deleteMany({});

    // create new entity
    // note: fetching as part of the transaction is not viable - those are complex business flows that take too long, and fetching takes place elsewhere
    const child = await ChildModel.create({
        foo: 1
    })

    // modify the entity
    child.foo = 2;

    // start transaction #1
    let session = await mongoose.startSession();
    await session.startTransaction();

    // trigger the update
    await child.save({session});

    // abort transaction #1
    await session.abortTransaction();
    await session.endSession()

    // change tracking is gone
    console.log("modified paths", child.modifiedPaths()); // []

    // retry with transaction #2
    session = await mongoose.startSession();
    await session.startTransaction();

    // trigger the update (doesn't do anything  since change tracking wasn't rolled back)
    await child.save({session});

    // commit the transaction
    await session.commitTransaction();
    await session.endSession()

    // fetch a fresh instance - won't reflect the changes.
    const freshCopy = await ChildModel.findOne({});
    console.log(freshCopy.foo); // 1

}

If we commit without retries, everything works fine. Also, as a dirty hack in this artificial test, i could just invoke child.markModified("foo"); before running save() again, but that's not applicable for us.
I guess we could also cache the modified paths for each entity, and restore them after a rollback. But that also sounds like a brittle workaround for something I would expect from the framework.

@vkarpov15 mentioned the transaction helper, but this is not an option for us, as we are using document.save, and are passing around the wrapped session object across repositories and complex business flows. But if transaction helper can properly restore tracking states, is there anything we could invoke in case of a transaction error in order to restore the tracked changes?

Also, I wasn't sure whether this is also related of #13973 that was fixed in 7.x?

Steps to Reproduce

  • change a field

  • start a transaction

  • run a doc.save()

  • roll back the transaction

  • start a new transaction

  • run a doc.save()

  • commit the new transaction

-> change is not persisted

Expected Behavior

Change tracking should reset if a transaction fails or is being rolled back in order to enable retries, which are a common MongoDB necessity.

Temporary Workaround

I implemented a simple helper that integrates with our transaction wrapper. This seems to solve the issue for us for now, but I only see that as a temporary solution. Given how simple it is, I hope this can be solved on the framework level.

import { IEntity } from "./Entities/IEntity";
import { Document } from "mongoose";
import { ArrayUtil } from "../Utils/ArrayUtil";


/**
 * Tracks modifications on updated entities in order to restore
 * change tracking after transaction rollbacks.
 * This is a workaround for https://github.com/Automattic/mongoose/issues/14460
 */
export class EntityChangeTracker {
    private changeMap = new Map<string, { entity: IEntity & Document, modifiedPaths: string[] }>();


    /**
     * Registers a set of entities and caches their tracked changes for
     * restoration in case they get lost during a transaction rollback.
     */
    trackChanges(entities: (IEntity & Document)[]) {
        entities.forEach(entity => {
            this.changeMap.set(entity.id, { entity, modifiedPaths: entity.modifiedPaths({ includeChildren: true }) });
        });
    }


    /**
     * Restores cached tracked changes for each registered entity.
     */
    restoreTrackedChanges() {
        for (const trackedEntity of ArrayUtil.fromMap(this.changeMap)) {
            const { entity, modifiedPaths } = trackedEntity;
            modifiedPaths.forEach(c => entity.markModified(c));
        }
    }
}
@vkarpov15
Copy link
Collaborator

@hardcodet you're right, this is exactly the same issue as #14268. I'll close this in favor of #14268.

@vkarpov15 vkarpov15 removed this from the 8.3.1 milestone Apr 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants