×

Timed exams with Firebase Scheduled Functions

In this guide, I discuss the approach I used to implement “timed exams” on an eLearning platform we built. We needed to allow instructors to set an exam time limit, after which students’ exams were automatically submitted. The problem was surprisingly thorny.

First attempts at timed exams

A pretty common scenario in real-world app development is something along these lines: you have a user action that needs to trigger some backend logic or function call, but you don’t want that function to be triggered immediately. In other words, you want to wait a given amount of time before executing the function. When using a Firebase backend, this can be a tricky problem to navigate.

I recently ran into this issue while working on an e-learning platform that relies on Firebase. The app consists of learning modules (courses broken down into various types of content) and exams; fairly standard as online learning goes. Each exam has an associated time limit- 2 hours, for example. When a student begins an exam, I had to ensure the exam would get graded either 1) when the student submits it before the time limit expires, or 2) when the time limit expires. And, of course, this had to happen regardless of whether the student closes the app.

Brainstorming how to implement this, a couple inelegant solutions came to mind first. Maybe it’d be possible to run a backend function every few minutes to clean up any expired exams. Or maybe whenever the user tries to pick an answer to a question, it would make sense to check if the exam is expired before saving their selection to Firestore. Both of these ideas have issues, though. The first would involve a lot of unnecessary database queries; it would also either be very intensive or would result in students getting unequal amounts of time to finish their exams, depending on how often we’d check for expired exams. The second would result in the student’s exam never being graded if they were to stop selecting answers before the time limit expired. Clearly, there had to be a better way. Enter Google Cloud Tasks.

Google Cloud Tasks

Cloud tasks enable us to schedule Firebase functions for execution at a specific time in the future. What’s more, they also let us cancel existing tasks before they’re executed (provided you know the ID of the task you want to cancel). This fit my use case perfectly: when a student starts an exam, I can schedule a task to grade his/her answers in when the time limit elapses. Also, if the student were to submit the exam before tune expired, I could simply cancel the scheduled task. This solution is elegant and about as clean as possible:

  1. the exam gets graded, even if the user closes the app or stops interacting with it
  2. the grading happens right when it’s supposed to, so we don’t have to worry about some students getting more time than others
  3. there’s no extraneous polling or unnecessary DB queries, which would be a waste of resources

So without further ado, here’s a walk-through on how to use scheduled Cloud Tasks.

Note: Cloud Tasks is separate from Firebase, so we don’t get to handle this configuration in the Firebase console. Luckily, there aren’t too many things we have to handle through the Cloud Tasks console, so you won’t need to be very familiar with it in order to get this set up.

Console set-up

To start: go to the Cloud Console and enable the Cloud Tasks API. You’ll need to have billing enabled in your project in order to use this service. Like most Google Cloud services, it’s very inexpensive for small-to-medium-scale projects: 1 million free tasks per month, then $0.40/million after that.

Next, install the gcloud CLI if you haven’t already done so. You can check if you have it installed by running gcloud -v in the command line.

To configure your project settings for the gcloud CLI, run gcloud init and follow the prompts.

With the CLI installed and configured, now it’s time to create a queue. Those who are curious can find more details about queues in the docs, but a queue is basically an abstraction to hold your task until it’s ready to run and then offload it to a worker and ensure it’s successfully executed.

Creating a queue is a simple one-liner- all you need to switch out is the name of your queue (I’ll name mine exam-grader):

gcloud tasks queues create exam-grader

Function code

Let’s go ahead and install the client-side Cloud Tasks SDK via npm:

npm install @google-cloud/tasks

Unfortunately, es6 import syntax doesn’t seem to work with @google-cloud/tasks, so we’ll use require and suppress linter warnings if there are any:

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
const { CloudTasksClient } = require("@google-cloud/tasks");

admin.initializeApp();

Now we can go about writing the actual functions we need. How you structure this will depend a bit on your use case, but in every scenario you’ll need one function that will create the task (Task-creation Function) and one function to be executed as part of the task (Task-execution function).

For the e-learning app, we create a new document when a student begins an exam, so it made sense to use an onCreate hook to create and schedule the task when a new exam doc is created. You can also schedule a task from an HTTP onRequest function all the same.

Here’s an overview of what we want to happen, so we’ll know how to architect these functions.

  1. (Precursor) Document creation - In this use case, we want to schedule a grade-exam task when a student starts an exam; specifically, we’ll use an onCreate hook for when the student’s exam document is created. When creating this document, I saved the exam’s timeLimit and the timestamp at which the student began the exam (startTime) as fields, since I knew I’d need to access them later.
  2. onCreate Hook
    1. When the onCreate hook fires, we’ll go ahead and create a scheduled task to grade the exam. This task will run at time startTime + timeLimit , which is why it was helpful to save that data on the exam document.
    2. We’ll also want to pass some information to the task-execution function as a payload in order to give it some context. In this case, we’d want to pass something like a studentId and other similar information.
    3. Finally, we’ll save the task ID to the document, just in case we need to cancel the task later
  3. Task-Execution Function
    1. This will be an HTTP onRequest function.
    2. In the body, we’ll get the data we need from the payload we used when creating the task. Then, we’ll do whatever logic we need to accomplish, and we’ll send back a status code 200

Phew! Alright, enough scheming; it’s time to dig into the code!

Let’s start with the function that’ll be creating the task. Here’s what that might look like as an onCreate hook:

