# Jobs

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.

```ts
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](https://fuast.com/docs/schedules.md) 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:

```ts
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.userId` is `null` and `displayName` is `"system"`. Check permissions when you schedule, and pass ids explicitly.
- `ctx.job` is set while a handler runs as a job: `{ id, name, attempt, maxAttempts, scheduledAt, scheduledBy }`. `scheduledBy` is a snapshot `{ userId, provider, displayName, email? }` of who scheduled it, or `null` for the system (schedules). Jobs scheduled by a job keep the original `scheduledBy`.
- `ctx.job` is `undefined` when 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](https://fuast.com/docs/reference.md#deploy-without-an-account), jobs are saved but do not run, and they are cancelled when a person claims the app. In a [restricted](https://fuast.com/docs/security.md#restricted-apps) 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

```sh
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 errors
```

`GET <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:

```sh
fuast run submit '"Laptop"' --port 3000
fuast jobs list --port 3000
```

To 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).
