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

56 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-22 12:53 +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 

15import asyncio 

16import logging 

17import time 

18from dataclasses import dataclass 

19from typing import Any, Dict, Optional 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24@dataclass 

25class CacheEntry: 

26 """Cache entry with expiration.""" 

27 

28 value: Any 

29 expires_at: float 

30 last_access: float 

31 

32 

33class ResourceCache: 

34 """Resource content cache with TTL expiration. 

35 

36 Attributes: 

37 max_size: Maximum number of entries 

38 ttl: Time-to-live in seconds 

39 _cache: Cache storage 

40 _lock: Async lock for thread safety 

41 """ 

42 

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

44 """Initialize cache. 

45 

46 Args: 

47 max_size: Maximum number of entries 

48 ttl: Time-to-live in seconds 

49 """ 

50 self.max_size = max_size 

51 self.ttl = ttl 

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

53 self._lock = asyncio.Lock() 

54 

55 async def initialize(self) -> None: 

56 """Initialize cache service.""" 

57 logger.info("Initializing resource cache") 

58 # Start cleanup task 

59 asyncio.create_task(self._cleanup_loop()) 

60 

61 async def shutdown(self) -> None: 

62 """Shutdown cache service.""" 

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

64 self.clear() 

65 

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

67 """Get value from cache. 

68 

69 Args: 

70 key: Cache key 

71 

72 Returns: 

73 Cached value or None if not found/expired 

74 """ 

75 if key not in self._cache: 75 ↛ 78line 75 didn't jump to line 78 because the condition on line 75 was always true

76 return None 

77 

78 entry = self._cache[key] 

79 now = time.time() 

80 

81 # Check expiration 

82 if now > entry.expires_at: 

83 del self._cache[key] 

84 return None 

85 

86 # Update access time 

87 entry.last_access = now 

88 return entry.value 

89 

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

91 """Set value in cache. 

92 

93 Args: 

94 key: Cache key 

95 value: Value to cache 

96 """ 

97 now = time.time() 

98 

99 # Check size limit 

100 if len(self._cache) >= self.max_size: 100 ↛ 102line 100 didn't jump to line 102 because the condition on line 100 was never true

101 # Remove least recently used 

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

103 del self._cache[lru_key] 

104 

105 # Add new entry 

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

107 

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

109 """Delete value from cache. 

110 

111 Args: 

112 key: Cache key to delete 

113 """ 

114 self._cache.pop(key, None) 

115 

116 def clear(self) -> None: 

117 """Clear all cached entries.""" 

118 self._cache.clear() 

119 

120 async def _cleanup_loop(self) -> None: 

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

122 while True: 

123 try: 

124 async with self._lock: 

125 now = time.time() 

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

127 for key in expired: 

128 del self._cache[key] 

129 

130 if expired: 

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

132 

133 except Exception as e: 

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

135 

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