Skip to content

Commit 05254c7

Browse files
committed
Enh(viewer): Use caching & timeouts
1 parent c8f79e7 commit 05254c7

3 files changed

Lines changed: 291 additions & 23 deletions

File tree

codeclash/viewer/app.py

Lines changed: 185 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
import json
1010
import logging
1111
import shutil
12+
import threading
1213
import time
1314
from concurrent.futures import ThreadPoolExecutor, as_completed
1415
from dataclasses import dataclass
16+
from datetime import datetime
1517
from pathlib import Path
1618
from 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

2022
from codeclash.analysis.significance import calculate_p_value
2123
from 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+
49147
class 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
318437
class 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
9141045
app.jinja_env.filters["nl2br"] = nl2br
9151046
app.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
11931331
def 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
13811529
def 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

codeclash/viewer/templates/batch.html

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,15 @@
530530
color: var(--text-muted);
531531
font-size: 0.85rem;
532532
margin-left: auto;
533+
cursor: pointer;
534+
transition: all 0.2s ease;
535+
padding: 0.5rem 0.75rem;
536+
border-radius: 0.25rem;
537+
}
538+
539+
.update-info:hover {
540+
color: var(--accent-color);
541+
background: var(--bg-tertiary);
533542
}
534543

535544
.status-counter {
@@ -698,7 +707,7 @@ <h1><i class="bi bi-cloud"></i> AWS Batch Job Monitor</h1>
698707
Bulk Actions
699708
</button>
700709

701-
<span class="update-info" id="last-refresh-info">Last refresh: just now</span>
710+
<span class="update-info" id="last-refresh-info" onclick="forceRefresh()" title="Click to refresh now">Last refresh: just now</span>
702711
</div>
703712

704713
<div id="error-container"></div>
@@ -797,9 +806,10 @@ <h2>Bulk Actions</h2>
797806
grid.innerHTML = html;
798807
}
799808

800-
async function fetchJobs() {
809+
async function fetchJobs(forceRefresh = false) {
801810
try {
802-
const response = await fetch(`/batch/api/jobs?hours_back=${timeRangeHours}`);
811+
const url = `/batch/api/jobs?hours_back=${timeRangeHours}&force_refresh=${forceRefresh}`;
812+
const response = await fetch(url);
803813
const data = await response.json();
804814

805815
if (data.success) {
@@ -818,6 +828,11 @@ <h2>Bulk Actions</h2>
818828
}
819829
}
820830

831+
function forceRefresh() {
832+
// Force refresh by invalidating cache
833+
fetchJobs(true);
834+
}
835+
821836
function applyTimeRangeFilter() {
822837
timeRangeHours = parseInt(document.getElementById('time-range-filter').value);
823838
selectedJobs.clear();
@@ -1260,8 +1275,8 @@ <h2>Bulk Actions</h2>
12601275
updateStatusCounterTitle();
12611276
fetchJobs();
12621277

1263-
// Auto-refresh every 10 seconds
1264-
setInterval(fetchJobs, 10000);
1278+
// Auto-refresh every 60 seconds
1279+
setInterval(fetchJobs, 60000);
12651280

12661281
// Update "last refresh" display every second
12671282
setInterval(updateLastRefreshDisplay, 1000);

0 commit comments

Comments
 (0)