243 lines
7.5 KiB
Python
243 lines
7.5 KiB
Python
"""
|
|
Flow Instance Cache Layer.
|
|
Provides Redis-based caching for FlowInstance to reduce database load.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import redis.asyncio as redis
|
|
|
|
from app.core.config import get_settings
|
|
from app.models.entities import FlowInstance
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FlowCache:
|
|
"""
|
|
Redis cache layer for FlowInstance state management.
|
|
|
|
Features:
|
|
- L1: In-memory cache (process-level, 5 min TTL)
|
|
- L2: Redis cache (shared, 1 hour TTL)
|
|
- Automatic fallback on cache miss
|
|
- Cache invalidation on flow completion/cancellation
|
|
|
|
Key format: flow:{tenant_id}:{session_id}
|
|
TTL: 3600 seconds (1 hour)
|
|
"""
|
|
|
|
# L1 cache: in-memory (process-level)
|
|
_local_cache: dict[str, tuple[FlowInstance, float]] = {}
|
|
_local_cache_ttl = 300 # 5 minutes
|
|
|
|
def __init__(self, redis_client: redis.Redis | None = None):
|
|
self._redis = redis_client
|
|
self._settings = get_settings()
|
|
self._enabled = self._settings.redis_enabled
|
|
self._cache_ttl = 3600 # 1 hour
|
|
|
|
async def _get_client(self) -> redis.Redis | None:
|
|
"""Get or create Redis client."""
|
|
if not self._enabled:
|
|
return None
|
|
if self._redis is None:
|
|
try:
|
|
self._redis = redis.from_url(
|
|
self._settings.redis_url,
|
|
encoding="utf-8",
|
|
decode_responses=True,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"[FlowCache] Failed to connect to Redis: {e}")
|
|
self._enabled = False
|
|
return None
|
|
return self._redis
|
|
|
|
def _make_key(self, tenant_id: str, session_id: str) -> str:
|
|
"""Generate cache key."""
|
|
return f"flow:{tenant_id}:{session_id}"
|
|
|
|
def _make_local_key(self, tenant_id: str, session_id: str) -> str:
|
|
"""Generate local cache key."""
|
|
return f"{tenant_id}:{session_id}"
|
|
|
|
async def get(
|
|
self,
|
|
tenant_id: str,
|
|
session_id: str,
|
|
) -> FlowInstance | None:
|
|
"""
|
|
Get FlowInstance from cache (L1 -> L2).
|
|
|
|
Args:
|
|
tenant_id: Tenant ID for isolation
|
|
session_id: Session ID
|
|
|
|
Returns:
|
|
Cached FlowInstance or None if not found
|
|
"""
|
|
# L1: Check local cache
|
|
local_key = self._make_local_key(tenant_id, session_id)
|
|
if local_key in self._local_cache:
|
|
instance, timestamp = self._local_cache[local_key]
|
|
import time
|
|
if time.time() - timestamp < self._local_cache_ttl:
|
|
logger.debug(f"[FlowCache] L1 hit: {local_key}")
|
|
return instance
|
|
else:
|
|
# Expired, remove from L1
|
|
del self._local_cache[local_key]
|
|
|
|
# L2: Check Redis cache
|
|
client = await self._get_client()
|
|
if client is None:
|
|
return None
|
|
|
|
key = self._make_key(tenant_id, session_id)
|
|
|
|
try:
|
|
data = await client.get(key)
|
|
if data:
|
|
logger.debug(f"[FlowCache] L2 hit: {key}")
|
|
instance_dict = json.loads(data)
|
|
instance = self._deserialize_instance(instance_dict)
|
|
|
|
# Populate L1 cache
|
|
import time
|
|
self._local_cache[local_key] = (instance, time.time())
|
|
|
|
return instance
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"[FlowCache] Failed to get from cache: {e}")
|
|
return None
|
|
|
|
async def set(
|
|
self,
|
|
tenant_id: str,
|
|
session_id: str,
|
|
instance: FlowInstance,
|
|
) -> bool:
|
|
"""
|
|
Set FlowInstance to cache (L1 + L2).
|
|
|
|
Args:
|
|
tenant_id: Tenant ID for isolation
|
|
session_id: Session ID
|
|
instance: FlowInstance to cache
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
# L1: Update local cache
|
|
local_key = self._make_local_key(tenant_id, session_id)
|
|
import time
|
|
self._local_cache[local_key] = (instance, time.time())
|
|
|
|
# L2: Update Redis cache
|
|
client = await self._get_client()
|
|
if client is None:
|
|
return False
|
|
|
|
key = self._make_key(tenant_id, session_id)
|
|
|
|
try:
|
|
instance_dict = self._serialize_instance(instance)
|
|
await client.setex(
|
|
key,
|
|
self._cache_ttl,
|
|
json.dumps(instance_dict, default=str),
|
|
)
|
|
logger.debug(f"[FlowCache] Set cache: {key}")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"[FlowCache] Failed to set cache: {e}")
|
|
return False
|
|
|
|
async def delete(
|
|
self,
|
|
tenant_id: str,
|
|
session_id: str,
|
|
) -> bool:
|
|
"""
|
|
Delete FlowInstance from cache (L1 + L2).
|
|
|
|
Args:
|
|
tenant_id: Tenant ID for isolation
|
|
session_id: Session ID
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
# L1: Remove from local cache
|
|
local_key = self._make_local_key(tenant_id, session_id)
|
|
if local_key in self._local_cache:
|
|
del self._local_cache[local_key]
|
|
|
|
# L2: Remove from Redis
|
|
client = await self._get_client()
|
|
if client is None:
|
|
return False
|
|
|
|
key = self._make_key(tenant_id, session_id)
|
|
|
|
try:
|
|
await client.delete(key)
|
|
logger.debug(f"[FlowCache] Deleted cache: {key}")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"[FlowCache] Failed to delete cache: {e}")
|
|
return False
|
|
|
|
def _serialize_instance(self, instance: FlowInstance) -> dict[str, Any]:
|
|
"""Serialize FlowInstance to dict."""
|
|
return {
|
|
"id": str(instance.id),
|
|
"tenant_id": instance.tenant_id,
|
|
"session_id": instance.session_id,
|
|
"flow_id": str(instance.flow_id),
|
|
"current_step": instance.current_step,
|
|
"status": instance.status,
|
|
"context": instance.context,
|
|
"started_at": instance.started_at.isoformat() if instance.started_at else None,
|
|
"completed_at": instance.completed_at.isoformat() if instance.completed_at else None,
|
|
"updated_at": instance.updated_at.isoformat() if instance.updated_at else None,
|
|
}
|
|
|
|
def _deserialize_instance(self, data: dict[str, Any]) -> FlowInstance:
|
|
"""Deserialize dict to FlowInstance."""
|
|
from datetime import datetime
|
|
from uuid import UUID
|
|
|
|
return FlowInstance(
|
|
id=UUID(data["id"]),
|
|
tenant_id=data["tenant_id"],
|
|
session_id=data["session_id"],
|
|
flow_id=UUID(data["flow_id"]),
|
|
current_step=data["current_step"],
|
|
status=data["status"],
|
|
context=data.get("context"),
|
|
started_at=datetime.fromisoformat(data["started_at"]) if data.get("started_at") else None,
|
|
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
|
|
updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None,
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
"""Close Redis connection."""
|
|
if self._redis:
|
|
await self._redis.close()
|
|
|
|
|
|
_flow_cache: FlowCache | None = None
|
|
|
|
|
|
def get_flow_cache() -> FlowCache:
|
|
"""Get singleton FlowCache instance."""
|
|
global _flow_cache
|
|
if _flow_cache is None:
|
|
_flow_cache = FlowCache()
|
|
return _flow_cache
|