Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions tpot-analyzer/src/api/metrics_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Response caching for expensive metrics computations.

Caches computed metrics responses to avoid recomputation when users
adjust sliders rapidly. Uses in-memory LRU cache with TTL.
"""
from __future__ import annotations

import hashlib
import json
import logging
import time
from dataclasses import dataclass
from functools import wraps
from typing import Any, Callable, Dict, Optional, Tuple

logger = logging.getLogger(__name__)


@dataclass
class CacheEntry:
"""Cache entry with data and metadata."""
data: Any
created_at: float
hits: int = 0


class MetricsCache:
"""In-memory cache for metrics computation responses.

Features:
- TTL-based expiration (default: 5 minutes)
- LRU eviction when max size reached
- Cache key based on computation parameters
- Hit/miss statistics
"""

def __init__(self, max_size: int = 100, ttl_seconds: int = 300):
"""Initialize cache.

Args:
max_size: Maximum number of entries (default: 100)
ttl_seconds: Time-to-live in seconds (default: 300 = 5 minutes)
"""
self.max_size = max_size
self.ttl_seconds = ttl_seconds
self._cache: Dict[str, CacheEntry] = {}
self._hits = 0
self._misses = 0

def _create_key(self, **params) -> str:
"""Create cache key from parameters.

Args:
**params: Request parameters (seeds, weights, alpha, etc.)

Returns:
Hex-encoded SHA256 hash of sorted parameters
"""
# Sort seeds for consistent hashing
if "seeds" in params:
params["seeds"] = tuple(sorted(params["seeds"]))

# Convert to canonical JSON representation
canonical = json.dumps(params, sort_keys=True, separators=(',', ':'))

# Hash to fixed-length key
return hashlib.sha256(canonical.encode()).hexdigest()[:16]

def get(self, **params) -> Optional[Any]:
"""Get cached result if available and fresh.

Args:
**params: Request parameters

Returns:
Cached data or None if not found/expired
"""
key = self._create_key(**params)
entry = self._cache.get(key)

if entry is None:
self._misses += 1
logger.debug(f"Cache MISS: {key}")
return None

# Check TTL
age = time.time() - entry.created_at
if age > self.ttl_seconds:
logger.debug(f"Cache EXPIRED: {key} (age={age:.1f}s)")
del self._cache[key]
self._misses += 1
return None

# Hit!
entry.hits += 1
self._hits += 1
logger.debug(f"Cache HIT: {key} (age={age:.1f}s, hits={entry.hits})")
return entry.data

def set(self, data: Any, **params) -> None:
"""Store result in cache.

Args:
data: Response data to cache
**params: Request parameters (used for key)
"""
key = self._create_key(**params)

# Evict oldest entry if at max size
if len(self._cache) >= self.max_size:
oldest_key = min(
self._cache.keys(),
key=lambda k: self._cache[k].created_at
)
logger.debug(f"Cache EVICT: {oldest_key} (LRU)")
del self._cache[oldest_key]

self._cache[key] = CacheEntry(
data=data,
created_at=time.time()
)
logger.debug(f"Cache SET: {key}")

def clear(self) -> None:
"""Clear all cache entries."""
count = len(self._cache)
self._cache.clear()
logger.info(f"Cache CLEARED: {count} entries removed")

def stats(self) -> Dict[str, Any]:
"""Get cache statistics.

Returns:
Dict with hits, misses, size, hit_rate
"""
total_requests = self._hits + self._misses
hit_rate = self._hits / total_requests if total_requests > 0 else 0

return {
"hits": self._hits,
"misses": self._misses,
"size": len(self._cache),
"max_size": self.max_size,
"hit_rate": round(hit_rate, 3),
"ttl_seconds": self.ttl_seconds
}


def cached_response(cache: MetricsCache) -> Callable:
"""Decorator to cache Flask route responses.

Args:
cache: MetricsCache instance

Returns:
Decorator function

Example:
@cached_response(metrics_cache)
def compute_metrics():
# expensive computation
return jsonify(result)
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
from flask import request, jsonify

# Extract cache parameters from request
data = request.json or {}
cache_params = {
"seeds": tuple(sorted(data.get("seeds", []))),
"weights": tuple(data.get("weights", [0.4, 0.3, 0.3])),
"alpha": data.get("alpha", 0.85),
"resolution": data.get("resolution", 1.0),
"include_shadow": data.get("include_shadow", True),
"mutual_only": data.get("mutual_only", False),
"min_followers": data.get("min_followers", 0),
}

# Try cache first
cached = cache.get(**cache_params)
if cached is not None:
return jsonify(cached)

# Cache miss - compute and store
response = func(*args, **kwargs)

# Extract data from response (handle both dict and Response objects)
if hasattr(response, 'get_json'):
data = response.get_json()
else:
data = response

