- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""
|
|
GUARDiA ITSM — 캐시 레이어 (Enhancement F-2)
|
|
|
|
Redis 우선, 미설치/연결 실패 시 인메모리 LRU 캐시로 자동 폴백.
|
|
|
|
기능:
|
|
1. 비동기 get/set/delete/invalidate
|
|
2. 접두어 기반 패턴 삭제 (invalidate_prefix)
|
|
3. 함수 결과 캐싱 데코레이터 (cached)
|
|
4. 캐시 통계 (히트/미스/에러 카운터)
|
|
5. 상태 조회 엔드포인트용 헬퍼
|
|
|
|
주요 캐시 키:
|
|
itsm:tasks:list:{hash} — SR 목록 (30초)
|
|
itsm:stats:summary — 대시보드 통계 (60초)
|
|
itsm:server:list — 서버 목록 (120초)
|
|
itsm:oncall:today — 오늘 당직자 (300초)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections import OrderedDict
|
|
from functools import wraps
|
|
from typing import Any, Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
|
CACHE_ENABLED = os.environ.get("CACHE_ENABLED", "true").lower() == "true"
|
|
CACHE_KEY_PREFIX = "itsm:"
|
|
_DEFAULT_TTL_SEC = 60
|
|
_MEMORY_MAX_ITEMS = 1000
|
|
|
|
# ── 통계 카운터 ───────────────────────────────────────────────────────────────
|
|
_stats = {"hits": 0, "misses": 0, "errors": 0, "sets": 0}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# ── 인메모리 LRU 캐시 (Redis 폴백) ─────────────────────────────────────────────
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class _MemoryCache:
|
|
"""TTL 지원 인메모리 LRU 캐시."""
|
|
|
|
def __init__(self, maxsize: int = _MEMORY_MAX_ITEMS):
|
|
self._store: OrderedDict[str, tuple[Any, float]] = OrderedDict()
|
|
self._maxsize = maxsize
|
|
|
|
def get(self, key: str) -> Optional[Any]:
|
|
if key not in self._store:
|
|
return None
|
|
value, expires_at = self._store[key]
|
|
if time.monotonic() > expires_at:
|
|
del self._store[key]
|
|
return None
|
|
self._store.move_to_end(key)
|
|
return value
|
|
|
|
def set(self, key: str, value: Any, ttl: int = _DEFAULT_TTL_SEC) -> None:
|
|
if key in self._store:
|
|
self._store.move_to_end(key)
|
|
self._store[key] = (value, time.monotonic() + ttl)
|
|
if len(self._store) > self._maxsize:
|
|
self._store.popitem(last=False)
|
|
|
|
def delete(self, key: str) -> None:
|
|
self._store.pop(key, None)
|
|
|
|
def keys_with_prefix(self, prefix: str) -> list[str]:
|
|
return [k for k in self._store if k.startswith(prefix)]
|
|
|
|
def flush(self) -> int:
|
|
n = len(self._store)
|
|
self._store.clear()
|
|
return n
|
|
|
|
def info(self) -> dict:
|
|
now = time.monotonic()
|
|
live = sum(1 for _, (_, exp) in self._store.items() if exp > now)
|
|
return {"backend": "memory", "items_total": len(self._store), "items_live": live}
|
|
|
|
|
|
_mem_cache = _MemoryCache()
|
|
_redis_client: Optional[Any] = None # aioredis / redis.asyncio 클라이언트
|
|
|
|
|
|
# ── Redis 연결 ────────────────────────────────────────────────────────────────
|
|
|
|
async def _get_redis():
|
|
"""Redis 클라이언트 싱글톤 (연결 실패 시 None)."""
|
|
global _redis_client
|
|
if _redis_client is not None:
|
|
return _redis_client
|
|
|
|
try:
|
|
import redis.asyncio as aioredis
|
|
client = aioredis.from_url(REDIS_URL, decode_responses=True, socket_timeout=1.0)
|
|
await client.ping()
|
|
_redis_client = client
|
|
logger.info("Redis 캐시 연결 성공: %s", REDIS_URL)
|
|
return _redis_client
|
|
except Exception as exc:
|
|
logger.info("Redis 미연결, 인메모리 캐시 사용: %s", exc)
|
|
return None
|
|
|
|
|
|
async def close_redis() -> None:
|
|
"""앱 종료 시 Redis 연결 해제."""
|
|
global _redis_client
|
|
if _redis_client:
|
|
await _redis_client.aclose()
|
|
_redis_client = None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# ── 공개 API ──────────────────────────────────────────────────────────────────
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def _full_key(key: str) -> str:
|
|
return f"{CACHE_KEY_PREFIX}{key}"
|
|
|
|
|
|
async def cache_get(key: str) -> Optional[Any]:
|
|
"""캐시에서 값 조회. 없으면 None."""
|
|
if not CACHE_ENABLED:
|
|
return None
|
|
full_key = _full_key(key)
|
|
try:
|
|
redis = await _get_redis()
|
|
if redis:
|
|
raw = await redis.get(full_key)
|
|
if raw is None:
|
|
_stats["misses"] += 1
|
|
return None
|
|
_stats["hits"] += 1
|
|
return json.loads(raw)
|
|
else:
|
|
val = _mem_cache.get(full_key)
|
|
if val is None:
|
|
_stats["misses"] += 1
|
|
else:
|
|
_stats["hits"] += 1
|
|
return val
|
|
except Exception as exc:
|
|
_stats["errors"] += 1
|
|
logger.debug("cache_get 오류: key=%s err=%s", key, exc)
|
|
return None
|
|
|
|
|
|
async def cache_set(key: str, value: Any, ttl: int = _DEFAULT_TTL_SEC) -> bool:
|
|
"""캐시에 값 저장."""
|
|
if not CACHE_ENABLED:
|
|
return False
|
|
full_key = _full_key(key)
|
|
try:
|
|
redis = await _get_redis()
|
|
if redis:
|
|
await redis.setex(full_key, ttl, json.dumps(value, default=str))
|
|
else:
|
|
_mem_cache.set(full_key, value, ttl)
|
|
_stats["sets"] += 1
|
|
return True
|
|
except Exception as exc:
|
|
_stats["errors"] += 1
|
|
logger.debug("cache_set 오류: key=%s err=%s", key, exc)
|
|
return False
|
|
|
|
|
|
async def cache_delete(key: str) -> bool:
|
|
"""캐시에서 특정 키 삭제."""
|
|
if not CACHE_ENABLED:
|
|
return True
|
|
full_key = _full_key(key)
|
|
try:
|
|
redis = await _get_redis()
|
|
if redis:
|
|
await redis.delete(full_key)
|
|
else:
|
|
_mem_cache.delete(full_key)
|
|
return True
|
|
except Exception as exc:
|
|
_stats["errors"] += 1
|
|
logger.debug("cache_delete 오류: key=%s err=%s", key, exc)
|
|
return False
|
|
|
|
|
|
async def cache_invalidate_prefix(prefix: str) -> int:
|
|
"""접두어로 시작하는 모든 캐시 키 삭제."""
|
|
if not CACHE_ENABLED:
|
|
return 0
|
|
full_prefix = _full_key(prefix)
|
|
count = 0
|
|
try:
|
|
redis = await _get_redis()
|
|
if redis:
|
|
keys = await redis.keys(f"{full_prefix}*")
|
|
if keys:
|
|
count = await redis.delete(*keys)
|
|
else:
|
|
keys = _mem_cache.keys_with_prefix(full_prefix)
|
|
for k in keys:
|
|
_mem_cache.delete(k)
|
|
count = len(keys)
|
|
except Exception as exc:
|
|
_stats["errors"] += 1
|
|
logger.debug("cache_invalidate_prefix 오류: prefix=%s err=%s", prefix, exc)
|
|
return count
|
|
|
|
|
|
def make_cache_key(*args, **kwargs) -> str:
|
|
"""인자 목록으로 캐시 키 생성 (MD5 해시)."""
|
|
raw = json.dumps({"args": list(args), "kwargs": kwargs}, sort_keys=True, default=str)
|
|
return hashlib.md5(raw.encode()).hexdigest()[:16]
|
|
|
|
|
|
# ── 캐시 데코레이터 ───────────────────────────────────────────────────────────
|
|
|
|
def cached(key_prefix: str, ttl: int = _DEFAULT_TTL_SEC):
|
|
"""
|
|
비동기 함수 결과 캐싱 데코레이터.
|
|
|
|
사용 예::
|
|
@cached("tasks:list", ttl=30)
|
|
async def get_tasks(...):
|
|
...
|
|
"""
|
|
def decorator(func: Callable):
|
|
@wraps(func)
|
|
async def wrapper(*args, **kwargs):
|
|
cache_key = f"{key_prefix}:{make_cache_key(*args, **kwargs)}"
|
|
cached_val = await cache_get(cache_key)
|
|
if cached_val is not None:
|
|
return cached_val
|
|
result = await func(*args, **kwargs)
|
|
await cache_set(cache_key, result, ttl)
|
|
return result
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
# ── 캐시 상태 조회 ────────────────────────────────────────────────────────────
|
|
|
|
async def cache_info() -> dict:
|
|
"""캐시 상태 정보 반환 (관리자 대시보드용)."""
|
|
try:
|
|
redis = await _get_redis()
|
|
if redis:
|
|
info = await redis.info("stats")
|
|
backend_info = {
|
|
"backend": "redis",
|
|
"url": REDIS_URL.split("@")[-1], # 비밀번호 마스킹
|
|
"keyspace_hits": info.get("keyspace_hits", 0),
|
|
"keyspace_misses": info.get("keyspace_misses", 0),
|
|
}
|
|
else:
|
|
backend_info = _mem_cache.info()
|
|
except Exception:
|
|
backend_info = {"backend": "memory (redis unavailable)", **_mem_cache.info()}
|
|
|
|
return {
|
|
"enabled": CACHE_ENABLED,
|
|
"backend": backend_info,
|
|
"app_stats": dict(_stats),
|
|
}
|
|
|
|
|
|
async def cache_flush_all() -> int:
|
|
"""전체 캐시 초기화 (테스트/관리자 전용)."""
|
|
try:
|
|
redis = await _get_redis()
|
|
if redis:
|
|
keys = await redis.keys(f"{CACHE_KEY_PREFIX}*")
|
|
if keys:
|
|
return await redis.delete(*keys)
|
|
return 0
|
|
else:
|
|
return _mem_cache.flush()
|
|
except Exception as exc:
|
|
logger.warning("cache_flush_all 오류: %s", exc)
|
|
return 0
|