-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
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
Snapshot and reset document modified state for custom transaction wrappers #14268
Comments
A few more updates: after running some more tests it seems like this issue may be related to Document state not resetting between transaction retries. If we modify the the second operation to force a field to be 'modified', everything works as expected:
However, my assumption is that this will only apply updates to that specific field; to ensure that all updates are applied properly, we would need to diff the og object with the updated object and mark each updated field as 'modified' for each subsequent transaction retry. So, a couple questions:
Thank you kindly, |
I managed to create a more simplified repro. The issue is that Mongoose's document state doesn't revert when the transaction aborts, and const mongoose = require('mongoose');
mongoose.set('debug', true);
main().catch(error => {
console.error(error);
process.exit(-1);
});
async function main() {
await mongoose.connect('mongodb://127.0.0.1:27017,127.0.0.1:27018/mongoose_test?replicaSet=rs');
const Test = mongoose.model('Test', mongoose.Schema({ name: String }));
await Test.deleteMany({});
const { _id } = await Test.create({ name: 'foo' });
const doc = await Test.findById(_id);
doc.name = 'bar';
for (let i = 0; i < 2; ++i) {
const session = await mongoose.startSession();
session.startTransaction();
await doc.save({ session });
if (i === 0) {
await session.abortTransaction();
} else {
await session.commitTransaction();
}
await session.endSession();
}
console.log(await Test.findById(_id)); // Still prints name: 'foo'
} The easiest workaround is to use Mongoose's Another workaround is to avoid modifying documents that are external to the transaction. For example, the following works fine:
Do either of these approaches work for you? |
This issue is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 5 days |
Bumping this for visibility! |
@brandonalfred do you have any thoughts on this comment? |
This issue is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 5 days |
you suggested this as an alternative: for (let i = 0; i < 2; ++i) {
const session = await mongoose.startSession();
session.startTransaction();
const doc = await Test.findById(_id);
doc.name = 'bar';
await doc.save({ session });
if (i === 0) {
await session.abortTransaction();
} else {
await session.commitTransaction();
}
await session.endSession();
} I don't think that's a viable approach to this problem. You are basically just repeating the whole thing twice, so that works of course, since the first and second attempt are not related at all, and work on independent entities. But that's not recovering from a failed transaction, but performing a full retry (which will include business logic etc. that may/should take place elsewhere). |
That's one of 2 alternatives I suggested, the other being to use Mongoose's transaction() helper, which handles reverting document state on transaction failure. If fetching in the transaction doesn't work for you, then try using the transaction() helper. I would love to have an api for this that lets you snapshot the state of a document and then later revert back to that state. Would be handy for cases where you want to handle transactions yourself instead of relying on transaction(). Would that help? |
@vkarpov15 The transaction helper is not always feasible for us, since it clashes with our own patterns. We introduced a wrapper around a session that we can pass around, and allows unrelated services to partake in transactional work. As outlined in #14460, a fully transparent handling would be greatly preferrable to us. Given that the workaround we did already was super simple (which is basically also just a dumb snapshot), we were hoping that providing that transparently on the framework level would be equally easy for you without sacrificing performance or making the API more complex. I assume clearing the change tracking after a transaction commit rather than doing it right away is not an option (in order to properly handle multiple updates on a document during a transaction)? But even then, just a simple map of performed changes might already give you everything you need for a quick rollback (you'd again have to consider the possibility of multiple updates of the same document there). But I assume that's what the transaction helper already does behind the scenes? |
Yeah the transaction helper does this behind the scenes. Unfortunately there's no good way for Mongoose to know when a transaction is committed vs aborted by just observing operations AFAIK, which is why Mongoose has its own transaction wrapper: so Mongoose can store document state when transaction starts and revert before transaction retry. Best we can do is provide an API for custom transaction wrappers to do this snapshotting and restarting themsevles. |
Ah, I see where you are coming from! A hook would be already good enough - we'd just add that to our wrapper and be done with it :) Food for thought for a transparent solution - here's how we create our wrapper: I assume the problem is that the returned |
Creating a wrapper around MongoDB Plus I think the snapshotting and resetting functionality could be useful in other cases. |
Prerequisites
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:
From the server logs, the second pass of the transaction behaves differently:
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:
The two operations for the transaction that are retried:
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.
The text was updated successfully, but these errors were encountered: