Dynamic Scheduled Background Jobs in Firebase

Last week, Firebase announced a new scheduled cron trigger for Cloud Functions that makes it easy to run serverless code on a set time interval. This function type is special because it combines the powers of Cloud Scheduler and Pub/Sub to guarantee security that you don’t have with a regular HTTP-triggered function.

Scheduling a function on a static time interval is straight forward, but what if you want to build a dynamic task queue where users can schedule their own background jobs? For example, you might want to…

  • allow users to customize times for transactional email delivery
  • schedule push notifications or similar alerts dynamically
  • enqueue background jobs to run at specific times
  • build robocallers 🤣 - please don’t

Basic Scheduled Function

Let’s start by looking at an example of a basic cron-scheduled Cloud Function.

Make sure you have the latest version of firebase-tools (or at least version 6.7) installed on your system, then initialize a new project.

Learn more about cron schedules.

command line
npm i firebase-tools@latest -g

firebase init functions

A basic scheduled Cloud Function can be defined on the pubsub namespace.

file_type_typescript index.ts
export const dailyJob = functions.pubsub

   .schedule('30 5 * * *').onRun(context => {
      console.log('This will be run every day at 5:30AM');
   });

export const everyFiveMinuteJob = functions.pubsub

    .schedule('every 5 minutes').onRun(context => {
        console.log('This will be run every 5 minutes!');  
     });

Dynamic Task Queue

Our task queue or job queue is simply a Firestore collection that will be queried by a Pub/Sub Cloud Function every 60 seconds. If the current time is greater than the performAt time of a task, then we execute it.

The task queue query expired tasks on each tick, then execute the business logic associated with the task worker field

The task queue query expired tasks on each tick, then execute the business logic associated with the task worker field

Step 1: Data Model for Background Jobs

A task is a generic document that tells the Cloud Function how to run the backgorund code.

  • performAt when to execute the task as a Firestore timestamp.
  • status the state of the tasks, useful for debugging and/or querying.
  • worker the name of worker function, which contains the business logic to execute. See step 3.
  • options a map containing extra data for the worker function, like a userID argument for example.
tasks/{taskID}/

   performAt: TimeStamp
   status: 'scheduled' | 'complete' | 'error'
   worker: string 
   options: Map
Tasks represent a simple data model for calling JS functions and passing data to our backend code

Tasks represent a simple data model for calling JS functions and passing data to our backend code

Step 2: Task Runner Cloud Function

Pro Tips

This function will be invoked approx 45,000 times per month, but it can complete multiple tasks per run. Firebase provides 125K free invocations per month.

Max out the memory and timeout settings for your function. This will ensure the task runner can handle large batches of jobs in a single run.

Next, we need to define a Pub/Sub Cloud Function that queries the task collection every 60s (or whatever granularity you want) for tasks that are ready to perform.

file_type_typescript functions/src/index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
admin.initializeApp();
const db = admin.firestore();

export const taskRunner = functions.runWith( { memory: '2GB' }).pubsub

    .schedule('* * * * *').onRun(async context => {

        // Consistent timestamp
        const now = admin.firestore.Timestamp.now();
        
        // Query all documents ready to perform
        const query = db.collection('tasks').where('performAt', '<=', now).where('status', '==', 'scheduled');

        const tasks = await query.get();


        // Jobs to execute concurrently. 
        const jobs: Promise<any>[] = [];

        // Loop over documents and push job.
        tasks.forEach(snapshot => {
            const { worker, options } = snapshot.data();

            const job = workers[worker](options)
                
                // Update doc with status on success or error
                .then(() => snapshot.ref.update({ status: 'complete' }))
                .catch((err) => snapshot.ref.update({ status: 'error' }));

            jobs.push(job);
        });

        // Execute all jobs concurrently
        return await Promise.all(jobs);

});

Keep in mind, this is a compound query that will require a Firestore index.

The query requires an index with status Ascending and performAt Ascending

The query requires an index with status Ascending and performAt Ascending

Step 3: Define Worker Functions to Run Jobs

Now that we have a working function in place, we can define the business logic (worker functions) that execute a task.

file_type_typescript functions/src/index.ts
// Optional interface, all worker functions should return Promise. 
interface Workers {
    [key: string]: (options: any) => Promise<any>
}

// Business logic for named tasks. Function name should match worker field on task document. 
const workers: Workers = {
    helloWorld: () => db.collection('logs').add({ hello: 'world' }),
}

Run firebase deploy --only functions.

After the function is deployed we just need to create a task document in Firestore that points the helloWorld worker. Within 1 minute you should see the task document update to complete

Questions? Let's chat

Open Discord