Coverage for src/lexigram/graphql/core/caching.py: 96%

128 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""GraphQL response caching. 

2 

3This module provides response caching to improve performance 

4for repeated queries. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass 

10from datetime import UTC, datetime, timedelta 

11from typing import TYPE_CHECKING, Any 

12 

13from lexigram import hashing # type: ignore[attr-defined] 

14from lexigram import serialization as json 

15from lexigram.logging import get_logger 

16from lexigram.primitives import clock as ambient_clock 

17 

18if TYPE_CHECKING: 

19 from lexigram.contracts.infra.cache.protocols import CacheBackendProtocol 

20 

21 

22logger = get_logger(__name__) 

23 

24 

25class _MemoryCacheShim: 

26 """Local in-memory fallback used when no CacheBackendProtocol is registered in the container. 

27 

28 Implements the full :class:`~lexigram.contracts.cache.CacheBackendProtocol` protocol so 

29 that static analysis and runtime ``isinstance`` checks pass. 

30 

31 .. warning:: 

32 Not suitable for production with multiple process instances — all state is 

33 in-process memory with no replication or persistence. 

34 """ 

35 

36 def __init__(self, default_ttl: int = 300) -> None: 

37 self._store: dict[str, tuple[Any, datetime | None]] = {} 

38 self._default_ttl = timedelta(seconds=default_ttl) 

39 self._locks: dict[str, bool] = {} 

40 

41 def _now(self) -> datetime: 

42 """Get current timestamp.""" 

43 return ambient_clock.now() 

44 

45 async def get(self, key: str) -> Any | None: 

46 """Get a value from cache.""" 

47 if key not in self._store: 

48 return None 

49 value, expires = self._store[key] 

50 if expires and expires < self._now(): 

51 del self._store[key] 

52 return None 

53 return value 

54 

55 async def set(self, key: str, value: Any, ttl: int | None = None) -> bool: 

56 """Set a value in cache.""" 

57 ttl_delta = timedelta(seconds=ttl) if ttl else self._default_ttl 

58 expires = self._now() + ttl_delta if ttl else None 

59 self._store[key] = (value, expires) 

60 return True 

61 

62 async def delete(self, key: str) -> bool: 

63 """Delete a value from cache.""" 

64 existed = key in self._store 

65 self._store.pop(key, None) 

66 return existed 

67 

68 async def delete_many(self, keys: list[str]) -> bool: 

69 """Delete multiple values from cache.""" 

70 for key in keys: 

71 self._store.pop(key, None) 

72 return True 

73 

74 async def exists(self, key: str) -> bool: 

75 """Check whether a key exists in cache.""" 

76 if key not in self._store: 

77 return False 

78 _, expires = self._store[key] 

79 if expires and expires < self._now(): 

80 del self._store[key] 

81 return False 

82 return True 

83 

84 async def clear(self) -> bool: 

85 """Clear all cache entries.""" 

86 self._store.clear() 

87 return True 

88 

89 async def get_many(self, keys: list[str]) -> dict[str, Any]: 

90 """Get multiple values from cache.""" 

91 result: dict[str, Any] = {} 

92 for key in keys: 

93 value = await self.get(key) 

94 if value is not None: 

95 result[key] = value 

96 return result 

97 

98 async def set_many(self, items: dict[str, Any], ttl: int | None = None) -> bool: 

99 """Set multiple values in cache.""" 

100 for key, value in items.items(): 

101 await self.set(key, value, ttl) 

102 return True 

103 

104 async def acquire_lock(self, key: str, ttl: int) -> bool: 

105 """Acquire a simple in-memory lock.""" 

106 if self._locks.get(key): 

107 return False 

108 self._locks[key] = True 

109 return True 

110 

111 async def release_lock(self, key: str) -> bool: 

112 """Release an in-memory lock.""" 

113 existed = self._locks.pop(key, False) 

114 return bool(existed) 

115 

116 