exports.onCreateExam = functions.firestore
  .document("/path/to/exam/document")
  .onCreate(async (snap, context) => {
    // Set up
    const examData = snap.data();
    const { startTime, timeLimit } = examData;
    const project = JSON.parse(process.env.FIREBASE_CONFIG!).projectId;
    const location = "us-central1";
    const queue = "exam-grader";
    const tasksClient = new CloudTasksClient();
    const queuePath: string = tasksClient.queuePath(project, location, queue);

     // execution
    const url = `https://${location}-${project}.cloudfunctions.net/getAndGradeExam`;
    const docPath = snap.ref.path;
    const payload = {
      docPath,
      studentId: context.params.studentId,
      courseId: context.params.courseId,
    };
    const body = Buffer.from(JSON.stringify(payload)).toString("base64");
    const executionTime = Math.ceil(startTime / 1000) + timeLimit * 60;

    const task = {
      httpRequest: {
        httpMethod: "POST",
        url,
        body,
        headers: {
          "Content-Type": "application/json",
        },
      },
      scheduleTime: {
        seconds: unixSeconds,
      },
    };

    const [response] = await tasksClient.createTask({
      parent: queuePath,
      task,
    });
    await snap.ref.update({ gradeTaskId: response.name });
  });

Let’s break this down, piece by piece.

exports.onCreateExam = functions.firestore
  .document("/path/to/exam/document")
  .onCreate(async (snap, context) => { ... }

Here, we set up an onCreate hook called onCreateExam. It runs when a document is created at the path /path/to/exam/document.

...
		// grab the data we need from the document. This includes the
		// exam start time and time limit, as mentioned above.
    const examData = snap.data();
    const { startTime, timeLimit } = examData;
		// get this Firebase project ID from environment config
    const project = JSON.parse(process.env.FIREBASE_CONFIG!).projectId;
		// set region in which function will run, and name of queue we created
    const location = "us-central1";
    const queue = "exam-grader";
		// instantiate new cloud tasks client with necessary information
    const tasksClient = new CloudTasksClient();
    const queuePath: string = tasksClient.queuePath(project, location, queue);
    const url = `https://${location}-${project}.cloudfunctions.net/gradeExam`;
		// path to document for exam we want to grade
    const docPath = snap.ref.path;
		// create payload to pass to task-execution function
    const payload = {
      docPath,
      studentId: context.params.studentId,
			// any other data you need here
    };
    const body = Buffer.from(JSON.stringify(payload)).toString("base64");
    const executionTime = Math.ceil(startTime / 1000) + timeLimit * 60;

		...
}

This part is mostly configuration and getting data ready to create the request.

In the body of the onCreate, we

  • Grab some data from the document that was just created; we’ll need this startTime and timeLimit to know when to schedule our request
  • Get the Firebase project ID from an environment variable. Check out the docs for how to save and fetch environment variables in the Firebase backend.
  • Hard-code some data we’ll need to create the task. This includes the location in which the function will run (mine is in us-central1) and the name of the queue which we created earlier.
  • Instantiate the Cloud Tasks Client and pass it some of the data we’ve prepped. Note that for the url, I’m naming my task-execution function gradeExam. Change this to whatever you want to call your function.
  • Create a payload to pass to the task-execution function. Then, base64 encode the payload.
  • Finally, prep the execution time for the task. startTime was a UNIX timestamp in milliseconds, so we convert to seconds and add in the timeLimit (converted to seconds). This gives us the epoch timestamp at which we want the task to execute.

And the last piece:

({
...
		// set up task object
		const task = {
      httpRequest: {
        httpMethod: "POST",
        url,
        body,
        headers: {
          "Content-Type": "application/json",
        },
      },
      scheduleTime: {
        seconds: unixSeconds,
      },
    };

		// create task
    const [response] = await tasksClient.createTask({
      parent: queuePath,
      task,
    });
		// save task identifier as field 'gradeTaskId' on current document,
		// in case we need to later cancel the task
    await snap.ref.update({ gradeTaskId: response.name });
});

Here, we actually create the task using the data we’ve prepped above. We get the task identifier from the createTask method and save that identifier on the current document. This is in case we need to cancel the task later on- we’ll need to know the task ID in order to know which to cancel!

Great, so that settles creating and scheduling the task. Now there’s just one more part to square away: the task-execution function. Here’s some skeleton example code:

// function is called 'gradeExam', the name we specified above
exports.gradeExam = functions.https.onRequest(async (req, res) => {
  const payload = req.body;
  try {
		// grab the student's exam document path from the payload,
		// fetch that document's data, and spin off into a function
		// for grading exams.
    let examData: any = await db.doc(payload.docPath).get();
    examData = examData.data();
    await gradeExamHelper(
      payload.studentId,
      payload.courseId,
      examData.selectedAnswers
    );
		// send a 200 response code
    res.send(200);
  } catch (error) {
    console.error(error);
		// Handle error somehow; send a 200 response code if you 
		// don't want to retry executing this task
    res.send(200);
  }
});

This part’s pretty straightforward, and will depend on whatever logic you’re trying to accomplish. One thing is worth emphasizing, though: if you don’t send a success response code, the task will be re-attempted until it gets a success code. If you want to retry until your function works, great. If not, you can send a success response code like I did above and your function won’t be retried.

Deleting Tasks

Thanks to the prep we did above, deleting tasks is very simple. If you have the ID of the task you want to delete- the ID we saved above, explicitly for this purpose- you can just run the following code:

const tasksClient = new CloudTasksClient()
await tasksClient.deleteTask({ name: expirationTask })

And that’s it for scheduled Cloud Tasks! There’s a bit to adjust to if you’re unfamiliar with the setup, but once you’ve gone through the process once it’s very easy to create new queues and task scheduling/executing functions on the backend.