diff options
author | Christian Fehmer <[email protected]> | 2024-07-02 20:39:27 +0200 |
---|---|---|
committer | GitHub <[email protected]> | 2024-07-02 20:39:27 +0200 |
commit | ce093c538da0973350f2a7e76b671e032f87adf7 (patch) | |
tree | 3885dab0dd701922f786ba91f443742c1c87c67f | |
parent | b8fce15490da1c4bf634859dbabb7a4463cd47a0 (diff) | |
download | monkeytype-ce093c538da0973350f2a7e76b671e032f87adf7.tar.gz monkeytype-ce093c538da0973350f2a7e76b671e032f87adf7.zip |
fix: concurrency issue while claiming rewards (@fehmer) (#5553)
-rw-r--r-- | backend/__tests__/dal/user.spec.ts | 207 | ||||
-rw-r--r-- | backend/src/dal/user.ts | 141 |
2 files changed, 273 insertions, 75 deletions
diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index e06bb577f..221e390a3 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -891,4 +891,211 @@ describe("UserDal", () => { ).rejects.toThrowError("User not found\nStack: update email"); }); }); + describe("updateInbox", () => { + it("claims rewards", async () => { + //GIVEN + const rewardOne: SharedTypes.MonkeyMail = { + id: "b5866d4c-0749-41b6-b101-3656249d39b9", + body: "test", + subject: "reward one", + timestamp: 1, + read: false, + rewards: [ + { type: "xp", item: 400 }, + { type: "xp", item: 600 }, + { type: "badge", item: { id: 4 } }, + ], + }; + const rewardTwo: SharedTypes.MonkeyMail = { + id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", + body: "test", + subject: "reward two", + timestamp: 2, + read: false, + rewards: [{ type: "xp", item: 2000 }], + }; + const rewardThree: SharedTypes.MonkeyMail = { + id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", + body: "test", + subject: "reward three", + timestamp: 3, + read: true, + rewards: [{ type: "xp", item: 2000 }], + }; + + let user = await UserTestData.createUser({ + xp: 100, + inbox: [rewardOne, rewardTwo, rewardThree], + }); + + //WNEN + await UserDAL.updateInbox( + user.uid, + [rewardOne.id, rewardTwo.id, rewardThree.id], + [] + ); + + //THEN + const { xp, inbox } = await UserDAL.getUser(user.uid, ""); + expect(xp).toEqual(3100); + + //inbox is sorted by timestamp + expect(inbox).toStrictEqual([ + { ...rewardThree }, + { ...rewardTwo, read: true, rewards: [] }, + { ...rewardOne, read: true, rewards: [] }, + ]); + }); + + it("removes", async () => { + //GIVEN + const rewardOne = { + id: "b5866d4c-0749-41b6-b101-3656249d39b9", + body: "test", + subject: "reward one", + timestamp: 0, + read: false, + rewards: [], + }; + const rewardTwo = { + id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", + body: "test", + subject: "reward two", + timestamp: 0, + read: true, + rewards: [], + }; + const rewardThree = { + id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", + body: "test", + subject: "reward three", + timestamp: 0, + read: false, + rewards: [], + }; + + let user = await UserTestData.createUser({ + xp: 100, + inbox: [rewardOne, rewardTwo, rewardThree], + }); + + //WNEN + await UserDAL.updateInbox(user.uid, [], [rewardOne.id, rewardTwo.id]); + + //THEN + const { inbox } = await UserDAL.getUser(user.uid, ""); + expect(inbox).toStrictEqual([rewardThree]); + }); + + it("updates badge", async () => { + //GIVEN + const rewardOne: SharedTypes.MonkeyMail = { + id: "b5866d4c-0749-41b6-b101-3656249d39b9", + body: "test", + subject: "reward one", + timestamp: 2, + read: false, + rewards: [ + { type: "xp", item: 400 }, + { type: "badge", item: { id: 4 } }, + ], + }; + const rewardTwo: SharedTypes.MonkeyMail = { + id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", + body: "test", + subject: "reward two", + timestamp: 1, + read: false, + rewards: [{ type: "badge", item: { id: 5 } }], + }; + const rewardThree: SharedTypes.MonkeyMail = { + id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", + body: "test", + subject: "reward three", + timestamp: 0, + read: true, + rewards: [{ type: "badge", item: { id: 6 } }], + }; + + let user = await UserTestData.createUser({ + inbox: [rewardOne, rewardTwo, rewardThree], + inventory: { badges: [{ id: 1, selected: true }] }, + }); + + //WNEN + await UserDAL.updateInbox( + user.uid, + [rewardOne.id, rewardTwo.id, rewardThree.id, rewardOne.id], + [] + ); + + //THEN + const { inbox, inventory } = await UserDAL.getUser(user.uid, ""); + expect(inbox).toStrictEqual([ + { ...rewardOne, read: true, rewards: [] }, + { ...rewardTwo, read: true, rewards: [] }, + { ...rewardThree }, + ]); + expect(inventory?.badges).toStrictEqual([ + { id: 1, selected: true }, + { id: 4 }, + { id: 5 }, + ]); + }); + + it("does not claim reward multiple times", async () => { + //GIVEN + const rewardOne: SharedTypes.MonkeyMail = { + id: "b5866d4c-0749-41b6-b101-3656249d39b9", + body: "test", + subject: "reward one", + timestamp: 0, + read: false, + rewards: [ + { type: "xp", item: 400 }, + { type: "xp", item: 600 }, + { type: "badge", item: { id: 4 } }, + ], + }; + const rewardTwo: SharedTypes.MonkeyMail = { + id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", + body: "test", + subject: "reward two", + timestamp: 0, + read: false, + rewards: [{ type: "xp", item: 2000 }], + }; + const rewardThree: SharedTypes.MonkeyMail = { + id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", + body: "test", + subject: "reward three", + timestamp: 0, + read: true, + rewards: [{ type: "xp", item: 2000 }], + }; + + let user = await UserTestData.createUser({ + xp: 100, + inbox: [rewardOne, rewardTwo, rewardThree], + }); + + const count = 100; + const calls = new Array(count) + .fill(0) + .map(() => + UserDAL.updateInbox( + user.uid, + [rewardOne.id, rewardTwo.id, rewardOne.id, rewardThree.id], + [] + ) + ); + + await Promise.all(calls); + + //THEN + + const { xp } = await UserDAL.getUser(user.uid, ""); + expect(xp).toEqual(3100); + }); + }); }); diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 9a87d770e..6424d4cc0 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -976,89 +976,80 @@ export async function addToInbox( ); } -function buildRewardUpdates( - rewards: SharedTypes.AllRewards[], - inventoryIsNull = false -): UpdateFilter<MonkeyTypes.DBUser> { - let totalXp = 0; - const newBadges: SharedTypes.Badge[] = []; - - rewards.forEach((reward) => { - if (reward.type === "xp") { - totalXp += isNaN(reward.item) ? 0 : reward.item; - } else if (reward.type === "badge") { - const item = _.omit(reward.item, "selected"); - newBadges.push(item); - } - }); - - const baseUpdate = { - $inc: { - xp: _.isNumber(totalXp) ? totalXp : 0, - }, - }; - - if (inventoryIsNull) { - return { - ...baseUpdate, - $set: { - inventory: { - badges: newBadges, - }, - }, - }; - } else { - return { - ...baseUpdate, - $push: { - "inventory.badges": { $each: newBadges }, - }, - }; - } -} - export async function updateInbox( uid: string, mailToRead: string[], mailToDelete: string[] ): Promise<void> { - const user = await getPartialUser(uid, "update inbox", [ - "inbox", - "inventory", - ]); + const readSet = [...new Set(mailToRead)]; + const deleteSet = [...new Set(mailToDelete)]; - const inbox = user.inbox ?? []; - - const mailToReadSet = new Set(mailToRead); - const mailToDeleteSet = new Set(mailToDelete); - - const allRewards: SharedTypes.AllRewards[] = []; - - const newInbox = inbox - .filter((mail) => { - const { id, rewards } = mail; - - if (mailToReadSet.has(id) && !mail.read) { - mail.read = true; - if (rewards.length > 0) { - allRewards.push(...rewards); - mail.rewards = []; - } - } - - return !mailToDeleteSet.has(id); - }) - .sort((a, b) => b.timestamp - a.timestamp); - - const baseUpdate = { - $set: { - inbox: newInbox, + const update = await getUsersCollection().updateOne({ uid }, [ + { + $addFields: { + tmp: { + $function: { + lang: "js", + args: ["$_id", "$inbox", "$xp", "$inventory"], + body: ` + function(_id, inbox, xp, inventory) { + var rewards = inbox + .filter(it => it.read === false) + .reduce((arr, current) => { + return arr.concat(current.rewards); + }, []); + + var xpGain = rewards + .filter(it => it.type === "xp") + .map(it => it.item) + .reduce((s, a) => s + a, 0); + + //remove deleted mail from inbox, sort by timestamp descending + var inboxUpdate = inbox + .filter(it => ${JSON.stringify( + deleteSet + )}.includes(it.id) === false) + .sort((a, b) => b.timestamp - a.timestamp); + + //mark read mail as read, remove rewards + inboxUpdate.filter(it => it.read === false && ${JSON.stringify( + readSet + )}.includes(it.id)).forEach(it => { + it.read = true; + it.rewards = []; + }); + + var badges = rewards + .filter(it => it.type === "badge") + .map(it => it.item); + + if(inventory === null) inventory = { badges:null }; + if(inventory.badges === null) inventory.badges = []; + inventory.badges.push(...badges); + + return { + _id, + xp: xp + xpGain, + inbox: inboxUpdate, + inventory: inventory, + }; + } + `, + }, + }, + }, }, - }; - const rewardUpdates = buildRewardUpdates(allRewards, user.inventory === null); - const mergedUpdates = _.merge(baseUpdate, rewardUpdates); + { + $set: { + xp: "$tmp.xp", + inbox: "$tmp.inbox", + inventory: "$tmp.inventory", + }, + }, + ]); - await getUsersCollection().updateOne({ uid }, mergedUpdates); + if (update.matchedCount !== 1) + throw new MonkeyError(404, "User not found", "update inbox"); } export async function updateStreak( |