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:
What is the best way to run the function just once and abort/skip any other invocation for the same user?
There is currently no way to deploy a single function with the Firebase CLI. Running `firebase deploy` will deploy all functions.
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.
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.
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) => { /* ... */ });
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.
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.
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.
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:
Examples of such integration are:
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.
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!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With