# Cron and jobs

> Run something of an app on a schedule, by calling a path or by running a command on a machine of its own, and run one-off commands as jobs with their exit code and output kept.

A schedule runs on a cron expression. It either wakes the app and calls a path of it, signed so the app can verify the call, or it runs a command on a machine of its own, built from the app's image and environment. A job is that same machine without a schedule: one command, run once.

## A path on a schedule

For frequent, light work the app does itself, down to every minute.

**CLI**

```bash
ry cron add web "*/15 * * * *" --path /api/tick
```

**curl**

```bash
curl -X POST "https://api.rytiv.app/v1/apps/web/schedules" \
  -H "Authorization: Bearer $RYTIV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"schedule": "*/15 * * * *", "path": "/api/tick", "method": "POST"}'
```

**TypeScript**

```ts
await fetch("https://api.rytiv.app/v1/apps/web/schedules", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.RYTIV_TOKEN}`, "Content-Type": "application/json" },
  body: JSON.stringify({ schedule: "*/15 * * * *", path: "/api/tick", method: "POST" }),
});
```

**Python**

```python
import os, requests

requests.post(
    "https://api.rytiv.app/v1/apps/web/schedules",
    headers={"Authorization": f"Bearer {os.environ['RYTIV_TOKEN']}"},
    json={"schedule": "*/15 * * * *", "path": "/api/tick", "method": "POST"},
)
```

The expression has five fields (minute, hour, day of month, month, day of week) or is `@hourly`, `@daily` and the like, read in the `timezone` you name (default `UTC`, for example `Europe/Berlin`). `method` is `GET` (default) or `POST`. Each attempt has `timeoutSec` (default 60, at most 240).

### Verify the call

A path is called through the app's address with these headers:

| Header | Value |
| --- | --- |
| `Rytiv-Cron-Signature` | `v1=<hex>`: HMAC-SHA256 of `<timestamp>.<path>` under the app's secret `RYTIV_CRON_SECRET` |
| `Rytiv-Cron-Timestamp` | Unix seconds when the call was signed |
| `Rytiv-Cron-Run` | The run's id, the same across retries |
| `Rytiv-Cron-Schedule` | The schedule's id |
| `Rytiv-Cron-Scheduled-For` | The slot the run is for |

`<path>` is the path with its query, exactly as sent. The secret `RYTIV_CRON_SECRET` is created as a managed secret with the app's first path schedule; **the app sees it from its next deploy**, and a run before that fails rather than being sent unsigned.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function cronCallIsGenuine(req: Request, path: string): boolean {
  const ts = req.headers.get("rytiv-cron-timestamp") ?? "";
  const sig = req.headers.get("rytiv-cron-signature") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = `v1=${createHmac("sha256", process.env.RYTIV_CRON_SECRET!).update(`${ts}.${path}`).digest("hex")}`;
  return expected.length === sig.length && timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
```

A call is retried up to three times (after 5 s, then 10 s) on a network error or a `5xx`; a `4xx` or a redirect is not retried. A slot that comes while the previous run of the same schedule still runs is skipped, not queued; a slot more than 10 minutes late counts as missed.

## A command on a schedule

For heavy, rare work: at most hourly, and every run gets a machine of its own, sized as you say.

```bash
ry cron add web "0 3 * * *" --timezone Europe/Berlin --command "npm run cleanup" --memory 8192
ry cron add web "@daily" -- node scripts/report.js --full
```

`--command` runs through `/bin/sh -c`; the form after `--` is the argument list without a shell. The machine runs the app's image with its secrets and environment, and the app's size unless `--memory` or `--vcpus` say otherwise. A run has `timeoutSec` (default 900, at most 3600). The minute field of the expression must name one minute.

## Manage schedules

```bash
ry cron list web
ry cron runs web sch-1a2b3c4d
ry cron run web sch-1a2b3c4d
ry cron pause web sch-1a2b3c4d
ry cron resume web sch-1a2b3c4d
ry cron rm web sch-1a2b3c4d
```

An app has at most 20 schedules; the last 100 runs of each are kept. `ry cron run` starts a run now, outside the expression (also on a paused schedule); resuming continues from the next slot, nothing missed is caught up. A run is `running`, `succeeded`, `failed`, `skipped` or `missed`. Removing a schedule takes its history with it.

## Jobs: one command, once

**CLI**

```bash
ry job run web -- npm run migrate
ry job run web --memory 4096 --timeout 1800 -- node scripts/report.js
```

**curl**

```bash
curl -X POST "https://api.rytiv.app/v1/apps/web/jobs" \
  -H "Authorization: Bearer $RYTIV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"command": ["npm", "run", "migrate"], "timeoutSec": 900}'
```

**TypeScript**

```ts
const res = await fetch("https://api.rytiv.app/v1/apps/web/jobs", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.RYTIV_TOKEN}`, "Content-Type": "application/json" },
  body: JSON.stringify({ command: ["npm", "run", "migrate"] }),
});
const job = await res.json(); // { id, state: "queued", ... }
```

**Python**

```python
import os, requests

job = requests.post(
    "https://api.rytiv.app/v1/apps/web/jobs",
    headers={"Authorization": f"Bearer {os.environ['RYTIV_TOKEN']}"},
    json={"command": ["npm", "run", "migrate"]},
).json()
```

A job runs the command on a machine of its own with the app's image, secrets and environment, and answers `202` with the job. `ry job run` streams the output and exits with the command's own exit code (1 for `failed` or `timeout`); Ctrl-C leaves the job running. Poll `GET /v1/jobs/:id` until `state` is `done`, `failed` or `timeout`:

| State | Meaning |
| --- | --- |
| `queued` | The machine is being placed. |
| `running` | The command runs; `startedAt` is set. |
| `done` | The command ran to its end; `exitCode` holds its verdict (a failing command is `done` with a non-zero code). |
| `failed` | It did not run to the end: placement failed, the machine died, or the job was cancelled. `exitCode` is `null`, the reason is the last line of `output`. |
| `timeout` | `timeoutSec` ended it. |

"Green" means `state === "done" && exitCode === 0`. `output` keeps the first 1 MiB. In the API, `env` adds environment for this job, `links` names apps the job's machine may reach, `build` runs a specific build of the app; the CLI offers `-e`, `-t`, `-m` and `-c`.

```bash
ry job list web
ry job get job-1a2b3c4d
ry job cancel job-1a2b3c4d
```

Cancelling (`DELETE /v1/jobs/:id`) ends a running job; its machine is gone after `finishedAt`, the job record stays.