117@dataclass 

118class ResponseCacheEntry: 

119 """Cached GraphQL response entry. 

120 

121 Attributes: 

122 data: The cached response data. 

123 extensions: Optional response extensions. 

124 created_at: When the entry was created. 

125 ttl: Time-to-live in seconds. 

126 """ 

127 

128 data: Any 

129 extensions: dict | None = None 

130 created_at: datetime | None = None 

131 ttl: int | None = None 

132 

133 

134def compute_cache_key( 

135 query: str, 

136 variables: dict | None = None, 

137 operation_name: str | None = None, 

138 user_id: str | None = None, 

139 tenant_id: str | None = None, 

140 query_hash: str | None = None, 

141) -> str: 

142 """Compute a deterministic cache key for a GraphQL operation. 

143 

144 The key incorporates the query, variables, operation name, and optionally 

145 the user and tenant identifiers so that multi-tenant or user-scoped caches 

146 cannot cross-contaminate each other's responses. 

147 

148 When APQ (Automatic Persisted Queries) is enabled, callers should pass the 

149 pre-computed ``query_hash`` from the APQ extension (SHA-256 hex digest of 

150 the query string). This avoids hashing the query twice — once in the APQ 

151 pipeline and again here. 

152 

153 Args: 

154 query: GraphQL query string. 

155 variables: Query variables. 

156 operation_name: Optional operation name. 

157 user_id: Optional user identifier for per-user cache isolation. 

158 tenant_id: Optional tenant identifier for multi-tenant isolation. 

159 query_hash: Optional pre-computed SHA-256 hex digest of *query* (e.g. 

160 from APQ ``extensions.persistedQuery.sha256Hash``). When provided, 

161 re-hashing the full query string is skipped. 

162 

163 Returns: 

164 Namespaced SHA-256 cache key string. 

165 """ 

166 # Use the pre-computed hash when available (APQ fast path) to avoid 

167 # re-hashing the query string. 

168 base_hash = query_hash or hashing.hash_hex(query) 

169 

170 key_parts = [base_hash] 

171 

172 if variables: 

173 key_parts.append(json.dumps(variables, sort_keys=True).decode("utf-8")) 

174 

175 if operation_name: 

176 key_parts.append(operation_name) 

177 

178 if tenant_id: 

179 key_parts.append(f"t:{tenant_id}") 

180 

181 if user_id: 

182 key_parts.append(f"u:{user_id}") 

183 

184 # Only need a second hash pass when there are extra discriminators beyond 

185 # the query hash — otherwise base_hash alone is already unique. 

186 if len(key_parts) == 1: 

187 return f"gql:{base_hash[:32]}" 

188 

189 key_string = "|".join(key_parts) 

190 return f"gql:{hashing.hash_hex(key_string)[:32]}" 

191 

192 

193class ResponseCache: 

194 """GraphQL response cache. 

195 

196 Caches complete query responses to improve performance for repeated queries. 

197 

198 This is a **response-level** cache — the entire query result is cached 

199 keyed on ``(query, variables, operation_name)``. This is the current 

200 implementation. 

201 

202 .. TODO:: 

203 **Field-level caching via ``@CacheControl`` directive** is not yet 

204 implemented. The idea is to annotate individual Strawberry fields with 

205 a ``@CacheControl(max_age=60)`` directive so that only the hot, slow 

206 resolvers are cached rather than the entire response. This requires: 

207 

208 1. A custom Strawberry extension that intercepts field resolution 

209 2. Generating ``Cache-Control`` HTTP headers from the directive values 

210 (per the Apollo ``@cacheControl`` spec) 

211 3. Integrating with :class:`CacheBackendProtocol` for per-field storage 

212 

213 Until this is implemented, use ``@functools.lru_cache`` or a 

214 ``CacheBackendProtocol`` call inside individual resolver methods as a workaround. 

215 

216 Example: 

217 ```python 

218 from lexigram.contracts.infra.cache.protocols import CacheBackendProtocol 

219 

220 cache = ResponseCache(backend=my_cache_backend) 

221 

222 # Check cache before execution 

223 key = compute_cache_key(query, variables) 

224 cached = await cache.get(key) 

225 

226 if cached: 

227 return cached 

228 

229 # Execute query and cache result 

230 result = await executor.execute(...) 

231 await cache.set(key, result) 

232 ``` 

233 """ 

