Add Redis-backed cache backend with env switch
This commit is contained in:
@@ -20,3 +20,8 @@ KEYCLOAK_ADMIN_REALM=master
|
|||||||
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
ADMIN_REQUIRED_GROUPS=member-admin
|
ADMIN_REQUIRED_GROUPS=member-admin
|
||||||
|
|
||||||
|
CACHE_BACKEND=memory
|
||||||
|
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
CACHE_PREFIX=memberapi
|
||||||
|
CACHE_DEFAULT_TTL_SECONDS=30
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ KEYCLOAK_ADMIN_REALM=
|
|||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
ADMIN_REQUIRED_GROUPS=member-admin
|
ADMIN_REQUIRED_GROUPS=member-admin
|
||||||
|
|
||||||
|
# Cache backend: memory | redis
|
||||||
|
CACHE_BACKEND=memory
|
||||||
|
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
CACHE_PREFIX=memberapi
|
||||||
|
CACHE_DEFAULT_TTL_SECONDS=30
|
||||||
|
|||||||
@@ -25,3 +25,9 @@ KEYCLOAK_ADMIN_REALM=
|
|||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
|
||||||
|
# Cache backend: memory | redis
|
||||||
|
CACHE_BACKEND=redis
|
||||||
|
CACHE_REDIS_URL=redis://redis:6379/0
|
||||||
|
CACHE_PREFIX=memberapi
|
||||||
|
CACHE_DEFAULT_TTL_SECONDS=30
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class Settings(BaseSettings):
|
|||||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
||||||
internal_shared_secret: str = ""
|
internal_shared_secret: str = ""
|
||||||
admin_required_groups: Annotated[list[str], NoDecode] = []
|
admin_required_groups: Annotated[list[str], NoDecode] = []
|
||||||
|
cache_backend: str = "memory"
|
||||||
|
cache_redis_url: str = "redis://127.0.0.1:6379/0"
|
||||||
|
cache_prefix: str = "memberapi"
|
||||||
|
cache_default_ttl_seconds: int = 30
|
||||||
|
|
||||||
@field_validator("public_frontend_origins", mode="before")
|
@field_validator("public_frontend_origins", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
import logging
|
||||||
|
import pickle
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
import time
|
import time
|
||||||
from typing import Callable, TypeVar
|
from typing import Callable, Protocol, TypeVar
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
except Exception: # pragma: no cover - optional dependency in local dev
|
||||||
|
redis = None
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackend(Protocol):
|
||||||
|
def get(self, key: str) -> object | None: ...
|
||||||
|
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object: ...
|
||||||
|
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T: ...
|
||||||
|
def bump_revision(self) -> int: ...
|
||||||
|
def revision(self) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -15,11 +34,8 @@ class _CacheEntry:
|
|||||||
revision: int
|
revision: int
|
||||||
|
|
||||||
|
|
||||||
class RuntimeCache:
|
class MemoryRuntimeCache:
|
||||||
"""Simple in-memory cache for local/prototype use.
|
"""Simple in-memory cache for local/single-instance environments."""
|
||||||
|
|
||||||
Cache is globally invalidated by `bump_revision()` which we call after CUD.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
@@ -68,4 +84,92 @@ class RuntimeCache:
|
|||||||
return self._revision
|
return self._revision
|
||||||
|
|
||||||
|
|
||||||
runtime_cache = RuntimeCache()
|
class RedisRuntimeCache:
|
||||||
|
"""Redis-backed cache for multi-instance deployments."""
|
||||||
|
|
||||||
|
def __init__(self, *, redis_url: str, prefix: str, default_ttl_seconds: int = 30) -> None:
|
||||||
|
if redis is None:
|
||||||
|
raise RuntimeError("redis_package_not_installed")
|
||||||
|
self._redis = redis.Redis.from_url(redis_url, decode_responses=False)
|
||||||
|
self._prefix = prefix.strip() or "memberapi"
|
||||||
|
self._default_ttl_seconds = max(int(default_ttl_seconds), 1)
|
||||||
|
self._revision_key = f"{self._prefix}:cache:revision"
|
||||||
|
self._rev_cache_value = 0
|
||||||
|
self._rev_cache_expires_at = 0.0
|
||||||
|
|
||||||
|
def _cache_key(self, key: str, revision: int) -> str:
|
||||||
|
key_hash = sha256(key.encode("utf-8")).hexdigest()
|
||||||
|
return f"{self._prefix}:cache:{revision}:{key_hash}"
|
||||||
|
|
||||||
|
def _get_revision_cached(self) -> int:
|
||||||
|
now = time.time()
|
||||||
|
if now < self._rev_cache_expires_at:
|
||||||
|
return self._rev_cache_value
|
||||||
|
try:
|
||||||
|
raw = self._redis.get(self._revision_key)
|
||||||
|
value = int(raw) if raw else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
self._rev_cache_value = value
|
||||||
|
self._rev_cache_expires_at = now + 1.0
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get(self, key: str) -> object | None:
|
||||||
|
try:
|
||||||
|
revision = self._get_revision_cached()
|
||||||
|
raw = self._redis.get(self._cache_key(key, revision))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return pickle.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object:
|
||||||
|
ttl = max(int(ttl_seconds), 1) if ttl_seconds else self._default_ttl_seconds
|
||||||
|
try:
|
||||||
|
revision = self._get_revision_cached()
|
||||||
|
self._redis.setex(self._cache_key(key, revision), ttl, pickle.dumps(value))
|
||||||
|
except Exception:
|
||||||
|
# Keep request path healthy even when Redis has issues.
|
||||||
|
pass
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T:
|
||||||
|
cached = self.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached # type: ignore[return-value]
|
||||||
|
return self.set(key, factory(), ttl_seconds=ttl_seconds) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def bump_revision(self) -> int:
|
||||||
|
try:
|
||||||
|
value = int(self._redis.incr(self._revision_key))
|
||||||
|
self._rev_cache_value = value
|
||||||
|
self._rev_cache_expires_at = time.time() + 1.0
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
# Fail-open: keep app usable; caller still succeeds.
|
||||||
|
return self._get_revision_cached()
|
||||||
|
|
||||||
|
def revision(self) -> int:
|
||||||
|
return self._get_revision_cached()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_runtime_cache() -> CacheBackend:
|
||||||
|
settings = get_settings()
|
||||||
|
backend = (settings.cache_backend or "memory").strip().lower()
|
||||||
|
if backend == "redis":
|
||||||
|
try:
|
||||||
|
cache = RedisRuntimeCache(
|
||||||
|
redis_url=settings.cache_redis_url,
|
||||||
|
prefix=settings.cache_prefix,
|
||||||
|
default_ttl_seconds=settings.cache_default_ttl_seconds,
|
||||||
|
)
|
||||||
|
logger.info("runtime cache backend: redis")
|
||||||
|
return cache
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("redis cache unavailable, fallback to memory: %s", exc)
|
||||||
|
logger.info("runtime cache backend: memory")
|
||||||
|
return MemoryRuntimeCache()
|
||||||
|
|
||||||
|
|
||||||
|
runtime_cache: CacheBackend = _build_runtime_cache()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"passlib[bcrypt]>=1.7.4",
|
"passlib[bcrypt]>=1.7.4",
|
||||||
"pyjwt[crypto]>=2.10.1",
|
"pyjwt[crypto]>=2.10.1",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"redis>=5.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user