Cron Is a Clock, Not a Scheduler
A nightly job fired at 2:00 exactly, on time, into a set of inputs that hadn’t arrived yet. It processed what was there — which was almost nothing — declared success, and went back to sleep. Cron had done its one job perfectly. The trouble was that I’d been expecting it to do a second job it has never, once, agreed to do.
Cron looks at the clock and runs the thing. That is the entire contract. Everything I wanted from it beyond that, I was imagining.
What a clock knows, and what it doesn’t
Cron’s guarantee is exactly one sentence: it will fire a command when the time matches. It does not know, and was never built to know:
- whether the previous run of this same job is still running;
- whether the last run succeeded or failed;
- whether the data this job needs has actually landed;
- whether it missed a window while the machine was down, and now owes a run.
Those four are what a scheduler tracks. They’re all about state — what happened, what’s ready, what’s in flight — and cron holds no state at all. It holds the time. Reaching for cron and expecting the other four is the whole bug, and it shows up in a different form every time.
The forms it takes
The pileup. A job usually finishes in twenty minutes, so it’s scheduled every thirty. One night it takes ninety. Cron doesn’t check whether the last copy is still working; it just fires the next one on schedule, and now two runs are racing on the same tables. The fix isn’t a cron expression — it’s a lock, an “if one is already running, don’t start another.” A clock can’t refuse to fire.
Firing into an empty room. The run is scheduled for 2am; the upstream file lands at 2:15 on the slow nights. Cron runs at 2am into missing or half-written input, produces a wrong-but-not-erroring result, and marks it done. Time is not readiness. What you wanted was “run when the file exists,” which is a sensor, not a schedule — a thing that waits on state, not on the clock.
Marching on after a failure. Last night’s run failed and left the tables half-built. Tonight’s fires anyway, right on top of the mess, because cron never learned that last night went badly — it doesn’t track outcomes, so it has nothing to check. What you wanted was “don’t proceed if the last run didn’t finish clean,” which is the same instinct as failing closed instead of fast: when the precondition is unmet, stop, don’t barrel ahead.
The missing window. The box was down from 1:45 to 2:30. The 2am run simply never happened, and cron has no memory that it was supposed to. There’s no catch-up, no “you owe three runs,” because owing a run requires knowing the difference between intended and actual — a ledger cron doesn’t keep. Backfill is a scheduler concept. A clock only knows now.
Time-triggered versus state-triggered
That’s the whole distinction, and it’s worth saying plainly: cron is time-triggered, and real orchestration is state-triggered. “Run at 2am” is a much weaker statement than “run when yesterday’s file has landed, and last night’s run finished clean, and no copy of me is already going.” In the second version the clock is one input to the decision, not the decision itself.
When a clock is genuinely enough
None of this means you need a heavy orchestrator for everything — that’s its own way to lose an afternoon. If a job is idempotent, cheap to re-run, has no upstream it can outrun, and can’t overlap into harm, then cron is exactly right, and standing up Airflow (or Dagster, or a managed pipeline scheduler like Fabric Data Factory or ADF) to run it is the overkill version of the same mistake. This is why “something outside decides when” is a perfectly good design when the thing being run holds no state and is safe to fire twice — a clock is all that layer needs to be.
The trap is assuming all your jobs are that simple. Most people do, right up until 2am, when they meet the specific one that wasn’t.
Cron isn’t broken, and it isn’t the problem. It’s a clock, and it’s a superb clock. The problem is bringing it questions only a scheduler can answer — did the inputs arrive, did the last one finish, do I owe a run — and then reading “it fired” as if it meant “it should have.”