fix: concurrency issue while claiming rewards (@fehmer) (#5553)

This commit is contained in:
Christian Fehmer 2024-07-02 20:39:27 +02:00 committed by GitHub
parent b8fce15490
commit ce093c538d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 274 additions and 76 deletions

View file

@ -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);
});
});
});

View file

@ -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(