Handle simultaneous quest accept/reject (#12090)

* Implement atomic quest accept/reject

* Persist quest.members early to avoid simultaneous handling of accept/reject

* Fix quest accept test (missing expectation)

* PR fixes
This commit is contained in:
Carl Vuorinen
2020-04-17 23:57:31 +03:00
committed by GitHub
parent 936d3ffc98
commit 2896cf77e0
3 changed files with 40 additions and 7 deletions

View File

@@ -112,7 +112,7 @@ describe('POST /groups/:groupId/quests/accept', () => {
await Promise.all([partyMembers[0].sync(), questingGroup.sync()]);
expect(leader.party.quest.RSVPNeeded).to.equal(false);
expect(questingGroup.quest.members[partyMembers[0]._id]);
expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(true);
});
it('does not begin the quest if pending invitations remain', async () => {

View File

@@ -193,12 +193,14 @@ api.acceptQuest = {
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyStartedFriendly'));
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
const acceptedSuccessfully = await group.handleQuestInvitation(user, true);
if (!acceptedSuccessfully) {
throw new NotAuthorized(res.t('questAlreadyAccepted'));
}
user.party.quest.RSVPNeeded = false;
await user.save();
group.markModified('quest');
group.quest.members[user._id] = true;
if (canStartQuestAutomatically(group)) {
await group.startQuest(user);
}
@@ -252,13 +254,15 @@ api.rejectQuest = {
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected'));
const rejectedSuccessfully = await group.handleQuestInvitation(user, false);
if (!rejectedSuccessfully) {
throw new NotAuthorized(res.t('questAlreadyRejected'));
}
user.party.quest = Group.cleanQuestUser(user.party.quest.progress);
user.markModified('party.quest');
await user.save();
group.quest.members[user._id] = false;
group.markModified('quest.members');
if (canStartQuestAutomatically(group)) {
await group.startQuest(user);
}

View File

@@ -651,6 +651,30 @@ schema.methods.sendChat = function sendChat (options = {}) {
return newChatMessage;
};
schema.methods.handleQuestInvitation = async function handleQuestInvitation (user, accept) {
if (!user) throw new InternalServerError('Must provide user to handle quest invitation');
if (accept !== true && accept !== false) throw new InternalServerError('Must provide accept param handle quest invitation');
// Handle quest invitation atomically (update only current member when still undecided)
// to prevent multiple concurrent requests overriding updates
// see https://github.com/HabitRPG/habitica/issues/11398
const Group = this.constructor;
const result = await Group.update(
{
_id: this._id,
[`quest.members.${user._id}`]: { $type: 10 }, // match BSON Type Null (type number 10)
},
{ $set: { [`quest.members.${user._id}`]: accept } },
).exec();
if (result.nModified) {
// update also current instance so future operations will work correctly
this.quest.members[user._id] = accept;
}
return Boolean(result.nModified);
};
schema.methods.startQuest = async function startQuest (user) {
// not using i18n strings because these errors are meant
// for devs who forgot to pass some parameters
@@ -680,6 +704,11 @@ schema.methods.startQuest = async function startQuest (user) {
// Changes quest.members to only include participating members
this.quest.members = _.pickBy(this.quest.members, _.identity);
// Persist quest.members early to avoid simultaneous handling of accept/reject
// while processing the rest of this script
await this.update({ $set: { 'quest.members': this.quest.members } }).exec();
const nonUserQuestMembers = _.keys(this.quest.members);
removeFromArray(nonUserQuestMembers, user._id);