Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Best practices to trigger a Firebase Cloud Function just once

I need to trigger a Firebase Cloud Function just once every time a new Firebase Auth user is created. I already wrote a fully working function that sends one email per user using the onCreate trigger. The function sends a welcome email and track some analytics data, so it is not idempotent.

The problem here is that Google arbitrarily calls that function multiple times. This is not a "bug" but an expected behaviour and the dev must handle it.

I want to know what is the best practice to change the "at least once" behaviour to the "exactly once" one.

What happens now:

  1. New User "A" signs up.
  2. Google fires the "sendWelcomeEmail" for the user A.
  3. Google fires the "sendWelcomeEmail" for the user A AGAIN.

What is the best way to run the function just once and abort/skip any other invocation for the same user?

like image 338
Antonio Avatar asked Sep 28 '18 15:09

Antonio


People also ask

How do I deploy a single Firebase function?

There is currently no way to deploy a single function with the Firebase CLI. Running `firebase deploy` will deploy all functions.

How do I trigger a Firebase function?

Cloud Firestore triggers onCreate : triggered when a document is written to for the first time. onUpdate : triggered when a document already exists and has any value changed. onDelete : triggered when a document with data is deleted. onWrite : triggered when onCreate, onUpdate or onDelete is triggered.

Can a cloud function have multiple triggers?

You cannot bind the same function to more than one trigger at a time, but you can have the same event cause multiple functions to execute by deploying multiple functions with the same trigger settings.

How do I set up Cloud Functions for Firebase?

If you don't have a project enabled for Cloud Functions for Firebase yet, then read Get Started: Write and Deploy Your First Functions to configure and set up your Cloud Functions for Firebase project. To define a Cloud Firestore trigger, specify a document path and an event type: .onWrite( (change, context) => { /* ... */ });

How can Firebase help you accelerate app development?

Tune in to learn how Firebase can help you accelerate app development, release with confidence, and scale with ease. Register With Cloud Functions, you can handle events in Cloud Firestore with no need to update client code. You can make Cloud Firestore changes via the DocumentSnapshot interface or via the Admin SDK.

Is it possible to add events to specific fields in Firebase?

It is not possible to add events to specific fields. If you don't have a project enabled for Cloud Functions for Firebase yet, then read Get Started: Write and Deploy Your First Functions to configure and set up your Cloud Functions for Firebase project.

How to trigger a function in response to Firebase remote config events?

You can trigger a function in response to Firebase Remote Config events, including the publication of a new config version or the rollback to an older version. Google Cloud Pub/Sub is a globally distributed message bus that automatically scales as you need it.


1 Answers

I faced a similar problem and there's no easy solution. I found that for any action that is using an external system it's quite impossible to make such a function idempotent. I'm using TypeScript and Firestore.

For a solution to this problem, you will need to use Firebase transactions. Only using transaction you'll be able to face race conditions which happen when a function is triggered multiple times, usually at the same time.

I found that there are 2 levels of this problem:

  1. You don't have an idempotent function, you just need sending email to be idempotent.
  2. You have a set of idempotent functions and need to execute some actions that require integration with external systems.

Examples of such integration are:

  • sending an email
  • connecting to a payment system

1. For non-idempotent function (simple case scenario)

async function isFirstRun(user: UserRecord) {
  return await admin.firestore().runTransaction(async transaction => {
    const userReference = admin.firestore().collection('users').doc(user.uid);

    const userData = await transaction.get(userReference) as any
    const emailSent = userData && userData.emailSent
    if (!emailSent) {
      transaction.set(userReference, { emailSent: true }, { merge: true })
      return true;
    } else {
      return false;
    }
  })
}

export const onUserCreated = functions.auth.user().onCreate(async (user, context) => {
  const shouldSendEmail = await isFirstRun(user);
  if (shouldSendEmail) {
    await sendWelcomeEmail(user)
  }
})

P.S. You can also use built-in eventId field to filter out duplicated event fires. See https://cloud.google.com/blog/products/serverless/cloud-functions-pro-tips-building-idempotent-functions. The work required will be comparable – you still have to store already processed actions or events.


2. For a set of already idempotent functions (real case scenario)

To make this work with a set of functions that are already idempotent I switched to the queuing system. I push actions to the collection and utilize Firebase Transactions to "lock" execution of the action to only one function at a time.

I'll try to put the minimal example here.

Deploy the action handler function

export const onActionAdded = functions.firestore
  .document('actions/{actionId}')
  .onCreate(async (actionSnapshot) => {
    const actionItem: ActionQueueItem = tryPickingNewAction(actionSnapshot)

    if (actionItem) {
      if (actionItem.type === "SEND_EMAIL") {
        await handleSendEmail(actionItem)
        await actionSnapshot.ref.update({ status: ActionQueueItemStatus.Finished } as ActionQueueItemStatusUpdate)
      } else {
        await handleOtherAction(actionItem)
      }
    }
  });

/** Returns the action if no other Function already started processing it */
function tryPickingNewAction(actionSnapshot: DocumentSnapshot): Promise<ActionQueueItem> {
  return admin.firestore().runTransaction(async transaction => {
    const actionItemSnapshot = await transaction.get(actionSnapshot.ref);
    const freshActionItem = actionItemSnapshot.data() as ActionQueueItem;

    if (freshActionItem.status === ActionQueueItemStatus.Todo) {
      // Take this action
      transaction.update(actionSnapshot.ref, { status: ActionQueueItemStatus.Processing } as ActionQueueItemStatusUpdate)
      return freshActionItem;
    } else {
      console.warn("Trying to process an item that is already being processed by other thread.");
      return null;
    }
  })
}

Push actions to the collection like this

admin.firestore()
    .collection('actions')
    .add({
      created: new Date(),
      status: ActionQueueItemStatus.Todo,
      type: 'SEND_EMAIL',
      data: {...}
    })

TypeScript definitions

export enum ActionQueueItemStatus {
  Todo = "NEW",
  Processing = "PROCESSING",
  Finished = "FINISHED"
}

export interface ActionQueueItem {
  created: Date
  status: ActionQueueItemStatus
  type: 'SEND_EMAIL' | 'OTHER_ACTION'
  data: EmailActionData
}

export interface EmailActionData {
  subject: string,
  content: string,
  userEmail: string,
  userDisplayName: string
}

You may need to adjust this with richer statuses and their changes, but this approach should work for any case and the code provided should a good starting point. This also doesn't include a mechanism to rerun failed actions, but they are easy to find.

If you know a simpler way of doing this - please tell me how :)

Good luck!

like image 107
damienix Avatar answered Nov 02 '22 13:11

damienix