Coverage for fastblocks/actions/sync/cache.py: 28%

272 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-21 04:50 -0700

1"""Cache synchronization and consistency management across layers.""" 

2 

3import typing as t 

4 

5from acb.debug import debug 

6 

7from .strategies import SyncResult, SyncStrategy 

8 

9 

10class CacheSyncResult(SyncResult): 

11 def __init__( 

12 self, 

13 *, 

14 invalidated_keys: list[str] | None = None, 

15 warmed_keys: list[str] | None = None, 

16 cleared_namespaces: list[str] | None = None, 

17 **kwargs: t.Any, 

18 ) -> None: 

19 super().__init__(**kwargs) 

20 self.invalidated_keys = invalidated_keys if invalidated_keys is not None else [] 

21 self.warmed_keys = warmed_keys if warmed_keys is not None else [] 

22 self.cleared_namespaces = ( 

23 cleared_namespaces if cleared_namespaces is not None else [] 

24 ) 

25 

26 

27async def sync_cache( 

28 *, 

29 operation: str = "refresh", 

30 namespaces: list[str] | None = None, 

31 keys: list[str] | None = None, 

32 warm_templates: bool = True, 

33 strategy: SyncStrategy | None = None, 

34) -> CacheSyncResult: 

35 if strategy is None: 

36 strategy = SyncStrategy() 

37 

38 if namespaces is None: 

39 namespaces = ["templates", "bccache", "responses"] 

40 

41 debug(f"Starting cache sync: operation={operation}, namespaces={namespaces}") 

42 

43 result = CacheSyncResult() 

44 

45 try: 

46 from acb.depends import depends 

47 

48 cache = depends.get("cache") 

49 

50 if not cache: 

51 result.errors.append(Exception("Cache adapter not available")) 

52 return result 

53 

54 except Exception as e: 

55 result.errors.append(e) 

56 return result 

57 

58 try: 

59 if operation == "refresh": 

60 await _refresh_cache(cache, namespaces, strategy, result) 

61 elif operation == "invalidate": 

62 await _invalidate_cache(cache, namespaces, keys, strategy, result) 

63 elif operation == "warm": 

64 await _warm_cache(cache, namespaces, strategy, result) 

65 elif operation == "clear": 

66 await _clear_cache(cache, namespaces, strategy, result) 

67 else: 

68 result.errors.append(ValueError(f"Unknown cache operation: {operation}")) 

69 return result 

70 

71 if ( 

72 warm_templates 

73 and operation in ("refresh", "invalidate") 

74 and result.invalidated_keys 

75 ): 

76 await _warm_template_cache(cache, strategy, result) 

77 

78 except Exception as e: 

79 result.errors.append(e) 

80 debug(f"Error in cache sync operation {operation}: {e}") 

81 

82 debug( 

83 f"Cache sync completed: {len(result.invalidated_keys)} invalidated, {len(result.warmed_keys)} warmed", 

84 ) 

85 

86 return result 

87 

88 

89async def _refresh_cache( 

90 cache: t.Any, 

91 namespaces: list[str], 

92 strategy: SyncStrategy, 

93 result: CacheSyncResult, 

94) -> None: 

95 await _invalidate_cache(cache, namespaces, None, strategy, result) 

96 

97 await _warm_cache(cache, namespaces, strategy, result) 

98 

99 

100async def _invalidate_cache( 

101 cache: t.Any, 

102 namespaces: list[str], 

103 keys: list[str] | None, 

104 strategy: SyncStrategy, 

105 result: CacheSyncResult, 

106) -> None: 

107 if strategy.dry_run: 

108 debug("DRY RUN: Would invalidate cache entries") 

109 result.invalidated_keys.append("DRY_RUN_INVALIDATION") 

110 return 

111 

112 if keys: 

113 for key in keys: 

114 try: 

115 await cache.delete(key) 

116 result.invalidated_keys.append(key) 

117 debug(f"Invalidated cache key: {key}") 

118 except Exception as e: 

119 result.errors.append(e) 

120 

121 for namespace in namespaces: 

122 try: 

123 if namespace == "templates": 

124 pattern = "template:*" 

125 deleted_keys = await cache.delete_pattern(pattern) 

