audit: no dead end survives a corrupt record, no lie survives a filesystem error
- a blank summary record bricked wake forever: it refused with 'Do the 0 compressions below' + a literal None, while nap said nothing was left. Both sites (wake's cover, nap's half) now name the one exit: forget. - tree_get/count/repair swallowed every OSError as 'not built yet', so an unreadable level was reported as pending work that could not be done. Only FileNotFoundError means absent; real failures surface with the path. - import accepted 2026-99-99, poisoning the store's date order forever. - the wake T error now says 'the log holds N memories', not 'entries'.
This commit is contained in:
parent
cf26dbeb44
commit
8c444ad991
2 changed files with 84 additions and 17 deletions
50
memo
50
memo
|
|
@ -205,7 +205,7 @@ def tree_path(d, size):
|
|||
def count(path, rec):
|
||||
try:
|
||||
return os.path.getsize(path) // rec
|
||||
except OSError:
|
||||
except FileNotFoundError: # any other failure is real and must surface
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -219,7 +219,7 @@ def repair(path, rec):
|
|||
every later record is misaligned. Callers hold the lock."""
|
||||
try:
|
||||
n = os.path.getsize(path)
|
||||
except OSError:
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if n % rec:
|
||||
with open(path, "r+b") as f:
|
||||
|
|
@ -271,7 +271,7 @@ def tree_get(d, lo, hi):
|
|||
with open(tree_path(d, size), "rb") as f:
|
||||
f.seek((lo // size) * TREE_REC)
|
||||
rec = f.read(TREE_REC)
|
||||
except OSError:
|
||||
except FileNotFoundError: # not built yet; any other failure is real
|
||||
return None
|
||||
return rec.decode().rstrip() or None
|
||||
|
||||
|
|
@ -425,7 +425,11 @@ def nap_prompt(d, lo, hi, left):
|
|||
for a, b in ((lo, mid), (mid, hi)):
|
||||
s = tree_get(d, a, b)
|
||||
if s is None:
|
||||
die("Summary %d-%d is missing. Run: %s nap" % (a, b - 1, ME))
|
||||
# pending() lists a block only after its halves settled, so
|
||||
# a missing half is a blank record -- a corrupt write. Drop
|
||||
# it and the next nap rebuilds it.
|
||||
die("The summary of #%d-%d is blank. Run: %s forget %d-%d"
|
||||
% (a, b - 1, ME, a, b - 1))
|
||||
halves.append(" #%d-%d %s" % (a, b - 1, s))
|
||||
body = "\n".join(halves)
|
||||
tail = "" if not left else "\n%s after this one." % (
|
||||
|
|
@ -537,8 +541,8 @@ def cmd_wake(d, args):
|
|||
if len(args) == 2:
|
||||
T = int(args[1])
|
||||
if T > now:
|
||||
die("T=%d, but the memory holds %s. Run: %s wake"
|
||||
% (T, plural(now, "entry"), ME))
|
||||
die("T=%d, but the log holds %s. Run: %s wake"
|
||||
% (T, plural(now, "memory"), ME))
|
||||
# A part is rendered as of T, so a note landing between two parts cannot
|
||||
# shift a boundary and drop a line.
|
||||
if not T:
|
||||
|
|
@ -553,17 +557,25 @@ def cmd_wake(d, args):
|
|||
else:
|
||||
s = tree_get(d, lo, hi)
|
||||
if s is None:
|
||||
# The ONLY reason to refuse: this document cannot be written
|
||||
# without that summary. Work that the document does not need
|
||||
# is handed over after the read instead, costing no round
|
||||
# trip.
|
||||
print("Cannot wake: the memory context needs #%d-%d, which is "
|
||||
"not compressed yet.\nDo the %s below, then run %s "
|
||||
"wake again.\n"
|
||||
% (lo, hi - 1,
|
||||
plural(pending_count(d, T), "compression"), ME))
|
||||
print(next_nap(d, T))
|
||||
sys.exit(1)
|
||||
nap = next_nap(d, T)
|
||||
if nap:
|
||||
# The ONLY reason to refuse: this document cannot be
|
||||
# written without that summary. Work that the document
|
||||
# does not need is handed over after the read instead,
|
||||
# costing no round trip.
|
||||
print("Cannot wake: the memory context needs #%d-%d, "
|
||||
"which is not compressed yet.\nDo the %s below, "
|
||||
"then run %s wake again.\n"
|
||||
% (lo, hi - 1,
|
||||
plural(pending_count(d, T), "compression"), ME))
|
||||
print(nap)
|
||||
sys.exit(1)
|
||||
s = tree_get(d, lo, hi) # a parallel session may have paid it
|
||||
if s is None:
|
||||
# Nothing is pending, so the record exists but is blank -- a
|
||||
# corrupt write. Drop it and the next nap rebuilds it.
|
||||
die("The summary of #%d-%d is blank. Run: %s forget %d-%d"
|
||||
% (lo, hi - 1, ME, lo, hi - 1))
|
||||
lines.append("#%d-%d %s" % (lo, hi - 1, s))
|
||||
parts = paginate(lines)
|
||||
if not 1 <= k <= len(parts):
|
||||
|
|
@ -712,6 +724,10 @@ def cmd_import(d, args):
|
|||
date, _, text = line.partition(" ")
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
|
||||
die("line %d: expected 'YYYY-MM-DD <text>', got: %s" % (i, line))
|
||||
try:
|
||||
datetime.datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
die("line %d: %s is not a real date." % (i, date))
|
||||
if date < last:
|
||||
die("line %d: date %s precedes the previous memory (%s)."
|
||||
% (i, date, last))
|
||||
|
|
|
|||
51
test.py
51
test.py
|
|
@ -431,6 +431,57 @@ r = run("wake", store=d2)
|
|||
check(r.stdout.rstrip().endswith("You are awake."),
|
||||
"a one-part wake never says `You are awake.`:\n" + r.stdout)
|
||||
|
||||
# a blank summary record (a corrupt write) is work nap cannot see: wake must
|
||||
# name the one exit, `forget`, instead of refusing forever
|
||||
d3 = tempfile.mkdtemp(prefix="optmem-blank-")
|
||||
for i in range(4):
|
||||
run("note", "corrupt store memory %d" % i, store=d3)
|
||||
for bid, s in (("0-1", "one"), ("2-3", "two"), ("0-3", "all")):
|
||||
run("nap", bid, s, store=d3)
|
||||
with open(os.path.join(d3, "config"), "w") as f:
|
||||
f.write("WAKE_LINES = 2\n")
|
||||
with open(os.path.join(d3, "TREE", "2"), "r+b") as f:
|
||||
f.write(b" " * 287 + b"\n")
|
||||
r = run("wake", store=d3)
|
||||
check(r.returncode == 1 and "forget 0-1" in r.stderr
|
||||
and "None" not in r.stdout,
|
||||
"a blank summary must point at forget:\n" + r.stdout + r.stderr)
|
||||
|
||||
# an unreadable level is a filesystem failure and must surface as one --
|
||||
# reading it as "not compressed yet" offers work that cannot be done
|
||||
os.chmod(os.path.join(d3, "TREE", "2"), 0)
|
||||
r_ = subprocess.run(memo + ["wake"], capture_output=True, text=True,
|
||||
env=dict(os.environ, MEMORY_DIR=d3))
|
||||
check(r_.returncode == 1 and "Permission denied" in r_.stderr
|
||||
and "not compressed" not in r_.stdout,
|
||||
"an unreadable level was read as pending work: " + r_.stdout + r_.stderr)
|
||||
os.chmod(os.path.join(d3, "TREE", "2"), 0o644)
|
||||
|
||||
# an impossible calendar date would poison every later import: the store's
|
||||
# order check compares against it forever
|
||||
with open(os.path.join(d3, "bad.txt"), "w") as f:
|
||||
f.write("2027-99-99 an impossible date\n")
|
||||
r = run("import", os.path.join(d3, "bad.txt"), store=d3)
|
||||
check(r.returncode == 1 and "not a real date" in r.stderr,
|
||||
"import accepted an impossible date: " + r.stdout + r.stderr)
|
||||
shutil.rmtree(d3)
|
||||
|
||||
# the same blank-record dead end at the other site: a big block's half
|
||||
d4 = tempfile.mkdtemp(prefix="optmem-half-")
|
||||
for i in range(32):
|
||||
run("note", "half probe memory %d" % i, store=d4)
|
||||
while True:
|
||||
r = run("nap", store=d4)
|
||||
if "Compress memories #0-31 " in r.stdout:
|
||||
break
|
||||
run("nap", nap_id(r.stdout), "settled", store=d4)
|
||||
with open(os.path.join(d4, "TREE", "16"), "r+b") as f:
|
||||
f.write(b" " * 287 + b"\n")
|
||||
r = run("nap", store=d4)
|
||||
check(r.returncode == 1 and "forget 0-15" in r.stderr,
|
||||
"a blank half summary must point at forget: " + r.stdout + r.stderr)
|
||||
shutil.rmtree(d4)
|
||||
|
||||
# re-running init on a lived-in store must not touch one byte of it: the
|
||||
# whole setup is idempotent, so it is safe on every provision. `init` only
|
||||
# ever makedirs(exist_ok), opens LOG.txt for APPEND, and writes `config`
|
||||
|
|
|
|||
Loading…
Reference in a new issue