The Runtime Theory
ApplicationInternalsarchitecture

Cron Trigger Trace: Schedule Matching, Spawning, and Overlap Protection

A step-by-step walk from cron daemon tick to schedule match, worker spawn, and overlap protection when a run outlives its interval.

The Runtime Theory Team3 min read07 steps

trace spine

  1. 01 Cron daemon ticks on its timer
  2. 02 Schedule fields are matched
  3. 03 Job entry is spawned as a process or task
  4. 04 Run is marked active
  5. 05 Overlap check prevents concurrent runs
  6. 06 Run completes and the lock is released
  7. 07 Missed run is handled or dropped
On this page

Cron looks trivial — "run this at midnight" — until a run takes 80 minutes on a 60-minute schedule and you get two jobs mutating the same table. This trace follows one */15 * * * * job from the daemon's tick to the spawned run, including what happens when a run refuses to finish on time.

1. The daemon ticks

The cron daemon (Vixie cron, a container init like supercronic, a managed scheduler, or time.Ticker) wakes on a fixed cadence — classically every 60 seconds, or every second for finer schedules. Each tick is cheap: load the job table, compare timestamps, do nothing for most entries. The tick is the heartbeat: a daemon that skips ticks (clock skew, pause, suspend) silently drifts the whole schedule.

2. The schedule fields match

For each job, the daemon expands the schedule expression into the set of matching instants:

cron
*/15 * * * *   # every 15th minute of every hour, every day

Field by field: minute (*/15 → 0, 15, 30, 45), hour (*), day of month, month, day of week. A job matches when all fields match the current instant. Two classic bugs live here: 0 0 * * * (midnight) vs 0 0 * * 0 (midnight on Sundays), and the day-of-month/day-of-week OR semantics in some implementations. The daemon also applies the job's timezone — TZ=America/New_York — and DST is where schedules silently double-fire (1:30 AM happens twice) or skip.

3. The run is spawned

On match, the daemon spawns the job: fork() + exec() for classic cron, a Job object in Kubernetes, a container task in a managed scheduler. Spawn cost: ~5-20ms. The daemon records the run's start; from this instant, the run has its own lifetime, and the daemon's only job is to not forget it.

4. The run is marked active

The job's first act in-process is to acquire its run guard — typically a lock: a row in a job table (UPDATE jobs SET status='running', started_at=now() WHERE name=$1 AND status != 'running' returning 1 row), a Redis SETNX with TTL, or a filesystem lockfile. This guard is the entire difference between "cron" and "cron that won't corrupt its own data." Without it, a delayed job runs concurrently with its own successor.

5. Overlap protection decides

The run takes 80 minutes on a 60-minute schedule. At the next match, the daemon spawns again — the guard is checked:

  • Allow: the second run waits or proceeds; both run, guarded only by the lock attempt.
  • Forbid (the default for concurrencyPolicy: Forbid and for most in-process schedulers): the new run checks the lock, sees it held, and exits immediately — a no-op run. The schedule resumes at the next tick. The data hazard is avoided; the price is that the schedule effectively skips while runs overrun.
  • Replace: the new run kills the old one (POST /jobs/:id/cancel, SIGKILL the PID) and starts fresh.

The right policy depends on what staleness costs: Forbid for idempotent nightly aggregation (late is fine, concurrent is fatal), Replace for polls that must never lag, Allow for anything with its own row-level guards.

6. The run completes and releases the guard

The run finishes its last step and releases the lock — delete the Redis key, set status='done', remove the lockfile. Crucially, the guard must have a TTL: a crashed run that dies without cleanup holds the lock forever, and every future run silently becomes a no-op. SETNX key EX 3600 (or a started_at older than max_run_time check) converts a stuck lock into a failure you can detect: stale runs are alarming, forgotten ones are silent.

7. Missed runs are resolved

If the daemon was down at 3:00 AM (deploy, crash, suspend), at boot it compares last-run time against the schedule. Policy: catch-up (run all missed matches, or just the latest — Kubernetes defaults to the latest) or drop (missed is missed; the next scheduled time wins). Catch-up is how a weekend-long outage turns into a Monday-morning batch pile-up; drop is how a critical daily job just silently doesn't run one day. Both are legitimate; neither should be a default you never chose.

The cost summary

Per tick: milliseconds of field matching for a whole job table. Per run: one spawn (~10ms) plus one lock round trip (~1ms to Redis or one row update). The real cost of cron is never the CPU — it is the failure modes: overlap, drift, stuck locks, and missed runs, all invisible until data is wrong.