Skip to content

Commit 083c287

Browse files
committed
Enh(viewer): Do not compute duplicate cache misses
1 parent 05254c7 commit 083c287

1 file changed

Lines changed: 95 additions & 29 deletions

File tree

codeclash/viewer/app.py

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,21 @@ class CacheEntry:
100100

101101

102102
class SimpleCache:
103-
"""Simple in-memory cache with timeout"""
103+
"""Simple in-memory cache with timeout and request coalescing
104+
105+
Prevents cache stampede by using per-key locks to ensure only one
106+
request computes a value while others wait.
107+
"""
104108

105109
def __init__(self):
106110
self._cache: dict[str, CacheEntry] = {}
107-
self._lock = threading.Lock()
111+
self._cache_lock = threading.Lock()
112+
self._compute_locks: dict[str, threading.Lock] = {}
113+
self._compute_locks_lock = threading.Lock()
108114

109115
def get(self, key: str, timeout_seconds: int | None = None) -> Any | None:
110116
"""Get cached value if it exists and hasn't expired"""
111-
with self._lock:
117+
with self._cache_lock:
112118
if key not in self._cache:
113119
return None
114120

@@ -125,20 +131,83 @@ def get(self, key: str, timeout_seconds: int | None = None) -> Any | None:
125131

126132
def set(self, key: str, value: Any):
127133
"""Set cache value"""
128-
with self._lock:
134+
with self._cache_lock:
129135
self._cache[key] = CacheEntry(data=value, timestamp=datetime.now())
130136

131137
def invalidate(self, key: str):
132138
"""Invalidate a specific cache entry"""
133-
with self._lock:
139+
with self._cache_lock:
134140
if key in self._cache:
135141
del self._cache[key]
136142

137143
def clear(self):
138144
"""Clear all cache entries"""
139-
with self._lock:
145+
with self._cache_lock:
140146
self._cache.clear()
141147

148+
def _get_compute_lock(self, key: str) -> threading.Lock:
149+
"""Get or create a lock for a specific cache key"""
150+
with self._compute_locks_lock:
151+
if key not in self._compute_locks:
152+
self._compute_locks[key] = threading.Lock()
153+
return self._compute_locks[key]
154+
155+
def _cleanup_compute_lock(self, key: str):
156+
"""Clean up compute lock after use to avoid memory leak"""
157+
with self._compute_locks_lock:
158+
if key in self._compute_locks:
159+
# Only delete if no one is using it (not locked)
160+
lock = self._compute_locks[key]
161+
if not lock.locked():
162+
del self._compute_locks[key]
163+
164+
def get_or_compute(self, key: str, compute_fn, timeout_seconds: int | None = None) -> Any:
165+
"""Get cached value or compute it, preventing concurrent computation
166+
167+
Args:
168+
key: Cache key
169+
compute_fn: Function to call if cache miss (should take no arguments)
170+
timeout_seconds: Cache timeout in seconds
171+
172+
Returns:
173+
Cached or computed value
174+
"""
175+
# First check: see if we have cached value
176+
cached_value = self.get(key, timeout_seconds)
177+
if cached_value is not None:
178+
logger.debug(f"Cache hit for key '{key}'")
179+
return cached_value
180+
181+
# Get per-key lock to prevent concurrent computation
182+
compute_lock = self._get_compute_lock(key)
183+
184+
# Check if we're waiting for another thread
185+
if compute_lock.locked():
186+
logger.info(f"Cache key '{key}' is being computed by another request, waiting...")
187+
188+
try:
189+
with compute_lock:
190+
# Second check: another thread might have computed it while we waited for lock
191+
cached_value = self.get(key, timeout_seconds)
192+
if cached_value is not None:
193+
logger.info(
194+
f"✓ Duplicate computation prevented for key '{key}' - using result from another request"
195+
)
196+
return cached_value
197+
198+
# Compute the value
199+
logger.info(f"Cache miss for key '{key}', computing value...")
200+
value = compute_fn()
201+
202+
# Store in cache
203+
self.set(key, value)
204+
logger.info(f"✓ Computed and cached value for key '{key}'")
205+
206+
return value
207+
finally:
208+
# Cleanup compute lock to avoid memory leak
209+
self._cleanup_compute_lock(key)
210+
142211

143212
# Global cache instance
144213
_cache = SimpleCache()
@@ -414,23 +483,18 @@ def find_all_game_folders(base_dir: Path) -> list[dict[str, Any]]:
414483
"""Find all game folders by locating metadata.json files
415484
416485
Uses parallel loading to speed up metadata.json reading for many game folders.
417-
Results are cached for 5 minutes.
486+
Results are cached for 5 minutes. Concurrent requests are coalesced.
418487
"""
419488
cache_key = f"game_folders:{base_dir}"
420-
cached_result = _cache.get(cache_key, timeout_seconds=300) # 5 min cache
421489

422-
if cached_result is not None:
423-
logger.info(f"Using cached game folders for {base_dir}")
424-
return cached_result
490+
def compute():
491+
try:
492+
return _find_all_game_folders_impl(base_dir)
493+
except TimeoutError:
494+
logger.error(f"Timeout while finding game folders in {base_dir}")
495+
return []
425496

426-
try:
427-
result = _find_all_game_folders_impl(base_dir)
428-
_cache.set(cache_key, result)
429-
return result
430-
except TimeoutError:
431-
logger.error(f"Timeout while finding game folders in {base_dir}")
432-
# Return empty list on timeout
433-
return []
497+
return _cache.get_or_compute(cache_key, compute, timeout_seconds=300)
434498

435499

436500
@dataclass
@@ -1527,7 +1591,10 @@ def _fetch_batch_jobs_impl(hours_back: int):
15271591
@app.route("/batch/api/jobs")
15281592
@print_timing
15291593
def batch_api_jobs():
1530-
"""API endpoint to get AWS Batch jobs with caching (2 min timeout)"""
1594+
"""API endpoint to get AWS Batch jobs with caching (2 min timeout)
1595+
1596+
Concurrent requests are coalesced to prevent multiple simultaneous AWS API calls.
1597+
"""
15311598
try:
15321599
hours_back = request.args.get("hours_back", default=24, type=int)
15331600
force_refresh = request.args.get("force_refresh", default="false", type=str).lower() == "true"
@@ -1539,16 +1606,15 @@ def batch_api_jobs():
15391606
_cache.invalidate(cache_key)
15401607
logger.info("Force refresh requested, invalidating batch jobs cache")
15411608

1542-
# Try to get from cache (2 min timeout)
1543-
cached_result = _cache.get(cache_key, timeout_seconds=120)
1544-
1545-
if cached_result is not None and not force_refresh:
1546-
logger.info(f"Using cached batch jobs for hours_back={hours_back}")
1547-
return jsonify({"success": True, **cached_result})
1609+
# Use get_or_compute to prevent concurrent AWS API calls
1610+
def compute():
1611+
try:
1612+
return _fetch_batch_jobs_impl(hours_back)
1613+
except TimeoutError:
1614+
logger.error("Timeout while fetching AWS Batch jobs")
1615+
raise # Re-raise to be caught by outer handler
15481616

1549-
# Fetch fresh data
1550-
result = _fetch_batch_jobs_impl(hours_back)
1551-
_cache.set(cache_key, result)
1617+
result = _cache.get_or_compute(cache_key, compute, timeout_seconds=120)
15521618
return jsonify({"success": True, **result})
15531619
except TimeoutError:
15541620
logger.error("Timeout while fetching AWS Batch jobs")

0 commit comments

Comments
 (0)