@@ -100,15 +100,21 @@ class CacheEntry:
100100
101101
102102class 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
15291593def 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