Make It Dependable
Scheduling It, and Noticing When It Stops
Choosing a trigger and stopping two runs from overlapping, then building the alert that catches the failure no error can report - the job that is no longer running at all.
Lesson 15 of 18 in the recommended order · About 25 min (estimate)
On this page
Workflow automation glossary — terms and common confusions
- Dry run
- A run that reports every change it would make and makes none of them.
A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.
- Idempotent
- Running it again leaves the same result as running it once.
"It did not crash the second time" is not the same property; check what the second run changed.
- Run record
- The stored facts about one execution: when it started, what it read, what it changed, how it ended.
A run record is not the log. The log is prose for a person; the record is data the next run reads.
- Transient failure
- A failure that a later identical attempt could succeed at, such as a timeout.
A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.
- Backoff
- Waiting longer between successive retries instead of retrying immediately.
Backoff without a maximum attempt count is an unbounded loop with a politeness delay.
- Request budget
- A hard cap on how many requests one run may make, checked before each request.
A page limit is not a budget if a retry can make extra requests the limit never counts.
- Quarantine
- Setting aside a record a run could not process, with the reason, so the run continues.
Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.
- Reconciliation
- Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.
A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.
- Trigger
- The event or time that causes a run to start.
Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.
- Heartbeat
- A signal a healthy run emits, whose absence is itself the alert.
Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.
- Staleness
- How old the most recent successful run is, compared with how old it is allowed to be.
A green last run is not freshness. Ask when it ran, not whether it passed.
- Atomic replace
- Writing output to a temporary name and renaming it into place in one step.
A reader never sees a half-written file; an interrupted run leaves the previous output intact.
- Transport
- The injected callable that actually performs a request, separate from the client that interprets it.
A client that builds its own connection cannot be tested without a network, which is why the seam exists.
- In-memory filesystem
- The filesystem this course’s browser exercises operate on, which lives only inside the tab.
It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.
Outcome
By the end of this lesson you can choose how a job is triggered and how two runs are stopped from overlapping, and you can write the check that notices a job has not succeeded recently — which is the only kind of monitoring that catches a job that has stopped running altogether.
Why it matters
Here is the outage that every automation eventually has, and the reason it lasts so long.
A job runs every night and sends a report. In March, the machine it runs on is rebuilt and the scheduled task is not recreated. The job does not fail. It does not error. It does not run. No message is sent, and nobody notices, because the absence of a report at seven in the morning looks exactly like a quiet week.
In June, somebody asks where the March figures went.
Every alert that watches for errors is blind to this. An error requires something to run. The only thing that catches it is an alert on silence: not "did the run fail" but "when did a run last succeed, and is that longer ago than it should be".
The second half of this lesson is the same idea applied earlier: a run that starts before the previous one has finished. Two copies of a job writing the same output is not usually reported as an error either. Both runs succeed, and one of them overwrites the other's work.
Concept
Choosing the trigger. Three shapes, and they are not interchangeable:
- On a clock. Every hour, every night at 02:00. Simple, and the default. The interval has to leave room for the run's own deadline, which the services module set.
- On an event. A file arriving, a message queued. Better when the work is caused by something rather than due at a time, and it needs its own guard against the event arriving twice.
- On demand only. Run by a person. The right answer more often than people expect, particularly while a job is new.
Stopping overlap. A run that takes longer than its interval will eventually meet the next one. Two mechanisms, and using both is normal:
- The scheduler's own policy. Windows Task Scheduler has a setting for what to do when the task is already running — do not start a new instance, queue it, or stop the existing one. Choose deliberately rather than accepting whatever the default is.
- A lock the job itself holds. A lock record can contain the run id and start time, but the job must acquire it atomically and release only the lock it owns. Done correctly, this guards starts from any trigger, including a person running the job by hand while the schedule fires. Merely creating and removing a file is not an exclusivity guarantee.
A lock needs recovery rules because a killed run can leave it behind. An age such as three times the expected runtime can prompt investigation, but age alone is not proof the owner has stopped: reclaiming a live run's lock can create the very overlap it was meant to prevent. Verify the owner is gone or use a lease with renewal and fencing before reclaiming; otherwise one crash can block later runs.
On Windows, the built-in scheduler is driven from the command line by schtasks. Microsoft's own documentation for that command is the reference to check the details against; the shape is:
schtasks /create /sc daily /st 02:00 /tn "nightly-invoices" ^
/tr "C:\path\to\python.exe C:\path\to\job.py --apply" /f
schtasks /query /tn "nightly-invoices" /v /fo LIST
schtasks /run /tn "nightly-invoices"
schtasks /delete /tn "nightly-invoices" /f
/sc daily with /st is the trigger, /tn names the task, /tr is what runs, and /f suppresses the confirmation prompt when a task of that name already exists. A task that runs as you, on your own machine, does not need administrator rights; one that runs as the system account does. Microsoft documents that schtasks may prompt for your account password when creating a task, which is normal behaviour rather than a sign that something is wrong.
Two details that catch people out, both worth checking with /query rather than assuming: the task runs with a working directory you did not choose, so use absolute paths for everything; and a task set to run only when the user is logged on does not run when they are not.
On Linux and macOS, the equivalent is a cron entry, and cron has no overlap policy at all, so the lock is not optional there.
Alerting on silence. The run record from the first lesson of this module is the heartbeat. The check is arithmetic:
age of the most recent *successful* run > interval + grace => stale
Three states, not two, and each needs a different message: healthy, stale, and never succeeded — a job that has never once completed is a different problem from one that stopped completing, and reporting both as "stale" sends whoever reads it looking for the wrong thing.
The grace period exists so that a run which is merely late does not alert. Make it a fraction of the interval, not a fixed number of minutes, or an hourly job and a monthly job end up sharing a tolerance that suits neither.
Two separate alerts. "The last run failed" and "no run has succeeded recently" are different questions with different answers, and a job can be in one state without the other: a job failing every night becomes stale when the time since its last success exceeds the interval plus grace, and a job that stopped running is stale while never having failed at all.
Read the code
import datetime
def parse(moment):
return datetime.datetime.fromisoformat(moment)
def health(records, now, interval_minutes, grace_minutes):
"""Is this job still succeeding often enough? Returns (status, message)."""
completed = [record for record in records if record["status"] == "completed"]
if not completed:
return "never succeeded", "no completed run on record"
latest = max(parse(record["finished_at"]) for record in completed)
age = int((parse(now) - latest).total_seconds() // 60)
allowed = interval_minutes + grace_minutes
if age > allowed:
return "stale", f"last success {age} minutes ago, allowed {allowed}"
return "healthy", f"last success {age} minutes ago, within {allowed}"
RECORDS = [
{"finished_at": "2026-03-01T02:04:00", "status": "completed"},
{"finished_at": "2026-03-02T02:06:00", "status": "completed"},
{"finished_at": "2026-03-03T02:01:00", "status": "failed"},
{"finished_at": "2026-03-04T02:03:00", "status": "failed"},
]
NOW = "2026-03-04T09:00:00"
status, message = health(RECORDS, NOW, interval_minutes=1440, grace_minutes=120)
print("newest record: ", max(record["finished_at"] for record in RECORDS))
print("newest success:", max(r["finished_at"] for r in RECORDS if r["status"] == "completed"))
print("status: ", status)
print("message:", message)
This is a job that ran on all four nights and succeeded on the first two. The two printed timestamps are there to make the distinction visible: something happened seven hours ago, and nothing has worked in more than two days.
completed filters before taking the maximum, so a failed run does not count as a heartbeat. That one line is the difference between a check that notices a job failing every night and one that reports it as healthy because it ran.
max over the parsed timestamps rather than the last element of the list: records are not guaranteed to arrive in order, and "the newest" is a question about values, not positions.
The age is computed in whole minutes. In this simulation all timestamps use the same naive clock; a deployed checker needs timestamps normalized to a common timezone and a policy for future-dated records.
Predict the output
The job runs daily, so interval_minutes is 1440 and the grace is 120, allowing 1560. Predict all four lines.
Check your prediction
newest record: 2026-03-04T02:03:00
newest success: 2026-03-02T02:06:00
status: stale
message: last success 3294 minutes ago, allowed 1560
The newest record is seven hours old, and it is a failure. The newest success is 02:06 on 2 March, which was 3294 minutes before nine o'clock on the fourth — well past the 1560 allowed — so the job is stale.
It would still be stale if it had failed loudly every single night since, which is the property that makes this check worth having.
A recent attempt is not a recent success
1. Run records in this simulated history
Mar 1 · 02:04- completed
Mar 2 · 02:06- completed · newest success
Mar 3 · 02:01- failed
Mar 4 · 02:03- failed · newest record
Mar 4 · 09:00- simulated check
The two failed records show activity, but neither resets the success heartbeat.
2. Allowed age and boundary
Interval- 1,440 minutes · daily
Grace- 120 minutes
Allowed- 1,560 minutes after last success
Allowance ends- Mar 3 · 04:06
First stale whole minute- Mar 3 · 04:07
The shown code floors age to whole minutes and tests age > allowed, so 04:06:59 still reads 1,560 and is healthy.
3. What the check reports
At simulated now- Newest success age: 3,294 minutes
Compared with- 1,560-minute allowance
Health- stale
Never succeeded- Separate state if no completed record exists
A failure alert and a stale alert answer different questions; the failed run 417 minutes ago is not a heartbeat.
4. Prevent overlap separately
Scheduler- Choose an already-running-instance policy
Job- Acquire a lock atomically for any trigger
Recovery- Confirm a lock owner is gone or use a safely renewed lease
This panel is a design decision, not evidence that a task or lock was installed.
Modify the code
Delete the completed filter, so latest is taken over every record regardless of status. The simplest way is to replace that line with completed = list(records).
What changes, and why
newest record: 2026-03-04T02:03:00
newest success: 2026-03-02T02:06:00
status: healthy
message: last success 417 minutes ago, within 1560
Nothing about the job changed. It has still not succeeded since 2 March, and the two timestamps above still say so. One line was deleted from the check, and a job that is more than two days into a continuous failure now reports healthy.
Read the message it produces, too: "last success 417 minutes ago" is false. The thing 417 minutes old is a failure, and the check is calling it a success because that is the only kind of record it now knows how to count.
Note what this does in practice. A job failing every night keeps producing recent records, so the staleness check keeps reporting healthy, forever. The check that exists to catch silence is satisfied by a job that is loudly and continuously broken — and if the failure alert is also missing, or is going to an unread mailbox, nothing anywhere says anything.
A heartbeat is a signal that the work was done. A record that the work was attempted is a different signal, and confusing the two produces monitoring that is worse than none, because it reports green.
Debug the bug
An assistant was asked for "monitoring so we know if the nightly job is working". It produced this.
def check(log_path):
lines = log_path.read_text().splitlines()
if not lines:
alert("no log output")
return
if "ERROR" in lines[-1]:
alert("job failed: " + lines[-1])
else:
print("job healthy")
What's actually wrong
It cannot detect the outage it was asked to detect.
- Nothing here looks at time. A job that stopped running three months ago leaves the same log file it left on its last successful night, whose last line contains no
ERROR, and this reports healthy every day forever. That is precisely the March-to-June outage. - It reads the last line only. A run that failed in the middle and then printed a cleanup line at the end reports healthy.
- It matches on the text
ERROR. Any change to the log format, and any error whose line is worded differently, becomes invisible. The status is data in the run record; this is inferring it from prose. - An empty log alerts, and a missing log file raises. The two cases most likely on the morning the job did not run are the two it handles worst.
- Nothing distinguishes "never worked" from "stopped working", which need different responses from whoever is woken up.
The version that catches silence:
def check(records, now, interval_minutes, grace_minutes):
status, message = health(records, now, interval_minutes, grace_minutes)
if status != "healthy":
alert(f"{status}: {message}")
last = max(records, key=lambda record: parse(record["finished_at"])) if records else None
if last and last["status"] not in ("completed", "refused"):
alert(f"most recent run {last['status']}: {last.get('reason', '')}")
Two independent checks over structured records: one for staleness, which fires when nothing has succeeded recently whether or not anything failed, and one for the most recent outcome, which fires on a failure whether or not the job is stale. Both use timestamps rather than trusting input order. A refused run is not automatically a failure alert, but a job may still choose to notify on refusals; refusal does not count as a successful heartbeat here.
Try it yourself
Write health, which decides whether a job is still succeeding often enough. It is run against three supplied jobs: one healthy, one that has not succeeded in far too long, and one that has never succeeded at all.
Loading this exercise…
Practical challenge (optional)
Optional, and the transfer task for this lesson: schedule something real, on your own machine.
This is the local-Python path, and local-python-setup.txt has the exact commands, Windows first, including the ones for removing what you create.
Write a two-line script that appends the current time to a file, schedule it to run every five minutes with schtasks on Windows or a cron entry elsewhere, and watch the file for fifteen minutes. Then do the part that matters: delete the scheduled task and confirm with schtasks /query that it is gone.
If you would rather schedule something with more to look at, starter_daily_digest.py is this module's ideas in one file, with its completed version and sample data. It has a --health flag that reports exactly the staleness check you just wrote.
What a good answer looks like
Most people meet at least one of three surprises. The task's working directory is not the folder the script is in, so a relative path writes the file somewhere unexpected — which is why absolute paths are the rule. The task configured to run only while you are logged on does nothing overnight. And the Python that runs is whichever one the task's command line names, not the one your terminal uses, so a script depending on an installed package fails under the scheduler and works by hand.
Every one of those is discovered in ten minutes by scheduling something trivial, and every one of them is discovered at three in the morning otherwise.
The deletion step is not tidiness. A forgotten five-minute task from an experiment is a small, permanent background process on your machine, and confirming with /query that it is gone is the habit that stops those accumulating.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why can no amount of error alerting detect a job that has stopped running?
- Why must a staleness check count only successful runs?
- Why are "stale" and "never succeeded" reported as different states?
- Why does a job need its own lock even when the scheduler has an overlap policy?
Answers
- An error requires something to have run. A job that is not running produces no output of any kind, and its silence is indistinguishable from a quiet period unless something is measuring time since the last success.
- Otherwise a job that fails every night keeps producing recent records, and the check that exists to detect silence is satisfied by a continuous outage. It then reports green, which is worse than reporting nothing.
- They need different responses. A job that has never completed is probably misconfigured and has never worked; one that stopped completing worked until something changed, and the useful question is what changed.
- Because an atomically acquired job lock can guard starts from any trigger — including a person running it by hand while the schedule fires — while the scheduler policy governs starts through that scheduler. Cron supplies no per-job overlap policy, so the job must provide its own guard.
Sign in to track your progress on this exercise.
Summary and next step
Choose the trigger deliberately, decide what happens when a run is still going when the next one is due, and acquire a job lock atomically with a safe recovery rule so one crash does not disable the job permanently or let two live runs overlap. Then build the alert that error handling cannot provide: the age of the most recent successful run, compared against the interval plus a grace period, reported as healthy, stale, or never succeeded. Nothing in a browser can install any of this, and the local path is where you do it for real. The capstone now assembles everything: a scheduled reporting workflow with validation, a run record, safe reruns, tested recovery, and the documentation somebody else could run it from.