Capstone: The Order Digest
Capstone Part Two: Interrupting It On Purpose
Testing recovery by choosing where the run dies rather than waiting for it to happen, then proving that resuming reaches the same result as an uninterrupted run and that running it again preserves the digest and accepted result with zero new work.
Lesson 17 of 18 in the recommended order · About 30 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 interrupt your own workflow at a place you choose, resume it, and demonstrate two things: that the resumed run reaches the same result as one that was never interrupted, and that a third run over the same input preserves the accepted result and digest while reporting zero new work. A durable job may still write a fresh run record or heartbeat.
Why it matters
Recovery code is the least-tested code in any automation, for a simple reason: making it run requires something to go wrong, and nothing goes wrong on demand.
So it gets written, reasoned about, and never executed. Then one night it executes for the first time — unattended, at whatever hour the failure happened, against real data — and whether it works is discovered afterwards, by looking at what it did.
The fix is to stop waiting. Make the failure point a parameter. A run that takes "stop after three records" as an argument can be interrupted anywhere you like, in a test that finishes instantly, as many times as you want. Recovery stops being a thing you believe in and becomes a thing you have watched happen.
Then there are two questions to actually ask, and only one of them is usually asked:
- Does the resumed run finish? Usually asked. Easy to satisfy, and not very interesting.
- Does the resumed run reach the same result as a run that was never interrupted? Rarely asked. This is the one that matters, and the answer is often no — because the resumed run skipped the setup, or double-counted the record that was in flight, or aggregated only what it processed itself rather than everything.
Concept
Choose the failure point. Add a parameter that says where to stop — after n records, before the write, during the third page. It costs one argument and it turns every recovery path into something a test can drive. In production it is never set, and there is no branch in the interesting code, only a check in the loop.
Five places worth interrupting, because they fail differently:
- Before anything is read. The easiest case; nothing has happened.
- Part-way through processing. First test a stop between records, as the worked
fail_afterfunction does. A crash inside a record’s side effect is a separate case: did that effect complete before the stop, and can it be replayed safely? - After processing, before the output is written. The work is done and invisible. A resumed run must not conclude that the work is done.
- Half-way through writing the output. Use a temporary name and a checked atomic replacement on a supported filesystem, so a failed temporary write leaves the previous complete output intact rather than publishing a truncated file.
- After the output, before the run record. The output exists, but the last durable run record does not include that completed work. The next run must repeat any work absent from that record and safely replace the derived output. In this design, output and run record are separate writes; changing their order alone cannot remove the gap. A shared transactional design would need a different storage contract.
Five conceptual interruption stops
Test map read → process → temporary output and rename → run-record write
Only a between-record subcase of stop 2 is injected by the worked in-memory function. The other stops need a separate durable-write harness.
1. Before read
Durable output- Previous complete version
Run record- Previous record
No new work has started.
2. During processing
Durable output- Previous complete version
Run record- Last durable record
Resume work absent from that record. The worked fail_after example stops between records in memory; an in-flight partial side effect needs its own replay-safe test.
3. After processing, before output
Durable output- Previous complete version
Run record- Last durable record
Computed work is not yet published; do not treat it as committed.
4. During temporary output write
Durable output- Previous complete version
Temporary file- May be incomplete
Write a temporary file, then use a checked atomic replacement where supported; a failed temporary write leaves the published output intact.
5. Output published, record pending
Durable output- New complete version
Run record- Last durable record
Replay work absent from that record. Cumulative derivation and idempotent replacement keep the digest stable.
This timeline is a conceptual test map. The worked function below stops only between records in memory, returns in-memory records, and performs no durable output or run-record write. An in-flight partial side effect needs another replay-safe test. The other four boundaries require a separate test setup, including a temporary output file for the mid-write case.
The two properties to check.
resume equivalence: resumed processed IDs + digest = uninterrupted processed IDs + digest
rerun safety: third run keeps IDs + digest, reports zero new work
The first says recovery is correct. The second says recovery is safe to trigger when it was not needed — which matters when the previous run might have finished. "Just run it again" is safe for the digest only when its output is derived cumulatively and replacement is idempotent; other effects require their own replay-safe design.
Compare the result, not just the status or totals. A resumed run that reports completed proves little. Compare the cumulative processed IDs and digest; equal aggregate counts and units can hide different records. The per-attempt new counts may differ between a resumed run and an uninterrupted one.
Aggregate over everything processed, not over what this run did. The most common recovery defect in a reporting job: the resumed run reports totals for the three records it handled rather than for all six, because the aggregation stage was handed this run's accepted list. The digest is a statement about the day, not about the attempt.
A rerun with nothing to do is a successful run. It should report completed, with zero new records, and write a record saying so. Reporting failed because there was nothing to do makes every recovery attempt look like a problem, and reporting nothing at all leaves the staleness check without a heartbeat.
Read the code
ORDERS = {
"ORD-1": {"units": 4, "status": "confirmed"},
"ORD-2": {"units": 6, "status": "confirmed"},
"ORD-3": {"units": 9, "status": "cancelled"},
"ORD-4": {"units": 2, "status": "confirmed"},
}
def digest_for(orders, processed):
"""The digest is a statement about everything processed, not about this run."""
accepted = [oid for oid in sorted(processed) if orders[oid]["status"] == "confirmed"]
return [f"orders: {len(accepted)}", f"units: {sum(orders[oid]['units'] for oid in accepted)}"]
def run(orders, previous, fail_after=None):
processed = list(previous["processed"]) if previous else []
done = set(processed)
new = 0
for order_id in sorted(orders):
if order_id in done:
continue
if fail_after is not None and new >= fail_after:
return {"status": "partial", "processed": processed, "new": new,
"digest": digest_for(orders, processed)}
processed.append(order_id)
done.add(order_id)
new += 1
return {"status": "completed", "processed": processed, "new": new,
"digest": digest_for(orders, processed)}
interrupted = run(ORDERS, None, fail_after=2)
resumed = run(ORDERS, interrupted)
straight = run(ORDERS, None)
print("interrupted:", interrupted["status"], interrupted["new"])
print("resumed:", resumed["status"], resumed["new"])
print("equivalent:", resumed["processed"] == straight["processed"]
and resumed["digest"] == straight["digest"])
print("digest:", "; ".join(resumed["digest"]))
digest_for takes processed — everything, cumulative — rather than the ids this run handled. That single argument is the difference between a digest about the day and a digest about the attempt.
fail_after is checked before appending, so a run that stops after two has processed exactly two, with no third record half-done.
The equivalence check compares both cumulative processed IDs and digest across independently produced runs. Comparing totals alone could miss a different set of records with the same count and units.
Predict the output
Predict all four lines.
Check your prediction
interrupted: partial 2
resumed: completed 2
equivalent: True
digest: orders: 3; units: 12
The interrupted run handled ORD-1 and ORD-2. The resumed run skipped those and handled ORD-3 and ORD-4, so it also reports two new — and the two runs together have covered all four.
The digest counts three orders, not four: ORD-3 was processed and is cancelled, so it is excluded from the accepted set while still being marked as handled. Units are 4 + 6 + 2 = 12.
equivalent: True is the line to care about. The resumed pair has the same cumulative processed IDs and digest as a run that was never interrupted.
Modify the code
Change digest_for(orders, processed) to digest_for(orders, processed[len(previous["processed"]) if previous else 0:]), so the digest covers only what this run handled.
What changes, and why
interrupted: partial 2
resumed: completed 2
equivalent: False
digest: orders: 1; units: 2
Both runs still report success. Both still processed every order exactly once. And the digest now says one order and two units, for a day that had three accepted orders and twelve units.
This is the recovery defect in its natural habitat. Nothing raised. No record was lost or duplicated. The resumption logic is perfectly correct. The report is simply about the wrong thing — the second attempt rather than the day — and the only reason it is visible here is the equivalent comparison against an uninterrupted run.
Without that comparison, the output looks entirely plausible. A digest saying one order is not obviously wrong on a quiet Tuesday.
Debug the bug
Somebody added recovery to the digest and asked an assistant to review it. The assistant said it looked correct.
def run(orders, state_path):
state = json.loads(state_path.read_text()) if state_path.exists() else {"processed": []}
processed = state["processed"]
accepted = []
for order_id in sorted(orders):
if order_id in processed:
continue
order = orders[order_id]
if order["status"] == "confirmed":
accepted.append(order_id)
processed.append(order_id)
write_digest(accepted)
state_path.write_text(json.dumps({"processed": processed}))
return len(accepted)
What's actually wrong
It resumes correctly and reports a different answer every time.
acceptedholds only this run's records. A resumed run writes a digest covering the records it handled, and the day's real totals are never reported by anybody. This is the defect theModifyTheCodestep just produced deliberately.- The digest is written before the state. A crash between those two lines leaves an output that the durable state does not describe. The next run repeats work absent from the last durable record. This flawed code derives the digest from only the current attempt, so replacement can still publish the wrong total; deriving a cumulative digest and replacing it idempotently are both required for safe replay. Appending would also risk double-counting.
- A rerun with nothing to do writes an empty digest. Every order is already in
processed,acceptedis empty, andwrite_digest([])replaces yesterday's correct digest with one reporting zero orders. "Just run it again" destroys the output. - No status is returned or recorded, so nothing distinguishes a complete run from an interrupted one, and the staleness check has no heartbeat to read.
processedis mutated in place from the parsed state, which is harmless here and stops being harmless the moment anything else holds a reference to it.
A corrected cumulative digest function for this in-memory example:
def run(orders, previous, write_digest, fail_after=None):
processed = list(previous["processed"]) if previous else []
done, new = set(processed), 0
for order_id in sorted(orders):
if order_id in done:
continue
if fail_after is not None and new >= fail_after:
return {"status": "partial", "processed": processed, "new": new}
processed.append(order_id)
done.add(order_id)
new += 1
write_digest(digest_for(orders, processed)) # every processed order, not just this run's
return {"status": "completed", "processed": processed, "new": new}
This corrected function derives the digest from the cumulative processed list, so its in-memory result agrees whether the day took one call or three. A call with nothing new passes the same digest to write_digest; safe durable replacement still depends on that writer and on a separately persisted run record. The status distinguishes the returned outcomes, and fail_after tests only the mid-loop stop.
Try it yourself
Write run, then let the starter interrupt it, resume it, and run it a third time. The resumed pair must return the same cumulative processed IDs and digest as an uninterrupted run, and the third run must keep both unchanged with zero new work.
Loading this exercise…
Practical challenge (optional)
Optional, and the transfer task for this lesson: interrupt it in the other four places.
The exercise interrupts part-way through processing. Write a test for each of the remaining four points from the concept list — before reading, after processing but before the write, half-way through the write, and after the write but before the record — and for each one, state in a sentence what the next run must do.
What a good answer looks like
The half-way-through-write case is the one that needs the technique rather than the reasoning: write to a temporary name and rename, so an interrupted write leaves the previous digest intact instead of a truncated file. Test it by having the write function raise after emitting half its lines, then assert the old digest is still readable and complete.
The after-write-before-record case has no code in this design that commits the two separate writes together. The next run reprocesses work absent from the last durable record. With a cumulative derived digest, idempotent replacement, and replay-safe side effects, that repeat can yield the same output. A transaction could coordinate the writes only if the architecture gives them a shared transactional boundary; simply changing write order does not. Check the new run record or heartbeat separately from digest equality.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why make the failure point a parameter rather than testing recovery with a real failure?
- What are the two properties a recovery test should check?
- Why must the digest be computed from everything processed rather than from what this run handled?
- Why is the gap between writing the output and writing the run record unavoidable, and what makes it survivable?
Answers
- Because nothing fails on demand, so recovery code that needs a real failure is never executed until it executes unattended against real data. A parameter makes every recovery path testable instantly and repeatedly.
- That a resumed run reaches the same accepted result and digest as an uninterrupted run, and that a further run has zero new work without changing that result.
- Because the digest is a statement about the day, not about the attempt. A resumed run that aggregates only its own records reports plausible, wrong totals, and every status in the run says success.
- In this separate-write design, changing the order of output and run-record writes cannot remove the gap. A retry repeats work missing from the last durable record; it is safe only if processing and other effects tolerate replay and the cumulative digest is replaced idempotently. A shared transactional architecture could change this boundary.
Sign in to track your progress on this exercise.
Summary and next step
Make the failure point an argument. The worked code tests the mid-loop stop; extend the test setup to the other four conceptual positions and check two things: that the resumed accepted result and digest equal the uninterrupted ones, and that a further run has zero new work without changing that result. Aggregate over everything processed rather than over one attempt, and treat a rerun with nothing to do as a completed run that writes its record. The last lesson turns the working project into one somebody else can operate: the layout, the settings, the rubric it is measured against, and the runbook that answers the questions nobody is awake to ask.