Search code examples
firebasegoogle-cloud-firestoretransactions

Am I doing Firestore Transactions correct?


I've followed the Firestore documentation with relation to transactions, and I think I have it all sorted correctly, but in testing I am noticing issues with my documents not getting updated properly sometimes. It is possible that multiple versions of the document could be submitted to the function in a very short interval, but I am only interested in only ever keeping the most recent version.

My general logic is this:
New/Updated document is sent to cloud function Check if document already exists in Firestore, and if not, add it.

If it does exist, check that it is "newer" than the instance in firestore, if it is, update it.

Otherwise, don't do anything.

Here is the code from my function that attempts to accomplish this...I would love some feedback if this is correct/best way to do this:

const ocsFlight = req.body;
  const procFlight = processOcsFlightEvent(ocsFlight);
  try {
    const ocsFlightRef = db.collection(collection).doc(procFlight.fltId);
    const originalFlight = await ocsFlightRef.get();

    if (!originalFlight.exists) {
      const response = await ocsFlightRef.set(procFlight);
      console.log("Record Added: ", JSON.stringify(procFlight));
      res.status(201).json(response); // 201 - Created
      return;
    }

    await db.runTransaction(async (t) => {
      const doc = await t.get(ocsFlightRef);
      const flightDoc = doc.data();

      if (flightDoc.recordModified <= procFlight.recordModified) {
        t.update(ocsFlightRef, procFlight);
        console.log("Record Updated: ", JSON.stringify(procFlight));
        res.status(200).json("Record Updated");
        return;
      }

      console.log("Record isn't newer, nothing changed.");
      console.log("Record:", JSON.stringify("Same Flight:", JSON.stringify(procFlight)));
      res.status(200).json("Record isn't newer, nothing done.");
      return;
    });
  } catch (error) {
    console.log("Error:", JSON.stringify(error));
    res.status(500).json(error.message);
  }

Solution

  • The Bugs

    First, you are trusting the value of req.body to be of the correct shape. If you don't already have type assertions that mirror your security rules for /collection/someFlightId in processOcsFlightEvent, you should add them. This is important because any database operations from the Admin SDKs will bypass your security rules.

    The next bug is sending a response to your function inside the transaction. Once you send a response back the client, your function is marked inactive - resources are severely throttled and any network requests may not complete or crash. As a transaction may be retried a handful of times if a database collision is detected, you should make sure to only respond to the client once the transaction has properly completed.

    You use set to write the new flight to Firestore, this can lead to trouble when working with transactions as a set operation will cancel all pending transactions at that location. If two function instances are fighting over the same flight ID, this will lead to the problem where the wrong data can be written to the database.

    In your current code, you return the result of the ocsFlightRef.set() operation to the client as the body of the HTTP 201 Created response. As the result of the DocumentReference#set() is a WriteResult object, you'll need to properly serialize it if you want to return it to the client and even then, I don't think it will be useful as you don't seem to use it for the other response types. Instead, a HTTP 201 Created response normally includes where the resource was written to as the Location header with no body, but here we'll pass the path in the body. If you start using multiple database instances, including the relevant database may also be useful.

    Fixing

    The correct way to achieve the desired result would be to do the entire read->check->write process inside of a transaction and only once the transaction has completed, then respond to the client.

    So we can send the appropriate response to the client, we can use the return value of the transaction to pass data out of it. We'll pass the type of the change we made ("created" | "updated" | "aborted") and the recordModified value of what was stored in the database. We'll return these along with the resource's path and an appropriate message.

    In the case of an error, we'll return a message to show the user as message and the error's Firebase error code (if available) or general message as the error property.

    // if not using express to wrangle requests, assert the correct method
    if (req.method !== "POST") {
      console.log(`Denied ${req.method} request`);
      res.status(405) // 405 - Method Not Allowed
        .set("Allow", "POST")
        .end(); 
      return;
    }
    
    const ocsFlight = req.body;
    try {
      // process AND type check `ocsFlight`
      const procFlight = processOcsFlightEvent(ocsFlight);
    
      const ocsFlightRef = db.collection(collection).doc(procFlight.fltId);
    
      const { changeType, recordModified } = await db.runTransaction(async (t) => {
        const flightDoc = await t.get(ocsFlightRef);
    
        if (!flightDoc.exists) {
          t.set(ocsFlightRef, procFlight);
          return {
            changeType: "created",
            recordModified: procFlight.recordModified
          };
        }
    
        // only parse the field we need rather than everything
        const storedRecordModified = flightDoc.get('recordModified');
    
        if (storedRecordModified <= procFlight.recordModified) {
          t.update(ocsFlightRef, procFlight);
          return {
            changeType: "updated",
            recordModified: procFlight.recordModified
          };
        }
    
        return {
          changeType: "aborted",
          recordModified: storedRecordModified
        };
      });
    
      switch (changeType) {
        case "updated":
          console.log("Record updated: ", JSON.stringify(procFlight));
          res.status(200).json({ // 200 - OK
            path: ocsFlightRef.path,
            message: "Updated",
            recordModified,
            changeType
          });
          return;
        case "created":
          console.log("Record added: ", JSON.stringify(procFlight));
          res.status(201).json({ // 201 - Created
            path: ocsFlightRef.path,
            message: "Created",
            recordModified,
            changeType
          });
          return;
        case "aborted":
          console.log("Outdated record discarded: ", JSON.stringify(procFlight));
          res.status(200).json({ // 200 - OK
            path: ocsFlightRef.path,
            message: "Record isn't newer, nothing done.",
            recordModified,
            changeType
          });
          return;
        default:
          throw new Error("Unexpected value for 'changeType': " + changeType);
      }
    } catch (error) {
      console.log("Error:", JSON.stringify(error));
      res.status(500) // 500 - Internal Server Error
        .json({
          message: "Something went wrong",
          // if available, prefer a Firebase error code
          error: error.code || error.message 
        });
    }
    

    References