Concurrency problems updating another's collection stats - node.js

I'm trying to make a notation system for movies
A user can note a Movie in their List.
Whenever the user clicks on the frontend, the listId, movieId, note are sent to the server to update the note. The note can be set to null, but it does not remove the entry from the list.
But if the user clicks too much times, the movie's totalNote and nbNotes are completely broken. Feels like there is some sort of concurrency problems ?
Is this the correct approach to this problem or am I updating in a wrong way ?
The mongoose schemas related :
// Movie Schema
const movieSchema = new Schema({
// ...
note: { type: Number, default: 0 },
totalNotes: { type: Number, default: 0 },
nbNotes: { type: Number, default: 0 },
})
movieSchema.statics.updateTotalNote = function (movieId, oldNote, newNote) {
if (!oldNote && !newNote) return
const nbNotes = !newNote ? -1 : (!oldNote ? 1 : 0) // If oldNote is null we +1, if newNote is null we -1
return Movie.findOneAndUpdate({ _id: movieId }, { $inc: { nbNotes: nbNotes, totalNotes: (newNote - oldNote) } }, { new: true }).catch(err => console.error("Couldn't update note from movie", err))
}
// List Schema
const movieEntry = new Schema({
_id: false, // movie makes an already unique attribute, which is populated on GET
movie: { type: Schema.Types.ObjectId, ref: 'Movies', required: true },
note: { type: Number, default: null, max: 21 },
})
const listSchema = new Schema({
user: { type: Schema.Types.ObjectId, ref: 'Users', required: true },
movies: [movieEntry]
})
The server update API (add / Remove movieEntry are similar with $push and $pull instead of $set)
exports.updateEntry = (req, res) => {
const { listId, movieId } = req.params
const movieEntry = { movieId: movieId, note: req.body.note }
List.findOneAndUpdate({ _id: listId, 'movies.movie': movieId }, { $set: { 'movies.$[elem]': movieEntry } }, { arrayFilters: [{ 'elem.movie': movieId }] })
.exec()
.then(list => {
if (!list) return res.sendStatus(404)
const oldNote = list.getMovieEntryById(movieId).note // getMovieEntryById(movieId) = return this.movies.find(movieEntry => movieEntry.movie == movieId)
Movie.updateTotalNote(movieId, oldNote, movieEntry.note)
let newList = list.movies.find(movieEntry => movieEntry.movie == movieId) // Because I needed the oldNote and findOneAndUpdate returns the list prior to modification, I change it to return it
newList.note = movieEntry.note
newList.status = movieEntry.status
newList.completedDate = movieEntry.completedDate
return res.status(200).json(list)
})
.catch(err => {
console.error(err)
return res.sendStatus(400)
})
}

The entries I needed to update were arrays that could grow indefinitely so I had to first change my models and use virtuals and another model for the the list entries.
Doing so made the work easier and I was able to create, update and delete the entries more easily and without any concurrency problems.
This might also not have been a concurrency problem in the first place, but a transaction problem.

Related

Mongoose unique if not null and if state

