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

1""" 

2Redis cache for resolved targeting context. 

3""" 

4 

5import json 

6import logging 

7from typing import Any, Optional 

8 

9import redis.asyncio as aioredis 

10 

11from server.services.growthbook.config import get_growthbook_settings 

12from server.utilities.redis_url import redis_url_or_default 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17class TargetingContextCache: 

18 def __init__(self) -> None: 

19 self._redis: Optional[aioredis.Redis] = None 

20 

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 ) 

39 

40 async def close(self) -> None: 

41 if self._redis: 

42 await self._redis.close() 

43 self._redis = None 

44 

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 

57 

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) 

71 

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) 

84 

85 

86targeting_context_cache = TargetingContextCache()