Coverage for server / services / growthbook / cache.py: 100%
52 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2Redis cache for resolved targeting context.
3"""
5import json
6import logging
7from typing import Any, Optional
9import redis.asyncio as aioredis
11from server.services.growthbook.config import get_growthbook_settings
12from server.utilities.redis_url import redis_url_or_default
14logger = logging.getLogger(__name__)
17class TargetingContextCache:
18 def __init__(self) -> None:
19 self._redis: Optional[aioredis.Redis] = None
21 async def connect(self) -> None:
22 if self._redis is not None:
23 return
24 redis_url = redis_url_or_default("redis://localhost:6379/0")
25 # Fail-fast timeouts, on the same principle used elsewhere here: Redis is an
26 # OPTIMIZATION here (the resolver falls back to a live GrowthBook
27 # fetch on miss), so a missing/unreachable Redis MUST NOT make every
28 # request wait the OS default TCP timeout (~30-75s). Observed on dev
29 # branches where REDIS_URL points at localhost but no daemon is
30 # running, causing user-visible page-load delay on every route that
31 # touches GrowthBook (auth/me, features/effective, organization,
32 # exam_catalog, student+teacher dashboards/assignments).
33 self._redis = aioredis.from_url(
34 redis_url,
35 decode_responses=True,
36 socket_connect_timeout=0.5,
37 socket_timeout=0.5,
38 )
40 async def close(self) -> None:
41 if self._redis:
42 await self._redis.close()
43 self._redis = None
45 async def get(self, key: str) -> Optional[dict[str, Any]]:
46 await self.connect()
47 if not self._redis:
48 return None
49 try:
50 raw = await self._redis.get(key)
51 if raw is None:
52 return None
53 return json.loads(raw)
54 except Exception as exc:
55 logger.warning("GrowthBook context cache get failed: %s", exc)
56 return None
58 async def set(self, key: str, value: dict[str, Any]) -> None:
59 await self.connect()
60 if not self._redis:
61 return
62 settings = get_growthbook_settings()
63 try:
64 await self._redis.setex(
65 key,
66 settings.growthbook_context_cache_ttl_seconds,
67 json.dumps(value),
68 )
69 except Exception as exc:
70 logger.warning("GrowthBook context cache set failed: %s", exc)
72 async def delete_by_district(self, district_id: str) -> None:
73 """Invalidate all cached contexts for a district."""
74 await self.connect()
75 if not self._redis:
76 return
77 settings = get_growthbook_settings()
78 pattern = f"gb:ctx:{settings.growthbook_service_name}:*:{district_id}:*"
79 try:
80 async for key in self._redis.scan_iter(match=pattern):
81 await self._redis.delete(key)
82 except Exception as exc:
83 logger.warning("GrowthBook context cache invalidation failed: %s", exc)
86targeting_context_cache = TargetingContextCache()