Coverage for core / session_cache.py: 98.5%

134 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2TTL-based session cache for global dictionaries. 

3 

4Replaces unbounded global dicts (user_agents, agent_data, etc.) with 

5auto-expiring caches that prevent memory leaks on long-running servers. 

6 

7Before: 11+ global dicts grow unbounded, accumulating GB of garbage. 

8After: Entries auto-expire after configurable TTL (default 2 hours). 

9""" 

10 

11import time 

12import threading 

13import logging 

14from collections import OrderedDict 

15 

16logger = logging.getLogger('hevolve_core') 

17 

18 

19class TTLCache: 

20 """ 

21 Thread-safe dictionary with automatic time-to-live expiration. 

22 

23 Features: 

24 - O(1) get/set/delete 

25 - Automatic cleanup of expired entries 

26 - Max size cap to prevent unbounded growth 

27 - Drop-in replacement for dict (supports [] operator, .get(), etc.) 

28 - **Touch-on-read**: every successful read via `[]` / `.get()` / 

29 `.setdefault()` extends the entry's TTL. This keeps actively-used 

30 session state alive for the full duration of an active recipe 

31 pipeline, while abandoned entries (no reads → no touch) still 

32 auto-expire to prevent the original memory-leak class. 

33 `__contains__` (`in` operator) is intentionally side-effect free 

34 to keep Python idioms predictable. 

35 

36 Touch-on-read fixes the 2026-05-08 "TTS without text" incident: 

37 `scheduler_check[user_prompt]` raised KeyError mid-recipe because 

38 the 2h TTL elapsed during a long pipeline run, even though the 

39 recipe was being read continuously. After this change, a recipe 

40 that's actively reading state stays warm; one that's been 

41 abandoned for 2h still gets cleaned up. 

