Make It Dependable
The Run Record, and the One Line Somebody Reads at Eight
Writing down what a run did as data the next run can read, with counts that reconcile and a single summary sentence that says whether anybody needs to act.
Lesson 13 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 produce two things at the end of every run: a record, in a form the next run can read, whose numbers can be checked against each other; and one sentence a person can read at eight in the morning and know whether anything needs doing.
Why it matters
A log and a run record are different things, and jobs that have only one of them have usually got the wrong one.
A log is prose for a person, written as the run goes: line after line, in order, useful when you already suspect something and are looking for where it went wrong. It is terrible for answering "did last night's run finish?", because answering that means reading to the end of a file and interpreting sentences.
A run record is data, written once, at the end: a small object with the run's identity, its outcome, its counts, and its position. It answers "did last night's run finish?" in one comparison, and — the part that matters most — the next run reads it. Where to resume from, whether a previous run is still going, how long it has been since anything succeeded: all of that is a lookup in the record and none of it is available from prose.
Then there is the third artefact, which is one sentence. Nobody reads a hundred-line log every morning, and nobody reads a JSON object every morning either. What gets read is a subject line. If that sentence carries the outcome and the numbers, most mornings end there.
The failure this lesson exists to prevent: a run that processed 118 of 120 records and reported success. Not because it lied — because nothing ever compared 118 with 120.
Concept
Six fields, in every record.
run_id unique to this run, so two runs can never be confused
started_at when it began
finished_at when it ended, which is how "still running" is detected
status completed | partial | failed | refused | inconsistent
counts read, accepted, quarantined, written
reason why it is not "completed", in words
status deserves more than two values. The first module made "refused" a normal outcome rather than an error; this module adds "partial", for a run that stopped at its budget with correct results that are not all of them. Collapsing those into failed makes a healthy refusal look like a bug, and collapsing them into completed makes a truncated run look finished.
Reconciliation is one line, and it is the point. Every record where records go in and results come out satisfies:
read == accepted + quarantined + rejected
Check it, in the code, at the end of the run. If it does not hold, the correct status is not completed — it is inconsistent, and the reason says which numbers disagreed. A job that cannot account for two of its 120 records has a defect, and the alternative to reporting it is that nobody finds out.
This is worth being blunt about. A summary that reports success without reconciling is not a summary of what happened; it is a summary of what the code believed happened, and those differ exactly when there is a bug.
The summary line. One sentence: what ran, what happened, the numbers, how long. Written from the record, never assembled separately — a summary computed independently of the record is a second implementation of the same claim, and the two will disagree eventually.
nightly-invoices completed: read 120, accepted 118, quarantined 2, in 41s
nightly-invoices inconsistent: read 120 != accepted 100 + quarantined 2
What never goes in. A run record is copied into tickets, pasted into messages, and kept for a long time. It carries counts, identifiers, and outcomes — not the records themselves, not anything that identifies a person, and not any value that authorises anything. The introductory course covers redaction at the point of use; the addition here is to hold a written list of field names that never reach a record, and drop them by name rather than by remembering.
Write it once, at the end, atomically. A record half-written by a run that was killed is worse than no record: the next run reads it and believes something false. Write to a temporary name and rename, the same technique the file module used.
Read the code
NEVER_RECORD = {"submitted_by", "home_address", "phone"}
def summarise(record):
"""The one line somebody reads. Derived from the record, never assembled separately."""
counts = record["counts"]
if record["status"] == "inconsistent":
return (
f"{record['job']} inconsistent: read {counts['read']} != "
f"accepted {counts['accepted']} + quarantined {counts['quarantined']}"
)
return (
f"{record['job']} {record['status']}: read {counts['read']}, "
f"accepted {counts['accepted']}, quarantined {counts['quarantined']}, "
f"in {record['seconds']}s"
)
def finish(job, counts, seconds, status="completed"):
reconciles = counts["read"] == counts["accepted"] + counts["quarantined"]
record = {
"job": job,
"status": status if reconciles else "inconsistent",
"counts": counts,
"seconds": seconds,
}
return record, summarise(record)
good, good_line = finish("nightly-invoices", {"read": 120, "accepted": 118, "quarantined": 2}, 41)
bad, bad_line = finish("weekly-suppliers", {"read": 50, "accepted": 40, "quarantined": 5}, 12)
print(good["status"])
print(good_line)
print(bad["status"])
print(bad_line)
finish computes the status rather than accepting it unconditionally: the caller says what it believes, and the reconciliation check can overrule it. That ordering is deliberate — the caller is the code that just ran, and it is the least reliable witness to its own success.
summarise reads only the record. Give it a record and it produces the line; there is no path by which the line and the record can disagree.
Predict the output
Predict all four lines.
Check your prediction
completed
nightly-invoices completed: read 120, accepted 118, quarantined 2, in 41s
inconsistent
weekly-suppliers inconsistent: read 50 != accepted 40 + quarantined 5
The second run was handed status="completed" by default and did not get it. Fifty records were read, forty-five are accounted for, and five are somewhere nobody knows about — so the run is reported as inconsistent even though nothing raised and every step of it appeared to work.
That is the entire value of the check: this is precisely the run that would otherwise have been reported as a success.
Modify the code
Move the reconciliation check into summarise, so finish records the status it was given and only the summary line mentions the discrepancy.
What changes, and why
The printed lines are almost the same — the summary still says inconsistent — but the first and third lines now read completed and completed. The record on disk says the run succeeded.
That difference is everything, because the record is the thing that is kept. The summary line is read once, by whoever happens to look at the message that morning. The record is what the next run reads to decide whether to resume, what a dashboard counts to say the job is healthy, and what somebody queries in six weeks when the numbers do not add up.
There is a general rule here worth naming: a check belongs where the result is stored, not where it is displayed. A validation that only runs on the way to a screen is absent from every other path, and there is always another path.
Debug the bug
An assistant was asked to "log what the job did so we can see if it worked". It produced this.
def process_all(records):
logging.info("starting")
accepted = 0
for record in records:
try:
handle(record)
accepted += 1
except Exception as error:
logging.warning(f"skipping {record}: {error}")
logging.info(f"done, processed {accepted} records")
return accepted
What's actually wrong
It produces prose, in the wrong place, containing the wrong things.
- Nothing reconciles.
acceptedis counted; the number read is not, and the number skipped is not. The final line reports a number with nothing to compare it against, so a run that skipped half its input reports the other half as a success. - "done" is unconditional. It is printed whether zero or four hundred records were handled, and it is the line a person scans for.
logging.warning(f"skipping {record}")writes the whole record into the log. Whatever the record contains — names, addresses, an entire customer row — is now in a log file that is copied, shipped, and kept far longer than the data policy for the records themselves.except Exceptionaroundhandlecatches this job's own bugs as though they were bad records. ATypeErrorinhandlemarks every record as skippable and the run still reports done.- There is no record, only lines. The next run cannot read "where did the last one get to", "is one already running", or "when did anything last succeed", because none of that exists as data.
- No run identity. Two runs interleaved in one log file are indistinguishable.
The version that can be acted on:
def process_all(job, run_id, records, handle):
read = accepted = quarantined = 0
reasons = []
for record in records:
read += 1
try:
handle(record)
accepted += 1
except RecordRejected as rejection:
quarantined += 1
reasons.append({"id": record["id"], "reason": str(rejection)})
counts = {"read": read, "accepted": accepted, "quarantined": quarantined}
status = "completed" if read == accepted + quarantined else "inconsistent"
return {"job": job, "run_id": run_id, "status": status, "counts": counts, "quarantined": reasons}
Every category is counted, the status is derived from the counts rather than asserted, only a rejection specific to a bad record is caught, the quarantine list names records by identifier rather than by contents, and the whole thing is a value the caller can write, compare, and hand to the next run.
Try it yourself
Write finish, which turns a job's counts into a run record and its summary line. Two supplied runs are passed through it: one whose counts reconcile and one whose counts do not. The record must also drop any field named in NEVER_RECORD.
Loading this exercise…
Practical challenge (optional)
Optional, and the transfer task for this lesson: work out what a week of records should be able to answer.
Write down five questions somebody might ask about a job — "when did it last succeed", "is one running now", "how many records has it quarantined this week", "is it getting slower", "did anything change on the day the numbers moved" — then check whether the six-field record above can answer each one. Add exactly the fields that are missing, and no more.
What a good answer looks like
Most people find they need two additions and are tempted by six. "Is it getting slower" needs seconds, which is already there. "Did anything change" usually needs the version or commit of the job that ran, which is one short string and is worth its space every time.
The temptation is to add the input filenames, the configuration, the parameters, and eventually the records themselves — at which point the record is a copy of the data with a timestamp on it, and it inherits every retention and privacy obligation the data has. A record is a receipt, not an archive: identifiers and counts, never contents.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- What can a run record do that a log cannot?
- What does the reconciliation check compare, and what status does a failure produce?
- Why derive the status from the counts rather than from what the calling code believes?
- Why should the summary line be computed from the record rather than assembled as the run goes?
Answers
- Be read by the next run. Where to resume, whether a run is already going, and when anything last succeeded are all lookups in a record and none of them can be got from prose without parsing sentences.
- That the number read equals the number accepted plus the number quarantined plus the number rejected. When it does not hold the status is
inconsistent, and the reason names the numbers that disagreed. - Because the code that just ran is the least reliable witness to its own success. A run with a bug believes it succeeded, and the counts are the only independent evidence available.
- Because two independent constructions of the same claim eventually disagree, and the one people read is the one that will be wrong. Deriving the line from the stored record makes disagreement impossible.
Sign in to track your progress on this exercise.
Summary and next step
End every run with a record — identity, outcome, counts, reason — written once, atomically, in a form the next run can read; check that the counts account for every record read, and let that check overrule the status the code believed; derive the one summary line from the record itself; and keep contents and anything identifying a person out of both. The next lesson is what the next run does with that record: telling a failure worth retrying from one that will fail again, and picking up where the last run stopped.