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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2TTL-based session cache for global dictionaries.
4Replaces unbounded global dicts (user_agents, agent_data, etc.) with
5auto-expiring caches that prevent memory leaks on long-running servers.
7Before: 11+ global dicts grow unbounded, accumulating GB of garbage.
8After: Entries auto-expire after configurable TTL (default 2 hours).
9"""
11import time
12import threading
13import logging
14from collections import OrderedDict
16logger = logging.getLogger('hevolve_core')
19class TTLCache:
20 """
21 Thread-safe dictionary with automatic time-to-live expiration.
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.
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 """
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
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
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}")
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)
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)
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
123 def __delitem__(self, key):
124 with self._lock:
125 self._remove(key)
127 def __len__(self):
128 with self._lock:
129 self._cleanup_expired(time.monotonic())
130 return len(self._data)
132 def get(self, key, default=None):
133 try:
134 return self[key]
135 except KeyError:
136 return default
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
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)
173 def keys(self):
174 with self._lock:
175 self._cleanup_expired(time.monotonic())
176 return list(self._data.keys())
178 def values(self):
179 with self._lock:
180 self._cleanup_expired(time.monotonic())
181 return list(self._data.values())
183 def items(self):
184 with self._lock:
185 self._cleanup_expired(time.monotonic())
186 return list(self._data.items())
188 def clear(self):
189 with self._lock:
190 self._data.clear()
191 self._timestamps.clear()
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
199 def _remove(self, key):
200 self._data.pop(key, None)
201 self._timestamps.pop(key, None)
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")
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 }