# Scheduling

> Deployment-agnostic scheduled jobs in Voltro — one *.cron.tsx definition that runs unchanged on a single box, a multi-instance fleet, or an external scheduler.



---

<!-- source: en/scheduling/overview.md -->
## Overview

_Deployment-agnostic scheduled jobs in Voltro — one *.cron.tsx definition that runs unchanged on a single box, a multi-instance fleet, or an external scheduler._

A **schedule** is a job the clock invokes — "send the digest at 09:00", "prune sessions every 15 minutes". In Voltro a schedule lives in a `*.cron.tsx` file and default-exports `defineSchedule({...})`.

The defining idea: **the job definition is identical across every hosting topology.** The cron expression, timezone, and handler never change whether you run one PM2 process, ten Kubernetes replicas, or hand the firing off to AWS EventBridge. What differs — *who* fires it and *how* exactly-once is guaranteed — is configuration, not code.

```tsx no-check
// apps/api/schedules/digest.cron.tsx
import { defineSchedule } from '@voltro/runtime'

export default defineSchedule({
  name:     'dailyDigest',
  cron:     '0 9 * * *',          // 09:00, five-field cron
  timezone: 'Europe/Berlin',      // REQUIRED — never server-local
  handler:  async ({ app }) => {
    const users = await app.store.users.where({ digestOptIn: true }).all()
    for (const u of users) await sendDigest(app, u)
  },
})
```

That's the whole job. Drop the file in your api app, run `voltro dev`, and it fires at 09:00 Berlin time.

## What's in this section

- [Defining a schedule](/docs/scheduling/definition) — `defineSchedule`, cron syntax, the timezone rule, the handler context
- [Coordination](/docs/scheduling/coordination) — `single` / `advisoryLock` / `cluster`: exactly-once across instances
- [Trigger drivers](/docs/scheduling/triggers) — `self` (in-app timer) vs `external` (platform scheduler hits an HTTP endpoint)
- [Overlap & backfill](/docs/scheduling/overlap-and-backfill) — what happens on slow runs and missed firings
- [Deployment](/docs/scheduling/deployment) — generating Kubernetes / AWS / GCP / Azure manifests with `voltro schedule-manifest`
- [Dashboard](/docs/scheduling/dashboard) — firing history, status, and "Run now"

## Schedule vs workflow

Schedules and [workflows](/docs/workflows/overview) are both durable execution, but they answer different questions.

| You have… | Use |
|---|---|
| A job the clock starts on a recurring cadence | **Schedule** |
| A multi-step job that must survive a deploy mid-flight | [Workflow](/docs/workflows/overview) |
| "Run this daily, and each run is one quick operation" | Schedule |
| "Run this daily, and each run is a long multi-step saga" | Schedule with a direct **workflow target** or a handler that starts one |
| A user-facing request returning in <100ms | [Mutation](/docs/data/mutations) |

The wedge: **schedules answer *when*, workflows answer *how durably*.** A schedule handler is "a mutation the clock invokes" — same `app` context, same store, same plugin interceptors. If the handler's work itself needs to survive crashes, the handler kicks off a workflow and returns.

That handoff works the same in `voltro dev` and the production API server: when the app has workflow files, schedule handlers receive `app.workflows` and can start durable work without importing a client.

```tsx
// apps/api/schedules/daily-rollup.cron.tsx
import { defineSchedule } from '@voltro/runtime'

export default defineSchedule({
  name: 'dailyRollup',
  cron: '0 2 * * *',
  timezone: 'UTC',
  handler: async ({ app, scheduledAt }) => {
    await app.workflows!.start('billing.dailyRollup', {
      day: scheduledAt.toISOString().slice(0, 10),
    })
  },
})
```

> Earlier framework guidance suggested modelling cron with a workflow plus `Effect.sleep`. That still works for one-off durable delays, but recurring cadences belong in a `*.cron.tsx` schedule — you get coordination, backfill, overlap control, and a dashboard for free.

## The three things you configure

1. **The job** — `defineSchedule({ cron, timezone, handler })`. Lives in the file. Topology-independent.
2. **The trigger** — who drives the clock: an in-app timer (`self`) or an outside scheduler (`external`). App-wide default, overridable per job. See [trigger drivers](/docs/scheduling/triggers).
3. **The coordination** — how N instances avoid double-firing: `single`, `advisoryLock`, or `cluster`. App-wide. See [coordination](/docs/scheduling/coordination).

```ts
// apps/api/app.config.ts
export default {
  type: 'api' as const,
  name: 'api',
  scheduling: {
    trigger:      'self',          // default; 'external' to delegate the clock
    coordination: 'advisoryLock',  // default on every multi-instance SQL store; 'single' on memory/sqlite
  },
}
```

Sensible defaults derive from your store: **`memory` / `sqlite` → `self` / `single`**, **every multi-instance SQL store (`postgres` / `mariadb` / `mysql` / `mssql`) → `self` / `advisoryLock`** (the claim-row gate works on all of them). Most apps never set this block.

