aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChristian Fehmer <[email protected]>2024-07-02 20:39:27 +0200
committerGitHub <[email protected]>2024-07-02 20:39:27 +0200
commitce093c538da0973350f2a7e76b671e032f87adf7 (patch)
tree3885dab0dd701922f786ba91f443742c1c87c67f
parentb8fce15490da1c4bf634859dbabb7a4463cd47a0 (diff)
downloadmonkeytype-ce093c538da0973350f2a7e76b671e032f87adf7.tar.gz
monkeytype-ce093c538da0973350f2a7e76b671e032f87adf7.zip
fix: concurrency issue while claiming rewards (@fehmer) (#5553)
-rw-r--r--backend/__tests__/dal/user.spec.ts207
-rw-r--r--backend/src/dal/user.ts141
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(