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.

// 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 scheduledefineSchedule, cron syntax, the timezone rule, the handler context
  • Coordinationsingle / advisoryLock / cluster: exactly-once across instances
  • Trigger driversself (in-app timer) vs external (platform scheduler hits an HTTP endpoint)
  • Overlap & backfill — what happens on slow runs and missed firings
  • Deployment — generating Kubernetes / AWS / GCP / Azure manifests with voltro schedule-manifest
  • Dashboard — firing history, status, and "Run now"

Schedule vs workflow

Schedules and workflows 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
"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

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.

// 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 jobdefineSchedule({ 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.
  3. The coordination — how N instances avoid double-firing: single, advisoryLock, or cluster. App-wide. See coordination.
// 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 / sqliteself / 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.
  • 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.