Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

4 changed files with 14 additions and 30 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
# claude # claude
.claude/ CLAUDE.md
# python # python
__pycache__/ __pycache__/

View File

@ -12,19 +12,17 @@ you `delete` or `clear` them.
`requirements.txt`: `requirements.txt`:
``` ```
aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.1 aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.0
``` ```
Direct: Direct:
```bash ```bash
pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.1" pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.0"
``` ```
Requires `aiofiles` (pulled transitively). Requires `aiofiles` (pulled transitively).
Drop the `@v0.1.1` suffix from the line above to install the latest unpinned.
## Usage ## Usage
```python ```python
@ -62,11 +60,8 @@ 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 **process** crash mid-write leaves `os.replace()`d over the target (atomic on POSIX). A crash mid-write leaves the
the previous good file intact, and a reader never observes a partial file. (This is previous good file intact, and a reader never observes a partial file. A single
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.
@ -82,12 +77,10 @@ are consistent and no update is lost. All blocking filesystem calls run via
## Error contract ## Error contract
- `get` / `set` / `get_all` raise on unexpected I/O. `_load` raises `JSONDecodeError` - `get` / `set` / `get_all` raise on unexpected I/O (and `_load` raises on a
on a truncated/corrupt file, and `ValueError` when the file holds valid JSON that truncated/corrupt file) so a real failure is visible rather than silently masked.
isn't an object (a bare list/number/string/null) — so corruption or a wrong-shaped
file is visible rather than silently masked.
- `delete` / `clear` log the exception and return `False` on error, `True` otherwise. - `delete` / `clear` log the exception and return `False` on error, `True` otherwise.
## Versioning ## Versioning
Releases are tagged `vX.Y.Z`. The install line above pins a release; drop the `@vX.Y.Z` suffix to install the latest unpinned. Pin deliberately for reproducible installs. Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`.

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aiokv" name = "aiokv"
version = "0.1.1" version = "0.1.0"
description = "Async file-backed key-value store for single-process local state — atomic writes, no TTL, config-free, installable." description = "Async file-backed key-value store for single-process local state — atomic writes, no TTL, config-free, installable."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [

View File

@ -82,17 +82,11 @@ 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:
try: if await asyncio.to_thread(os.path.exists, self.file):
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")
@ -113,7 +107,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", encoding="utf-8") as f: async with aiofiles.open(self.file, mode="r") as f:
data = await f.read() data = await f.read()
if not data: if not data:
return {} return {}
@ -126,10 +120,7 @@ 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
process crash mid-write leaves the previous good file intact. note this is crash mid-write leaves the previous good file intact.
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)
@ -137,7 +128,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", encoding="utf-8") as f: async with aiofiles.open(tmp, mode="w") 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: