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.
setTimeoutdelays 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
selfinstances 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 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/schedules/<name>/trigger
The delivery is at-least-once. The firing is not.
This is the part worth understanding before you deploy it. Kubernetes states plainly that a CronJob may create a Job twice for one scheduled time, and a Job with a backoffLimit retries after a failure. So every arrival at this endpoint is a maybe-duplicate, and the app is the only place that can tell.
It tells by not trusting the caller's clock. The arrival time is snapped back to the nearest occurrence of the cron expression the app itself declares, and that instant is claimed. Three consequences:
- A retry, a redelivery, or two replicas' CronJobs firing at once all resolve to the same
scheduledAt, so the second one is answered200 {"status":"duplicate"}with the run id of the firing that is already happening. The handler runs once. backoffLimitis therefore safe to set, which it would not be against a server that tooknowas the scheduled time.- An arrival that snaps to no occurrence is refused with
409, naming the cadence the app holds. That is almost always a manifest that is out of date — so the drift detects itself instead of quietly firing on the wrong schedule. - An arrival stating a time in the future is refused too. The skew budget forgives lateness, which is the only thing a platform scheduler produces; claiming an occurrence before it is due would turn the real firing into a duplicate and lose its work, with nothing erroring.
How late an arrival may be is scheduling.externalTriggerSkewMs (default 5 minutes), and it is additionally bounded by the schedule's own period — no setting can make an arrival reach back past the previous occurrence.
It answers immediately and you poll
A schedule may legitimately run for maxRuntimeMs (30 minutes by default); no ingress will hold a connection that long. So the endpoint answers 202 as soon as the run row exists:
{ "status": "accepted", "runId": "sr_...", "scheduledAt": "2026-03-04T09:00:00.000Z" }and the caller polls:
GET /_voltro/schedules/runs/<runId>
which reports terminal: true when the run is over, with its status. The generated CronJob does exactly this and exits with the run's outcome — which is the whole reason to make a cron a cluster object: the Job status your alerting watches means something. A fire-and-forget POST would make every Job green regardless of what the handler did.
The credential
The endpoint is gated by VOLTRO_SCHEDULE_TRIGGER_TOKEN, presented as Authorization: Bearer …. It is not the inspect token, and that separation is deliberate: the inspect credential also authorises plugin-governance's irreversible personal-data erasure, migration rollbacks and row writes. An organisation that mandates CronJob objects is usually doing it for governance reasons, and the credential mounted into every cron pod must not be able to do those things.
voltro dev mints one per project. voltro serve and voltro start mint nothing — in production the endpoint answers 503 with the remedy until an operator sets it. The absence of a secret is not consent.
When nobody fires
In this mode the app arms no timer, so a platform scheduler that stops firing produces silence — which on a dashboard looks exactly like a schedule with nothing to do. scheduling.externalMissedPolicy (default 'record') reconciles the difference: occurrences that were never triggered are written to the ledger as missed and counted in voltro_schedule_external_missed_total. Set it to 'record-and-run' to also run them, or 'off' to do neither.
Recording, not running, is the default on purpose: the premise of this mode is that the platform owns the clock, and a framework that quietly fires anyway has taken it back without saying so.
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. Every platform scheduler is minute-resolution, so such a job is rejected at definition time (or at boot, when the trigger is inherited from the app-wide default) rather than left to fire never.
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)