Developers & Practitioners

Avoiding GCF anti-patterns part 1: How to write event-driven Cloud Functions properly by coding with idempotency in mind

Editor's note: Over the next several weeks, you'll see a series of blog posts focusing on best practices for writing Google Cloud Functions based on common questions or misconceptions as seen by the Support team. We refer to these as "anti-patterns" and offer you ways to avoid them. This article is the first post in the series.

Scenario

You noticed that your Cloud Function executes multiple times for a single request. For example, your Function saves duplicate entries to Firestore for a single request.

Most common anti-pattern

Your Cloud Function is not idempotent. In this context, idempotent means that you should be able to call your Function multiple times with the same input data and the result of the initial call does not change. Examples are provided below. 

How to investigate:

For background functions, it is important to be familiar with execution guarantees. A background Function is guaranteed to be invoked at least once, which implies the following: 

  1. You need to test that you can call your Function multiple times with the same input data that does not change the initial result. 
  2. Or if you cannot write your Function in an idempotent manner, you have a plan on how to handle these additional calls. 

Examples of idempotent functions

In Serverless, it is important to write your code with idempotency in mind. If it is not possible to achieve idempotency, it's important to have a plan on how to handle duplicate invocations.  

Let's first examine idempotent functions by exploring HTTP-triggered functions. Then you'll see an example of idempotency for background functions. 

HTTP Function example

In the idempotent example below, a request contains a unique value (i.e. sensor ID & timestamp) which is used as the key to save to Firestore. Notice how Firestore.set() is used to specify this unique key. Therefore, even if the HTTP Function gets the request twice (theoretically speaking), you won't get duplicate entries in Firestore because the same unique key was used to create the document.

  // idempotent http example
const Firestore = require("@google-cloud/firestore");
const firestoreDb = new Firestore();
 
exports.idempotentHttp = async (req, res) => {
 try {
   const timestamp = req.body.timestamp;
   const sensor_id = req.body.sensor_id;
   const key = timestamp + "-" + sensor_id;
 
   const firestoreDoc = firestoreDb.doc(`sensorData/${key}`);
   await firestoreDoc.set({ sensor_id: req.body.sensor_id, temperature: req.body.temperature }, { merge: true });
   res.send(`data saved at: ${timestamp} for sensor: ${sensor_id}`);
 } catch (error) {
   console.error("got error: ", error);
   res.status(500).send("Unable to complete request");
 }
};

Now consider a non-idempotent HTTP Function example to show how the behavior differs from an idempotent example. This HTTP Function shown below uses Firestore auto-generated keys to save data by calling Firestore.add(). Therefore, if the HTTP Function gets the request twice (theoretically speaking), the Function will create two new entries in Firestore. 

  // non-idempotent http example
const Firestore = require("@google-cloud/firestore");
const firestoreDb = new Firestore();
 
exports.nonIdempotentHttp = async (req, res) => {
 try {
   const timestamp = req.body.timestamp;
   const sensor_id = req.body.sensor_id;
   console.log(sensor_id + " " + timestamp);
 
   await firestoreDb
     .collection("sensorData")
     .add({ sensor_id: req.body.sensor_id, temperature: req.body.temperature }, { merge: true });
 
   res.send(`data saved at: ${timestamp} for sensor: ${sensor_id}`);
 } catch (error) {
   console.error("got error: ", error);
   res.status(500).send("Unable to complete request");
 }
};

Background Function example

Now that you've seen an example using HTTP Functions, let's explore idempotency in background Functions. There are many ways to make retriable background Functions idempotent

Let's continue with this example of saving data to Firestore, but this time let's change the Function to be triggered by PubSub instead of an HTTP request. 

  const { Firestore } = require("@google-cloud/firestore");
const firestoreDb = new Firestore();
 
// write to Firestore via Pub/Sub trigger
exports.idempotentPubSub = async (data, context) => {
 const message = decodePubSubMessage(data);
 
 const timestamp = message.timestamp;
 const sensor_id = message.sensor_id;
 const temperature = message.temperature;
 const key = timestamp + "-" + sensor_id;
 
 const firestoreDoc = firestoreDb.doc(`idempotentPubSubTest/${key}`);
 await firestoreDoc.set({ sensor_id: sensor_id, temperature: temperature });
};
 
function decodePubSubMessage(message) {
 return JSON.parse(Buffer.from(message.data, "base64").toString());
}

Generally speaking, you have two options when writing idempotent functions: 

  1. Check to see if the data you are about to save already exists.
  2. Check to see if there's an idempotent version of the API call available e.g. Firestore.set() vs Firestore.add(). 

What if you cannot write an idempotent Function?

If you cannot make your function idempotent, e.g. you are relying on data from an outside source like stock inventory, you will need to implement workarounds or have a plan to deal with duplicate entries. 

The key here is awareness. You want to strive for idempotency. And if you cannot write an idempotent function, it is important to be aware of any potential side effects of multiple requests. 

Other helpful tips

  • Once you have written an background Function with idempotency in mind, you may want to consider implementing retries in case of an intermittent or application error.
  • Before enabling retries, you will want to confirm that your code cannot get into a continuous retry loop.