## Guarantees and honest caveats

- **Deterministic firing instant.** `scheduledAt` is derived from the cron expression, not each replica's `Date.now()`, so every instance computes the same time-bucket — the basis for clock-skew-safe coordination.
- **Non-dying timer.** The `self` driver re-arms after every firing, even if a handler throws; a handler defect can't break the chain.
- **Exactly-once is only as strong as your coordination.** `single` does not dedupe — running two `single` instances double-fires. Use `advisoryLock` or `cluster` for multi-instance. See [coordination](/docs/scheduling/coordination).
- **Sub-minute `cluster` firings lag.** The `cluster` strategy waits for shard assignment (~10s on a cold runner) before the first firing. Fine for minute-and-up cadences; not for "every second".
- **External triggers are minute-granular.** Six-field (seconds) cron is rejected when generating external manifests — k8s/EventBridge/Cloud Scheduler can't express sub-minute. See [deployment](/docs/scheduling/deployment).



---

<!-- source: en/scheduling/definition.md -->
## Defining a schedule

_defineSchedule — the *.cron.tsx file convention, cron expression syntax, the mandatory timezone, and the handler context._

A schedule is a `*.cron.tsx` file that default-exports `defineSchedule({...})`. The CLI discovers it the same way it discovers queries and mutations — no registration, no manifest.

```tsx no-check
// apps/api/schedules/cleanup.cron.tsx
import { defineSchedule } from '@voltro/runtime'

export default defineSchedule({
  name:        'cleanupSessions',
  cron:        '*/15 * * * *',
  timezone:    'UTC',
  description: 'Prune expired sessions.',
  handler: async ({ app, scheduledAt }) => {
    await app.store.sessions.where({ expiresAt: { lt: scheduledAt } }).delete()
  },
})
```

## The config

| Field | Required | Default | Notes |
|---|---|---|---|
| `name` | yes | — | Stable identifier. Used as the idempotency/coordination key and the dashboard label. |
| `cron` | yes | — | Standard cron expression (see below). Validated eagerly. |
| `timezone` | yes | — | IANA zone (`"UTC"`, `"Europe/Berlin"`). Never server-local. |
| `handler` | one of `handler` / `workflow` | — | `(ctx) => void \| Promise<void>`. Custom work for this firing. |
| `workflow` | one of `handler` / `workflow` | — | Direct workflow target: `{ name, payload }`. Use when the schedule only starts durable work. |
| `trigger` | no | app default | `'self'` or `'external'` — per-job override. See [triggers](/docs/scheduling/triggers). |
| `onOverlap` | no | `'skip'` | `'skip'` / `'queue'` / `'parallel'`. See [overlap](/docs/scheduling/overlap-and-backfill). |
| `backfill` | no | `'skip'` | `'skip'` / `'latest'` / `'all'`. See [backfill](/docs/scheduling/overlap-and-backfill). |
| `maxRuntimeMs` | no | `1_800_000` (30 min) | Watchdog. A run exceeding it is recorded `failed` with reason `timeout`. |
| `description` | no | — | Shown in the dashboard and generated external manifests. |

## Direct workflow target

When the schedule's only job is to start a durable workflow, declare it directly instead of writing a one-line handler:

```tsx
// apps/api/schedules/daily-rollup.cron.tsx
import { defineSchedule } from '@voltro/runtime'

export default defineSchedule({
  name: 'dailyRollup',
  cron: '0 2 * * *',
  timezone: 'UTC',
  workflow: {
    name: 'billing.dailyRollup',
    payload: ({ scheduledAt }) => ({
      day: scheduledAt.toISOString().slice(0, 10),
    }),
  },
})
```

Use `handler` when the firing needs custom branching, extra writes, or multiple side effects. You can still call `ctx.app.workflows.start(...)` manually from that handler.

## Cron syntax

The expression is parsed by `effect`'s `Cron` module, which accepts **five fields** (minute precision) or **six fields** (with a leading seconds field):

```
 ┌───────────── second (0–59)   ← optional 6th field
 │ ┌─────────── minute (0–59)
 │ │ ┌───────── hour (0–23)
 │ │ │ ┌─────── day of month (1–31)
 │ │ │ │ ┌───── month (1–12)
 │ │ │ │ │ ┌─── day of week (0–6, Sun=0)
 │ │ │ │ │ │
 * * * * * *
```

| Expression | Fires |
|---|---|
| `0 9 * * *` | 09:00 every day |
| `*/15 * * * *` | every 15 minutes |
| `0 0 1 * *` | midnight on the 1st of each month |
| `0 0 * * 1` | midnight every Monday |
| `*/30 * * * * *` | every 30 **seconds** (six-field) |

A typo throws at **definition time**, surfaced in the boot log — not silently swallowed by a scheduler loop that then never fires:

```
defineSchedule("dailyDigest"): invalid cron expression "0 25 * * *" (tz="UTC") — …
```

