ai-robot-core/ai-service/app/services/cache/flow_cache.py

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