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 ## Durability
Writes are **atomic**: data is written to a temp file in the same directory and 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 `os.replace()`d over the target (atomic on POSIX). A **process** crash mid-write leaves
previous good file intact, and a reader never observes a partial file. A single 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 `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 are consistent and no update is lost. All blocking filesystem calls run via
`asyncio.to_thread`, so nothing stalls the event loop. `asyncio.to_thread`, so nothing stalls the event loop.

View File

@ -82,11 +82,17 @@ class AioKV:
return False return False
async def clear(self) -> bool: 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: try:
async with self.lock: async with self.lock:
if await asyncio.to_thread(os.path.exists, self.file): try:
await asyncio.to_thread(os.remove, self.file) await asyncio.to_thread(os.remove, self.file)
except FileNotFoundError:
pass
return True return True
except Exception: except Exception:
log.exception("aiokv.clear() failed") log.exception("aiokv.clear() failed")
@ -107,7 +113,7 @@ class AioKV:
""" """
if not await asyncio.to_thread(os.path.exists, self.file): if not await asyncio.to_thread(os.path.exists, self.file):
return {} 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() data = await f.read()
if not data: if not data:
return {} return {}
@ -120,7 +126,10 @@ class AioKV:
"""write the store atomically: temp file in the same dir, then os.replace """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 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 "." directory = os.path.dirname(self.file) or "."
await asyncio.to_thread(os.makedirs, directory, exist_ok=True) await asyncio.to_thread(os.makedirs, directory, exist_ok=True)
@ -128,7 +137,7 @@ class AioKV:
payload = json.dumps(cache) payload = json.dumps(cache)
tmp = f"{self.file}.{os.getpid()}.tmp" tmp = f"{self.file}.{os.getpid()}.tmp"
try: 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 f.write(payload)
await asyncio.to_thread(os.replace, tmp, self.file) await asyncio.to_thread(os.replace, tmp, self.file)
except Exception: except Exception: