-
Notifications
You must be signed in to change notification settings - Fork 0
feat: resolve agent result cache (#200) #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
c52dc72
8b10ef7
b4ddc28
e488017
3aac4ef
c09fd5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,145 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Generic async LRU cache with TTL and in-flight deduplication.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from dataclasses import dataclass, field | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Generic, TypeVar | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| K = TypeVar("K") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| V = TypeVar("V") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Registry of all cache instances for bulk clear | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _all_caches: list[LRUCache] = [] # type: ignore[type-arg] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class _CacheEntry(Generic[V]): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """A cached value with creation timestamp.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created: float | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value: V | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class LRUCache(Generic[K, V]): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Async LRU cache with TTL and in-flight deduplication. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Entries expire after ``ttl_seconds``. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - When ``max_size`` is reached the oldest entry is evicted. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Concurrent calls for the same key share a single computation. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| All instances are registered for bulk clearing via ``clear_all()``. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: str | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hits: int = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| max_size: int = 10_000 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| misses: int = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ttl_seconds: float = 86400.0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _cache: dict[K, _CacheEntry[V]] = field(default_factory=dict) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _in_flight: dict[K, asyncio.Event] = field(default_factory=dict) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _lock: asyncio.Lock = field(default_factory=asyncio.Lock) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def __post_init__(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Register this cache instance for bulk clearing.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _all_caches.append(self) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def get_or_compute( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key: K, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| compute: asyncio.coroutines, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> V: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Return a cached value or compute it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key: The cache key (must be hashable). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| compute: An async callable that produces the value on cache miss. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Cached or freshly-computed value. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async with self._lock: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| entry = self._cache.get(key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if entry and (time.monotonic() - entry.created) < self.ttl_seconds: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.hits += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._cache[key] = self._cache.pop(key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("%s hit key=%s", self.name, key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return entry.value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
70
to
73
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event = self._in_flight.get(key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if event is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass # fall through to await below | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event = asyncio.Event() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._in_flight[key] = event | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event = None # signal that we are the owner | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if event is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await event.wait() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async with self._lock: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| entry = self._cache.get(key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if entry: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.hits += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._cache[key] = self._cache.pop(key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return entry.value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.misses += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("%s miss key=%s", self.name, key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
92
to
95
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value = await compute() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async with self._lock: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ev = self._in_flight.pop(key, None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ev is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ev.set() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async with self._lock: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(self._cache) >= self.max_size: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| oldest = next(iter(self._cache)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| del self._cache[oldest] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._cache[key] = _CacheEntry( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created=time.monotonic(), value=value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | |
| value = await compute() | |
| finally: | |
| async with self._lock: | |
| ev = self._in_flight.pop(key, None) | |
| if ev is not None: | |
| ev.set() | |
| async with self._lock: | |
| if len(self._cache) >= self.max_size: | |
| oldest = next(iter(self._cache)) | |
| del self._cache[oldest] | |
| self._cache[key] = _CacheEntry( | |
| created=time.monotonic(), value=value | |
| ) | |
| success = False | |
| try: | |
| value = await compute() | |
| success = True | |
| finally: | |
| async with self._lock: | |
| if success: | |
| if len(self._cache) >= self.max_size: | |
| oldest = next(iter(self._cache)) | |
| del self._cache[oldest] | |
| self._cache[key] = _CacheEntry( | |
| created=time.monotonic(), value=value | |
| ) | |
| ev = self._in_flight.pop(key, None) | |
| if ev is not None: | |
| ev.set() |
Copilot
AI
Feb 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The stats property reads self.hits, self.misses, and len(self._cache) without acquiring self._lock. This could lead to race conditions where the stats are inconsistent or stale.
For example, if one coroutine is updating the cache while another calls stats, the hit rate calculation could be based on mismatched values of hits and misses, or the size could be inconsistent with the hit/miss counts.
Consider either:
- Acquiring the lock in the stats property:
async with self._lock: return {...} - Documenting that stats are eventually consistent and may be slightly stale
- Using atomic operations or a separate lock for stats if performance is a concern
Note that making this an async property would require changing all call sites to await it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
computeparameter type annotation is incorrect:asyncio.coroutinesis not a useful callable/awaitable type here and won’t type-check. Consider typing this as an async callable (e.g.,Callable[[], Awaitable[V]]) so callers and static type checkers have the right contract.