> Six-field (seconds) expressions only run under the `self` trigger. They are rejected when generating [external manifests](/docs/scheduling/deployment) because k8s/EventBridge/Cloud Scheduler are minute-granular.

## The timezone is mandatory — on purpose

There is no default timezone. Omitting it throws. Server-local time is a bug factory: a container's TZ is usually UTC regardless of where your users are, so "09:00" silently means something different in dev, CI, and prod. Stating the zone makes the intent explicit and identical everywhere.

```tsx
timezone: 'Europe/Berlin'   // 09:00 Berlin — DST handled by effect's Cron
```

## The handler context

The handler receives a `ScheduleContext` — the same `app` a mutation gets, plus firing metadata.

> **The API is identical; the subject is not.** `ctx.app.store` here is **not
> tenant-scoped** — a schedule runs as `system` with no tenant. Reads see every
> tenant's rows, and a write to a `tenant()` table fails with
> `TenantScopeViolation` unless you pass `tenantId` explicitly. See
> [below](#a-schedule-runs-as-the-system-subject--no-tenant). This sentence is
> here rather than only further down because "same shape as a mutation" is what
> sets the expectation that gets violated.

```tsx
handler: async (ctx) => {
  ctx.scheduledAt   // Date — the instant this firing was scheduled for (deterministic, cron-derived)
  ctx.firedAt       // Date — when the handler actually started (may lag under load/contention)
  ctx.runId         // string — the _voltro_schedule_runs row id for this firing
  ctx.trigger       // 'self' | 'external' | 'manual' — where the firing came from
  ctx.app           // AppContext — store, request subject, webhooks, plugin interceptors
}
```

Use `scheduledAt`, not `Date.now()`, for any time-bucketed query (the "prune sessions older than this slot" pattern). It's stable across replicas and reflects the intended instant even if the run was delayed.

`ctx.trigger === 'manual'` distinguishes a dashboard **Run now** click from a clock firing — handy when a manual run should skip a guard (e.g. "only on weekdays") that the scheduled path enforces.

## A schedule runs as the SYSTEM subject — no tenant

`ctx.app.store` is **not tenant-scoped**. A schedule has no request, so it has
no signed-in user and no tenant to infer, and the framework refuses to pick one
for you. Reads see every tenant's rows.

That is the right default for what crons usually are — a backfill, a reconcile,
a GC sweep — but it means a **per-tenant** cron has to say which tenant it means:

```tsx
handler: async (ctx) => {
  const tenants = await ctx.app.store.select('tenants').all()
  for (const t of tenants) {
    const stale = await ctx.app.store.select('sessions')
      .where('tenantId', t.id)                    // explicit, not inferred
      .where('expiresAt', '<', ctx.scheduledAt)
      .all()
    // …
  }
}
```

Writes to a `tenant()` table need the same treatment: pass `tenantId`
explicitly, or the write fails with `TenantScopeViolation` rather than landing
somewhere arbitrary.

### `ctx.app.storeForTenant(id)` — the fan-out shortcut

Doing that by hand means every `.where('tenantId', …)` and every explicit
`tenantId:` is one forgotten call away from reading or writing across tenants.
`storeForTenant` hands you a store scoped to exactly one:

```tsx
handler: async (ctx) => {
  for (const t of await ctx.app.store.select('tenants').all()) {
    const scoped = ctx.app.storeForTenant(t.id)
    await scoped.insert('digests', { body: summary })   // tenantId stamped, not passed
  }
}
```

Inside a REQUEST this is almost always the wrong tool — the subject already
carries a tenant, and reaching for another one is a cross-tenant access with
extra steps. It exists because the system subject has *no* tenant to infer.

One implementation detail worth knowing, because it is counter-intuitive: the
scoped store does **not** run as a `system` subject with a tenant attached. A
system subject carries `tenantId: null` by construction and the tenant mixin
special-cases it to skip scoping entirely — on a system subject, a null tenant
means *all* tenants. So the scoped view runs as a `serviceAccount` bound to that
one tenant, keeping the schedule's scopes.

This behaves identically under `voltro dev` and `voltro serve`. It did not
always — before 0.10.0, dev scoped schedules to `$TENANT` (default `acme`)
while production ran them unscoped, so the same cron read one tenant in
development and all of them in production. If you added `.unscoped()` to a cron
to work around that, it is now a no-op and can go.

## Discovery

`voltro dev` and `voltro build` glob `**/*.cron.{ts,tsx}` under your api app. Each discovered schedule is logged at boot:

```
scheduler: starting  total=2 self=2 external=0 coordinator=advisoryLock
```

The framework also creates the `_voltro_schedule_runs` and (for `advisoryLock`) `_voltro_schedule_claims` tables — `voltro migrate` emits their DDL automatically, so you never hand-write a migration for them.



---

<!-- source: en/scheduling/coordination.md -->
## Coordination

_How N instances of your app avoid double-firing a schedule — single, advisoryLock, and cluster exactly-once strategies._

When more than one instance of your app is running, each one's `self` timer wants to fire the same schedule at the same instant. **Coordination** is the gate that decides which instance actually runs it. It's an app-wide setting:

```ts
// apps/api/app.config.ts
scheduling: { coordination: 'advisoryLock' }
```

The job definition never mentions coordination — you can move from one box to a fleet without touching a `*.cron.tsx` file.

## The three strategies

| Strategy | Exactly-once across instances? | Needs | Use when |
|---|---|---|---|
| `single` | **No** — every instance fires | nothing | One process: PM2 single instance, a single container, local dev |
| `advisoryLock` | Yes — Postgres arbitrates | Postgres | Multiple instances, no orchestrator: 2–N replicas behind a load balancer |
| `cluster` | Yes — shard owner fires | Postgres + cluster runner | You already run `@effect/cluster` for workflows and want schedules on the same fabric |

Default: **`single` on a memory store, `advisoryLock` on Postgres.** You rarely set this explicitly.

## `single`

No gate. The instance's timer fires, the handler runs. Zero coordination overhead.

This is correct **only if exactly one instance runs the schedule.** Two `single` instances = two firings. That's not a bug to work around — it's the contract. If you scale past one instance, switch to `advisoryLock`.

## `advisoryLock`

The portable multi-instance strategy. It needs nothing but the Postgres you already have — no Redis, no orchestrator, no leader election.

**How it works.** Each firing computes a deterministic key, `<name>@<iso-second>` (e.g. `dailyDigest@2026-05-28T09:00:00`), from the cron-derived `scheduledAt` — *not* `Date.now()`, so every replica computes the identical key regardless of clock skew within the firing window. The bucket is second-precision (`YYYY-MM-DDTHH:MM:SS`, 19 chars), so a 6-field cron like `*/10 * * * * *` gets a distinct key for every firing instant within a minute. Every replica races to `INSERT` that key into `_voltro_schedule_claims`. The table's primary key makes exactly one `INSERT` win; the rest hit the conflict and stand down.

```
replica A ─┐                          ┌─ INSERT dailyDigest@…09:00  → wins, runs
replica B ─┼─ same bucket, same key ─┤
replica C ─┘                          └─ PK conflict → stands down (no run row)
```

- **Self-expiring.** The key includes the firing instant, so a crashed winner doesn't block the next firing — the next firing instant is a new key.
- **A claim does not outlive its bucket.** When a replica wins a claim it deletes that schedule's own older rows in the same pass, so the table's steady-state size is a small multiple of the number of schedules rather than a function of uptime. How far back it prunes scales with how far apart that caller's buckets are — a cron keeps roughly an hour of predecessors, a 250 ms coordinated task about a minute. The grace exists because deleting a claim too early is a **double fire**: a replica that is running late must still find the row that says its bucket was taken.
- **Swept, on the scale it fills — on every dialect.** `_voltro_schedule_claims` gets a retention policy on both boot paths: rows older than **24 hours** by `claimedAt` are deleted, tunable with `VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS`. The announcement at boot names it (`retention: N policy(ies) armed`). 24 h rather than the 30 days the framework's *history* tables get, because this is a lock ledger — a claim row answers a question about one firing instant, and nothing reads yesterday's. One row per (schedule, second-bucket) adds up faster than people expect: a deployment with a handful of sub-minute schedules measured 1 557 rows/hour, which a 30-day window would have let reach a million before the first row aged out. If you raise it, the number to reason about is the longest a replica may be paused and still be trusted not to re-fire a bucket it already lost. The sweep is the **backstop**, not the main bound — it is what cleans up after a schedule you renamed or deleted, which the per-schedule prune above can never revisit. (Through 0.32.0 the whole sweep was registered behind a postgres-only gate, so on the other four dialects it never ran and the boot said nothing about it. Fixed in 0.33.0.)
- **Looked up by primary key.** The existence check the coordinator runs before its `INSERT` is a single-row read on `id`, which *is* the claim key — so the table's size does not enter the firing path.
- **Pool-safe.** Unlike a session-level `pg_advisory_lock` (tied to a connection a pool may reassign), a claims row is durable and connection-independent.
- **Fail-closed.** If the claims table is unreachable, the coordinator logs a warning and declines to fire rather than risk a double-fire. A missing run is recoverable via [backfill](/docs/scheduling/overlap-and-backfill); a double-fire (two charge emails) often isn't.

Losers don't write a run row — at scale that would be N−1 noise rows per firing. Only the winner's run appears in the dashboard, tagged `wonLock`.

**If your app registers its own bound for a framework table, yours wins.** `registerRetention({ table, timeColumn, ttlMs })` from a startup outranks the framework's default for the same table — app > plugin > framework, and `source` defaults to `'app'` so you do not have to pass anything. A real disagreement is reported at boot, on the line beside the policy that survived:

```
  ! _voltro_schedule_claims: two registrations — kept the app's 1h,
    dropped the framework's 24h. An app registration wins over a plugin's,
    and a plugin's over a framework default.
```

Through 0.32.0 this was a silent last-write-wins: a deployment's 1-hour bound was replaced one second later by the framework's default, the startup went on logging `bounded to 1h` at every boot, and it was found by counting rows. Note the direction — the loser is chosen by WHO registered, not by which TTL is narrower. "Narrower wins" would let a framework default we tighten in a later release silently start deleting your data faster than you asked for.

## `cluster`

If you already run `@effect/cluster` (for [workflows](/docs/workflows/cluster)), schedules can ride the same sharding fabric. Each schedule becomes a `ClusterCron` singleton; the cluster assigns it to exactly one shard owner, and only that runner fires. The in-app `self` timer is **not** armed in this mode — the cluster owns the clock, so arming it too would double-fire.

Runs are tagged `cluster`. Coordination is handled by the cluster's shard assignment, so there's no claims table for cluster-mode schedules.

**Caveat — sub-minute lag.** A freshly started runner waits for shard assignment before the first firing (~10s observed on a cold runner). For minute-and-up cadences this is invisible. For "every second" it isn't — use `self` + `single`/`advisoryLock` for sub-minute work, or accept the warm-up.

**Caveat — Postgres only.** `cluster` (and `advisoryLock`) need Postgres. On a memory store they fall back to `single` with a boot warning:

```
scheduling.coordination=cluster needs store=postgres — falling back to single (dev/memory)
```

## Choosing

```
Single process? ───────────────────────────────→ single
Multiple instances, no orchestrator? ──────────→ advisoryLock   (← the default on Postgres)
Already running @effect/cluster for workflows? → cluster
Letting k8s/EventBridge drive the clock? ──────→ trigger: 'external'  (coordination is moot — the platform fires once)
```

When the platform scheduler drives the firing (the [`external` trigger](/docs/scheduling/triggers)), the platform guarantees once-only, so coordination doesn't apply — the run is tagged `external`.

## What each run records

Every firing writes a `_voltro_schedule_runs` row with a `coordinationOutcome` so you can see, after the fact, *why* this instance ran it:

| `coordinationOutcome` | Meaning |
|---|---|
| `single` | No coordination — single-instance mode |
| `wonLock` | Won the `advisoryLock` race |
| `cluster` | Fired as the cluster shard owner |
| `external` | Driven by an outside scheduler hitting the fire endpoint |

(`lostLock` firings are not recorded — see above.) The dashboard surfaces this per run; see [the dashboard doc](/docs/scheduling/dashboard).



---

<!-- source: en/scheduling/triggers.md -->
## Trigger drivers

_self vs external — whether an in-app supervised timer drives the clock, or an outside scheduler (k8s CronJob, EventBridge, Cloud Scheduler) hits an HTTP endpoint._

The **trigger** decides *who keeps time*. Two drivers:

- **`self`** — an in-app supervised timer fires the schedule from inside your process.
- **`external`** — your app exposes an HTTP endpoint; an outside scheduler (k8s CronJob, AWS EventBridge, GCP Cloud Scheduler, …) POSTs to it on the cadence.

It's an app-wide default, overridable per job:

```ts
// apps/api/app.config.ts
scheduling: { trigger: 'self' }      // default
```

```tsx
// override one heavy job to be platform-driven
export default defineSchedule({
  name: 'monthlyInvoice',
  cron: '0 3 1 * *',
  timezone: 'UTC',
  trigger: 'external',               // a dedicated k8s CronJob drives this one
  handler: async ({ app }) => { /* … */ },
})
```

## `self` — the in-app timer

The default, and the right choice for most deployments. Each `self` schedule gets a self-rescheduling `setTimeout` (not `setInterval` — cron is not fixed-interval, and `setInterval` drifts and double-fires under event-loop pressure). After each firing the timer re-arms for the next cron occurrence.

Properties:

- **Non-dying.** The re-arm happens in a `finally` — a handler that throws cannot break the chain.
- **Long-wait safe.** `setTimeout` delays are 32-bit milliseconds (max ~24.8 days). A quarterly or yearly schedule is chunked into shorter sleeps and re-evaluated, so it doesn't silently clamp and fire on every tick.
- **Survives restarts.** Run state persists to `_voltro_schedule_runs`; on boot, missed firings are reconciled per the [backfill](/docs/scheduling/overlap-and-backfill) policy.
- **Coordinated.** Multiple `self` instances dedupe via the [coordination](/docs/scheduling/coordination) strategy.

This is what runs under `voltro dev`, a single PM2 process, or a fleet of replicas (with `advisoryLock`).

## `external` — the platform drives the clock

Some environments want the orchestrator, not the app, to own scheduling — an enterprise k8s policy that all cron lives in `CronJob` objects, or a serverless deployment that scales the app to zero between firings. In `external` mode the app does **not** arm an in-app timer. Instead it exposes:

```
POST /_voltro/schedule/<name>/fire?trigger=external
```

The outside scheduler hits that endpoint on the cron cadence; the app runs the handler and records the run tagged `external`. Because the platform fires exactly once, [coordination](/docs/scheduling/coordination) does not apply.

You don't hand-write the scheduler config. `voltro schedule-manifest` reads your `*.cron.tsx` files and emits the matching CronJob / EventBridge / Cloud Scheduler manifest pointing at this endpoint — see [deployment](/docs/scheduling/deployment).

> The endpoint should be protected in any non-trivial deployment (network policy, an auth token, or an ingress rule) so only your scheduler can POST to it. Treat it like any other privileged internal query.

## Mixing drivers

`trigger` is per-job, so you can split by workload:

| Job | Trigger | Why |
|---|---|---|
| `cacheWarmer` (every 5 min) | `self` | Lightweight, in-process, no infra |
| `monthlyInvoice` (1st of month) | `external` | Heavy; run it as a dedicated k8s `CronJob` pod that scales independently |

A six-field (seconds) cron forces `self` — external schedulers can't express sub-minute, so such a job is rejected by the manifest generator.

## Picking a driver

```
Want zero scheduling infra, app owns the clock? ──────────→ self   (the default)
Enterprise policy: all cron must be k8s CronJobs? ───────→ external
App scales to zero / serverless between firings? ────────→ external
Sub-minute cadence (six-field cron)? ────────────────────→ self  (external can't do sub-minute)
```



---

<!-- source: en/scheduling/overlap-and-backfill.md -->
## Overlap & backfill

_What happens when a run is still in flight at the next firing (overlap), what happens to firings missed during downtime (backfill), and the per-run watchdog._

Two timing edge cases every recurring job hits eventually: a run that's still going when the next firing is due, and firings that were missed while the process was down. Voltro makes both explicit policies on the schedule.

## Overlap — `onOverlap`

When a firing arrives and the previous run of the **same schedule** is still in flight:

| Policy | Behaviour |
|---|---|
| `skip` *(default)* | Don't start a second run. Record a `skipped` row and move on. |
| `queue` | Serialize: wait for the in-flight run to finish, then run this one. Never concurrent. |
| `parallel` | Start the new run immediately, alongside the old one. |

```tsx
defineSchedule({
  name: 'reindex',
  cron: '*/5 * * * *',
  timezone: 'UTC',
  onOverlap: 'skip',          // a slow reindex shouldn't pile up
  handler: async ({ app }) => { /* … */ },
})
```

**Choosing:**

- `skip` — idempotent or "latest state wins" jobs (reindex, cache warm). The default, and almost always right.
- `queue` — every firing's work matters and must happen in order (sequential batch processing). Runs serialize behind one another.
- `parallel` — runs are independent and you genuinely want concurrency (fan-out to per-tenant work).

> **`queue` caveat — unbounded growth.** If a `queue` job consistently takes longer than its interval, the queue grows without bound and the schedule falls further behind. `queue` assumes runs are *usually* faster than the cadence, with occasional overruns. If runs are reliably slower than the interval, your cadence is wrong, not your overlap policy.

A manual **Run now** from the dashboard always runs, regardless of `onOverlap` — operators expect the button to fire.

## The watchdog — `maxRuntimeMs`

Every run races a watchdog (default **30 minutes**). A run that exceeds it stops being awaited and is recorded `failed` with `errorTag: 'timeout'`, so a run row never sits `running` forever.

```tsx
defineSchedule({
  name: 'nightlyExport',
  cron: '0 2 * * *',
  timezone: 'UTC',
  maxRuntimeMs: 2 * 60 * 60_000,   // 2 hours — a big export
  handler: async ({ app }) => { /* … */ },
})
```

> The watchdog stops *waiting* and records the timeout; it cannot truly abort a Promise's in-flight side effects (JavaScript has no thread-kill). Make long handlers cooperative — check a deadline, or do the heavy lifting in a [workflow](/docs/workflows/overview) with its own step-level durability.

## Backfill — `backfill`

When the process was down across one or more firing instants, what should happen on boot? Computed from the last `_voltro_schedule_runs` row for the schedule.

| Policy | Behaviour |
|---|---|
| `skip` *(default)* | Ignore missed firings. Resume from the next future occurrence. |
| `latest` | Fire **once** to catch up to the most recent missed slot; record the older missed slots as `missed` (not silently dropped). |
| `all` | Fire **every** missed slot in order. |

```tsx
defineSchedule({
  name: 'dailyDigest',
  cron: '0 9 * * *',
  timezone: 'Europe/Berlin',
  backfill: 'latest',         // missed Tuesday's 9am after a deploy? send one catch-up, log the rest as missed
  handler: async ({ app }) => { /* … */ },
})
```

**Choosing:**

- `skip` — the firing was time-sensitive and a late run is worse than no run ("send the 9am alert" — 9am has passed, don't send it at noon).
- `latest` — you want the side effect to have happened recently, but replaying every missed slot would spam ("the digest should be reasonably current").
- `all` — every slot represents real work that must not be lost (per-period billing rollups). **Dangerous for side-effecting jobs** — a week of downtime means a week of catch-up firings. Opt in deliberately.

Backfill runs **before** the live timer is armed, so a caught-up firing never races the first scheduled one. The catch-up walk is capped (1000 slots) so a schedule that hasn't run in months doesn't enumerate forever.

Missed slots recorded under `latest` show up in the dashboard with the `missed` status — visible evidence of the gap, not a silent hole.

## Backfilling an explicit range — `voltro schedule backfill`

Boot backfill only walks forward from the **last recorded run**, and cluster-cron coordination caps its own catch-up at one day. A longer outage — or a schedule added after the fact that should have "always existed" — needs an explicit operator instruction naming the range:

```sh
voltro schedule backfill hourly-sync --from 2026-08-10T00:00:00Z --to 2026-08-12T00:00:00Z
```

Every cron occurrence in `(from, to]` fires **sequentially, in order, each against its own cron-derived `scheduledAt`** — a handler (or a `workflow:` payload function) reading `ctx.scheduledAt` computes against its slot, not "now". Firings record as `trigger: 'manual'` in `_voltro_schedule_runs`, so the catch-up is a legible ledger; a failed slot records its failure and the next slot still fires.

The verb is **bounded and confirmable**, because a range verb that can enqueue 100k runs is an outage generator:

- Above **25** occurrences it refuses and prints the count — re-run with `--yes` after reading the number.
- Above the per-request cap (default **1,000**, raisable with `--limit` up to a hard ceiling of 10,000) it refuses outright, firing **nothing** — never a silent prefix that reports completeness. Run bigger catch-ups in slices.

The same verb is `POST /_voltro/inspect/schedules/:name/backfill` with `{ "from", "to", "confirm", "limit" }` (a refusal answers `409` with the count and reason), on `voltro dev` and `voltro serve` alike — it needs the inspect surface open (`VOLTRO_INSPECT_TOKEN`).



---

<!-- source: en/scheduling/deployment.md -->
## Deployment

_Run the same schedule on PM2, a Kubernetes fleet, or an external scheduler — and generate the platform manifest with voltro schedule-manifest._

The point of the `*.cron.tsx` primitive is that **the job definition doesn't change when the topology does.** You pick a trigger + coordination per environment; the cron, timezone, and handler stay put. This page maps common hosting setups to the right configuration.

## The topology matrix

| Hosting | `trigger` | `coordination` | Notes |
|---|---|---|---|
| Local dev (`voltro dev`) | `self` | `single` | Memory store default. |
| Single VPS / one PM2 process | `self` | `single` | No coordination needed — one process. |
| PM2 cluster / multiple containers | `self` | `advisoryLock` | Postgres arbitrates. No extra infra. |
| Kubernetes, N replicas | `self` | `advisoryLock` | The pragmatic default — pods race the Postgres claim. |
| Kubernetes, already running `@effect/cluster` | `self` | `cluster` | Schedules ride the existing sharding fabric. |
| "All cron must be k8s CronJobs" (policy) | `external` | — | The cluster fires; generate a `CronJob` manifest. |
| AWS (EventBridge owns cron) | `external` | — | Generate an EventBridge rule. |
| GCP (Cloud Scheduler) / Azure (Functions timer) | `external` | — | Generate the matching job. |

The only code that changes between any two rows is the `scheduling` block in `app.config.ts` (and occasionally a per-job `trigger:` override). The schedules themselves are untouched.

## Self-hosted, in-process (the common case)

Nothing to deploy beyond your app. Set the coordination that matches your instance count:

```ts
// apps/api/app.config.ts
scheduling: { coordination: 'advisoryLock' }   // 2+ instances on Postgres
```

`advisoryLock` is the default on Postgres, so multi-instance "just works" — see [coordination](/docs/scheduling/coordination). This is the right answer for most fleets: no orchestrator coupling, no separate scheduler to operate.

The production API server starts the same scheduler engine as `voltro dev`. If a schedule starts a workflow, `app.workflows.start(...)` is available in the handler; with `coordination: 'cluster'`, Voltro acquires the cluster-cron layer during boot so the clock does not wait for the first incoming request.

## Delegating to an external scheduler

When the platform must own the clock (enterprise policy, serverless scale-to-zero), set `trigger: 'external'` and generate the manifest:

```bash
voltro schedule-manifest --provider kubernetes --base-url https://api.internal.example.com
```

| `--provider` | Emits |
|---|---|
| `kubernetes` | A `CronJob` per schedule that `curl`s the fire endpoint |
| `aws` | EventBridge Scheduler / rule config |
| `gcp` | A `gcloud scheduler jobs create http` command |
| `azure` | An Azure Functions timer-trigger outline |
| `generic` *(default)* | A README with the `curl` shape and the cron table |

Each generated job POSTs to:

```
POST <base-url>/_voltro/schedule/<name>/fire?trigger=external
```

Example Kubernetes output (abridged):

```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: dailydigest
spec:
  schedule: "0 9 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: trigger
              image: curlimages/curl:8.11.0
              command:
                - curl
                - -fsS
                - -X
                - POST
                - "https://api.internal.example.com/_voltro/schedule/dailyDigest/fire?trigger=external"
          restartPolicy: OnFailure
```

> **Six-field crons are rejected here.** External schedulers are minute-granular — k8s `CronJob`, EventBridge, and Cloud Scheduler can't express seconds. A schedule with a six-field expression must stay on the `self` trigger. The generator errors rather than silently dropping the seconds field.

## What to deploy alongside

- **`voltro migrate`** creates `_voltro_schedule_runs` (and `_voltro_schedule_claims` for `advisoryLock`) automatically — run it as part of your release, the same as your other tables. You never hand-write these migrations.
- **Protect the fire endpoint.** `/_voltro/schedule/<name>/fire` runs a handler — gate it (network policy, ingress rule, or a shared token) so only your scheduler can reach it.
- See [self-hosting](/docs/deployment/self-hosting) and [Voltro Cloud](/docs/deployment/voltro-cloud) for the broader deployment story.

## Switching topologies later

Moving from a single box to a fleet is a config change, not a rewrite:

```diff
  scheduling: {
-   coordination: 'single',
+   coordination: 'advisoryLock',
  }
```

Moving cron ownership to Kubernetes:

```diff
  scheduling: {
-   trigger: 'self',
+   trigger: 'external',
  }
```
…then `voltro schedule-manifest --provider kubernetes --base-url …` and apply the output. The `*.cron.tsx` files don't change.



---

<!-- source: en/scheduling/dashboard.md -->
## Dashboard

_Inspecting schedules in the DevTools and Voltro Cloud dashboards — discovered jobs, firing history, status, and "Run now"._

Every discovered schedule shows up in the **Schedules** panel of both the local DevTools dashboard (`voltro dev`) and the Voltro Cloud dashboard. Same component, two transports: DevTools polls the app's inspect endpoints directly; Cloud streams firing history live via its reactive bridge.

## What you see

**Per schedule:**

- Name, cron expression, timezone, and a live **next-firing countdown** ("in 4h 59m").
- Trigger badge — `self` or `external`.
- The effective **coordination** strategy for the app (`single` / `advisoryLock` / `cluster`), shown once at the top.
- `overlap`, `backfill`, and `max runtime` settings.
- A **Run now** button (capability-gated) — fires the handler immediately, bypassing the clock and coordination, recorded as a `manual` run.

**Per firing (expand a schedule's run timeline):**

- Status — `succeeded`, `failed`, `skipped`, `missed`, or `running`, each colour-toned.
- When it fired (relative + absolute), how long it took, and which replica ran it.
- The `coordinationOutcome` (`single` / `wonLock` / `cluster` / `external`) — *why* this instance ran it.
- For failures: the `errorTag` and message (e.g. `SmtpError: connection refused`, or `timeout` from the [watchdog](/docs/scheduling/overlap-and-backfill)).

## Reading the statuses

| Status | Meaning |
|---|---|
| `succeeded` | Handler completed within `maxRuntimeMs`. |
| `failed` | Handler threw, or the watchdog tripped (`errorTag: timeout`). |
| `skipped` | An overlapping firing under `onOverlap: 'skip'`. |
| `missed` | A firing skipped during downtime, recorded by `backfill: 'latest'`. Visible evidence of a gap, not a silent hole. |
| `running` | In flight right now. |

`missed` and `skipped` rows matter: they're the system telling you a firing *didn't* run and why. A wall of `missed` after a deploy means your downtime crossed firing instants — expected with `backfill: 'skip'`, a signal to consider `latest` if those runs mattered.

## "Run now"

The **Run now** button triggers the handler out-of-band. The run is tagged `trigger: 'manual'` and always executes regardless of `onOverlap` — operators expect the button to fire. Inside the handler you can branch on `ctx.trigger === 'manual'` to skip schedule-only guards (e.g. a weekday check) during a manual test.

It's gated on the same capability as workflow run-control, so read-only dashboard viewers see the history but can't trigger firings.

## The endpoints behind it

The dashboard is a thin client over the app's inspect API — useful if you're scripting:

```
GET  /_voltro/inspect/schedules            # discovered schedules + effective coordination
GET  /_voltro/inspect/schedules/runs?name= # firing history for one schedule
POST /_voltro/inspect/schedules/:name/fire # "Run now"
```

In the Cloud dashboard, firing history is **live** — the cloud API mirrors each app's inspect stream into a reactive cache, so new runs appear without a refresh. In local DevTools the panel polls every few seconds.

See [workflow debugging](/docs/workflows/debugging) for the analogous Workflows panel.
