Skip to content
pilots
Dashboard

← All notes


Vivek

  • cron
  • scheduling
  • scale-to-zero
  • router

Cron for an App That Is Asleep

Scheduled jobs usually need something kept warm to run them. On Pilots a cron fires through the same held-wake path a visitor's request takes, so a scale-to-zero app runs its nightly job with nothing running in between.


Scale to zero and scheduled jobs pull in opposite directions. Nothing is running, and something has to happen at 3am.

The usual answers add a component. A scheduler service somewhere that holds the crontab and calls your app. A sidecar that stays up to run the timer. A managed cron product with its own dashboard and its own bill. Each of those is a thing to operate so that a thing you already operate can be woken up.

On Pilots the schedule fires through the router's own wake path, and there is no new component at all.

The shape

A machine's cron jobs live in its knobs, as a list of {cron, path} or {cron, cmd} entries. One 10-second loop in hostd, sitting beside the waker, checks them.

On an expression's minute, a path job makes a GET to <machine>.<domain> through the router's internal handler. That is the same resolve, the same held wake, and the same proxy a visitor's request gets. A cmd job runs through exec instead.

So a scale-to-zero app runs its cron with nothing kept warm for it. The machine is asleep at 02:59, the loop makes a request at 03:00, the router holds the connection while the machine restores, the job runs, and the idle monitor puts it back to sleep a minute later.

The cost of having scheduled jobs on a sleeping app is the seconds the job actually takes.

Which host fires it

The host that owns the machine fires the job. That is the row's single-writer host, so every fire is local and there is no leader.

For a service with several replicas, the lowest-id current replica fires and the rest stand down.

I tried gating that on the autoscaler's arbiter first, which seemed tidier since the arbiter is already the one host allowed to add capacity. It was wrong, and the reason is a nice illustration of why "who is in charge" questions get answered differently in different parts of this system. The arbiter's live set is a per-host clock window, so two hosts can disagree about it for a minute. Two hosts that disagree both fire. A rule computed from replicated rows, like lowest id, gives every host the same answer without a clock.

The header, and why it needs no secret

The request carries X-Pilot-Cron.

That header is stripped on the public listener, beside the other internal header the router owns. Which means an app can trust it with no shared secret and no signature. A copy of it arriving from the internet is deleted before any handler sees it, so a request that still carries one came from inside the fleet.

Worth being exact about where that boundary sits, since it is the whole value of the header. It is not a proof that hostd made the request. A sibling machine in the same app reaches its peers over .internal without passing the public listener, so it can set the header itself, the same way it can set any other. That is the tenant's own code talking to the tenant's own app. What the strip rules out is the internet, which is what the header is for.

That is the same trick as the forwarded headers, and it is one of my favourite patterns in the router. Strip on the way in, set on the way through, and the header becomes a fact about where the request came from rather than a claim the caller made.

At-least-once, said out loud

The delivery guarantee is at-least-once and I would rather write down what that actually means here than let "reliable" cover it.

  • Fired minutes live in memory, so a hostd restart inside a minute may fire that minute again.
  • A job still running when its next minute comes is skipped rather than overlapped.
  • Handlers are idempotent by convention, which is to say by yours.

Exactly-once would need durable per-fire state, gossiped, on a path that currently needs no writes at all. The trade is a component and a set of rows against an occasional duplicate of a job that should be idempotent anyway.

That is Vercel's model of cron, running on the wake path that already existed. The matcher for the expressions themselves is a hundred lines in the repo rather than a dependency, because parsing five fields of cron syntax is a solved problem that is also a small one.

What it is not

It is not a workflow engine. There are no retries with backoff, no fan-out, no DAG, no per-run history to browse. A fire is a request, and the evidence it happened is in your application's logs and in the machine's console log.

I keep going back and forth on whether that is a gap. The argument for keeping it small is that a cron that is a request is a cron you can test with curl, and the moment it becomes a workflow engine it has its own semantics to learn. The argument against is that everybody eventually wants a retry.

For now it stays a request, and the thing I would build next is not retries but visibility: the last fire and its status code, on the machine, where you are already looking.


Every note, or install the CLI and try the thing this one is about.