Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/adr/031-cache-locks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: Atomic cache locks with Cache.lock()
date: 2026-10-21
---

## Problem

Applications need to make sure only one worker runs a piece of work at a time (rebuilding a report, charging an order, running a scheduled job on one of many servers). Laravel offers `Cache::lock($name, $seconds)` with `get`, `block`, `release` and `forceRelease`, backed by the cache store so every worker that shares the store sees the same lock. The cache module has no locks, and the check-then-write `put` cannot be used for one because two workers can both see the key missing.

## Alternatives

- **Lock built on `get` + `put`.** Not atomic across processes. Rejected.
- **`asyncio.Lock`.** Process-local only. Rejected.
- **One generic lock that calls a new atomic `add(key, value, seconds)` on every store.** Each store still needs its own atomic primitive, and release must compare the owner and delete atomically too, which `add` does not cover. Rejected.
- **A `Lock` base class with the shared `get`/`block`/`async with` behaviour, and one subclass per store implementing `acquire`, `release` and `force_release` with that store's atomic primitive.** Chosen, matching Laravel's `Lock` / `CacheLock` / `DatabaseLock` split.

## Decision

- `cache/lock.py` defines `LockTimeoutError` and the abstract `Lock(name, seconds=0, owner=None)`. `owner` defaults to a random token; passing a known owner restores a lock acquired elsewhere (another request or process) so it can be released there. `seconds <= 0` means the lock never expires.
- `await lock.get()` tries once and returns `bool`; `await lock.get(callback)` runs the sync or async callback only when acquired, releases afterwards (also on error) and returns the callback result, or `False` when not acquired.
- `await lock.block(seconds, callback=None)` retries every 250 ms (`lock.sleep`) until acquired or `seconds` passed, then raises `LockTimeoutError`; with a callback it runs and releases like `get`.
- `async with Cache.lock("name", 10):` acquires once or raises `LockTimeoutError`, and releases on exit. Use `block` to wait.
- `release()` deletes the lock only when this owner holds it and it has not expired, and returns `bool` (an owner's own expired lock returns `False` on every store); `force_release()` deletes it regardless of owner.
- Locks are not re-entrant: acquiring a lock the same owner already holds fails, on every store.
- Store locks:
- `ArrayStore`: a `locks` dict of `name -> (owner, expires_at)` on the store. Check and set run without an `await`, so they are atomic within the event loop.
- `FileStore`: one file per lock under `<path>/locks/<sha1>`, holding JSON `[owner, expires_at]`. Each operation opens the file, takes an exclusive `fcntl.flock`, reads, decides, rewrites or truncates it, and closes it, so acquire and owner-checked release are atomic across processes on one host. Files are truncated, never unlinked, because unlinking a file another process has open would let both lock different inodes. Lock files live outside the two-hex shard directories, so `flush` does not touch them. `fcntl` is imported on use, so the file cache still imports on Windows; locks on the file store are POSIX-only.
- `DatabaseStore`: a row in the cache table with key `lock:<name>`, the pickled owner as value and the usual expiration column. Acquire inserts; on a primary-key conflict it updates the row only where `expiration <= now`, and the lock is acquired when one row changed. Release deletes where key and value match the owner and the row has not expired. Reusing the cache table needs no new migration; `flush` therefore also clears database locks. The PostgreSQL transaction caveat of ADR 029 applies to the insert conflict.
- `RedisStore`: key `lock:<name>` (the Redis connection prefix applies) holding the raw owner. Acquire is `SET NX PX` (no `PX` when the lock never expires). Release is a Lua script that deletes the key only while it holds this owner, and force release is `DEL`. Redis expires the key itself, so releasing your own expired lock returns `False`. The key is outside the store's `<prefix>:` namespace, so `flush` keeps Redis locks. The key also never collides with a cache entry, whose corrupt-value handling would otherwise delete the lock.
- `Repository.lock(name, seconds=0, owner=None)` prefixes the name like a cache key and returns the store's lock; a store without locks raises `TypeError`. The facade reaches it through the manager; the stub adds `lock`.

## Usage

```python
from fastapi_startkit.facades import Cache

lock = Cache.lock("reports", 10)
if await lock.get():
try:
await build_report()
finally:
await lock.release()

await Cache.lock("reports", 10).get(build_report)
await Cache.lock("reports", 10).block(5, build_report)

async with Cache.lock("reports", 10):
await build_report()

owner = lock.owner
await Cache.lock("reports", 10, owner).release()
```

## Validation

`tests/cache/test_lock.py` (unittest) runs one shared case class against the array, file, database (SQLite) and Redis (fakeredis with Lua) stores: acquire once, exactly one winner among concurrent `asyncio.gather` acquires, second owner fails, same owner fails, expired lock can be taken, `seconds=0` never expires, release only by owner, releasing an own expired lock returns `False`, force release, restore by owner, `get` with sync/async callback releases also on error, `block` waits for a release and times out with `LockTimeoutError`, `async with` acquires/releases, releases when the body raises, and raises when held, the repository prefixes lock names, and a store without locks raises `TypeError`. The file store also checks that `flush` keeps locks and that a corrupt lock file counts as free. The Redis store checks the prefixed key and its TTL, that the lock does not collide with a cache key, and that `flush` keeps locks.
42 changes: 42 additions & 0 deletions docs/adr/034-cache-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
title: Tagged cache entries with Cache.tags()
date: 2026-10-21
---

## Problem

Applications cache many related entries (every page of a user's posts, every report for a team) and need to invalidate the whole group at once without knowing every key. Laravel offers `Cache::tags([...])`: entries written through a tagged cache can be read only with the same tags, and `Cache::tags([...])->flush()` invalidates every entry that carries any of those tags. The cache module has no grouping, and `flush()` clears the whole store.

## Alternatives

- **Store a key list per tag and delete each key on flush.** Every store needs a list or set type and an atomic append, and the file and database stores have neither. Lists also grow without bound for keys that expire. Rejected.
- **Scan keys by pattern on flush.** Not possible for the file store (keys are hashed) and slow for the database. Rejected.
- **Namespaced keys (Laravel's original tagged cache).** Each tag has a random version id stored in the cache. A tagged entry's key is prefixed with a hash of its tags' ids. Flushing a tag writes a new id, so old entries can no longer be addressed. Works on every store with plain `get`/`put`. Chosen.

## Decision

- `cache/tagged_store.py` adds `TagSet(store, names, key)` and `TaggedStore(store, tags)`.
- `TagSet` keeps the names deduplicated and sorted, so `tags(["a", "b"])` and `tags(["b", "a"])` address the same entries (Laravel is order-sensitive; sorting removes that trap). The id of tag `name` lives in the wrapped store under the repository key `tag:<name>:key` (`<prefix>:tag:<name>:key` with a prefix), stored forever, and is created as a random hex token on first use. `namespace()` returns the SHA-1 of the ids joined with `|`; `reset()` writes a new id for every tag.
- `TaggedStore` implements the store contract over the wrapped store. `get`, `put` and `forget` use the key `<prefix>:<namespace>:<key>` (`<namespace>:<key>` without a prefix). The prefix comes first, as in Laravel, so a store that flushes by prefix, such as the Redis store's `SCAN <prefix>:*`, still reaches tagged entries. `flush` resets the tags instead of flushing the store.
- Tag ids share the user key space: `tag:<name>:key` is an ordinary cache key, so `Cache.put("tag:people:key", ...)` or `Cache.forget(...)` on it changes or resets that tag. Avoid user keys of that form.
- `Repository.tags(names)` accepts a string or a sequence of strings and returns an unprefixed `Repository` over `TaggedStore`, which applies the prefix itself, so every repository method (`get`, `put`, `remember`, the decorator, ...) works tagged. It wraps whatever store the repository has, so `Cache.memo().tags(...)` and `Cache.store("file").tags(...)` work too. An empty tag list raises `ValueError`. A tagged repository has no `lock` and raises `TypeError`.
- Flushed entries are not deleted; they stay in the store until they expire or the store is flushed. Use a TTL on tagged entries in long-lived stores. If two workers create a tag's first id at the same time, the last write wins, and entries the other worker wrote under its id become misses. That only costs a recompute.
- Reading a tagged entry costs one extra store read per tag.
- The facade reaches `tags` through the manager; the stub adds `tags`.

## Usage

```python
from fastapi_startkit.facades import Cache

await Cache.tags(["people", "artists"]).put("john", john, 600)
john = await Cache.tags(["people", "artists"]).get("john")

posts = await Cache.tags("posts").remember(f"user:{user_id}", 600, load_posts)

await Cache.tags("people").flush()
```

## Validation

`tests/cache/test_tagged_store.py` (unittest, array, file, in-memory SQLite database and fakeredis Redis stores): the tagged key is `<prefix>:<sha1>:<key>`, tagged put/get round-trips and untagged or differently tagged reads miss; tag order and duplicates do not matter; flushing one tag invalidates every entry carrying it and keeps entries without it, and keeps untagged entries; tag ids persist in the store and are prefixed; forget removes only the tagged key; `remember` and the decorator work tagged; a string tag is accepted; an empty list raises; `tags().lock()` raises `TypeError`; tags work on a memo repository; a Redis store flush (`SCAN <prefix>:*`) removes tagged entries and tag ids.
2 changes: 2 additions & 0 deletions docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ Read this index first, then open the records relevant to the change. Before impl
| [026](026-schema-column-change.md) | 2026-10-20 | Altering existing columns with `.change()` | `.change()` fully redefines the column, the way Laravel does. MySQL (`MODIFY`) and SQLite (table rebuild) were already correct. Postgres now emits `TYPE ... USING` casts, correct lengths, quoted defaults or `DROP DEFAULT`, `SET`/`DROP NOT NULL`, and replaces the enum check constraint. MSSQL drops the current default constraint (looked up in `sys.default_constraints`), runs one `ALTER COLUMN` per column, then adds `[DF_<table>_<col>]`. Identifiers are bracket- or double-quote-escaped and the MSSQL drop target is built server-side with `QUOTENAME`; changing a Postgres serial column drops its `nextval` default. |
| [029](029-cache-module.md) | 2026-10-21 | Cache module with callable remember and pluggable stores | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` (`CACHE_CONNECTION`, `CACHE_PREFIX`, `stores`) via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. A `with` form is impossible in Python. Shipped in slices into `feature/caches`. |
| [030](030-cache-memo.md) | 2026-10-21 | Request-scoped cache memoization with Cache.memo() | `Cache.memo(name=None)` returns a `Repository` over `MemoStore`, which wraps the named store and memoizes reads (misses too) for the current asyncio task; `put`/`forget` write through and drop the key, `flush` clears memo and store. State is a `ContextVar` holding `(weakref to owning task, memo)`, and a task that finds another task's memo starts a fresh one, so requests, jobs and child tasks never share memo state. Memoized values ignore TTL for the rest of the task. |
| [031](031-cache-locks.md) | 2026-10-21 | Atomic cache locks with Cache.lock() | `Cache.lock(name, seconds=0, owner=None)` returns a store-specific `Lock` (abstract base in `cache/lock.py`): `get(callback=None)` tries once, `block(seconds, callback=None)` polls every 250 ms then raises `LockTimeoutError`, `async with` acquires once or raises, `release()` is owner-checked, `force_release()` is not; callbacks release on error; passing `owner` restores a lock elsewhere; not re-entrant; `seconds<=0` never expires. Array: dict on the store, atomic in the event loop. File: `<path>/locks/<sha1>` JSON `[owner, expires_at]` under `fcntl.flock`, truncated never unlinked, kept by `flush`, POSIX-only. Database: row `lock:<name>` in the cache table, insert then update-if-expired on conflict, release deletes by owner; no new migration, `flush` clears it. Redis: `lock:<name>` via `SET NX PX` and an owner-checked Lua delete, kept by `flush`. |
| [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. |
| [034](034-cache-tags.md) | 2026-10-21 | Tagged cache entries with Cache.tags() | `Cache.tags(name or names)` returns a `Repository` over `TaggedStore`, using Laravel's namespaced-key design. Each tag has a random id stored forever at `<prefix>:tag:<name>:key`. A tagged entry's key is `<prefix>:sha1(ids joined by "|"):<key>` (prefix first, so prefix-scoped flushes such as Redis `SCAN` still reach it), tag ids share the user key space, and `flush()` writes new ids, so every entry carrying any flushed tag becomes unreachable on every store with plain get/put. Tags are deduplicated and sorted, so order does not matter. Flushed entries stay until they expire or the store is flushed. An empty list raises `ValueError`. It composes with `memo()` and named stores. |
6 changes: 6 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/cache/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from .array_store import ArrayStore
from .config import ArrayStoreConfig, CacheConfig, DatabaseStoreConfig, FileStoreConfig, RedisStoreConfig
from .file_store import FileStore
from .lock import Lock, LockTimeoutError
from .manager import CacheManager
from .memo_store import MemoStore
from .provider import CacheProvider
from .repository import Repository
from .store import MISSING, Store
from .tagged_store import TaggedStore, TagSet

__all__ = [
"MISSING",
Expand All @@ -17,8 +19,12 @@
"DatabaseStoreConfig",
"FileStore",
"FileStoreConfig",
"Lock",
"LockTimeoutError",
"MemoStore",
"RedisStoreConfig",
"Repository",
"Store",
"TagSet",
"TaggedStore",
]
32 changes: 32 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/cache/array_store.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from typing import Any

from .lock import Lock
from .store import MISSING, expired, expires_at


class ArrayStore:
def __init__(self) -> None:
self.storage: dict[str, tuple[float | None, Any]] = {}
self.locks: dict[str, tuple[str, float | None]] = {}

async def get(self, key: str) -> Any:
entry = self.storage.get(key)
Expand All @@ -28,3 +30,33 @@ async def forget(self, key: str) -> bool:
async def flush(self) -> bool:
self.storage.clear()
return True

def lock(self, name: str, seconds: float = 0, owner: str | None = None) -> "ArrayLock":
return ArrayLock(self, name, seconds, owner)


class ArrayLock(Lock):
def __init__(self, store: ArrayStore, name: str, seconds: float = 0, owner: str | None = None):
super().__init__(name, seconds, owner)
self.store = store

def holder(self) -> str | None:
entry = self.store.locks.get(self.name)
if entry is None or expired(entry[1]):
return None
return entry[0]

async def acquire(self) -> bool:
if self.holder() is not None:
return False
self.store.locks[self.name] = (self.owner, expires_at(self.ttl))
return True

async def release(self) -> bool:
if self.holder() != self.owner:
return False
del self.store.locks[self.name]
return True

async def force_release(self) -> None:
self.store.locks.pop(self.name, None)
43 changes: 39 additions & 4 deletions fastapi_startkit/src/fastapi_startkit/cache/database_store.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
import math
import pickle
import time
from collections.abc import Callable
from typing import Any

Expand All @@ -9,9 +10,14 @@
from fastapi_startkit.masoniteorm.connections.connection import Connection
from fastapi_startkit.masoniteorm.models.builder import QueryBuilder

from .lock import Lock
from .store import MISSING, expired, expires_at


def encode(value: Any) -> str:
return base64.b64encode(pickle.dumps(value)).decode()


class DatabaseStore:
def __init__(self, connection: Callable[[], Connection], table: str = "caches"):
self.connection = connection
Expand Down Expand Up @@ -39,10 +45,7 @@ async def get(self, key: str) -> Any:

async def put(self, key: str, value: Any, seconds: float | None) -> bool:
expiration = expires_at(seconds)
record = {
"value": base64.b64encode(pickle.dumps(value)).decode(),
"expiration": None if expiration is None else math.ceil(expiration),
}
record = {"value": encode(value), "expiration": None if expiration is None else math.ceil(expiration)}
if await self.query().where("key", key).update(record):
return True
try:
Expand All @@ -57,3 +60,35 @@ async def forget(self, key: str) -> bool:
async def flush(self) -> bool:
await self.query().delete()
return True

def lock(self, name: str, seconds: float = 0, owner: str | None = None) -> "DatabaseLock":
return DatabaseLock(self, name, seconds, owner)


class DatabaseLock(Lock):
def __init__(self, store: DatabaseStore, name: str, seconds: float = 0, owner: str | None = None):
super().__init__(name, seconds, owner)
self.store = store
self.key = f"lock:{name}"

async def acquire(self) -> bool:
expiration = expires_at(self.ttl)
record = {"value": encode(self.owner), "expiration": None if expiration is None else math.ceil(expiration)}
try:
await self.store.query().insert({"key": self.key, **record})
except IntegrityError:
expired_lock = self.store.query().where("key", self.key).where("expiration", "<=", time.time())
return bool(await expired_lock.update(record))
return True

async def release(self) -> bool:
held = (
self.store.query()
.where("key", self.key)
.where("value", encode(self.owner))
.where(lambda query: query.where_null("expiration").or_where("expiration", ">", time.time()))
)
return bool(await held.delete())

async def force_release(self) -> None:
await self.store.query().where("key", self.key).delete()
Loading
Loading