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:

// apps/api/app.config.ts
scheduling: { trigger: 'self' }      // default
// 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 policy.
  • Coordinated. Multiple self instances dedupe via the 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 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.

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)