cache.set(data, **cache_params)
Comment on lines +181 to +195

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid caching tuple responses in metrics cache decorator

The cache decorator unconditionally stores whatever the wrapped view returns and, on a hit, blindly feeds the cached object to jsonify. When compute_metrics errors it returns a tuple (Response, 500). That tuple is cached as-is, so the next identical request executes the cache hit branch and jsonify((Response, 500)) raises TypeError because Flask cannot serialize a Response object. Even for non-error tuples, the wrapper would strip status codes and headers by replacing them with a new 200 response. Consider skipping caching for non-200 responses and, when caching, store only JSON-serializable payloads and return a full Response (with status/headers) on hits.

Useful? React with 👍 / 👎.

return response

return wrapper
return decorator
39 changes: 37 additions & 2 deletions tpot-analyzer/src/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
discover_subgraph,
validate_request,
)
from src.api.metrics_cache import MetricsCache, cached_response
from src.api.snapshot_loader import get_snapshot_loader
from src.config import get_cache_settings
from src.data.fetcher import CachedDataFetcher
Expand Down Expand Up @@ -70,7 +71,7 @@ def _append_analysis_log(line: str) -> None:
analysis_status["log"] = analysis_status["log"][-200:]


def _analysis_worker(active_list: str, include_shadow: bool, alpha: float) -> None:
def _analysis_worker(active_list: str, include_shadow: bool, alpha: float, metrics_cache: MetricsCache) -> None:
global analysis_thread
cmd = [
sys.executable or "python3",
Expand Down Expand Up @@ -105,7 +106,10 @@ def _analysis_worker(active_list: str, include_shadow: bool, alpha: float) -> No
analysis_status["finished_at"] = datetime.utcnow().isoformat() + "Z"
analysis_status["status"] = "succeeded"
analysis_status["error"] = None
# Clear metrics cache after successful graph rebuild
metrics_cache.clear()
_append_analysis_log("Analysis completed successfully.")
_append_analysis_log("Metrics cache cleared.")
else:
with analysis_lock:
analysis_status["finished_at"] = datetime.utcnow().isoformat() + "Z"
Expand Down Expand Up @@ -207,6 +211,13 @@ def create_app(cache_db_path: Path | None = None) -> Flask:
snapshot_loader = get_snapshot_loader()
app.config["SNAPSHOT_LOADER"] = snapshot_loader

# Initialize metrics response cache
# TTL: 5 minutes (rapid slider adjustments cached, but not stale after graph rebuild)
# Max size: 100 entries (reasonable for typical usage patterns)
metrics_cache = MetricsCache(max_size=100, ttl_seconds=300)
app.config["METRICS_CACHE"] = metrics_cache
logger.info("Initialized metrics cache (max_size=100, ttl=300s)")

# Try to load snapshot on startup
logger.info("Checking for graph snapshot...")
should_use, reason = snapshot_loader.should_use_snapshot()
Expand Down Expand Up @@ -330,6 +341,26 @@ def get_performance_metrics():
logger.exception("Error getting performance metrics")
return jsonify({"error": str(e)}), 500

@app.route("/api/metrics/cache/stats", methods=["GET"])
def get_cache_stats():
"""Get metrics cache statistics."""
try:
stats = metrics_cache.stats()
return jsonify(stats)
except Exception as e:
logger.exception("Error getting cache stats")
return jsonify({"error": str(e)}), 500

@app.route("/api/metrics/cache/clear", methods=["POST"])
def clear_cache():
"""Clear metrics cache. Useful after graph rebuild or data updates."""
try:
metrics_cache.clear()
return jsonify({"status": "cleared", "message": "Metrics cache cleared successfully"})
except Exception as e:
logger.exception("Error clearing cache")
return jsonify({"error": str(e)}), 500

@app.route("/api/graph-data", methods=["GET"])
def get_graph_data():
"""
Expand Down Expand Up @@ -445,6 +476,7 @@ def get_graph_data():
return jsonify({"error": str(e)}), 500

@app.route("/api/metrics/compute", methods=["POST"])
@cached_response(metrics_cache)
def compute_metrics():
"""
Compute graph metrics with custom seeds and weights.
Expand All @@ -459,6 +491,9 @@ def compute_metrics():
"mutual_only": false,
"min_followers": 0
}

Responses are cached for 5 minutes to improve UI responsiveness
during rapid slider adjustments.
"""
try:
data = request.json or {}
Expand Down Expand Up @@ -779,7 +814,7 @@ def run_analysis():

analysis_thread = threading.Thread(
target=_analysis_worker,
args=(active_list, include_shadow, alpha),
args=(active_list, include_shadow, alpha, metrics_cache),
daemon=True,
)
analysis_thread.start()
Expand Down