Long Running Jobs
SST ships with Job
— a simple way to run functions that can take up to 8 hours.
Tasks related to video processing, ETL, and ML can take long. These exceed Lambda's 15 minute timeout limit. Job
provides a convenient way to run these tasks.
Overview
Job
is made up of the following pieces:
Job
— a construct that creates the necessary infrastructure.JobHandler
— a handler function that wraps around your function code in a typesafe way.Job.run
— a helper function to invoke the job.
Quick start
To follow along, you can create a new SST app by running npx create-sst@latest
. Alternatively, you can refer to this example repo that's based on the same template.
Create the infrastructure
To create a new job, import
Job
at the top ofstacks/MyStack.ts
.stacks/MyStack.tsimport { Job } from "sst/constructs";
And add a
Job
construct below the API.const job = new Job(stack, "myJob", {
srcPath: "services",
handler: "functions/myJob.handler",
});Grant permissions
Give
api
the permissions to run the job.stacks/MyStack.tsapi.bind([job]);
Define the handler function
Create the function with the code that needs to run for long. Here for example, we are creating a function to calculate the factorial of a given number.
Define the shape of the function payload.
packages/functions/src/myJob.tsimport { JobHandler } from "sst/node/job";
declare module "sst/node/job" {
export interface JobTypes {
myJob: {
num: number;
};
}
}Note that we are defining the job payload to contain a
num
property with typenumber
. This'll ensure that we'll get a type error in our editor when we try to pass in a string. We talk more about typesafety below.Then create the handler function using the
JobHandler
helper. Append this tomyJob.ts
.export const handler = JobHandler("myJob", async (payload) => {
// Calculate factorial
let result = 1;
for (let i = 2; i <= payload.num; i++) {
result = result * i;
}
console.log(`Factorial of ${payload.num} is ${result}`);
});Run the job
And finally we can run this job in our API using the
Job.myJob.run
helper. Changepackages/functions/src/lambda.ts
to:packages/functions/src/lambda.tsimport { Job } from "sst/node/job";
import { APIGatewayProxyHandlerV2 } from "aws-lambda";
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
await Job.myJob.run({
payload: {
num: 100,
},
});
return {
statusCode: 200,
headers: { "Content-Type": "text/plain" },
body: `Job started at ${event.requestContext.time}.`,
};
};You'll notice that your editor will autocomplete the
payload
for you.
info
Job.myJob.run
returns right after it starts the long running job.
And that's it. You can now add long running jobs to your apps!
Runtime environment
The job function runs inside a docker container, using the official aws-lambda-nodejs
Node.js 16 container image. This image is similar to the AWS Lambda execution environment.
Jobs currently only support Node.js runtimes, and they are always bundled by esbuild with the esm
format. If you are interested in other runtimes, talk to us on Discord.
You can optionally configure the memory size and the timeout for the job.
const job = new Job(stack, "myJob", {
srcPath: "services",
handler: "functions/myJob.handler",
timeout: "1 hour",
memorySize: "7GB",
});
See a full list of memory size and timeout configurations.
Referencing AWS resources
Similar to Functions, you can use the bind
fields to pass other resources to your job, and reference them at runtime.
tip
Resource Binding works inside a job.
For example, to access a Table
inside a job:
// Create a DynamoDB table
const table = new Table(stack, "myTable", { /* ... */ });
// Create a Job
new Job (stack, "myJob, {
srcPath: "services",
handler: "functions/myJob.handler",
bind: [table], // bind table to job
});
Now you can access the table at runtime.
import { Table } from "sst/node/table";
import { JobHandler } from "sst/node/job";
export const handler = JobHandler("myJob", async (payload) => {
console.log(Table.myTable.tableName);
});
How it works
Let's look at how Job
works. It uses a few resources behind the scenes:
- An AWS CodeBuild project that runs the handler function inside a docker container.
- An invoker Lambda function that triggers the CodeBuild project.
sst deploy
Calling
new Job()
creates the above resources.When binding to an API (or any other function):
api.bind([job]);
The API route is granted the IAM permission to invoke the invoker Lambda function. The invoker Lambda's function name is also passed into the route as Lambda environment variable,
SST_Job_functionName_myJob
.At runtime, when running the job:
await Job.myJob.run({
payload: {
num: 100,
},
});Job.myJob.run
gets the name of the invoker function fromprocess.env.SST_Job_functionName_myJob
, and invokes the function with the payload.The invoker function then triggers the CodeBuild job. The function payload is JSON stringified and passed to the CodeBuild job as environment variable,
SST_PAYLOAD
.Finally, the CodeBuild job decodes
process.env.SST_PAYLOAD
, and runs the job handler with the decoded payload in a Lambda execution environment.
sst dev
On sst dev
, the invoker function is replaced with a stub function. The stub function sends the request to your local machine, and the local version of the job function is executed. This is similar to how Live Lambda Development works for a Function
.
info
Your locally invoked job has the same IAM permissions as the deployed CodeBuild job.
SST Console
The job can be found in the console under the Functions tab. And you can manually invoke the job.
Here we are passing in {"num":5}
as the payload for the job.
Typesafe payload
In our example, we defined the job type in packages/functions/src/myJob.ts
.
export interface JobTypes {
myJob: {
num: number;
};
}
This is being used in two places to ensure typesafety.
When running the job, the payload is validated against the job type.
await Job.myJob.run({
payload: {
num: 100,
},
});And, when defining the
JobHandler
, thepayload
argument is automatically typed. Your editor can also autocompletepayload.num
for you, and reports a type error if an undefined field is accessed by mistake.export const handler = JobHandler("myJob", async (payload) => {
// Editor can autocomplete "num"
console.log(payload.num);
// Editor shows a type error
console.log(payload.foo);
});
Behind the scenes
Let's take a look at how this is all wired up.
First, the
sst/node/job
package predefines two interfaces.export interface JobNames {}
export interface JobTypes {}JobNames
is managed by SST. When SST builds the app, it generates a type file and adds all job names to theJobNames
interface.node_modules/@types/@serverless-stack__node/Job-LongJob.d.tsimport "sst/node/job";
declare module "sst/node/job" {
export interface JobNames {
myJob: string;
}
}This type file then gets appended to
index.d.ts
.node_modules/@types/@serverless-stack__node/index.d.tsexport * from "./Job-LongJob";
JobTypes
is managed by you. In our example, you defined the payload types inpackages/functions/src/myJob.ts
.packages/functions/src/myJob.tsexport interface JobTypes {
myJob: {
num: number;
};
}With
JobNames
andJobTypes
defined,Job.myJob.run
has the type:export type JobRunProps<T extends keyof JobResources> = {
payload?: JobTypes[T];
};
async function run(props: JobRunProps<Name>) {}props.payload
must be the corresponding job type for the given job
And finally
JobHandler
has the type:function JobHandler<C extends keyof JobNames>(
name: C,
cb: (payload: JobTypes[C]) => void
) {}name
must be one of the job names defined in your stackspayload
passed into thecb
callback function has the job type for the given job
Cost
CodeBuild has a free tier of 100 build minutes per month. After that, you are charged per build minute. You can find the full pricing here — https://aws.amazon.com/codebuild/pricing/. The general1
instance types are used.
FAQ
Here are some frequently asked questions about Job
.
When should I use Job
vs Function
?
Job
is a good fit for running functions that takes longer than 15 minutes, such as
- ML jobs
- ETL jobs
- Video processing
Note that, Jobs
have a much longer cold start time. When a job starts up, CodeBuild has to download the docker image before running the code. This process can take around 30 seconds. Job
is not a good choice if you are looking to run something right away.
Is Job
a good fit for batch jobs?
There are a few AWS services that can help you schedule running batch jobs: AWS Batch and Step Functions, etc.
Setting up AWS Batch and Step Functions requires using multiple AWS services, and requires more experience to wire up all the moving parts.
For one off jobs, where you just want to run something longer than 15 minutes, use Job
. And if you need to run certain jobs on a regular basis, you can explore the above options.
Why CodeBuild instead of Fargate?
We evaluated both CodeBuild and ECS Fargate as the backing service for Job
.
Both services are similar in the way that they can run code inside a docker container environment. We decided to go with CodeBuild because:
- It can be deployed without a VPC
- It offers a free tier of 100 build minutes per month
- A CodeBuild project is a single AWS resource, and is much faster to deploy
As we collect more feedback on the usage, we are open to switching to using Fargate. When we do, it will be a seamless switch as the implementation details are not exposed.