Coverage for mcpgateway/cache/resource_cache.py: 91%

56 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-09 11:03 +0100

1# -*- coding: utf-8 -*- 

2"""Resource Cache Implementation. 

3 

4Copyright 2025 

5SPDX-License-Identifier: Apache-2.0 

6Authors: Mihai Criveti 

7 

8This module implements a simple in-memory cache with TTL expiration for caching 

9resource content in the MCP Gateway. Features: 

10- TTL-based expiration 

11- Maximum size limit with LRU eviction 

12- Thread-safe operations 

13""" 

14 

15# Standard 

16import asyncio 

17from dataclasses import dataclass 

18import logging 

19import time 

20from typing import Any, Dict, Optional 

21 

22logger = logging.getLogger(__name__) 

23 

24 

25@dataclass 

26class CacheEntry: 

27 """Cache entry with expiration.""" 

28 

29 value: Any 

30 expires_at: float 

31 last_access: float 

32 

33 

34class ResourceCache: 

35 """Resource content cache with TTL expiration. 

36 

37 Attributes: 

38 max_size: Maximum number of entries 

39 ttl: Time-to-live in seconds 

40 _cache: Cache storage 

41 _lock: Async lock for thread safety 

42 """ 

43 

44 def __init__(self, max_size: int = 1000, ttl: int = 3600): 

45 """Initialize cache. 

46 

47 Args: 

48 max_size: Maximum number of entries 

49 ttl: Time-to-live in seconds 

50 """ 

51 self.max_size = max_size 

52 self.ttl = ttl 

53 self._cache: Dict[str, CacheEntry] = {} 

54 self._lock = asyncio.Lock() 

55 

56 async def initialize(self) -> None: 

57 """Initialize cache service.""" 

58 logger.info("Initializing resource cache") 

59 # Start cleanup task 

60 asyncio.create_task(self._cleanup_loop()) 

61 

62 async def shutdown(self) -> None: 

63 """Shutdown cache service.""" 

64 logger.info("Shutting down resource cache") 

65 self.clear() 

66 

67 def get(self, key: str) -> Optional[Any]: 

68 """Get value from cache. 

69 

70 Args: 

71 key: Cache key 

72 

73 Returns: 

74 Cached value or None if not found/expired 

75 """ 

76 if key not in self._cache: 

77 return None 

78 

79 entry = self._cache[key] 

80 now = time.time() 

81 

82 # Check expiration 

83 if now > entry.expires_at: 

84 del self._cache[key] 

85 return None 

86 

87 # Update access time 

88 entry.last_access = now 

89 return entry.value 

90 

91 def set(self, key: str, value: Any) -> None: 

92 """Set value in cache. 

93 

94 Args: 

95 key: Cache key 

96 value: Value to cache 

97 """ 

98 now = time.time() 

99 

100 # Check size limit 

101 if len(self._cache) >= self.max_size: 

102 # Remove least recently used 

103 lru_key = min(self._cache.keys(), key=lambda k: self._cache[k].last_access) 

104 del self._cache[lru_key] 

105 

106 # Add new entry 

107 self._cache[key] = CacheEntry(value=value, expires_at=now + self.ttl, last_access=now) 

108 

109 def delete(self, key: str) -> None: 

110 """Delete value from cache. 

111 

112 Args: 

113 key: Cache key to delete 

114 """ 

115 self._cache.pop(key, None) 

116 

117 def clear(self) -> None: 

118 """Clear all cached entries.""" 

119 self._cache.clear() 

120 

121 async def _cleanup_loop(self) -> None: 

122 """Background task to clean expired entries.""" 

123 while True: 

124 try: 

125 async with self._lock: 

126 now = time.time() 

127 expired = [key for key, entry in self._cache.items() if now > entry.expires_at] 

128 for key in expired: 128 ↛ 129line 128 didn't jump to line 129 because the loop on line 128 never started

129 del self._cache[key] 

130 

131 if expired: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 logger.debug(f"Cleaned {len(expired)} expired cache entries") 

133 

134 except Exception as e: 

135 logger.error(f"Cache cleanup error: {e}") 

136 

137 await asyncio.sleep(60) # Run every minute