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

Array addToSet() and push() methods do not work with transaction retries #14848

Closed
2 tasks done
juona opened this issue Aug 30, 2024 · 7 comments · Fixed by #14875
Closed
2 tasks done

Array addToSet() and push() methods do not work with transaction retries #14848

juona opened this issue Aug 30, 2024 · 7 comments · Fixed by #14875

Comments

@juona
Copy link

juona commented Aug 30, 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.6.0

Node.js version

20.16.0

MongoDB server version

5

Typescript version (if applicable)

N/A

Description

Hello there,

Scenario:

  1. A document has a subdocument array.
  2. The array is modified via push().
  3. The document is save()d inside a transaction.
  4. The transaction is retried (the whole transaction as opposed to just the commit).

Result: duplicate entries are saved. Actually, this is exactly the same as in #14340 - the total number of documents added to the array grows exponentially with every retry - it is 2^n, where n is the number of retries.

I chose not to reopen the old issue because I have a hunch that this time the cause might be different.

There is also a closely-related problem when using addToSet(). Other array methods (pop(), pull(), remove(), set(), shift() and even splice()) all seem to be working correctly.

My analysis

Let's take this for example (you'll find the full script below):

doc.items.push({ name: "test3" });

await mongoose.connection.transaction(async (session) => {
  console.log(`Attempt: ${attempt}`);
  await doc.save({ session });
});

The first attempt to save() generates the following updateOne query:

{"$push":{"items":{"$each":[{"name":"test3"}]}},"$inc":{"__v":1}}

If the transaction is retried, the second attempt to save() generates this updateOne query:

{"$set":{"__v":1},"$push":{"items":{"$each":[{"name":"test3"},{"name":"test3"}]}}}

Attempt 3:

{"$set":{"__v":1},"$push":{"items":{"$each":[{"name":"test3"},{"name":"test3"},{"name":"test3"},{"name":"test3"}]}}}

Etc. - as you can see, with each attempt the number of elements in $push.items.$each doubles.

A side-effect of this problem is that the update query can grow significantly in size. If it grows too large, it might fail with one of these errors:

RangeError: The value of "offset" is out of range. It must be >= 0 && <= 17825792. Received 17825793

MongoServerError: BSONObj size: 16928643 (0x1024F83) is invalid. Size must be between 0 and 16793600(16MB) First element: update: "mymodels"

As a matter of fact, this was the first problem that I ran into since I noticed that saving after addToSet() was sometimes failing. Now I know that sometimes was when a transaction was retried a lot.

Steps to Reproduce

import mongoose from "mongoose";
import { MongoServerError, MongoErrorLabel } from "mongodb";

console.log("connecting...");

// Connecting to a *ReplicaSet* specifically to make sure that transactions work correctly.
// Nevertheless, whether or not an actual ReplicaSet is used has no effect on the issue.
await mongoose.connect(
  "mongodb://mongo-node1:27017,mongo-node2:27018,mongo-node3:27019/xxx?replicaSet=eof"
);

console.log("connected!");

// Define some schemas...
const subItemSchema = new mongoose.Schema(
  {
    name: { type: String, required: true },
  },
  { _id: false }
);

const itemSchema = new mongoose.Schema(
  {
    name: { type: String, required: true },
    subItems: { type: [subItemSchema], required: true },
  },
  { _id: false }
);

const schema = new mongoose.Schema({
  items: { type: [itemSchema], required: true },
});

// ...and a model
const Model = mongoose.model("MyModel", schema);

// Clear the collection...
await Model.deleteMany({});

// ...and create one document
await Model.create({
  items: [
    { name: "test1", subItems: [{ name: "x1" }] },
    { name: "test2", subItems: [{ name: "x2" }] },
  ],
});

const doc = await Model.findOne();

doc.items.addToSet({ name: "test3", subItems: [{ name: "x3" }] });

let attempt = 0;

// Controls the number of retries.
// 17 works, 18 fails
let retries = 18;

await mongoose.connection.transaction(async (session) => {
  console.log(`Attempt: ${attempt}`);

  await doc.save({ session });

  if (attempt < retries) {
    attempt += 1;
    throw new MongoServerError({
      message: "Test transient transaction failures & retries",
      errorLabels: [MongoErrorLabel.TransientTransactionError],
    });
  }
});

await mongoose.disconnect();
@juona
Copy link
Author

juona commented Sep 2, 2024

A temporary workaround until this is fixed:

await mongoose.connection.transaction(async (session) => {
  console.log(`Attempt: ${attempt}`);

  // This! Apparently this resets something inside the Document so that
  // `save()` works correctly for this field. Of course, if there are multiple
  // fields that are being modified in this way, they would all have to be
  // "reset" like this individually.
  doc.items = doc.items;

  await doc.save({ session });

  if (attempt < retries) {
    attempt += 1;
    throw new MongoServerError({
      message: "Test transient transaction failures & retries",
      errorLabels: [MongoErrorLabel.TransientTransactionError],
    });
  }
});

@juona
Copy link
Author

juona commented Sep 2, 2024

Correction - it looks like .pull() is affected somehow too, albeit in a slightly different way. I need more retries (like 30) and the error is then:

MongoServerError: assertion src/mongo/bson/bsonelement.cpp

It does not say anything else. Since I am in a bit of hurry, I did not dig deeper and just used the workaround above. Perhaps some other methods are affected as well.

@dmint789
Copy link

dmint789 commented Sep 4, 2024

I'm getting an issue when using .push() and then .save() in general, and it just started happening after a recent update (I'm on version 8.5.5, because versions 8.6.0 and 8.6.1 are giving me bugs when I try to filter by createdAt, which was working just fine before.)

@dmint789
Copy link

dmint789 commented Sep 6, 2024

Actually, I just tried it with 8.6.1 and it still doesn't work. When I use .push(await schema.create()) on a document array of refs, it does create the new document, but it doesn't store it in the array as a ref, but rather as a pure object with all of the fields, including internal fields like createdAt and updatedAt.

Edit: just discovered that using document.arrayField = [...document.arrayField, await schema.create()] works just fine, even though .push() doesn't. Still strange that it's like that though. The .push() way worked just fine at some Mongoose version in the past.

@juona
Copy link
Author

juona commented Sep 7, 2024

@dmint789 I believe that's a separate issue. What I am seeing relates specifically to transaction retries.

vkarpov15 added a commit that referenced this issue Sep 10, 2024
fix(transaction): avoid unnecessarily updating initial state in between transactions
@dmint789
Copy link

@dmint789 I believe that's a separate issue. What I am seeing relates specifically to transaction retries.

Do you know if there is an open issue for the one I described? I thought this was supposed to be fixed already.

@vkarpov15
Copy link
Collaborator

@dmint789 can you please open a new issue and follow the issue template? It's hard to guess what the issue might be without code samples.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants