fix: clear() treats a concurrent delete as success; explicit utf-8; durability prose

clear() handles FileNotFoundError as success (the goal state — no file — is reached)
instead of returning False. read/write open with explicit encoding='utf-8'. atomic-write
prose scoped to process-crash safety (NOT power-loss durability — no fsync), in module,
README, and CLAUDE.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-29 21:35:23 -04:00
parent 8747b61705
commit 1e364fcfdb
2 changed files with 19 additions and 7 deletions

View File

@ -62,8 +62,11 @@ Prefer `AioKV` in new code.
## Durability
Writes are **atomic**: data is written to a temp file in the same directory and
`os.replace()`d over the target (atomic on POSIX). A crash mid-write leaves the
previous good file intact, and a reader never observes a partial file. A single
`os.replace()`d over the target (atomic on POSIX). A **process** crash mid-write leaves
the previous good file intact, and a reader never observes a partial file. (This is
process-crash safety, not power-loss durability — there's no `fsync`, so an OS/power
failure could still lose the last write; fine for reconstructible single-process state.)
A single
`asyncio.Lock` guards every read and write, so concurrent operations on one instance
are consistent and no update is lost. All blocking filesystem calls run via
`asyncio.to_thread`, so nothing stalls the event loop.

View File

@ -82,11 +82,17 @@ class AioKV:
return False
async def clear(self) -> bool:
"""remove the backing file entirely; returns True on success, False on error"""
"""remove the backing file entirely; returns True on success, False on error
a file already absent (or removed concurrently between the check and the remove)
is success the goal state, no file, is reached.
"""
try:
async with self.lock:
if await asyncio.to_thread(os.path.exists, self.file):
try:
await asyncio.to_thread(os.remove, self.file)
except FileNotFoundError:
pass
return True
except Exception:
log.exception("aiokv.clear() failed")
@ -107,7 +113,7 @@ class AioKV:
"""
if not await asyncio.to_thread(os.path.exists, self.file):
return {}
async with aiofiles.open(self.file, mode="r") as f:
async with aiofiles.open(self.file, mode="r", encoding="utf-8") as f:
data = await f.read()
if not data:
return {}
@ -120,7 +126,10 @@ class AioKV:
"""write the store atomically: temp file in the same dir, then os.replace
os.replace is atomic on POSIX, so a reader never sees a partial file and a
crash mid-write leaves the previous good file intact.
process crash mid-write leaves the previous good file intact. note this is
process-crash safety, NOT power-loss durability there is no fsync of the temp
file or the directory, so an OS/power failure could still lose the most recent
write (acceptable here: this is reconstructible single-process state, not a db).
"""
directory = os.path.dirname(self.file) or "."
await asyncio.to_thread(os.makedirs, directory, exist_ok=True)
@ -128,7 +137,7 @@ class AioKV:
payload = json.dumps(cache)
tmp = f"{self.file}.{os.getpid()}.tmp"
try:
async with aiofiles.open(tmp, mode="w") as f:
async with aiofiles.open(tmp, mode="w", encoding="utf-8") as f:
await f.write(payload)
await asyncio.to_thread(os.replace, tmp, self.file)
except Exception: