mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-31 03:08:29 +08:00
fix: concurrency issue while claiming rewards (@fehmer) (#5553)
This commit is contained in:
parent
b8fce15490
commit
ce093c538d
2 changed files with 274 additions and 76 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 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,
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
xp: "$tmp.xp",
|
||||
inbox: "$tmp.inbox",
|
||||
inventory: "$tmp.inventory",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
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 rewardUpdates = buildRewardUpdates(allRewards, user.inventory === null);
|
||||
const mergedUpdates = _.merge(baseUpdate, rewardUpdates);
|
||||
|
||||
await getUsersCollection().updateOne({ uid }, mergedUpdates);
|
||||
if (update.matchedCount !== 1)
|
||||
throw new MonkeyError(404, "User not found", "update inbox");
|
||||
}
|
||||
|
||||
export async function updateStreak(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue