javascriptfirebasegoogle-cloud-firestore

Firestore transaction with multiple writes - is my syntax okay?


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?


Solution

  • 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.