99import json
1010import logging
1111import shutil
12+ import threading
1213import time
1314from concurrent .futures import ThreadPoolExecutor , as_completed
1415from dataclasses import dataclass
16+ from datetime import datetime
1517from pathlib import Path
1618from typing import Any
1719
18- from flask import Flask , jsonify , redirect , render_template , request , send_file , url_for
20+ from flask import Flask , copy_current_request_context , jsonify , redirect , render_template , request , send_file , url_for
1921
2022from codeclash .analysis .significance import calculate_p_value
2123from codeclash .tournaments .utils .git_utils import filter_git_diff , split_git_diff_by_files
@@ -46,6 +48,102 @@ def wrapper(*args, **kwargs):
4648 return wrapper
4749
4850
51+ class TimeoutError (Exception ):
52+ """Exception raised when a function times out"""
53+
54+
55+ def timeout (seconds : int ):
56+ """Decorator to add timeout to functions
57+
58+ Args:
59+ seconds: Maximum number of seconds the function can run
60+ """
61+
62+ def decorator (func ):
63+ @functools .wraps (func )
64+ def wrapper (* args , ** kwargs ):
65+ result = [TimeoutError (f"Function { func .__name__ } timed out after { seconds } s" )]
66+
67+ # Copy Flask request context to the new thread
68+ @copy_current_request_context
69+ def target ():
70+ try :
71+ result [0 ] = func (* args , ** kwargs )
72+ except Exception as e :
73+ result [0 ] = e
74+
75+ thread = threading .Thread (target = target )
76+ thread .daemon = True
77+ thread .start ()
78+ thread .join (timeout = seconds )
79+
80+ if thread .is_alive ():
81+ logger .error (f"Function { func .__name__ } timed out after { seconds } s" )
82+ raise TimeoutError (f"Function { func .__name__ } timed out after { seconds } s" )
83+
84+ if isinstance (result [0 ], Exception ):
85+ raise result [0 ]
86+
87+ return result [0 ]
88+
89+ return wrapper
90+
91+ return decorator
92+
93+
94+ @dataclass
95+ class CacheEntry :
96+ """Cache entry with data and timestamp"""
97+
98+ data : Any
99+ timestamp : datetime
100+
101+
102+ class SimpleCache :
103+ """Simple in-memory cache with timeout"""
104+
105+ def __init__ (self ):
106+ self ._cache : dict [str , CacheEntry ] = {}
107+ self ._lock = threading .Lock ()
108+
109+ def get (self , key : str , timeout_seconds : int | None = None ) -> Any | None :
110+ """Get cached value if it exists and hasn't expired"""
111+ with self ._lock :
112+ if key not in self ._cache :
113+ return None
114+
115+ entry = self ._cache [key ]
116+
117+ # Check if cache is still valid
118+ if timeout_seconds is not None :
119+ age = (datetime .now () - entry .timestamp ).total_seconds ()
120+ if age > timeout_seconds :
121+ del self ._cache [key ]
122+ return None
123+
124+ return entry .data
125+
126+ def set (self , key : str , value : Any ):
127+ """Set cache value"""
128+ with self ._lock :
129+ self ._cache [key ] = CacheEntry (data = value , timestamp = datetime .now ())
130+
131+ def invalidate (self , key : str ):
132+ """Invalidate a specific cache entry"""
133+ with self ._lock :
134+ if key in self ._cache :
135+ del self ._cache [key ]
136+
137+ def clear (self ):
138+ """Clear all cache entries"""
139+ with self ._lock :
140+ self ._cache .clear ()
141+
142+
143+ # Global cache instance
144+ _cache = SimpleCache ()
145+
146+
49147class Metadata :
50148 """A wrapper around metadata dictionary with convenient access methods"""
51149
@@ -231,12 +329,9 @@ def _load_game_metadata(folder_info: dict[str, Any]) -> dict[str, Any]:
231329 return folder_info
232330
233331
234- @print_timing
235- def find_all_game_folders (base_dir : Path ) -> list [dict [str , Any ]]:
236- """Find all game folders by locating metadata.json files
237-
238- Uses parallel loading to speed up metadata.json reading for many game folders.
239- """
332+ @timeout (120 )
333+ def _find_all_game_folders_impl (base_dir : Path ) -> list [dict [str , Any ]]:
334+ """Internal implementation of find_all_game_folders with timeout"""
240335 if not base_dir .exists ():
241336 return []
242337
@@ -314,6 +409,30 @@ def find_all_game_folders(base_dir: Path) -> list[dict[str, Any]]:
314409 return sorted (all_folders , key = lambda x : x ["name" ])
315410
316411
412+ @print_timing
413+ def find_all_game_folders (base_dir : Path ) -> list [dict [str , Any ]]:
414+ """Find all game folders by locating metadata.json files
415+
416+ Uses parallel loading to speed up metadata.json reading for many game folders.
417+ Results are cached for 5 minutes.
418+ """
419+ cache_key = f"game_folders:{ base_dir } "
420+ cached_result = _cache .get (cache_key , timeout_seconds = 300 ) # 5 min cache
421+
422+ if cached_result is not None :
423+ logger .info (f"Using cached game folders for { base_dir } " )
424+ return cached_result
425+
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 []
434+
435+
317436@dataclass
318437class GameMetadata :
319438 """Metadata about a game session"""
@@ -874,9 +993,9 @@ def get_navigation_info(selected_folder: str) -> dict[str, str | None]:
874993 return {"previous" : previous_game , "next" : next_game }
875994
876995
877- @print_timing
878- def render_game_viewer (folder_path : Path , selected_folder : str ) -> str :
879- """Common logic for rendering game viewer pages """
996+ @timeout ( 120 )
997+ def _render_game_viewer_impl (folder_path : Path , selected_folder : str ) -> str :
998+ """Internal implementation of render_game_viewer with timeout """
880999 # Parse the selected game
8811000 parser = LogParser (folder_path )
8821001 metadata = parser .parse_game_metadata ()
@@ -910,6 +1029,18 @@ def render_game_viewer(folder_path: Path, selected_folder: str) -> str:
9101029 )
9111030
9121031
1032+ @print_timing
1033+ def render_game_viewer (folder_path : Path , selected_folder : str ) -> str :
1034+ """Common logic for rendering game viewer pages with timeout protection"""
1035+ try :
1036+ return _render_game_viewer_impl (folder_path , selected_folder )
1037+ except TimeoutError :
1038+ logger .error (f"Timeout while rendering game viewer for { selected_folder } " )
1039+ return render_template (
1040+ "error.html" , error_message = "Request timed out after 2 minutes. The game data may be too large to load."
1041+ )
1042+
1043+
9131044# Register the custom filters
9141045app .jinja_env .filters ["nl2br" ] = nl2br
9151046app .jinja_env .filters ["unescape_content" ] = unescape_content
@@ -1188,6 +1319,13 @@ def move_folder():
11881319 return jsonify ({"success" : False , "error" : str (e )})
11891320
11901321
1322+ @timeout (120 )
1323+ def _analyze_line_counts_impl (folder_path : Path ):
1324+ """Internal implementation of line counts analysis with timeout"""
1325+ parser = LogParser (folder_path )
1326+ return parser .analyze_line_counts ()
1327+
1328+
11911329@app .route ("/analysis/line-counts" )
11921330@print_timing
11931331def analysis_line_counts ():
@@ -1205,10 +1343,11 @@ def analysis_line_counts():
12051343 return jsonify ({"success" : False , "error" : "Invalid folder" })
12061344
12071345 try :
1208- parser = LogParser (folder_path )
1209- analysis_data = parser .analyze_line_counts ()
1210-
1346+ analysis_data = _analyze_line_counts_impl (folder_path )
12111347 return jsonify ({"success" : True , "data" : analysis_data })
1348+ except TimeoutError :
1349+ logger .error (f"Timeout while analyzing line counts for { selected_folder } " )
1350+ return jsonify ({"success" : False , "error" : "Request timed out after 2 minutes" }), 504
12121351 except Exception as e :
12131352 return jsonify ({"success" : False , "error" : str (e )})
12141353
@@ -1376,16 +1515,44 @@ def batch_monitor():
13761515 return render_template ("batch.html" )
13771516
13781517
1518+ @timeout (120 )
1519+ def _fetch_batch_jobs_impl (hours_back : int ):
1520+ """Internal implementation of batch jobs fetching with timeout"""
1521+ monitor = AWSBatchMonitor (job_queue = "codeclash-queue" , region = "us-east-1" , logs_base_dir = LOG_BASE_DIR )
1522+ jobs = monitor .get_formatted_jobs (hours_back = hours_back )
1523+ total_cpus = monitor .get_total_cpus_running ()
1524+ return {"jobs" : jobs , "total_cpus_running" : total_cpus }
1525+
1526+
13791527@app .route ("/batch/api/jobs" )
13801528@print_timing
13811529def batch_api_jobs ():
1382- """API endpoint to get AWS Batch jobs"""
1530+ """API endpoint to get AWS Batch jobs with caching (2 min timeout) """
13831531 try :
13841532 hours_back = request .args .get ("hours_back" , default = 24 , type = int )
1385- monitor = AWSBatchMonitor (job_queue = "codeclash-queue" , region = "us-east-1" , logs_base_dir = LOG_BASE_DIR )
1386- jobs = monitor .get_formatted_jobs (hours_back = hours_back )
1387- total_cpus = monitor .get_total_cpus_running ()
1388- return jsonify ({"success" : True , "jobs" : jobs , "total_cpus_running" : total_cpus })
1533+ force_refresh = request .args .get ("force_refresh" , default = "false" , type = str ).lower () == "true"
1534+
1535+ cache_key = f"batch_jobs:{ hours_back } "
1536+
1537+ # If force_refresh is true, invalidate cache
1538+ if force_refresh :
1539+ _cache .invalidate (cache_key )
1540+ logger .info ("Force refresh requested, invalidating batch jobs cache" )
1541+
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 })
1548+
1549+ # Fetch fresh data
1550+ result = _fetch_batch_jobs_impl (hours_back )
1551+ _cache .set (cache_key , result )
1552+ return jsonify ({"success" : True , ** result })
1553+ except TimeoutError :
1554+ logger .error ("Timeout while fetching AWS Batch jobs" )
1555+ return jsonify ({"success" : False , "error" : "Request timed out after 2 minutes" }), 504
13891556 except Exception as e :
13901557 logger .error (f"Error fetching AWS Batch jobs: { e } " , exc_info = True )
13911558 return jsonify ({"success" : False , "error" : str (e )}), 500
0 commit comments