I have a unique index like this
code: {
type: String,
index: {
unique: true,
partialFilterExpression: {
code: { $type: 'string' }
}
},
default: null
},
state: { type: Number, default: 0 },
but When the state is 2 (archived) I want to keep the code, but it should be able to reuse the code, so it cannot be unique if state is 2.
Is there any away that I could accomplish this?
This is possible, though it's through a work around documented here https://jira.mongodb.org/browse/SERVER-25023.
In MongoDB 4.7 you will be able to apply different index options to the same field but for now you can add a non-existent field to separate the two indexes.
Here's an example using the work around.
(async () => {
const ItemSchema = mongoose.Schema({
code: {
type: String,
default: null
},
state: {
type: Number,
default: 0,
},
});
// Define a unique index for active items
ItemSchema.index({code: 1}, {
name: 'code_1_unique',
partialFilterExpression: {
$and: [
{code: {$type: 'string'}},
{state: {$eq: 0}}
]
},
unique: true
})
// Defined a non-unique index for non-active items
ItemSchema.index({code: 1, nonExistantField: 1}, {
name: 'code_1_nonunique',
partialFilterExpression: {
$and: [
{code: {$type: 'string'}},
{state: {$eq: 2}}
]
},
})
const Item = mongoose.model('Item', ItemSchema)
await mongoose.connect('mongodb://localhost:27017/so-unique-compound-indexes')
// Drop the collection for test to run correctly
await Item.deleteMany({})
// Successfully create an item
console.log('\nCreating a unique item')
const itemA = await Item.create({code: 'abc'});
// Throws error when trying to create with the same code
await Item.create({code: 'abc'})
.catch(err => {console.log('\nThrowing a duplicate error when creating with the same code')})
// Change the active code
console.log('\nChanging item state to 2')
itemA.state = 2;
await itemA.save();
// Successfully created a new doc with sama code
await Item.create({code: 'abc'})
.then(() => console.log('\nSuccessfully created a new doc with sama code'))
.catch(() => console.log('\nThrowing a duplicate error'));
// Throws error when trying to create with the same code
Item.create({code: 'abc'})
.catch(err => {console.log('\nThrowing a duplicate error when creating with the same code again')})
})();
This is not possible with using indexes. Even if you use a compound index for code and state there will still be a case where
new document
{
code: 'abc',
state: 0
}
archived document
{
code: 'abc',
state: 2
}
Now although you have the same code you will not be able to archive the new document or unarchive the archived document.
You can do something like this
const checkCode = await this.Model.findOne({code:'abc', active:0})
if(checkCode){
throw new Error('Code has to be unique')
}
else{
.....do something
}

Filtering documents by objects in an array

I have an array of strings in my schema, and I'm trying to filter documents depending on their arrays containing certain strings or not. Below is my schema, where the ingredients array is what I'm trying to filter by:
const foodSchema = mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
},
ingredients: [
{
type: String,
required: true,
trim: true,
},
],
});
In nodejs, I have the following inside my express router:
router.get('/food', auth, async (req, res) => {
const match = {};
if (req.query.name) {
match.name = req.query.name;
}
if (req.query.ingredients) {
match.ingredients = req.query.ingredients;
}
try {
const allFood = await Food.find(match);
res.send({
allFood,
});
} catch (error) {
res.status(400).send(error);
}
});
Here's an example request being sent:
{{url}}/food?ingredients=Salmon Meal&ingredients=Deboned Turkey
I would expect to get all food documents where their ingredients array contain both Salmon Meal and Deboned Turkey, but I always get an empty array. The casing is correct. If I just use one ingredient in the query, it works fine.
Use $all
match.ingredients = { $all: req.query.ingredients };
https://docs.mongodb.com/manual/reference/operator/query/all/
Use $in
if (req.query.ingredients && req.query.ingredients.length > 0) {
match.ingredients = { $in: req.query.ingredients };
}

Change a single value of a subdocument in mongodb

I have a Model called Notes. It has a subdocument requests which holds various documents with values userId, reqType, accepted value( false by default) and noteId of the sender, request and note respectively. When the user hits a certain route I want to keep all the data to be as their previous values, just updating the accepted field to true.
The below code leads to no change in the data or a different iteration leads to erasing all the data other than accepted field and modifying it to true.
How should I do this?
const noteSchema = new mongoose.Schema(
requests: [
{
userId: mongoose.Schema.ObjectId,
noteId: mongoose.Schema.ObjectId,
reqType: String,
accepted: {
type: Boolean,
default: false,
},
},
],
}
)
const Note = mongoose.model('Note', noteSchema)
const note = await Note.findById(req.body.noteId)
await note.updateOne({
requests: {
$elemMatch: {
userId: req.body.userId,
reqType: req.body.reqType,
noteId: req.body.noteId,
},
$set: { "requests.$.accepted": true },
},
})
You do not need to retrieve the document and then update it. Just update it. Use this one:
await Note.updateOne(
{
"requests.userId": req.body.userId,
"requests.reqType": req.body.reqType,
"requests.noteId": req.body.noteId
},
{
$set:
{
"requests.$.accepted":true
}
}
);
I checked, it worked.
With first part mongoose will find the document, with $set it will be updated.

Mongoose — findOneAndUpdate() causing document duplication

