Your crontab says every five minutes. Did your job actually run every five minutes? Mine didn't. Not because cron is broken, but because I believed four things about it that were never true.
Here is the short version: cron is a trigger, not a queue. It fires a process. It remembers nothing.
Myth 1: "One cron slot means one run"
Cron does not check whether the previous run finished. It just starts another process.
A job that takes 8 minutes on a 5-minute schedule overlaps itself. Now you have two writers on the same file.
Try it. Two instances, one lock:
$ (python3 jobwrap.py -- sleep 20 &) ; sleep 1 $ python3 jobwrap.py -- sleep 5; echo "exit=$?" exit=75 Exit 75 means "skipped, busy". That is a real answer, not a failure. My wrapper returns it instead of stacking a second run.
Myth 2: "Exit code 0 is a receipt"
A process can exit 0 after writing half a file. It can exit 0 after an empty API response.
Exit codes describe the process, not the outcome. So my wrapper writes a completion row with a run_id. Downstream consumers read the marker, never the schedule.
Do you trust a schedule, or do you trust a record? Pick the record.
Myth 3: "My schedule is in my timezone"
Containers default to UTC more often than people expect. DST moves the wall clock twice a year.
Probe it before you debug anything else:
$ date -u +%FT%TZ; date +%FT%T%z $ python3 -c "import datetime;print(datetime.datetime.now(datetime.timezone.utc))" $ echo "TZ=${TZ:-unset}" If TZ is unset, write your schedule in UTC on purpose. A deliberate UTC schedule is boring. A guessed local one is a 3 a.m. page.
Myth 4: "Missed runs get retried"
There is no retry. There is no backoff. There is no catch-up.
If the host was asleep, the slot is gone. If the job timed out, the slot is gone. Cron will not tell you. It has already moved on.
This is why I keep a ledger. A gap in the ledger is evidence. A missing log line is not.
The artifact: a run wrapper you can copy
One file, standard library only. It takes a non-blocking lock, enforces a deadline, and appends every state change to JSONL.
#!/usr/bin/env python3 """jobwrap.py - run a scheduled job with an overlap guard and a ledger.""" import datetime, fcntl, json, os, subprocess, sys, time, uuid LEDGER = os.environ.get("JOB_LEDGER", "/tmp/job-ledger.jsonl") LOCK = os.environ.get("JOB_LOCK", "/tmp/job.lock") DEADLINE = int(os.environ.get("JOB_DEADLINE_S", "600")) def emit(rec): rec["ts"] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") with open(LEDGER, "a") as f: f.write(json.dumps(rec) + "\n") def main(argv): cmd = argv[1:] if argv and argv[0] == "--" else argv if not cmd: print("usage: jobwrap.py -- CMD [ARGS...]", file=sys.stderr) return 64 run_id = str(uuid.uuid4()) fh = open(LOCK, "a+") try: fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: emit({"event": "skip_busy", "run_id": run_id, "cmd": cmd}) return 75 started = time.monotonic() emit({"event": "start", "run_id": run_id, "cmd": cmd, "pid": os.getpid()}) try: p = subprocess.run(cmd, timeout=DEADLINE, capture_output=True, text=True) rc, out, err = p.returncode, p.stdout, p.stderr except subprocess.TimeoutExpired: rc, out, err = "timeout", "", "" emit({"event": "end", "run_id": run_id, "rc": rc, "duration_s": round(time.monotonic() - started, 3), "stdout_tail": out[-400:], "stderr_tail": err[-400:]}) return 0 if rc == 0 else 1 if __name__ == "__main__": sys.exit(main(sys.argv[1:])) And the crontab line stays dumb:
*/5 * * * * TZ=UTC /usr/bin/python3 /srv/jobwrap.py -- /usr/bin/python3 /srv/task.py Then read the ledger, not your feelings:
import json, statistics rows = [json.loads(l) for l in open("/tmp/job-ledger.jsonl")] starts = [r for r in rows if r["event"] == "start"] ends = {r["run_id"]: r for r in rows if r["event"] == "end"} unclosed = [s for s in starts if s["run_id"] not in ends] durs = [e["duration_s"] for e in ends.values() if isinstance(e["duration_s"], (int, float))] print("starts", len(starts), "unclosed", len(unclosed)) print("median_s", statistics.median(durs) if durs else None) unclosed is the number that matters. An unclosed run means the process died or the host vanished mid-job. Compare median duration to your interval. If the median is close to the interval, the schedule is already fiction.
Where a free model fits in the job
The nightly job I actually run is a short digest step: read the ledger, call a model endpoint, write a summary. Nothing exotic.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I used MonkeyCode's free model access for that step, running the wrapper on its free server option. Treat those two availability claims as starting points, not guarantees. Quotas and limits can change, so check the provider's current docs before you depend on them.
Keep the call bounded. A scheduled job must never wait forever on a network API:
import os, urllib.request timeout = int(os.environ.get("JOB_DEADLINE_S", "600")) // 2 req = urllib.request.Request( os.environ["MODEL_URL"], data=b'{"input": "summarize this ledger"}', headers={"Content-Type": "application/json", "Authorization": f"Bearer {os.environ['MODEL_KEY']}"}, ) with urllib.request.urlopen(req, timeout=timeout) as r: body = r.read().decode("utf-8") A timeout here is a normal result, not an outage. Log it, exit non-zero, let the next slot try.
When cron is the wrong tool
| Need | cron + wrapper | real queue |
|---|---|---|
| One run per interval, misses tolerated | yes | overkill |
| Retry with backoff | no | yes |
| Exactly one run across several hosts | no | yes |
| Backfill missed windows | no | yes |
| Job longer than the interval | no | yes |
| Nightly digest, single box | yes | overkill |
Four "no" rows. That is the whole point of the table.
Limitations and who should skip this
-
flockis per host. On NFS or exotic overlays, do not assume it works. - A sleeping server writes no skip row. The gap is silent until the ledger is long enough.
- A timeout kills the direct child only. Grandchildren may keep running.
- The ledger lives on local disk. Ship it off the box, or a rebuild erases your evidence.
- The wrapper proves a process started and returned. It cannot prove the job did the right thing.
Do not use this for money, billing, or anything requiring exactly-once effects. Do not use it across multiple hosts. Do not schedule below one minute. If you need retries or backfill, you need a queue, not a cleverer wrapper.
So: does your schedule describe intent, or does your ledger describe reality? Run the wrapper for a week and diff the two. The gap is your real system. Try the same wrapper against a free model and server option if you want a zero-cost testbed for the experiment.


