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

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

2"""Resource Cache Implementation.

3

4 Copyright 2025

5 SPDX-License-Identifier: Apache-2.0

6 Authors: Mihai Criveti

7

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

9 resource 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

16 import asyncio

17 from dataclasses import dataclass

18 import logging

19 import time

20 from typing import Any, Dict, Optional

21

22 logger = logging.getLogger(__name__)

23

24

25@dataclass

26 class CacheEntry:

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

28

29 value: Any

30 expires_at: float

31 last_access: float

32

33

34 class 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