Skip to content

Commit e7728a3

Browse files
perf: cache identical analyze requests (imDarshanGK#140)
* perf: cache identical analyze requests * fix: align analyze cache contract with issue --------- Co-authored-by: Darshan G K <122042809+imDarshanGK@users.noreply.github.com>
1 parent 61e9efe commit e7728a3

5 files changed

Lines changed: 91 additions & 5 deletions

File tree

backend/app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class Settings:
5353
rate_limit_window_seconds: int = _int_env("RATE_LIMIT_WINDOW_SECONDS", 60)
5454
cache_enabled: bool = _bool_env("CACHE_ENABLED", True)
5555
cache_ttl_seconds: int = _int_env("CACHE_TTL_SECONDS", 300)
56+
cache_max_entries: int = _int_env("CACHE_MAX_ENTRIES", 100)
5657
redis_url: str | None = os.getenv("REDIS_URL")
5758
sentry_dsn: str | None = os.getenv("SENTRY_DSN")
5859
sentry_traces_sample_rate: float = _float_env("SENTRY_TRACES_SAMPLE_RATE", 0.0)

backend/app/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ async def add_process_time_header(request: Request, call_next):
103103
return response
104104

105105

106+
@app.middleware("http")
107+
async def add_cache_header(request: Request, call_next):
108+
response = await call_next(request)
109+
110+
if request.url.path == "/analyze/" and request.method == "POST":
111+
response.headers.setdefault("X-Cache", "MISS")
112+
113+
return response
114+
115+
106116
# ── Routers ───────────────────────────────────────────────────────────────────
107117
app.include_router(explanation.router, prefix="/explanation", tags=["Explanation"])
108118
app.include_router(debugging.router, prefix="/debugging", tags=["Debugging"])

backend/app/routers/analyze.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
"""Full analysis router — POST /analyze/"""
2-
from fastapi import APIRouter
2+
from fastapi import APIRouter, Response
33
from ..schemas import CodeRequest, AnalyzeResponse
4+
from ..services.cache import cache
45
from ..services.code_assistant import full_analysis
56

67
router = APIRouter()
78

89
@router.post("/", response_model=AnalyzeResponse, summary="Run full analysis (explain + debug + suggest)")
9-
async def analyze(req: CodeRequest):
10-
return full_analysis(req.code, req.language)
10+
async def analyze(req: CodeRequest, response: Response):
11+
cache_input = f"{req.language or 'auto'}\n{req.code}"
12+
cached_payload = cache.get("analyze:v1", cache_input)
13+
14+
if cached_payload is not None:
15+
response.headers["X-Cache"] = "HIT"
16+
return cached_payload
17+
18+
payload = full_analysis(req.code, req.language)
19+
cache.set("analyze:v1", cache_input, payload)
20+
response.headers["X-Cache"] = "MISS"
21+
return payload

backend/app/services/cache.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import logging
44
import time
5+
from collections import OrderedDict
56
from threading import Lock
67

78
from ..config import settings
@@ -11,7 +12,7 @@
1112

1213
class AppCache:
1314
def __init__(self):
14-
self._memory_store: dict[str, tuple[float, dict]] = {}
15+
self._memory_store: OrderedDict[str, tuple[float, dict]] = OrderedDict()
1516
self._memory_lock = Lock()
1617
self._redis_client = None
1718
self._backend = "memory"
@@ -30,7 +31,7 @@ def backend(self) -> str:
3031
return self._backend
3132

3233
def _make_key(self, namespace: str, code: str) -> str:
33-
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
34+
digest = hashlib.md5(code.encode("utf-8")).hexdigest()
3435
return f"ai-assistant:{namespace}:{digest}"
3536

3637
def get(self, namespace: str, code: str) -> dict | None:
@@ -57,6 +58,7 @@ def get(self, namespace: str, code: str) -> dict | None:
5758
self._memory_store.pop(key, None)
5859
return None
5960

61+
self._memory_store.move_to_end(key)
6062
return payload
6163

6264
def set(self, namespace: str, code: str, payload: dict) -> None:
@@ -74,6 +76,14 @@ def set(self, namespace: str, code: str, payload: dict) -> None:
7476
expires_at = time.time() + settings.cache_ttl_seconds
7577
with self._memory_lock:
7678
self._memory_store[key] = (expires_at, payload)
79+
self._memory_store.move_to_end(key)
80+
81+
while len(self._memory_store) > settings.cache_max_entries:
82+
self._memory_store.popitem(last=False)
83+
84+
def clear_memory(self) -> None:
85+
with self._memory_lock:
86+
self._memory_store.clear()
7787

7888

7989
cache = AppCache()

backend/tests/test_endpoints.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,60 @@ def test_full_analyze():
553553
assert d["provider"] == "rule-based"
554554
assert d["analysis_time_ms"] is not None
555555

556+
def test_full_analyze_uses_cache_for_identical_inputs():
557+
from app.main import _request_counts
558+
from app.services.cache import cache
559+
560+
_request_counts.clear()
561+
cache.clear_memory()
562+
payload = {"code": PYTHON_BUGGY, "language": "python"}
563+
564+
first = client.post("/analyze/", json=payload)
565+
second = client.post("/analyze/", json=payload)
566+
567+
assert first.status_code == 200
568+
assert second.status_code == 200
569+
assert first.headers["X-Cache"] == "MISS"
570+
assert second.headers["X-Cache"] == "HIT"
571+
assert second.json() == first.json()
572+
_request_counts.clear()
573+
574+
def test_analyze_cache_expires(monkeypatch):
575+
from app.services import cache as cache_module
576+
from app.services.cache import cache
577+
578+
cache.clear_memory()
579+
payload = {"code": PYTHON_BUGGY, "language": "python"}
580+
start = 1_700_000_000
581+
582+
monkeypatch.setattr(cache_module.time, "time", lambda: start)
583+
first = client.post("/analyze/", json=payload)
584+
585+
monkeypatch.setattr(cache_module.time, "time", lambda: start + 301)
586+
second = client.post("/analyze/", json=payload)
587+
588+
assert first.status_code == 200
589+
assert second.status_code == 200
590+
assert first.headers["X-Cache"] == "MISS"
591+
assert second.headers["X-Cache"] == "MISS"
592+
cache.clear_memory()
593+
594+
def test_memory_cache_evicts_least_recently_used_entries():
595+
from app.services.cache import cache
596+
597+
cache.clear_memory()
598+
599+
for index in range(100):
600+
cache.set("test", f"item-{index}", {"index": index})
601+
602+
assert cache.get("test", "item-0") == {"index": 0}
603+
cache.set("test", "item-100", {"index": 100})
604+
605+
assert cache.get("test", "item-1") is None
606+
assert cache.get("test", "item-0") == {"index": 0}
607+
assert cache.get("test", "item-100") == {"index": 100}
608+
cache.clear_memory()
609+
556610
def test_full_analyze_all_languages():
557611
for code, lang in [
558612
(JS_CODE, "javascript"),

0 commit comments

Comments
 (0)