42 """ 

43 

44 def __init__(self, ttl_seconds: int = 7200, max_size: int = 1000, name: str = 'cache', loader=None): 

45 self._data = OrderedDict() 

46 self._timestamps = {} 

47 self._ttl = ttl_seconds 

48 self._max_size = max_size 

49 self._name = name 

50 self._lock = threading.Lock() 

51 self._cleanup_counter = 0 

52 self._loader = loader # callable(key) → value or None 

53 

54 def __setitem__(self, key, value): 

55 with self._lock: 

56 now = time.monotonic() 

57 if key in self._data: 

58 del self._data[key] 

59 self._data[key] = value 

60 self._timestamps[key] = now 

61 

62 # Evict oldest if over max size 

63 while len(self._data) > self._max_size: 

64 oldest_key, _ = self._data.popitem(last=False) 

65 self._timestamps.pop(oldest_key, None) 

66 logger.debug(f"[{self._name}] Evicted oldest entry: {oldest_key}") 

67 

68 # Periodic cleanup every 100 writes 

69 self._cleanup_counter += 1 

70 if self._cleanup_counter >= 100: 

71 self._cleanup_counter = 0 

72 self._cleanup_expired(now) 

73 

74 def __getitem__(self, key): 

75 with self._lock: 

76 if key in self._data and not self._is_expired(key): 

77 # Touch-on-read: extend TTL for actively-used keys. Active 

78 # recipe pipelines continuously read state — without this, 

79 # the 2h TTL would hard-evict mid-flow and produce KeyError 

80 # (root cause of the 2026-05-08 TTS-without-text incident). 

81 # Abandoned entries (no reads) still age out via the 

82 # original timestamp + _cleanup_expired path, preserving 

83 # the memory-leak-prevention guarantee. 

84 self._timestamps[key] = time.monotonic() 

85 return self._data[key] 

86 # Clean up expired entry if present 

87 if key in self._data: 

88 self._remove(key) 

89 # Try loader before raising KeyError 

90 if self._loader: 

91 try: 

92 value = self._loader(key) 

93 except Exception as e: 

94 logger.debug(f"[{self._name}] Loader error for {key}: {e}") 

95 raise KeyError(key) 

96 if value is not None: 

97 self._data[key] = value 

98 self._timestamps[key] = time.monotonic() 

99 logger.debug(f"[{self._name}] Loaded {key} from persistent storage") 

100 return value 

101 raise KeyError(key) 

102 

103 def __contains__(self, key): 

104 with self._lock: 

105 if key in self._data and not self._is_expired(key): 

106 return True 

107 # Clean up expired entry if present 

108 if key in self._data: 

109 self._remove(key) 

110 # Try loader 

111 if self._loader: 

112 try: 

113 value = self._loader(key) 

114 except Exception: 

115 return False 

116 if value is not None: 

117 self._data[key] = value 

118 self._timestamps[key] = time.monotonic() 

119 logger.debug(f"[{self._name}] Loaded {key} from persistent storage") 

120 return True 

121 return False 

122 

123 def __delitem__(self, key): 

124 with self._lock: 

125 self._remove(key) 

126 

127 def __len__(self): 

128 with self._lock: 

129 self._cleanup_expired(time.monotonic()) 

130 return len(self._data) 

131 

132 def get(self, key, default=None): 

133 try: 

134 return self[key] 

135 except KeyError: 

136 return default 

137 

138 def setdefault(self, key, default=None): 

139 with self._lock: 

140 if key in self._data and not self._is_expired(key): 

141 # Touch-on-read: same rationale as __getitem__. 

142 # setdefault is a read-or-create operation; the read 

143 # path should also extend TTL. 

144 self._timestamps[key] = time.monotonic() 

145 return self._data[key] 

146 # Clean up expired entry if present 

147 if key in self._data: 

148 self._remove(key) 

149 # Try loader before using default 

150 if self._loader: 

151 try: 

152 value = self._loader(key) 

153 except Exception: 

154 value = None 

155 if value is not None: 

156 self._data[key] = value 

157 self._timestamps[key] = time.monotonic() 

158 return value 

159 self._data[key] = default 

160 self._timestamps[key] = time.monotonic() 

161 return default 

162 

163 def pop(self, key, *args): 

164 with self._lock: 

165 if key in self._data: 

166 value = self._data.pop(key) 

167 self._timestamps.pop(key, None) 

168 return value 

169 if args: 

170 return args[0] 

171 raise KeyError(key) 

172 

173 def keys(self): 

174 with self._lock: 

175 self._cleanup_expired(time.monotonic()) 

176 return list(self._data.keys()) 

177 

178 def values(self): 

179 with self._lock: 

180 self._cleanup_expired(time.monotonic()) 

181 return list(self._data.values()) 

182 

183 def items(self): 

184 with self._lock: 

185 self._cleanup_expired(time.monotonic()) 

186 return list(self._data.items()) 

187 

188 def clear(self): 

189 with self._lock: 

190 self._data.clear() 

191 self._timestamps.clear() 

192 

193 def _is_expired(self, key) -> bool: 

194 ts = self._timestamps.get(key) 

195 if ts is None: 

196 return True 

197 return (time.monotonic() - ts) > self._ttl 

198 

199 def _remove(self, key): 

200 self._data.pop(key, None) 

201 self._timestamps.pop(key, None) 

202 

203 def _cleanup_expired(self, now): 

204 expired = [k for k, ts in self._timestamps.items() if (now - ts) > self._ttl] 

205 for k in expired: 

206 self._remove(k) 

207 if expired: 

208 logger.debug(f"[{self._name}] Cleaned up {len(expired)} expired entries") 

209 

210 def stats(self) -> dict: 

211 with self._lock: 

212 now = time.monotonic() 

213 active = sum(1 for ts in self._timestamps.values() if (now - ts) <= self._ttl) 

214 return { 

215 'name': self._name, 

216 'total': len(self._data), 

217 'active': active, 

218 'expired': len(self._data) - active, 

219 'max_size': self._max_size, 

220 'ttl_seconds': self._ttl, 

221 }