0
votes

Per Firebase cloud functions docs, "Events are delivered at least once, but a single event may result in multiple function invocations. Avoid depending on exactly-once mechanics, and write idempotent functions."

When looking at a firestore cloud function doc example below of a restaurant rating, they are using an increment counter to calculate the total number of ratings. What are some of the best ways to maintain the accuracy of this counter by using an idempotent function?

Is it reasonable to store the context.eventId in a subcollection document field and only execute the function if the new context.eventId is different?

function addRating(restaurantRef, rating) {
    // Create a reference for a new rating, for use inside the transaction
    var ratingRef = restaurantRef.collection('ratings').doc();

    // In a transaction, add the new rating and update the aggregate totals
    return db.runTransaction((transaction) => {
        return transaction.get(restaurantRef).then((res) => {
            if (!res.exists) {
                throw "Document does not exist!";
            }

            // Compute new number of ratings
            var newNumRatings = res.data().numRatings + 1;

            // Compute new average rating
            var oldRatingTotal = res.data().avgRating * res.data().numRatings;
            var newAvgRating = (oldRatingTotal + rating) / newNumRatings;

            // Commit to Firestore
            transaction.update(restaurantRef, {
                numRatings: newNumRatings,
                avgRating: newAvgRating
            });
            transaction.set(ratingRef, { rating: rating });
        });
    });
}
1

1 Answers

0
votes

Is it reasonable to store the context.eventId in a subcollection document field and only execute the function if the new context.eventId is different?

Yes, for your use case, using the Cloud Function eventId is the best solution to make you Cloud Function indempotent. I guess you have already watched this Firebase video.

In the Firebase doc from which you have taken the code in your question, you will find at the bottom, the similar code for a Cloud Function. I've adapted this code as follows, in order to check if a doc with ID = eventId exists in a dedicated ratingUpdateIds subcollection:

exports.aggregateRatings = functions.firestore
    .document('restaurants/{restId}/ratings/{ratingId}')
    .onWrite(async (change, context) => {

        try {

            // Get value of the newly added rating
            const ratingVal = change.after.data().rating;
            const ratingUpdateId = context.eventId;

            // Get a reference to the restaurant
            const restRef = db.collection('restaurants').doc(context.params.restId);

            // Get a reference to the ratingUpdateId doc
            const ratingUpdateIdRef = restRef.collection("ratingUpdateIds").doc(ratingUpdateId);

            // Update aggregations in a transaction
            await db.runTransaction(async (transaction) => {

                const ratingUpdateIdDoc = await transaction.get(ratingUpdateIdRef);

                if (ratingUpdateIdDoc.exists) {
                    // The CF is retried
                    throw "The CF is being retried";
                }

                const restDoc = await transaction.get(restRef);

                // Compute new number of ratings
                const newNumRatings = restDoc.data().numRatings + 1;

                // Compute new average rating
                const oldRatingTotal = restDoc.data().avgRating * restDoc.data().numRatings;
                const newAvgRating = (oldRatingTotal + ratingVal) / newNumRatings;

                // Update restaurant info and set ratingUpdateIdDoc
                transaction
                    .update(restRef, {
                        avgRating: newAvgRating,
                        numRatings: newNumRatings
                    })
                    .set(ratingUpdateIdRef, { ratingUpdateId })

            });

            return null;

        } catch (error) {
            console.log(error);
            return null;
        }

    });

PS: I made the assumption that the Cloud Function eventId can be used as a Firestore document ID. I didn't find any doc or info stating the opposite. In case using the eventId as an ID would be a problem, since you execute the transaction in a Cloud Function (and therefore use the Admin SDK), you could query the document based on a field value (where you store the eventId) instead of getting it through a Reference based on its ID.