I'm using findOneAndUpdate() with upsert: true in order for a document to be updated if it exists and to be created otherwise. The tracks variable contains an array of Track instances. tracks does contain a few duplicates and that's where the problem begins. It causes the piece of code on line 7 (Observation.findOneAndUpdate(...)) to create a (low) number of duplicates, i.e. multiple documents that have the same (user, track) pair. Note that those duplicates are inserted randomly: running twice this piece of code brings different duplicated documents. My guess is that it has something to do with how the locking of data is done in MongoDB and that I'm doing too many operations at the same time. Any idea on how I could overcome this problem?
const promises = [];
​
tracks.forEach((track) => {
const query = { user, track };
const options = { new: true, upsert: true };
const newOb = { user, track, type: 'recent' };
promises.push(Observation.findOneAndUpdate(query, newOb, options));
});
​
return Promise.all(promises);
I'm using mongoose 5.5.8 and node 11.10.0.
Here's the Observation model:
const { Schema } = mongoose;
const ObservationSchema = new Schema({
track: { type: Schema.Types.ObjectId, ref: 'Track' },
user: { type: Schema.Types.ObjectId, ref: 'User' },
type: String
});
ObservationSchema.index({ track: 1, user: 1 }, { unique: true });
const Observation = mongoose.model('Observation', ObservationSchema);
And this is a sample of what the tracks array contains:
[
{ artists: [ 5da304b140185c5cb82d7eee ],
_id: 5da304b240185c5cb82d7f48,
spotifyId: '4QrEErhD78BjNFXpXDaTjH',
__v: 0,
isrc: 'DEF058230916',
name: 'Hungarian Dance No.17 In F Sharp Minor',
popularity: 25 },
{ artists: [ 5da304b140185c5cb82d7eee ],
_id: 5da304b240185c5cb82d7f5d,
spotifyId: '06dn1SnXsax9kJwMEpgBhD',
__v: 0,
isrc: 'DEF058230912',
name: 'Hungarian Dance No.13 In D',
popularity: 25 }
]
Thanks :)
I think this is due to your Promise.all method.
You should await every single query in the loop instead of awaiting everything at the same time at the end. Here an example with find:
async function retrieveApples() {
const apples = [];
arr.forEach(apple => {
const foundApple = await AppleModel.findOne({ apple });
apples.push(foundApple);
});
return apples
}

How do you implement a mongo aggregate pipeline within the pre middleware?

I have two schemas - Products & Orders - the each order contains an array of products referenced by the object.id. What I'm trying to do is $sum over the product.sellPrice in the array and set totalSale before saving the new order document.
const orderSchema = new mongoose.Schema({
products: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Product"
}
],
totalSale: {
type: Number,
min: 0
}
});
const productSchema = new mongoose.Schema({
name: {
type: String,
unique: true,
required: true
},
sellPrice: {
type: Number,
min: 0,
required: true
}}):
So each order would contain an array of products. So what tried doing was in the orderSchema.pre('save') hook, I tried running an aggregation like so:
orderSchema.pre("save", function(next) {
let order = this;
order.totalSale = order.aggregate([
{ $unwind: { path: "$products" } },
{ $group: { _id: null, $sum: {} } }
]);
Is this even possible or is there a better way to sum up the prices of each product for the order. It doesnt really have to be pre('save')
First of all, yes, it is possible to use aggregate function in pre.
If you want to use it, you need to add $lookup in your aggregation pipeline to populate product data into the order.
How about just using find() on Product?
const Product = mongoose.model('Product');
OrderSchema.pre('save', function (next) {
let order = this;
// if products array is empty, skip the process
if (!this.products || !Array.isArray(this.products) || !this.products.length < 1) {
return next();
}
Product.find({ $in: this.products }, { sellPrice: 1 })
.lean()
.exec(function (err, products) {
let totalSale = 0;
if (Array.isArray(products)) {
products.forEach(product => {
totalSale += product.sellPrice;
});
}
order.totalSale = totalSale;
next(err);
});
});
orderSchema.pre("save", function(next) {
let order = this;
let temp = order.products.map(product => {
return { _id: mongoose.Types.ObjectId(product) };
});
let totalSale = 0;
let promises = Product.find({ _id: { $in: temp } }).exec();
promises
.then(data => {
data.forEach(product => {
totalSale += product.sellPrice;
});
order.totalSale = totalSale;
next();
})
.catch(err => {
console.error(err);
});
});
Since find returns all matching the conditions, I saved them in a variable and then iterated over them.

Resources