126 result.invalidated_keys.extend(deleted_keys or [pattern]) 

127 debug(f"Invalidated template cache: {pattern}") 

128 

129 elif namespace == "bccache": 

130 pattern = "bccache:*" 

131 deleted_keys = await cache.delete_pattern(pattern) 

132 result.invalidated_keys.extend(deleted_keys or [pattern]) 

133 debug(f"Invalidated bytecode cache: {pattern}") 

134 

135 elif namespace == "responses": 

136 pattern = "response:*" 

137 deleted_keys = await cache.delete_pattern(pattern) 

138 result.invalidated_keys.extend(deleted_keys or [pattern]) 

139 debug(f"Invalidated response cache: {pattern}") 

140 

141 elif namespace == "gather": 

142 pattern = "routes:*" 

143 deleted_keys = await cache.delete_pattern(pattern) 

144 result.invalidated_keys.extend(deleted_keys or [pattern]) 

145 

146 pattern = "templates:*" 

147 deleted_keys = await cache.delete_pattern(pattern) 

148 result.invalidated_keys.extend(deleted_keys or [pattern]) 

149 

150 pattern = "middleware:*" 

151 deleted_keys = await cache.delete_pattern(pattern) 

152 result.invalidated_keys.extend(deleted_keys or [pattern]) 

153 

154 debug("Invalidated gather cache") 

155 

156 else: 

157 pattern = f"{namespace}:*" 

158 deleted_keys = await cache.delete_pattern(pattern) 

159 result.invalidated_keys.extend(deleted_keys or [pattern]) 

160 debug(f"Invalidated namespace {namespace}: {pattern}") 

161 

162 except Exception as e: 

163 result.errors.append(e) 

164 debug(f"Error invalidating namespace {namespace}: {e}") 

165 

166 

167async def _warm_cache( 

168 cache: t.Any, 

169 namespaces: list[str], 

170 strategy: SyncStrategy, 

171 result: CacheSyncResult, 

172) -> None: 

173 if strategy.dry_run: 

174 debug("DRY RUN: Would warm cache entries") 

175 result.warmed_keys.append("DRY_RUN_WARMING") 

176 return 

177 

178 for namespace in namespaces: 

179 try: 

180 if namespace == "templates": 

181 await _warm_template_cache(cache, strategy, result) 

182 elif namespace == "responses": 

183 await _warm_response_cache(cache, strategy, result) 

184 elif namespace == "gather": 

185 await _warm_gather_cache(cache, strategy, result) 

186 

187 except Exception as e: 

188 result.errors.append(e) 

189 debug(f"Error warming namespace {namespace}: {e}") 

190 

191 

192async def _clear_cache( 

193 cache: t.Any, 

194 namespaces: list[str], 

195 strategy: SyncStrategy, 

196 result: CacheSyncResult, 

197) -> None: 

198 if strategy.dry_run: 

199 debug("DRY RUN: Would clear cache namespaces") 

200 result.cleared_namespaces.extend(namespaces) 

201 return 

202 

203 for namespace in namespaces: 

204 try: 

205 await cache.clear(namespace) 

206 result.cleared_namespaces.append(namespace) 

207 debug(f"Cleared cache namespace: {namespace}") 

208 

209 except Exception as e: 

210 result.errors.append(e) 

211 debug(f"Error clearing namespace {namespace}: {e}") 

212 

213 

214async def _warm_template_cache( 

215 cache: t.Any, 

216 strategy: SyncStrategy, 

217 result: CacheSyncResult, 

218) -> None: 

219 common_templates = [ 

220 "base.html", 

221 "layout.html", 

222 "index.html", 

223 "404.html", 

224 "500.html", 

225 "login.html", 

226 "dashboard.html", 

227 ] 

228 

229 try: 

230 from acb.depends import depends 

231 

232 storage = depends.get("storage") 

233 

234 if not storage: 

235 debug("Storage not available for template warming") 

236 return 

237 

238 for template_path in common_templates: 

239 try: 

240 if await storage.templates.exists(template_path): 

241 content = await storage.templates.read(template_path) 

242 

243 cache_key = f"template:{template_path}" 

