diff --git a/README.md b/README.md index aaf119d..5309308 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,18 @@ you `delete` or `clear` them. `requirements.txt`: ``` -aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.2.1 +aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.2.2 ``` Direct: ```bash -pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.2.1" +pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.2.2" ``` Requires `aiofiles` (pulled transitively). -Drop the `@v0.2.1` suffix from the line above to install the latest unpinned. +Drop the `@v0.2.2` suffix from the line above to install the latest unpinned. ## Usage @@ -66,11 +66,17 @@ Writes are **atomic**: data is written to a temp file in the same directory and written through rather than clobbered. 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.) Orphaned -`..tmp` files left by a hard crash of a *different, dead* process are swept on the -next save. 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 and -JSON (de)serialization run via `asyncio.to_thread`, so nothing stalls the event loop. +still lose the last write; fine for reconstructible single-process state.) `clear()` is +symlink-safe the same way: it removes the realpath'd target, not the symlink itself, so +a symlinked store is genuinely erased rather than just having its symlink unlinked. +Orphaned `..tmp` files left by a hard crash of a *different, dead* process are swept +on the next save; the sweep matches only the exact `..tmp` shape +it creates, so a differently-shaped neighbor file is never touched and a store filename +containing glob-like characters (e.g. `state[prod].json`) doesn't miss its own orphans or +cross-match another store's. 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 and JSON (de)serialization run via `asyncio.to_thread`, so nothing stalls +the event loop. ## Scope — read this diff --git a/pyproject.toml b/pyproject.toml index 2a25f82..9b95fe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aiokv" -version = "0.2.1" +version = "0.2.2" description = "Async file-backed key-value store for single-process local state — atomic writes, no TTL, config-free, installable." requires-python = ">=3.10" dependencies = [ diff --git a/src/aiokv/aiokv.py b/src/aiokv/aiokv.py index e0192d8..2e0ce4f 100644 --- a/src/aiokv/aiokv.py +++ b/src/aiokv/aiokv.py @@ -17,9 +17,9 @@ cross-process storage. """ import os +import re import json import time -import glob import asyncio import logging from typing import Any, Dict @@ -71,11 +71,15 @@ class AioKV: return False async def clear(self) -> bool: - """remove the backing file entirely; True on success or if already absent, False on error""" + """remove the backing file entirely; True on success or if already absent, False on error + + symlink-safe: removes the realpath'd target rather than the symlink itself, mirroring + _save()'s symlink-safe writes - a symlinked store's real data file is what gets erased""" try: async with self.lock: + target = await asyncio.to_thread(os.path.realpath, self.file) try: - await asyncio.to_thread(os.remove, self.file) + await asyncio.to_thread(os.remove, target) except FileNotFoundError: pass return True @@ -134,18 +138,25 @@ class AioKV: raise async def _sweep_stale_tmp(self, directory: str) -> None: - """remove orphaned ..tmp files left by a hard crash of a different, dead process""" + """remove orphaned ..tmp files left by a hard crash of a different, dead process + + matches only the exact shape _save() creates (..tmp), via a + re.escape'd regex over a directory listing rather than a raw glob - a glob pattern + both crosses unrelated dots (matching non-aiokv neighbors like foo.backup.999.tmp) and + mistreats glob metacharacters in the store's own filename (e.g. state[prod].json)""" base = os.path.basename(await asyncio.to_thread(os.path.realpath, self.file)) - pattern = os.path.join(directory, f"{base}.*.tmp") - for path in await asyncio.to_thread(glob.glob, pattern): - try: - pid = int(path.rsplit(".", 2)[-2]) - except (ValueError, IndexError): + candidate = re.compile(rf"^{re.escape(base)}\.(\d+)\.tmp$") + names = await asyncio.to_thread(os.listdir, directory) + for name in names: + match = candidate.match(name) + if not match: continue + pid = int(match.group(1)) if pid == os.getpid(): continue if await asyncio.to_thread(self._pid_alive, pid): continue + path = os.path.join(directory, name) try: await asyncio.to_thread(os.remove, path) except FileNotFoundError: