SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
3.6 KB · 119 lines python
Raw Blame History
1"""Redis helpers: API cache (prefix `aia:api:<db namespace>:`), distributed locks (`aia:lock:`), heartbeats.23The API cache is namespaced by a hash of `DATABASE_URL`: two API processes pointing at different databases (dev on :8331, a production4copy on :8332) but sharing one Redis db must never serve each other's bodies or derived id sets (that is how `/frontier` once reported5frontier ids from another database and found no prices for them)."""6from __future__ import annotations78import asyncio9import hashlib10import logging11from collections.abc import AsyncIterator12from contextlib import asynccontextmanager13from typing import Any1415import orjson16from redis.asyncio import Redis1718from aiatlas.config import settings1920log = logging.getLogger(__name__)21_redis: Redis | None = None22_NS: str | None = None232425def redis() -> Redis:26    global _redis27    if _redis is None:28        _redis = Redis.from_url(settings.redis_url, decode_responses=False, socket_connect_timeout=3, socket_timeout=5)29    return _redis303132def namespace() -> str:33    """8-hex namespace derived from the database URL (host, port, database name) — same DB ⇒ same cache, different DB ⇒ disjoint keys."""34    global _NS35    if _NS is None:36        _NS = hashlib.sha1(settings.database_url.encode()).hexdigest()[:8]37    return _NS383940def api_key(key: str) -> str:41    return f"aia:api:{namespace()}:{key}"424344async def cache_get(key: str) -> Any | None:45    try:46        raw = await redis().get(api_key(key))47        return orjson.loads(raw) if raw else None48    except Exception:  # noqa: BLE00149        return None505152async def cache_set(key: str, value: Any, ttl_s: int = 300) -> None:53    try:54        await redis().set(api_key(key), orjson.dumps(value, default=str), ex=ttl_s)55    except Exception:  # noqa: BLE00156        pass575859async def cache_invalidate(prefix: str = "") -> int:60    """Delete `aia:api:<ns>:<prefix>*` — only this database's cache."""61    n = 062    try:63        async for key in redis().scan_iter(match=f"{api_key(prefix)}*", count=500):64            await redis().delete(key)65            n += 166    except Exception:  # noqa: BLE00167        pass68    return n697071@asynccontextmanager72async def lock(name: str, ttl_s: int = 3600) -> AsyncIterator[bool]:73    """Best-effort distributed lock. Yields False when someone else holds it (callers must skip)."""74    key = f"aia:lock:{name}"75    acquired = False76    try:77        acquired = bool(await redis().set(key, b"1", nx=True, ex=ttl_s))78    except Exception as exc:  # noqa: BLE00179        log.warning("redis unavailable, proceeding without lock", extra={"error": str(exc)})80        acquired = True81    try:82        yield acquired83    finally:84        if acquired:85            try:86                await redis().delete(key)87            except Exception:  # noqa: BLE00188                pass899091async def heartbeat(service: str, payload: dict[str, Any], ttl_s: int = 180) -> None:92    try:93        await redis().set(f"aia:heartbeat:{service}", orjson.dumps(payload, default=str), ex=ttl_s)94    except Exception:  # noqa: BLE00195        pass969798async def heartbeats() -> dict[str, Any]:99    out: dict[str, Any] = {}100    try:101        async for key in redis().scan_iter(match="aia:heartbeat:*"):102            raw = await redis().get(key)103            if raw:104                out[key.decode().rsplit(":", 1)[-1]] = orjson.loads(raw)105    except Exception:  # noqa: BLE001106        pass107    return out108109110async def close() -> None:111    global _redis112    if _redis is not None:113        await _redis.aclose()114        _redis = None115    await asyncio.sleep(0)116117118__all__ = ["api_key", "cache_get", "cache_invalidate", "cache_set", "close", "heartbeat", "heartbeats", "lock", "namespace", "redis"]119