244 await cache.set(cache_key, content, ttl=86400) 

245 result.warmed_keys.append(cache_key) 

246 

247 debug(f"Warmed template cache: {template_path}") 

248 

249 except Exception as e: 

250 debug(f"Error warming template {template_path}: {e}") 

251 

252 except Exception as e: 

253 debug(f"Error in template cache warming: {e}") 

254 

255 

256async def _warm_response_cache( 

257 cache: t.Any, 

258 strategy: SyncStrategy, 

259 result: CacheSyncResult, 

260) -> None: 

261 debug("Response cache warming not implemented - requires HTTP client") 

262 

263 

264async def _warm_gather_cache( 

265 cache: t.Any, 

266 strategy: SyncStrategy, 

267 result: CacheSyncResult, 

268) -> None: 

269 try: 

270 from fastblocks.actions.gather import ( 

271 gather_middleware, 

272 gather_routes, 

273 gather_templates, 

274 ) 

275 

276 try: 

277 routes_result = await gather_routes() 

278 if routes_result.total_routes > 0: 

279 result.warmed_keys.append("gather:routes") 

280 debug("Warmed gather routes cache") 

281 except Exception as e: 

282 debug(f"Error warming routes cache: {e}") 

283 

284 try: 

285 templates_result = await gather_templates() 

286 if templates_result.total_components > 0: 

287 result.warmed_keys.append("gather:templates") 

288 debug("Warmed gather templates cache") 

289 except Exception as e: 

290 debug(f"Error warming templates cache: {e}") 

291 

292 try: 

293 middleware_result = await gather_middleware() 

294 if middleware_result.total_middleware > 0: 

295 result.warmed_keys.append("gather:middleware") 

296 debug("Warmed gather middleware cache") 

297 except Exception as e: 

298 debug(f"Error warming middleware cache: {e}") 

299 

300 except Exception as e: 

301 debug(f"Error warming gather cache: {e}") 

302 

303 

304async def invalidate_template_cache( 

305 template_paths: list[str] | None = None, 

306 cache_namespace: str = "templates", 

307) -> dict[str, t.Any]: 

308 result: dict[str, t.Any] = { 

309 "invalidated": [], 

310 "errors": [], 

311 } 

312 

313 try: 

314 from acb.depends import depends 

315 

316 cache = depends.get("cache") 

317 

318 if not cache: 

319 result["errors"].append("Cache adapter not available") 

320 return result 

321 

322 if not template_paths: 

323 pattern = f"{cache_namespace}:*" 

324 deleted_keys = await cache.delete_pattern(pattern) 

325 result["invalidated"].extend(deleted_keys or [pattern]) 

326 else: 

327 for template_path in template_paths: 

328 try: 

329 template_key = f"{cache_namespace}:{template_path}" 

330 await cache.delete(template_key) 

331 result["invalidated"].append(template_key) 

332 

333 bytecode_key = f"bccache:{template_path}" 

334 await cache.delete(bytecode_key) 

335 result["invalidated"].append(bytecode_key) 

336 

337 await cache.delete_pattern(f"{cache_namespace}:*:{template_path}") 

338 await cache.delete_pattern(f"bccache:*:{template_path}") 

339 

340 debug(f"Invalidated cache for template: {template_path}") 

341 

342 except Exception as e: 

343 result["errors"].append(f"{template_path}: {e}") 

344 

345 except Exception as e: 

346 result["errors"].append(str(e)) 

347 debug(f"Error invalidating template cache: {e}") 

348 

349 return result 

350 

351 

352async def get_cache_stats( 

353 namespaces: list[str] | None = None, 

354) -> dict[str, t.Any]: 

355 if namespaces is None: 

356 namespaces = ["templates", "bccache", "responses", "gather"] 

357 

358 stats: dict[str, t.Any] = { 

359 "total_keys": 0, 

360 "namespaces": {}, 

361 "memory_usage": 0, 

362 "hit_rate": 0.0, 

363 "errors": [], 

364 } 

365 

366 try: 

367 cache = await _get_cache_adapter(stats) 

368 if not cache: 

369 return stats 

370 

371 await _collect_cache_info(cache, stats) 

