From 1e364fcfdb63178325dd461df50e78f5b5bca5e5 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 29 Jun 2026 21:35:23 -0400 Subject: [PATCH] fix: clear() treats a concurrent delete as success; explicit utf-8; durability prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 7 +++++-- src/aiokv/aiokv.py | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cbcdbbc..0176dcf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/aiokv/aiokv.py b/src/aiokv/aiokv.py index 522f463..061d3ea 100644 --- a/src/aiokv/aiokv.py +++ b/src/aiokv/aiokv.py @@ -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: