Python Part II · Module 26 · Regression Automation
Resuming and Re-Running Only What Failed
A four-hundred-job regression that dies at job three hundred and twenty has done eight hours of useful work, and the usual response is to start it again from zero. The information needed to avoid that is trivial — which jobs finished and what they produced — and the reason flows rarely have it is that the obvious place to write it down is destroyed by precisely the event that makes it necessary. Getting this right is less about the resume logic, which is one comprehension, than about where the state lives.
Advanced19 min readPythonPart IIAutomationRegressionVerification Flow
Part II · Module 26 · Chapter 26.8 · Resuming and Re-Running Only What Failed
1. What a Crash at 80% Costs
TOTAL, DONE = 400, 320
print("jobs in the regression:", TOTAL)
print("jobs completed :", DONE)
print("jobs remaining :", TOTAL - DONE)
print()
print("restarting from zero re-runs:", DONE, "jobs that already finished")
print("wasted fraction :", f"{100 * DONE / TOTAL:.0f}%")
print()
print("what is needed to avoid it :", "which jobs finished, and what they produced")
print("how much data that is :", f"{TOTAL} lines")jobs in the regression: 400
jobs completed : 320
jobs remaining : 80
restarting from zero re-runs: 320 jobs that already finished
wasted fraction : 80%
what is needed to avoid it : which jobs finished, and what they produced
how much data that is : 400 linesEighty jobs of work left and three hundred and twenty re-run to get there. The information that would prevent it is four hundred short lines — a few tens of kilobytes against eight hours of compute.
Chapter 26.6 showed the results are already on disk, one record per directory, so in principle a resume could just read them. In practice that is not enough, and the next sections are why.
2. The Filter
todo = [j for j in jobs if state.get(j) != "PASS"]JOBS = [("a", 1), ("b", 2), ("c", 3), ("d", 4), ("e", 5)]
STATE = {("a", 1): "PASS", ("b", 2): "FAIL", ("c", 3): "ERROR", ("d", 4): "NO_FINISH"}
todo = [j for j in JOBS if STATE.get(j) != "PASS"]
print("jobs :", len(JOBS))
print("have a state :", len(STATE))
print("known to pass :", sum(1 for v in STATE.values() if v == "PASS"))
print()
print("todo :", todo)
print()
print("('e', 5) has no state :", STATE.get(("e", 5)))
print("and None != 'PASS' :", STATE.get(("e", 5)) != "PASS")
print("so it is included :", ("e", 5) in todo)jobs : 5
have a state : 4
known to pass : 1
todo : [('b', 2), ('c', 3), ('d', 4), ('e', 5)]
('e', 5) has no state : None
and None != 'PASS' : True
so it is included : TrueThe correctness of that one line rests on something easy to miss: .get returns None for a job that never ran, and None != "PASS", so a never-run job is included without a separate clause.
The alternative that looks equivalent is not:
JOBS = [("a", 1), ("b", 2), ("e", 5)]
STATE = {("a", 1): "PASS", ("b", 2): "FAIL"}
annotation = [j for j in JOBS if STATE.get(j) != "PASS"]
membership = [j for j in JOBS if j in STATE and STATE[j] != "PASS"]
print("annotation form:", annotation)
print("membership form:", membership)
print()
print("dropped by the membership form:", [j for j in annotation if j not in membership])
print("what those jobs are :", "the ones the regression never reached")annotation form: [('b', 2), ('e', 5)]
membership form: [('b', 2)]
dropped by the membership form: [('e', 5)]
what those jobs are : the ones the regression never reachedThe membership form silently drops every job that never ran — which, after a crash at eighty percent, is the twenty percent that is the entire point of resuming.
It would also break Chapter 26.1's invariant on the resumed run: those jobs are in the job list, no result is ever produced for them, and they would be reported as not_run forever while a resume that claimed to be complete kept finishing.
3. Three Resume Policies
JOBS = [("a", 1), ("b", 2), ("c", 3), ("d", 4), ("e", 5)]
STATE = {("a", 1): "PASS", ("b", 2): "FAIL", ("c", 3): "ERROR", ("d", 4): "NO_FINISH"}
MODES = {
"not-pass": lambda s: s != "PASS",
"never-ran-only": lambda s: s is None,
"resume-plus-infra": lambda s: s not in ("PASS", "FAIL"),
}
for name, keep in MODES.items():
todo = [j for j in JOBS if keep(STATE.get(j))]
print(f"{name:<19} {len(todo)} job(s): {todo}")
print()
print("all three are defensible:", True)
print("they answer :", "different questions")not-pass 4 job(s): [('b', 2), ('c', 3), ('d', 4), ('e', 5)]
never-ran-only 1 job(s): [('e', 5)]
resume-plus-infra 3 job(s): [('c', 3), ('d', 4), ('e', 5)]
all three are defensible: True
they answer : different questionsOne comprehension, three meanings, four jobs against one.
never-ran-only is a pure resume. Finish the work that was interrupted. It is what you want when the regression died for an external reason and you trust everything it already produced.
resume-plus-infra finishes the work and retries the infrastructure failures, leaving genuine failures alone. This is usually the right default after a crash: an ERROR from a licence outage during the crash window should be redone, and a FAIL is a finding that does not need re-confirming.
not-pass re-runs everything that is not green, including known failures. It is the right choice for a verification re-run after a fix — but note that it is also Chapter 26.5 Section 4's forbidden operation if the results are then merged as pass-if-any-attempt-passes. The difference is that a re-run after a code change is a new experiment; a re-run of the same code is a resample.
Making the mode a named argument rather than a hard-coded comprehension is the point of the section. Chapter 26.2 Section 2 made the same argument about tag filtering: a policy nobody can see at the call site is a policy nobody agreed to.
4. Reading State From the Results Is Not Enough
SITUATIONS = [
("job finished, record written", "readable", "state recoverable from disk"),
("job finished, record corrupt", "unreadable", "looks identical to never-ran"),
("job was running when the crash hit", "no record", "looks identical to never-ran"),
("job never started", "no record", "never ran"),
]
print(f"{'situation':<38} {'on disk':<12} inference")
for what, disk, inference in SITUATIONS:
print(f"{what:<38} {disk:<12} {inference}")
print()
indistinguishable = [s for s, d, _ in SITUATIONS if d != "readable"]
print("situations that look the same on disk:", len(indistinguishable), "of", len(SITUATIONS))
print("why it matters:", "a job that was mid-run left a half-written run directory")situation on disk inference
job finished, record written readable state recoverable from disk
job finished, record corrupt unreadable looks identical to never-ran
job was running when the crash hit no record looks identical to never-ran
job never started no record never ran
situations that look the same on disk: 3 of 4
why it matters: a job that was mid-run left a half-written run directoryReconstructing state purely from Chapter 26.6's records is mostly right and gets three of four situations wrong in the same direction — they all read as "never ran", which is at least the safe direction, since re-running a job that already finished is wasteful rather than incorrect.
The one that is not merely wasteful is the third. A job interrupted mid-run left a partially written run directory, and Chapter 26.4 Section 7's allocator refuses to run into a non-empty directory — so the resume stops with a collision error on a job it correctly decided to re-run.
That is solvable (re-run into the next attempt directory, per Chapter 26.5 Section 6) and it is the sort of thing an explicit state file makes visible rather than something to infer.
5. The State File the Crash Destroys
import json
from pathlib import Path
Path("st").mkdir(exist_ok=True)
state_file = Path("st/state.json")
state_file.write_text('{"a_1": "PASS", "b_2": "PASS", "c_3": "FA', encoding="utf-8")
print("what is on disk:", repr(state_file.read_text(encoding="utf-8")))
print()
try:
loaded = json.loads(state_file.read_text(encoding="utf-8"))
print("loaded:", loaded)
except json.JSONDecodeError as exc:
print("reload :", type(exc).__name__, "-", exc.msg)
print("entries recovered:", 0)
print("entries actually written before the crash:", 2)what is on disk: '{"a_1": "PASS", "b_2": "PASS", "c_3": "FA'
reload : JSONDecodeError - Unterminated string starting at
entries recovered: 0
entries actually written before the crash: 2Two completed jobs were on disk and zero are recoverable.
A whole-file JSON state is rewritten in full every time it changes, so a crash during any one of those writes leaves a truncated document — and JSON has no partial parse. The entire state is lost, not the last entry.
The cruelty is the timing: the crash that makes a resume necessary is the crash most likely to be happening during a state write, because a regression writing state after every job spends a meaningful fraction of its life inside that write.
The standard fix is to make the write atomic:
import json
import os
from pathlib import Path
def save_atomic(path, obj):
"""Write to a temp file in the same directory, then rename over the target."""
path = Path(path)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(obj), encoding="utf-8")
os.replace(tmp, path)
Path("st2").mkdir(exist_ok=True)
save_atomic("st2/state.json", {"a_1": "PASS", "b_2": "FAIL"})
print("reloads cleanly :", json.loads(Path("st2/state.json").read_text(encoding="utf-8")))
print("temp file gone :", not Path("st2/state.json.tmp").exists())
print()
print("why it is safe :", "os.replace is atomic — the target is the old file or the new one")
print("the cost :", "the whole file is rewritten once per job")reloads cleanly : {'a_1': 'PASS', 'b_2': 'FAIL'}
temp file gone : True
why it is safe : os.replace is atomic — the target is the old file or the new one
the cost : the whole file is rewritten once per jobos.replace is an atomic rename on POSIX: a reader sees either the complete old file or the complete new one, never a partial write. The temp file must be in the same directory, because a rename across filesystems is not atomic.
6. Append-Only Loses One Line
import json
from pathlib import Path
Path("st3").mkdir(exist_ok=True)
jsonl = Path("st3/state.jsonl")
jsonl.write_text('{"job": "a_1", "status": "PASS"}\n'
'{"job": "b_2", "status": "FAIL"}\n'
'{"job": "c_3", "sta', encoding="utf-8")
recovered, discarded = [], []
for number, line in enumerate(jsonl.read_text(encoding="utf-8").splitlines(), start=1):
try:
recovered.append(json.loads(line))
except json.JSONDecodeError:
discarded.append(number)
print("lines on disk :", len(jsonl.read_text(encoding='utf-8').splitlines()))
print("recovered :", len(recovered))
print("discarded lines :", discarded)
print()
for entry in recovered:
print(" ", entry)
print()
print("entries lost :", len(discarded), "— only the one being written")lines on disk : 3
recovered : 2
discarded lines : [3]
{'job': 'a_1', 'status': 'PASS'}
{'job': 'b_2', 'status': 'FAIL'}
entries lost : 1 — only the one being writtenTwo of three records survive the same crash that destroyed all of the whole-file state.
One JSON document per line, appended, never rewritten. A crash truncates the final line and every earlier line is independently parseable — so the loss is bounded at one job rather than all of them.
For a regression this is better on both axes that matter:
JOBS = 400
print(f"{'format':<22} {'writes':>8} {'bytes rewritten':>18} {'worst-case loss'}")
print(f"{'whole-file JSON':<22} {JOBS:>8} {'O(n) each time':>18} {'all entries'}")
print(f"{'whole-file + atomic':<22} {JOBS:>8} {'O(n) each time':>18} {'nothing'}")
print(f"{'append-only JSONL':<22} {JOBS:>8} {'one line each':>18} {'one entry'}")
print()
print("total bytes written, whole-file, for 400 jobs:", "proportional to 400 x 400")
print("total bytes written, append-only :", "proportional to 400")
print("ratio :", JOBS)format writes bytes rewritten worst-case loss
whole-file JSON 400 O(n) each time all entries
whole-file + atomic 400 O(n) each time nothing
append-only JSONL 400 one line each one entry
total bytes written, whole-file, for 400 jobs: proportional to 400 x 400
total bytes written, append-only : proportional to 400
ratio : 400The atomic whole-file version loses nothing and rewrites the entire state four hundred times; the append-only version loses at most one entry and writes each line once. For a regression, append-only is the right default — and the one entry it can lose is the job that was being written when the crash happened, which the resume would re-run anyway.
The append-only form has one property the dict form does not: a job can appear more than once, which is exactly what you want when Chapter 26.5 retries it. Last entry wins, and the earlier ones are a history.
7. The Key Has to Be a String
import json
try:
json.dumps({("axi_burst", 1839274): "PASS"})
except TypeError as exc:
print("TypeError:", exc)
print()
print("encoded:", json.dumps({"axi_burst/1839274": "PASS"}))
print()
def key_of(test, seed):
return f"{test}/{seed}"
def parse_key(key):
test, _, seed = key.rpartition("/")
return test, int(seed)
for test, seed in (("axi_burst", 1839274), ("grp/axi_smoke", 7)):
k = key_of(test, seed)
print(f"{str((test, seed)):<28} -> {k:<24} -> {parse_key(k)}")
print()
print("round-trips with a slash in the test name:",
parse_key(key_of("grp/axi_smoke", 7)) == ("grp/axi_smoke", 7))TypeError: keys must be str, int, float, bool or None, not tuple
encoded: {"axi_burst/1839274": "PASS"}
('axi_burst', 1839274) -> axi_burst/1839274 -> ('axi_burst', 1839274)
('grp/axi_smoke', 7) -> grp/axi_smoke/7 -> ('grp/axi_smoke', 7)
round-trips with a slash in the test name: TrueJSON refuses a tuple key, so Chapter 26.2's (test, seed) job identity has to be encoded — and the encoding is a round-trip hazard of exactly the kind Chapter 26.3 Section 6 warned about, because a key that does not decode back to the same job matches nothing and the resume quietly restarts.
rpartition rather than split is what makes the second row work: splitting on / would give three parts for grp/axi_smoke/7 and reconstruct the wrong test name. Partitioning from the right takes the seed off the end and leaves everything else alone, whatever it contains.
The append-only form sidesteps this entirely by storing the fields rather than a composite key — {"test": ..., "seed": ..., "status": ...} — which is one more reason to prefer it.
8. Resume, Assembled
import json
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Job:
test: str
seed: int
MODES = {
"never-ran-only": lambda s: s is None,
"resume-plus-infra": lambda s: s not in ("PASS", "FAIL"),
"not-pass": lambda s: s != "PASS",
}
def record_state(path, job, status):
"""Append one line. Never rewrites, so a crash costs at most this entry."""
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps({"test": job.test, "seed": job.seed,
"status": status}) + "\n")
handle.flush()
def load_state(path):
"""Last entry wins. A truncated final line is discarded."""
state, dropped = {}, 0
if not Path(path).exists():
return state, dropped
for line in Path(path).read_text(encoding="utf-8").splitlines():
try:
e = json.loads(line)
state[Job(e["test"], e["seed"])] = e["status"]
except (json.JSONDecodeError, KeyError):
dropped += 1
return state, dropped
def plan_resume(jobs, state, *, mode="resume-plus-infra"):
if mode not in MODES:
raise ValueError(f"unknown resume mode {mode!r} — expected one of {sorted(MODES)}")
keep = MODES[mode]
return [j for j in jobs if keep(state.get(j))]
JOBS = [Job("axi_smoke", 1), Job("axi_reset", 2), Job("axi_burst", 3),
Job("axi_lic", 4), Job("axi_new", 5)]
path = Path("state.jsonl")
for job, status in zip(JOBS, ["PASS", "PASS", "FAIL", "ERROR"]):
record_state(path, job, status)
# the crash: a truncated final line
with open(path, "a", encoding="utf-8") as h:
h.write('{"test": "axi_new", "se')
state, dropped = load_state(path)
print("entries recovered :", len(state))
print("lines discarded :", dropped)
print()
for mode in MODES:
todo = plan_resume(JOBS, state, mode=mode)
print(f"{mode:<19} {len(todo)} job(s): {[(j.test, j.seed) for j in todo]}")
print()
try:
plan_resume(JOBS, state, mode="clever")
except ValueError as exc:
print("guard:", exc)
print()
print("total is still the saved job list:", len(JOBS))
print("so the invariant holds on resume :", True)entries recovered : 4
lines discarded : 1
never-ran-only 1 job(s): [('axi_new', 5)]
resume-plus-infra 2 job(s): [('axi_lic', 4), ('axi_new', 5)]
not-pass 3 job(s): [('axi_burst', 3), ('axi_lic', 4), ('axi_new', 5)]
guard: unknown resume mode 'clever' — expected one of ['never-ran-only', 'not-pass', 'resume-plus-infra']
total is still the saved job list: 5
so the invariant holds on resume : TrueFive decisions, and the last is what connects this chapter to Chapter 26.1.
The state is appended and flushed per job, so the crash costs the one entry being written — which is axi_new, which the resume re-runs anyway.
load_state discards an unparseable line and counts it. Chapter 26.6's rule: a silently skipped record is a lost job, and the count is what makes it visible.
Last entry wins, so a retried job's final status is what the resume sees, and the earlier entries remain as a history of the attempts.
An unknown mode raises with the valid set listed, rather than falling through to a default.
total comes from the saved job list, not from the resumed subset. A resumed regression still has five jobs; it runs three of them and reuses two results. Reporting total=3 would make the summary balance against the wrong number and quietly erase two passes — Chapter 26.1's invariant checked against the wrong denominator is no invariant at all.
9. One Failure, One Command
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class Result:
test: str
seed: int
sim: str
status: str
reason: Optional[str] = None
@property
def rerun(self):
return f"flow run --test {self.test} --seed {self.seed} --sim {self.sim} --waves"
FAILURES = [Result("axi_burst", 1839274, "vcs", "FAIL", "UVM_ERROR"),
Result("axi_hang", 771002, "vcs", "NO_FINISH", "TIMEOUT")]
print("re-run one failure:")
for f in FAILURES:
print(f" {f.test:<11} {f.rerun}")
print()
print("re-run everything that failed:")
print(" flow regress --resume --mode not-pass")
print()
print("the difference:", "one command debugs; the other re-confirms")re-run one failure:
axi_burst flow run --test axi_burst --seed 1839274 --sim vcs --waves
axi_hang flow run --test axi_hang --seed 771002 --sim vcs --waves
re-run everything that failed:
flow regress --resume --mode not-pass
the difference: one command debugs; the other re-confirmsTwo different operations that get confused constantly.
The single-job command is for debugging and it is Chapter 25.5 Section 5's, unchanged — every argument from the record, waves on, one job, one directory, one waveform.
The bulk re-run is for confirming a fix and it must be understood as a new experiment against changed code. Chapter 26.5 Section 4's arithmetic applies the moment it is not: re-running the same code and reporting the best outcome is resampling, whatever the flag is called.
The practical defence is to make the bulk form record what changed. A resume against the same commit and a re-run after a fix are different events, and Chapter 24.6's environment record plus the source fingerprint from Chapter 25.2 Section 8 are exactly what distinguishes them.
10. Predict, Run, Observe
import json
from pathlib import Path
JOBS = [("a", 1), ("b", 2), ("c", 3)]
STATE = {("a", 1): "PASS", ("b", 2): "FAIL"}
# A
print("A:", STATE.get(("c", 3)))
# B
print("B:", [j for j in JOBS if STATE.get(j) != "PASS"])
# C
print("C:", [j for j in JOBS if j in STATE and STATE[j] != "PASS"])
# D
print("D:", [j for j in JOBS if STATE.get(j) is None])
# E
try:
json.dumps({("a", 1): "PASS"})
except TypeError as exc:
print("E:", str(exc)[:42])
# F
print("F:", "grp/t/7".rpartition("/")[0], "grp/t/7".split("/")[0])
# G
Path("pro.jsonl").write_text('{"x": 1}\n{"y": 2}\n{"z"', encoding="utf-8")
ok = 0
for line in Path("pro.jsonl").read_text(encoding="utf-8").splitlines():
try:
json.loads(line)
ok += 1
except json.JSONDecodeError:
pass
print("G:", ok)
# H
Path("pro.json").write_text('{"x": 1, "y"', encoding="utf-8")
try:
json.loads(Path("pro.json").read_text(encoding="utf-8"))
print("H: loaded")
except json.JSONDecodeError:
print("H: 0 recovered")The verified output:
A: None
B: [('b', 2), ('c', 3)]
C: [('b', 2)]
D: [('c', 3)]
E: keys must be str, int, float, bool or None
F: grp/t grp
G: 2
H: 0 recoveredA — a never-run job has no state, and .get says so without raising.
B — so the annotation's filter includes it.
C — and the membership form does not. Section 2.
D — isolating the never-ran jobs is the pure-resume mode.
E — a tuple key is refused outright.
F — rpartition keeps a slash-containing test name intact where split does not.
G — two of three JSONL records survive a truncated last line.
H — the equivalent whole-file document recovers nothing.
11. Exercises
Exercise 1 — the cost. For your own regression, work out what restarting from 80% costs in compute hours.
Exercise 2 — the data. Say how many bytes of state would have avoided it.
Exercise 3 — the subtle .get. Explain in one sentence why the annotation includes a never-run job.
Exercise 4 — the membership trap. Write the j in state form and show which jobs it drops.
Exercise 5 — and the invariant. Say what those dropped jobs do to Chapter 26.1's balance.
Exercise 6 — three modes. Implement all three and report the job counts for a state of your own.
Exercise 7 — pick a default. Choose one for a post-crash resume and justify it in two sentences.
Exercise 8 — new experiment or resample. Distinguish a re-run after a fix from a re-run of the same code, citing Chapter 26.5.
Exercise 9 — from the records. Try reconstructing state from Chapter 26.6's record files alone and list what you cannot tell apart.
Exercise 10 — the mid-run directory. Say what Chapter 26.4's allocator does to a job that was interrupted, and how to resolve it.
Exercise 11 — destroy the state. Truncate a whole-file JSON state and report how many entries survive.
Exercise 12 — the cruel timing. Explain why this crash is the likely one.
Exercise 13 — atomic. Implement save_atomic and say why the temp file must be in the same directory.
Exercise 14 — append-only. Truncate a JSONL state and report how many entries survive.
Exercise 15 — two axes. Compare the two formats on bytes written and worst-case loss for your job count.
Exercise 16 — history for free. Say what the append-only form gives you when a job is retried.
Exercise 17 — the tuple key. Reproduce the TypeError and write an encode/decode pair.
Exercise 18 — rpartition. Find a test name that breaks split and show rpartition handling it.
Exercise 19 — save the list. Write the expanded job list to disk and have the resume read it. Say what that prevents.
Exercise 20 — the right total. Show a resumed summary balancing against the full job list rather than the resumed subset.
12. Summary
- A crash at 80% wastes 320 of 400 jobs, and the state that would prevent it is a few hundred short lines.
state.get(j) != "PASS"includes a job that never ran, because.getreturnsNoneandNone != "PASS".- The
j in stateform silently drops exactly those jobs — after a crash, the part that matters. - Three resume modes are defensible — never-ran-only, resume-plus-infra, not-pass — and they answer different questions, so make the mode explicit.
- A bulk re-run of unchanged code is a resample, whatever the flag is called; a re-run after a fix is a new experiment.
- State reconstructed from result records alone confuses three situations, all in the safe direction except the mid-run directory, which collides with Chapter 26.4's allocator.
- A whole-file JSON state truncated mid-write loses everything, not one entry — and that write is where a regression spends a meaningful fraction of its life.
os.replaceafter a temp write is atomic, and the temp file must be in the same directory.- Append-only JSONL loses only the last line and writes each entry once instead of rewriting the file per job.
- Prefer append-only for a regression, and the one entry it can lose is the job the resume would re-run anyway.
- It gives a retry history for free, with last-entry-wins.
- JSON refuses a tuple key, so the job identity must be encoded — and a key that does not round-trip makes the resume restart silently.
- Use
rpartitionto take the seed off the end, so a test name containing a separator survives. - Store the fields rather than a composite key where you can, which append-only makes natural.
- Save the expanded job list to disk and read it on resume, never regenerate it.
totalis the saved job list, not the resumed subset, or the invariant is checked against the wrong denominator.
The last chapter of this module hands the jobs to something that owns the machines.
Where this fits
Part of the Python curriculum.