372 await _collect_namespace_stats(cache, namespaces, stats) 

373 

374 except Exception as e: 

375 stats["errors"].append(str(e)) 

376 debug(f"Error getting cache stats: {e}") 

377 

378 return stats 

379 

380 

381async def _get_cache_adapter(stats: dict[str, t.Any]) -> t.Any: 

382 from acb.depends import depends 

383 

384 cache = depends.get("cache") 

385 if not cache: 

386 stats["errors"].append("Cache adapter not available") 

387 return cache 

388 

389 

390async def _collect_cache_info(cache: t.Any, stats: dict[str, t.Any]) -> None: 

391 try: 

392 info = await cache.info() 

393 stats["memory_usage"] = info.get("used_memory", 0) 

394 hits = info.get("keyspace_hits", 0) 

395 misses = info.get("keyspace_misses", 0) 

396 stats["hit_rate"] = hits / max(hits + misses, 1) 

397 except Exception as e: 

398 stats["errors"].append(f"Error getting cache info: {e}") 

399 

400 

401async def _collect_namespace_stats( 

402 cache: t.Any, 

403 namespaces: list[str], 

404 stats: dict[str, t.Any], 

405) -> None: 

406 for namespace in namespaces: 

407 try: 

408 pattern = f"{namespace}:*" 

409 keys = await cache.keys(pattern) 

410 key_count = len(keys) if keys else 0 

411 

412 stats["namespaces"][namespace] = { 

413 "key_count": key_count, 

414 "sample_keys": keys[:5] if keys else [], 

415 } 

416 

417 stats["total_keys"] += key_count 

418 

419 except Exception as e: 

420 stats["errors"].append(f"Error checking namespace {namespace}: {e}") 

421 

422 

423async def optimize_cache( 

424 max_memory_mb: int = 512, 

425 eviction_policy: str = "allkeys-lru", 

426) -> dict[str, t.Any]: 

427 result: dict[str, t.Any] = { 

428 "optimizations": [], 

429 "warnings": [], 

430 "errors": [], 

431 } 

432 

433 try: 

434 from acb.depends import depends 

435 

436 cache = depends.get("cache") 

437 

438 if not cache: 

439 result["errors"].append("Cache adapter not available") 

440 return result 

441 

442 try: 

443 max_memory_bytes = max_memory_mb * 1024 * 1024 

444 await cache.config_set("maxmemory", max_memory_bytes) 

445 result["optimizations"].append(f"Set max memory to {max_memory_mb}MB") 

446 except Exception as e: 

447 result["errors"].append(f"Error setting max memory: {e}") 

448 

449 try: 

450 await cache.config_set("maxmemory-policy", eviction_policy) 

451 result["optimizations"].append(f"Set eviction policy to {eviction_policy}") 

452 except Exception as e: 

453 result["errors"].append(f"Error setting eviction policy: {e}") 

454 

455 stats = await get_cache_stats() 

456 

457 if stats["hit_rate"] < 0.8: 

458 result["warnings"].append(f"Low cache hit rate: {stats['hit_rate']:.2%}") 

459 

460 if stats["total_keys"] > 10000: 

461 result["warnings"].append(f"High key count: {stats['total_keys']}") 

462 

463 for namespace, info in stats["namespaces"].items(): 

464 if info["key_count"] > 5000: 

465 result["warnings"].append( 

466 f"Namespace {namespace} has {info['key_count']} keys", 

467 ) 

468 

469 except Exception as e: 

470 result["errors"].append(str(e)) 

471 debug(f"Error optimizing cache: {e}") 

472 

473 return result 

474 

475 

476def get_cache_sync_summary(result: CacheSyncResult) -> dict[str, t.Any]: 

477 return { 

478 "invalidated_count": len(result.invalidated_keys), 

479 "warmed_count": len(result.warmed_keys), 

480 "cleared_namespaces": len(result.cleared_namespaces), 

481 "errors": len(result.errors), 

482 "success": len(result.errors) == 0, 

483 "operation_summary": { 

484 "invalidated": result.invalidated_keys[:10], 

485 "warmed": result.warmed_keys[:10], 

486 "cleared": result.cleared_namespaces, 

487 }, 

488 }