I am hosting a competition where groups of players (classes in a school) compete for points.
The players play individually and earns points for both themselves and for their class.
I track individual player points and total class points (to see which class is in the lead and to see the top players within a class).
Every time a player earns points, I make a call to the backend to update the number of player's points and the number of the player's class' points. It looks something like this:
UPDATED CODE: I have added event guards and use admin.firestore.FieldValue.increment when updating points.
const finishMission = (env, playerId, startYear, endYear, shipImage, newAchievements) => {
return new Promise(async (resolve) => {
const db = admin.firestore();
const playersDbName = env === 'demo' ? 'players-demo' : 'players';
const pointEventsDbName = env === 'demo' ? 'point-events-demo' : 'point-events';
/* Generate next mission */
let nextMission = generateNewMission();
if (!nextMission) {
resolve({status: 'error'});
return;
}
db.runTransaction(async (tx) => {
/* Get player data */
const playerRef = db.collection(playersDbName).doc(playerId);
const playerSnapFresh = await tx.get(playerRef);
if (!playerSnapFresh.exists) throw new Error('player not found');
const playerFresh = playerSnapFresh.data() || {};
/* Get player missions */
const missionsFresh = Array.isArray(playerFresh.missions)
? JSON.parse(JSON.stringify(playerFresh.missions))
: [];
if (missionsFresh.length <= 1) throw new Error('no missions');
/* Finish mission */
const missionIndex = missionsFresh.length - 2;
if (!missionsFresh[missionIndex]) throw new Error('no mission with index ' + missionIndex);
/* Calculate mission points */
let missionPoints = missionsFresh[missionIndex].missionPoints
? Number(missionsFresh[missionIndex].missionPoints) : 0;
const maxPts = (
missionsFresh[missionIndex].measurementIds &&
missionsFresh[missionIndex].measurementIds.length
)
? missionsFresh[missionIndex].measurementIds.length
: undefined;
if (Number.isFinite(maxPts) && missionPoints > maxPts) missionPoints = maxPts;
/* Separate idempotency guards for class and player updates */
const classEventId = `missionClassPoints:${playerFresh.classId}:${playerId}:${missionIndex}`;
const playerEventId = `missionPlayerPoints:${playerId}:${missionIndex}`;
const classEventRef = db.collection(pointEventsDbName).doc(classEventId);
const playerEventRef = db.collection(pointEventsDbName).doc(playerEventId);
const [classEventSnap, playerEventSnap] = await Promise.all([
tx.get(classEventRef),
tx.get(playerEventRef)
]);
const classEventExists = classEventSnap.exists;
const playerEventExists = playerEventSnap.exists;
/* Calculate change in player points */
const currentPlayerPoints = Number(playerFresh.points || 0);
const playerPointsAfter = currentPlayerPoints + missionPoints;
if (classEventExists && playerEventExists && missionsFresh[missionIndex].timeOfCompletion) {
/* Both events exist AND mission effects have already been applied */
return {status: 'success', missionPoints, playerPointsAfter: playerFresh.points};
}
if (!classEventExists) {
/* Class event missing - apply class points */
const classRef = db.collection('classes').doc(playerFresh.classId);
const classDelta = missionPoints;
tx.update(classRef, {points: admin.firestore.FieldValue.increment(classDelta)});
tx.set(classEventRef, {
playerId,
classId: playerFresh.classId,
type: 'missionClassPoints',
missionIndex,
delta: classDelta,
createdAt: Date.now(),
});
}
if (!playerEventExists && missionsFresh[missionIndex].timeOfCompletion) {
/* Player event missing but mission effects have been applied, add guard */
tx.set(playerEventRef, {
playerId,
type: 'missionPlayerPoints',
missionIndex,
delta: missionPoints,
createdAt: Date.now(),
});
} else {
/* Player event missing and/or mission effects have not been applied, apply effects */
/* Mark mission as completed */
missionsFresh[missionIndex].timeOfCompletion = Date.now();
/* Add new mission */
missionsFresh.push(nextMission);
/* Get new achievements (if any) */
const achievementsFresh = Array.isArray(playerFresh.achievements) ? playerFresh.achievements : [];
let updatedAchievements = achievementsFresh;
if (Array.isArray(newAchievements) && newAchievements.length > 0) {
updatedAchievements = achievementsFresh.concat(newAchievements);
}
/* Update player (atomic increment for points) */
const playerUpdate = {
missions: missionsFresh,
currentMissionImageIndex: 1,
points: admin.firestore.FieldValue.increment(missionPoints),
};
if (updatedAchievements !== achievementsFresh) {
playerUpdate.achievements = updatedAchievements;
}
tx.update(playerRef, playerUpdate);
if (!playerEventExists) {
/* Add player event guard */
tx.set(playerEventRef, {
playerId,
type: 'missionPlayerPoints',
missionIndex,
delta: missionPoints,
createdAt: Date.now(),
});
}
}
return { status: 'success', missionPoints, playerPoints: playerPointsAfter };
}).then((r) => {
return resolve(r || { status: 'success' });
}).catch((error) => {
return resolve({ status: 'error', error: String(error && error.message ? error.message : error) });
});
});
};
OLD CODE:
db.runTransaction((transaction) => {
const classDocRef = db.collection('classes').doc(playerData.classId);
return transaction.get(classDocRef).then((classDoc) => {
/* Update class points */
const points = classDoc.data().points
? classDoc.data().points + missionPoints
: missionPoints;
return transaction.update(classDocRef, {points});
}).then(() => {
/* Update player data */
const playerDocRef = db.collection(playersDbName).doc(playerId);
return transaction.update(playerDocRef, playerUpdates);
}).catch((error) => {
console.error(error);
resolve({status: 'error', error: error});
});
}).then(() => {
resolve({status: 'success'});
}).catch((error) => {
console.error(error);
resolve({status: 'error', error: error});
});
If any of the updates fails, it will try again, right?
I am wondering, because there is a tendency for the players / classes to have more points than they should have. I am worried that when a transaction has to run again due to contention, the points are added twice. Could that happen?
Yes, Firestore will retry the entire transaction again in the case of a concurrent edit. According to the Firestore documentation about transactions:
In the case of a concurrent edit, Cloud Firestore runs the entire transaction again. For example, if a transaction reads documents and another client modifies any of those documents, Cloud Firestore retries the transaction. This feature ensures that the transaction runs on up-to-date and consistent data.
If you are worried about the potential of double counting of points, I suggest that you take a look into FieldValue.increment which performs an atomic increment.