Browse the docs
Build
Jobs
Run a mutation or action later with ctx.scheduler. Durable, transactional delayed jobs with retries, inspection, and cancellation.
A job runs one of your mutations or actions later: a reminder 24 hours after a request, a follow-up email, a retry of a flaky API call, a long task split into steps. Schedule it from a handler with ctx.scheduler. fuast stores the job in the app's database and runs it on time, after restarts and deploys too.
import { capsule, mutation, number, string, table } from "fuast/server";
const app = capsule({
schema: {
requests: table({ title: string(), status: string(), ownerId: string() }),
},
mutations: {
submit: mutation(async (ctx, title: string) => {
const me = ctx.auth.requireSignedIn();
const id = await ctx.db.requests.insert({ title, status: "pending", ownerId: me.userId });
// One day later, remind the approver if it is still pending.
await ctx.scheduler.runAfter(24 * 3_600_000, "remind", id);
return id;
}),
remind: mutation(async (ctx, id: string) => {
const r = await ctx.db.requests.get(id);
if (!r || r.status !== "pending") return; // decided in the meantime: nothing to do
ctx.log.info("reminder", { id, scheduledBy: ctx.job?.scheduledBy?.userId });
}),
},
});
export default app;Use schedules for work on a fixed clock (every morning, every 15 minutes). Use jobs for work that one event starts.
Schedule, cancel
| Call | Does |
|---|---|
ctx.scheduler.runAfter(delayMs, name, ...args) |
Runs mutation or action name with args after delayMs milliseconds. 0 runs it as soon as possible. |
ctx.scheduler.runAt(timestamp, name, ...args) |
Runs it at timestamp (epoch milliseconds or a Date). A time in the past runs as soon as possible. |
ctx.scheduler.cancel(jobId) |
Cancels a pending job. Resolves false when it already ran, is running, or does not exist. |
ctx.scheduler.withOptions({ retry }) |
The same scheduler with options for the jobs it schedules. |
runAfter and runAt resolve to the job id (a string). Store it if you may cancel the job later.
Arguments are positional JSON values, like fuast run and useMutation. They are copied when you schedule: the job sees the values from scheduling time, not later edits.
Typed names and arguments
ctx.scheduler accepts any name and checks it when the job is scheduled (an unknown name throws ctx.scheduler: no mutation or action named <name>.). For compile-time checks, take a typed view of your capsule:
const app = capsule({
mutations: {
submit: mutation(async (ctx, title: string) => {
const jobs = ctx.scheduler.typed<typeof app>();
await jobs.runAfter(60_000, "remind", title); // name and args are checked
// jobs.runAfter(60_000, "remind", 42); // type error
}),
// A handler that RETURNS a typed job id needs a return type: the capsule's type refers to itself.
later: mutation(async (ctx, title: string): Promise<string> => ctx.scheduler.typed<typeof app>().runAfter(5000, "remind", title)),
remind: mutation(async (ctx, title: string) => { /* ... */ }),
},
});Where you can schedule
| Handler | Scheduling is |
|---|---|
Mutations, writable endpoints, schedules, onGuestUpgrade, jobs |
Transactional. The job is written in the same transaction as the handler's database writes. If the handler throws, times out, or hits a limit, the job is never scheduled. |
| Actions, read-only endpoints | Saved when the handler ends. There is no transaction, so the jobs are saved whether the action returns or throws. Use this to chain external work: fetch in an action, then schedule a mutation to store the result. |
| Queries | Not available. Queries are read-only. ctx.scheduler is missing from the query type and throws not_allowed. |
A job can schedule more jobs, including itself, to work through a big task in batches.
How jobs run
- A job runs as the system, like a schedule:
ctx.auth.userIdisnullanddisplayNameis"system". Check permissions when you schedule, and pass ids explicitly. ctx.jobis set while a handler runs as a job:{ id, name, attempt, maxAttempts, scheduledAt, scheduledBy }.scheduledByis a snapshot{ userId, provider, displayName, email? }of who scheduled it, ornullfor the system (schedules). Jobs scheduled by a job keep the originalscheduledBy.ctx.jobisundefinedwhen the same mutation is called directly, so one mutation can serve both.- Jobs have the same limits as any handler: 5 s of CPU, 10 s wall clock (actions 5 s), the database budgets.
- In an unclaimed preview, jobs are saved but do not run, and they are cancelled when a person claims the app. In a restricted app, jobs wait until the owner trusts it (
fuast trust), then run in due order. - Their writes update every subscribed client, like any mutation.
- Each run is logged with
source: "system"and the handler name:job remind (<id>) finished in 12ms, or the error. - Jobs do not count as requests. Each run counts as one mutation in the daily quota. When the mutation quota is used up, due jobs wait until it resets.
- Due jobs start within about a second. Do not depend on sub-second timing.
Delivery and retries
| Kind | Delivery | Retries on a thrown error |
|---|---|---|
| Mutation job | Exactly once. The job is marked done in the same transaction as the mutation's writes. | None by default. withOptions({ retry: n }) allows n more attempts. |
| Action job | At least once. If the runtime stops during a run, the job runs again after its lease (2 minutes) expires. | 3 by default (4 attempts). withOptions({ retry: n }) sets it, 0 to 10. |
Retries back off: 1 s, 4 s, 16 s, 64 s, then 4× longer each time, up to 1 hour. ctx.job.attempt is 1 on the first run.
Make action jobs idempotent: an action can run twice, for example when the runtime restarts in the middle of it. Give external APIs an idempotency key (the job id works: ctx.job.id), or record what you did in a mutation and check it first.
A job whose handler no longer exists in the running deploy (renamed or deleted) fails without retrying. Keep old job handlers around until their jobs have run, or cancel them.
Limits
| Limit | Value | Error |
|---|---|---|
| Pending jobs per app (pending + running) | 10000 (FUAST_LIMIT_JOBS_PENDING) |
Pending job limit exceeded (10000). Cancel jobs or let them finish first. |
| Jobs scheduled by one handler call | 1000 (FUAST_LIMIT_JOBS_PER_CALL) |
Job limit exceeded: at most 1000 jobs per handler call. |
| Arguments per job | 64 KiB of JSON (FUAST_LIMIT_JOB_ARGS_BYTES); action jobs also have the 16 KiB action argument limit |
Job arguments exceed 65536 bytes. |
| Delay | 0 to 1 year | ctx.scheduler.runAfter() needs a delay in milliseconds (0 or more). / ... at most 31536000000 ms (1 year) ahead. |
| Retries | 0 to 10 | ctx.scheduler: retry must be an integer between 0 and 10. |
| Job runs per app (hosted) | about 20 per second | Extra due jobs wait for the next round. |
Limit errors from scheduling are thrown in the handler, so a mutation that hits one rolls back. The newest 1000 finished jobs are kept for inspection. Jobs are stored next to your tables but are not rows: they do not count toward the state row and byte limits and are not part of db export.
Inspect and cancel
fuast jobs list --port 3000 # local: pending first, then recent finished jobs
fuast jobs list my-app --status failed # deployed app, by slug
fuast jobs cancel <jobId> my-app # cancel a pending job
fuast logs my-app --follow # watch runs and errorsGET <app>/__fuast/jobs returns { jobs, counts, nextRunAt } in fuast dev and hosted (owner and workspace admins), with ?status= and ?limit=. The dashboard's Schedules tab lists jobs under the cron schedules and can cancel pending ones.
Test jobs
fuast dev runs jobs with the same rules as hosted: same transactions, retries, ctx.job, logs, and inspection. Schedule with a delay of 0 while testing, then read the result:
fuast run submit '"Laptop"' --port 3000
fuast jobs list --port 3000To test the job body itself, call the mutation directly with fuast run remind '"<id>"'. ctx.job is undefined in that call.
Run a cron schedule without waiting for its time: fuast run schedule <name> [slug] (or Run now in the dashboard).