Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e68e0b9ccf | |||
| 1e364fcfdb | |||
| 8747b61705 | |||
| 22e91d2b2d | |||
| 5ee0292dcb |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,5 @@
|
|||||||
# claude
|
# claude
|
||||||
CLAUDE.md
|
.claude/
|
||||||
|
|
||||||
# python
|
# python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
21
README.md
21
README.md
@ -12,17 +12,19 @@ you `delete` or `clear` them.
|
|||||||
`requirements.txt`:
|
`requirements.txt`:
|
||||||
|
|
||||||
```
|
```
|
||||||
aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.0
|
aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct:
|
Direct:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.0"
|
pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
@ -60,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.
|
||||||
@ -77,10 +82,12 @@ 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 (and `_load` raises on a
|
- `get` / `set` / `get_all` raise on unexpected I/O. `_load` raises `JSONDecodeError`
|
||||||
truncated/corrupt file) so a real failure is visible rather than silently masked.
|
on a truncated/corrupt file, and `ValueError` when the file holds valid JSON that
|
||||||
|
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
|
||||||
|
|
||||||
Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`.
|
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.
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "aiokv"
|
name = "aiokv"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
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 = [
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user