Concurrency policies
Every so often a task fires while the last run is still going. RunWisp
has to do something about that, and on_overlap is how you tell it
what. Three policies cover just about every real case: queue, skip,
and terminate.
The shape of the question
Section titled “The shape of the question”It really comes down to two knobs:
max_concurrent— how many runs of a task are allowed to overlap. Defaults to1, which is what most cron-style work wants.on_overlap— what to do when a new run shows up and you’re already at that limit.
As long as there’s a free slot, a new run just starts — the policy
never even comes into it. on_overlap only has a say once the task is
maxed out.
The three policies
Section titled “The three policies”queue (the default for tasks)
Section titled “queue (the default for tasks)”Reach for this when every tick has to run eventually. New runs line up in a FIFO queue, and the moment a slot frees up the next one in line starts. Nothing jumps ahead.
[tasks.process-uploads]cron = "*/5 * * * *"on_overlap = "queue" # default — could be omittedrun = "/usr/local/bin/process"It’s a good fit when:
- Skipping a tick means skipping real work.
- Runs are short next to the schedule, so the queue mostly sits empty anyway.
- Each run works on its own inputs (a per-tick batch, a queue of jobs) rather than fighting over shared state.
The queue isn’t bottomless —
queue_max caps it. Once it’s full, the
next firing gets logged with end_reason = "queue_full" and the
existing queue keeps draining in order. Seeing queue-full firings is a
signal the task is over budget: slow the cron down, bump
max_concurrent, or switch to skip / terminate.
Reach for this when missing a tick beats piling them up. The new run
gets turned away — recorded with end_reason = "skipped" and exit code
-1 — while the run that’s already going carries on untouched.
[tasks.health-probe]cron = "* * * * *"on_overlap = "skip"run = "curl -sf https://example.com/health"It’s a good fit when:
- The work is safe to skip — a probe, a poll, a status check. Miss one and it’s no big deal; what matters is the next one runs.
- Stacking would actually hurt. You only want one
pg_dumprunning at a time, say, and a skipped tick is far better than two dumps trampling each other.
Skipped runs still show up in history — that’s the whole point. They land in the Web UI under the skipped status (its own end-reason, not “failed”), so a task that keeps overlapping reads as a visible pattern instead of just silence.
And to be clear, end_reason = "skipped" is not a failure:
notify_on_failurestays quiet on skips, so a* * * * *health probe that overlaps doesn’t blow up your Slack.- Retries leave skips alone — the original run is still going, so a retry would just race it.
- The stats counters keep it apart from
failed/crashed/timeout, so your failure rate stays honest.
terminate
Section titled “terminate”Reach for this when the newest run is the one that matters. RunWisp stops the oldest active run, and once it’s gone the new one starts.
[tasks.deploy-hook]on_overlap = "terminate"run = "/usr/local/bin/deploy.sh"It’s a good fit when:
- Latest wins. Fire a deploy hook twice in quick succession and the second one is the one you actually meant.
- The work goes stale the instant a new request lands — rebuilding a search index, regenerating a cached report, that kind of thing.
When terminate stops the in-flight run, that run gets graceful_stop
seconds to tidy up before a SIGKILL, and it’s recorded with
end_reason = "stopped" — exactly like a manual stop. Stopped runs
don’t retry. See Retries.
max_concurrent > 1
Section titled “max_concurrent > 1”Set max_concurrent = N and up to N runs of the same task can be going
at once. on_overlap stays out of it until all N slots are taken.
[tasks.thumbnail-render]cron = "* * * * *"max_concurrent = 4on_overlap = "queue"run = "/usr/local/bin/render"So here the queue only kicks in once 4 renders are running. Pair
max_concurrent = 4 with on_overlap = "skip" instead and you get a
hard ceiling of 4 concurrent renders, with any 5th tick turned away.
For most cron-style work, max_concurrent = 1 is the right call. Only
bump it when:
- The runs are genuinely independent — no shared state, no shared resource they’d fight over.
- The schedule is uneven: a
* * * * *tick against work that usually wraps up in seconds but every now and then drags on much longer.
If what you really want is a pool of long-running parallel workers,
reach for [services.<name>] with instances = N instead — that’s
exactly what services are for.
Either way, max_concurrent > 1 is safe. The daemon makes the overlap
decision one run at a time even when several are executing, so two runs
can never race for the same slot.
Manual triggers and the queue
Section titled “Manual triggers and the queue”A manual trigger — from the CLI, REST, or UI — runs through the exact
same evaluation as a cron firing. Trigger a skip task while it’s
running and you’ll get a response telling you the firing was skipped,
with the same end_reason = "skipped" on record.
And the queue doesn’t care where a run came from. Cron firings and manual triggers line up together and drain in order, no favorites.
Services and on_overlap
Section titled “Services and on_overlap”Services default to on_overlap = "skip", and that’s almost always the
right thing to leave alone. The supervisor keeps instances replicas
alive, each in its own slot, so overlap basically never comes up —
unless you manually trigger a service that’s already running, which is
exactly the case skip is there to refuse.