From 955019e8d7efc7d38394e5131ff76c916e0ab67b Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 3 Apr 2026 02:38:54 +0800 Subject: [PATCH] Add Redis-backed cache backend with env switch --- backend/.env.development | 5 ++ backend/.env.example | 6 ++ backend/.env.production.example | 6 ++ backend/app/core/config.py | 4 + backend/app/services/runtime_cache.py | 118 ++++++++++++++++++++++++-- backend/pyproject.toml | 1 + docs/ARCHITECTURE.md | 1 + docs/BACKEND_TASKPLAN.md | 1 + docs/LOCAL_DEV_RUNBOOK.md | 13 +++ 9 files changed, 148 insertions(+), 7 deletions(-) diff --git a/backend/.env.development b/backend/.env.development index dd42105..d6804f9 100644 --- a/backend/.env.development +++ b/backend/.env.development @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 03c3592..13e5b77 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/.env.production.example b/backend/.env.production.example index fc4d7a0..ce0aac9 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e055a6a..043ccbb 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/services/runtime_cache.py b/backend/app/services/runtime_cache.py index b44bbc9..0907a9b 100644 --- a/backend/app/services/runtime_cache.py +++ b/backend/app/services/runtime_cache.py @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f1e7602..7633731 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f745345..0a48fff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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。 diff --git a/docs/BACKEND_TASKPLAN.md b/docs/BACKEND_TASKPLAN.md index abaa52c..698eb28 100644 --- a/docs/BACKEND_TASKPLAN.md +++ b/docs/BACKEND_TASKPLAN.md @@ -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`。 diff --git a/docs/LOCAL_DEV_RUNBOOK.md b/docs/LOCAL_DEV_RUNBOOK.md index a215c2c..b162836 100644 --- a/docs/LOCAL_DEV_RUNBOOK.md +++ b/docs/LOCAL_DEV_RUNBOOK.md @@ -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。