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

275 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -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 gather 

271 

272 try: 

273 routes_result = await gather.routes() 

274 if routes_result.total_routes > 0: 

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

276 debug("Warmed gather routes cache") 

277 except Exception as e: 

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

279 

280 try: 

281 templates_result = await gather.templates() 

282 if templates_result.total_components > 0: 

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

284 debug("Warmed gather templates cache") 

285 except Exception as e: 

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

287 

288 try: 

289 middleware_result = await gather.middleware() 

290 if middleware_result.total_middleware > 0: 

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

292 debug("Warmed gather middleware cache") 

293 except Exception as e: 

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

295 

296 except Exception as e: 

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

298 

299 

300async def invalidate_template_cache( 

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

302 cache_namespace: str = "templates", 

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

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

305 "invalidated": [], 

306 "errors": [], 

307 } 

308 

309 try: 

310 from acb.depends import depends 

311 

312 cache = depends.get("cache") 

313 

314 if not cache: 

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

316 return result 

317 

318 if not template_paths: 

319 pattern = f"{cache_namespace}:*" 

320 deleted_keys = await cache.delete_pattern(pattern) 

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

322 else: 

323 for template_path in template_paths: 

324 try: 

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

326 await cache.delete(template_key) 

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

328 

329 bytecode_key = f"bccache:{template_path}" 

330 await cache.delete(bytecode_key) 

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

332 

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

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

335 

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

337 

338 except Exception as e: 

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

340 

341 except Exception as e: 

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

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

344 

345 return result 

346 

347 

348async def get_cache_stats( 

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

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

351 if namespaces is None: 

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

353 

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

355 "total_keys": 0, 

356 "namespaces": {}, 

357 "memory_usage": 0, 

358 "hit_rate": 0.0, 

359 "errors": [], 

360 } 

361 

362 try: 

363 cache = await _get_cache_adapter(stats) 

364 if not cache: 

365 return stats 

366 

367 await _collect_cache_info(cache, stats) 

368 await _collect_namespace_stats(cache, namespaces, stats) 

369 

370 except Exception as e: 

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

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

373 

374 return stats 

375 

376 

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

378 from acb.depends import depends 

379 

380 cache = depends.get("cache") 

381 if not cache: 

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

383 return cache 

384 

385 

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

387 try: 

388 info = await cache.info() 

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

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

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

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

393 except Exception as e: 

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

395 

396 

397async def _collect_namespace_stats( 

398 cache: t.Any, 

399 namespaces: list[str], 

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

401) -> None: 

402 for namespace in namespaces: 

403 try: 

404 pattern = f"{namespace}:*" 

405 keys = await cache.keys(pattern) 

406 key_count = len(keys) if keys else 0 

407 

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

409 "key_count": key_count, 

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

411 } 

412 

413 stats["total_keys"] += key_count 

414 

415 except Exception as e: 

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

417 

418 

419async def optimize_cache( 

420 max_memory_mb: int = 512, 

421 eviction_policy: str = "allkeys-lru", 

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

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

424 "optimizations": [], 

425 "warnings": [], 

426 "errors": [], 

427 } 

428 

429 try: 

430 cache = await _get_cache_adapter(result) 

431 if not cache: 

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

433 return result 

434 

435 # Configure memory settings 

436 await _configure_memory_settings(cache, max_memory_mb, eviction_policy, result) 

437 

438 # Analyze cache stats and generate warnings 

439 await _analyze_cache_stats(result) 

440 

441 except Exception as e: 

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

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

444 

445 return result 

446 

447 

448async def _configure_memory_settings( 

449 cache: t.Any, max_memory_mb: int, eviction_policy: str, result: dict[str, t.Any] 

450) -> None: 

451 """Configure cache memory settings.""" 

452 try: 

453 max_memory_bytes = max_memory_mb * 1024 * 1024 

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

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

456 except Exception as e: 

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

458 

459 try: 

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

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

462 except Exception as e: 

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

464 

465 

466async def _analyze_cache_stats(result: dict[str, t.Any]) -> None: 

467 """Analyze cache statistics and generate warnings.""" 

468 stats = await get_cache_stats() 

469 

470 if stats["hit_rate"] < 0.8: 

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

472 

473 if stats["total_keys"] > 10000: 

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

475 

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

477 if info["key_count"] > 5000: 

478 result["warnings"].append( 

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

480 ) 

481 

482 

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

484 return { 

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

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

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

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

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

490 "operation_summary": { 

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

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

493 "cleared": result.cleared_namespaces, 

494 }, 

495 }