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. |
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).
queuecaveat — unbounded growth. If aqueuejob consistently takes longer than its interval, the queue grows without bound and the schedule falls further behind.queueassumes 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.
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 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. |
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.