Add Redis-backed cache backend with env switch

This commit is contained in:
Chris
2026-04-03 02:38:54 +08:00
parent 55e640f2fb
commit 73ed4ff0ff
6 changed files with 133 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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]