Add Redis-backed cache backend with env switch

This commit is contained in:
Chris
2026-04-03 02:38:54 +08:00
parent ed413ce39d
commit 955019e8d7
9 changed files with 148 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
INTERNAL_SHARED_SECRET=CHANGE_ME
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
INTERNAL_SHARED_SECRET=CHANGE_ME
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
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"]
internal_shared_secret: str = ""
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")
@classmethod

View File

@@ -1,11 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
import logging
import pickle
from threading import RLock
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")
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
@@ -15,11 +34,8 @@ class _CacheEntry:
revision: int
class RuntimeCache:
"""Simple in-memory cache for local/prototype use.
Cache is globally invalidated by `bump_revision()` which we call after CUD.
"""
class MemoryRuntimeCache:
"""Simple in-memory cache for local/single-instance environments."""
def __init__(self) -> None:
self._lock = RLock()
@@ -68,4 +84,92 @@ class RuntimeCache:
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",
"pyjwt[crypto]>=2.10.1",
"httpx>=0.28.1",
"redis>=5.2.0",
]
[project.optional-dependencies]

View File

@@ -31,6 +31,7 @@
- 站台角色指派(`PUT /admin/sites/{site_key}/roles``PUT /admin/roles/{role_key}/sites`)會即時同步到 Keycloak Group Role Mapping。
- 使用者加入 Site 時,透過同步邏輯使其在 IdP 端取得對應角色能力。
- 讀取效能:後端採用 memory cache後續可換 Redis`GET` 先讀快取;`POST/PUT/PATCH/DELETE` 成功後自動失效快取。
- 快取後端可由 `.env` 切換:`CACHE_BACKEND=memory|redis`(無需改程式)。
## 後台安全線
- `/admin/*` 必須 Bearer token。

View File

@@ -24,3 +24,4 @@
- [x] Role CRUD 同步 Provider Client Role新增/修改/刪除會同步到 Provider
- [x] Site/Role 關聯指派同步 Keycloak Group Role Mapping雙向指派入口皆同步
- [x] 後端讀取快取memory與 CUD 自動失效機制(可後續切 Redis
- [x] 快取後端抽象完成:`.env` 可切換 `memory` / `redis`

View File

@@ -52,6 +52,19 @@ npm run dev
- `KEYCLOAK_ADMIN_CLIENT_ID`
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
- `ADMIN_REQUIRED_GROUPS`
- `CACHE_BACKEND``memory``redis`
- `CACHE_REDIS_URL`
- `CACHE_PREFIX`
- `CACHE_DEFAULT_TTL_SECONDS`
### Cache 切換範例
- 本地(預設):
- `CACHE_BACKEND=memory`
- 切 Redis
- `CACHE_BACKEND=redis`
- `CACHE_REDIS_URL=redis://127.0.0.1:6379/0`
調整後重啟後端生效。
## 5) 基本檢查
1. `GET http://127.0.0.1:8000/healthz` 應為 200。