Coverage for packages / core / common / cache.py: 22%

104 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-08 08:37 +1200

1"""Simple cache wrapper with Redis primary and in-memory dict fallback. 

2 

3Tries Redis first. Falls back to an in-memory dict with TTL if Redis 

4is unavailable or disabled. Uses JSON serialization for values. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import threading 

11import time 

12from typing import Any 

13 

14from packages.core.common.logging import get_logger 

15from packages.core.common.settings import get_settings 

16 

17logger = get_logger(__name__) 

18 

19# --------------------------------------------------------------------------- 

20# In-memory fallback backend 

21# --------------------------------------------------------------------------- 

22 

23_memory_store: dict[str, tuple[float | None, str]] = ( 

24 {} 

25) # key -> (expires_at, json_value) 

26_lock = threading.Lock() 

27 

28 

29def _memory_get(key: str) -> Any | None: 

30 """Get a value from the in-memory store.""" 

31 with _lock: 

32 entry = _memory_store.get(key) 

33 if entry is None: 

34 return None 

35 expires_at, json_value = entry 

36 if expires_at is not None and time.monotonic() > expires_at: 

37 del _memory_store[key] 

38 return None 

39 try: 

40 return json.loads(json_value) 

41 except json.JSONDecodeError: 

42 return None 

43 

44 

45def _memory_set(key: str, value: Any, ttl: int | None = None) -> None: 

46 """Set a value in the in-memory store with optional TTL (seconds).""" 

47 expires_at = (time.monotonic() + ttl) if ttl is not None else None 

48 with _lock: 

49 _memory_store[key] = (expires_at, json.dumps(value, default=str)) 

50 

51 

52def _memory_delete(key: str) -> None: 

53 """Delete a key from the in-memory store.""" 

54 with _lock: 

55 _memory_store.pop(key, None) 

56 

57 

58def _memory_clear() -> None: 

59 """Clear all entries from the in-memory store.""" 

60 with _lock: 

61 _memory_store.clear() 

62 

63 

64# --------------------------------------------------------------------------- 

65# Redis backend (lazy init) 

66# --------------------------------------------------------------------------- 

67 

68_redis_client: Any = None 

69_redis_available: bool | None = None 

70 

71 

72def _get_redis_client() -> Any | None: 

73 """Return the global Redis client, or None if unavailable/disabled.""" 

74 global _redis_client, _redis_available 

75 

76 if _redis_available is False: 

77 return None 

78 

79 if _redis_client is not None: 

80 return _redis_client 

81 

82 settings = get_settings() 

83 if not settings.redis.enabled: 

84 logger.info("Redis caching is disabled via settings, using in-memory fallback") 

85 _redis_available = False 

86 return None 

87 

88 try: 

89 import redis as _redis # type: ignore[import-untyped] 

90 

91 _redis_client = _redis.from_url( 

92 settings.redis.url, 

93 decode_responses=True, 

94 socket_connect_timeout=2, 

95 socket_timeout=2, 

96 ) 

97 _redis_client.ping() 

98 logger.info( 

99 "Redis cache backend active", 

100 extra={"url": settings.redis.url}, 

101 ) 

102 _redis_available = True 

103 return _redis_client 

104 except Exception as exc: 

105 logger.warning( 

106 "Redis unavailable, falling back to in-memory cache", 

107 extra={"error": str(exc)}, 

108 ) 

109 _redis_available = False 

110 return None 

111 

112 

113# --------------------------------------------------------------------------- 

114# Public API 

115# --------------------------------------------------------------------------- 

116 

117_backend: str | None = None 

118 

119 

120def get_cache() -> str: 

121 """Return the name of the active cache backend ('redis' or 'memory').""" 

122 global _backend 

123 if _backend is None: 

124 if _get_redis_client() is not None: 

125 _backend = "redis" 

126 else: 

127 _backend = "memory" 

128 logger.info("Cache backend resolved", extra={"backend": _backend}) 

129 return _backend 

130 

131 

132def cache_get(key: str) -> Any | None: 

133 """Retrieve a value from cache. 

134 

135 Args: 

136 key: Cache key. 

137 

138 Returns: 

139 Deserialized value or None if missing / expired. 

140 """ 

141 client = _get_redis_client() 

142 if client is not None: 

143 try: 

144 raw = client.get(key) 

145 if raw is None: 

146 return None 

147 return json.loads(raw) 

148 except Exception as exc: 

149 logger.warning( 

150 "Redis cache_get failed, falling back", extra={"error": str(exc)} 

151 ) 

152 return _memory_get(key) 

153 

154 return _memory_get(key) 

155 

156 

157def cache_set(key: str, value: Any, ttl: int | None = None) -> None: 

158 """Store a value in cache. 

159 

160 Args: 

161 key: Cache key. 

162 value: Value to store (must be JSON-serializable). 

163 ttl: Time-to-live in seconds. Uses ``settings.redis.ttl_seconds`` 

164 when *ttl* is ``None`` and Redis is the active backend. 

165 """ 

166 if ttl is None: 

167 ttl = get_settings().redis.ttl_seconds 

168 

169 client = _get_redis_client() 

170 if client is not None: 

171 try: 

172 client.setex(key, ttl, json.dumps(value, default=str)) 

173 return 

174 except Exception as exc: 

175 logger.warning( 

176 "Redis cache_set failed, falling back", extra={"error": str(exc)} 

177 ) 

178 

179 _memory_set(key, value, ttl) 

180 

181 

182def cache_delete(key: str) -> None: 

183 """Remove a single key from cache.""" 

184 client = _get_redis_client() 

185 if client is not None: 

186 try: 

187 client.delete(key) 

188 return 

189 except Exception: 

190 pass 

191 _memory_delete(key) 

192 

193 

194def cache_clear() -> None: 

195 """Flush the entire cache (both backends if available).""" 

196 client = _get_redis_client() 

197 if client is not None: 

198 try: 

199 client.flushdb() 

200 except Exception: 

201 pass 

202 _memory_clear()