234 

235 def __init__( 

236 self, 

237 backend: CacheBackendProtocol, 

238 enabled: bool = True, 

239 default_ttl: int = 300, 

240 ): 

241 """Initialize the cache. 

242 

243 Args: 

244 backend: Cache backend implementation. 

245 enabled: Whether caching is enabled. 

246 default_ttl: Default time-to-live in seconds. 

247 """ 

248 self._backend = backend 

249 self._enabled = enabled 

250 self._default_ttl = default_ttl 

251 

252 @property 

253 def enabled(self) -> bool: 

254 """Check if caching is enabled.""" 

255 return self._enabled 

256 

257 async def get(self, key: str) -> ResponseCacheEntry | None: 

258 """Get a cached response. 

259 

260 Args: 

261 key: Cache key. 

262 

263 Returns: 

264 Cached entry if found, None otherwise. 

265 """ 

266 if not self._enabled: 

267 return None 

268 

269 try: 

270 return await self._backend.get(key) # type: ignore[return-value] 

271 except (OSError, LookupError, RuntimeError) as e: 

272 logger.warning("Cache get failed: %s", e) 

273 return None 

274 

275 async def set( 

276 self, 

277 key: str, 

278 data: Any, 

279 extensions: dict | None = None, 

280 ttl: int | None = None, 

281 ) -> None: 

282 """Cache a response. 

283 

284 Args: 

285 key: Cache key. 

286 data: Response data to cache. 

287 extensions: Optional response extensions. 

288 ttl: Optional time-to-live in seconds. 

289 """ 

290 if not self._enabled: 

291 return 

292 

293 try: 

294 entry = ResponseCacheEntry( 

295 data=data, 

296 extensions=extensions, 

297 created_at=datetime.now(UTC), 

298 ttl=ttl or self._default_ttl, 

299 ) 

300 await self._backend.set(key, entry, ttl) 

301 except (OSError, LookupError, RuntimeError) as e: 

302 logger.warning("Cache set failed: %s", e) 

303 

304 async def invalidate(self, key: str) -> None: 

305 """Invalidate a cached entry. 

306 

307 Args: 

308 key: Cache key to invalidate. 

309 """ 

310 try: 

311 await self._backend.delete(key) 

312 except (OSError, LookupError, RuntimeError) as e: 

313 logger.warning("Cache invalidation failed: %s", e) 

314 

315 async def clear(self) -> None: 

316 """Clear all cached entries.""" 

317 try: 

318 await self._backend.clear() 

319 except (OSError, LookupError, RuntimeError) as e: 

320 logger.warning("Cache clear failed: %s", e) 

321 

322 

323def create_response_cache( 

324 backend_type: str = "memory", 

325 **kwargs: Any, 

326) -> ResponseCache: 

327 """Create a response cache with the specified backend. 

328 

329 Args: 

330 backend_type: Type of backend ('memory'). 

331 **kwargs: Additional arguments for the backend. 

332 

333 Returns: 

334 Configured ResponseCache. 

335 """ 

336 if backend_type == "memory": 

337 backend: CacheBackendProtocol = _MemoryCacheShim(**kwargs) # type: ignore[assignment] 

338 else: 

339 raise ValueError(f"Unknown cache backend type: {backend_type}") 

340 

341 return ResponseCache(backend=backend) 

342 

343 

344__all__ = [ 

345 "ResponseCache", 

346 "ResponseCacheEntry", 

347 "compute_cache_key", 

348 "create_response_cache", 

349]