diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e0436f5..0f27684 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -8,7 +8,7 @@ axum = "0.8" rusqlite = { version = "0.37", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] } tower-http = { version = "0.6", features = ["cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/backend/src/main.rs b/backend/src/main.rs index fe2cc66..b3c35b7 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -13,7 +13,8 @@ use std::{ env, fs, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path as FsPath, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, + time::Duration, }; use tokio::net::TcpListener; use tower_http::{ @@ -22,11 +23,16 @@ use tower_http::{ }; const MAX_TEAM_NAME_LENGTH: usize = 80; +const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300); -#[derive(Clone)] struct AppState { battles_db: PathBuf, teams_db: PathBuf, + leaderboard_cache: Mutex>, +} + +struct CachedLeaderboard { + teams: Vec, } #[derive(Debug)] @@ -92,7 +98,7 @@ struct HealthResponse { databases: BTreeMap<&'static str, bool>, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] struct LeaderboardResponse { teams: Vec, } @@ -120,7 +126,7 @@ struct TeamSearchRow { members: i64, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] struct TeamLeaderboardRow { team_id: i64, name: String, @@ -288,7 +294,9 @@ async fn main() -> Result<(), Box> { let state = Arc::new(AppState { battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"), teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"), + leaderboard_cache: Mutex::new(None), }); + spawn_leaderboard_refresh(state.clone()); let app = Router::new() .route("/health", get(health)) @@ -373,7 +381,17 @@ async fn leaderboard( State(state): State>, Query(query): Query, ) -> ApiResult { - let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100)); + let limit = usize::try_from(query.limit.unwrap_or(100).clamp(1, 100)).unwrap_or(100); + if let Some(teams) = cached_leaderboard(&state, limit)? { + return Ok(Json(LeaderboardResponse { teams })); + } + + let rows = leaderboard_rows(&state, limit)?; + cache_leaderboard(&state, &rows)?; + Ok(Json(LeaderboardResponse { teams: rows })) +} + +fn leaderboard_rows(state: &AppState, limit: usize) -> Result, ApiError> { let teams_conn = open_db(&state.teams_db)?; let battles_conn = open_db(&state.battles_db)?; // Deduplicate teams by name across tournaments — pick the highest team_id @@ -389,11 +407,15 @@ async fn leaderboard( .map_err(db_error)?; let teams = stmt - .query_map(params![limit], |row| read_team_record(row)) + .query_map(params![limit as i64], |row| read_team_record(row)) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; - let mut summaries = team_summaries_for(&battles_conn)?; + let team_names = teams + .iter() + .map(|team| team.name.as_str()) + .collect::>(); + let mut summaries = team_summaries_for(&battles_conn, &team_names)?; let mut rows = Vec::with_capacity(teams.len()); for team in teams { @@ -412,7 +434,29 @@ async fn leaderboard( }); } - Ok(Json(LeaderboardResponse { teams: rows })) + Ok(rows) +} + +fn spawn_leaderboard_refresh(state: Arc) { + tokio::spawn(async move { + loop { + let refresh_state = state.clone(); + match tokio::task::spawn_blocking(move || leaderboard_rows(&refresh_state, 100)).await { + Ok(Ok(rows)) => { + if let Err(error) = cache_leaderboard(&state, &rows) { + tracing::warn!("could not cache leaderboard refresh: {}", error.message); + } + } + Ok(Err(error)) => { + tracing::warn!("could not refresh leaderboard cache: {}", error.message); + } + Err(error) => { + tracing::warn!("leaderboard refresh task failed: {}", error); + } + } + tokio::time::sleep(LEADERBOARD_CACHE_TTL).await; + } + }); } async fn recent_games( @@ -655,28 +699,38 @@ fn team_summary_for(conn: &Connection, team_name: &str) -> Result Result, ApiError> { - let mut stmt = conn - .prepare( - "SELECT - team_name, - COUNT(DISTINCT session_id), - SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END), - SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END), - COALESCE(SUM(ground_kills), 0), - COALESCE(SUM(air_kills), 0), - COALESCE(SUM(assists), 0), - COALESCE(SUM(deaths), 0), - COALESCE(SUM(score), 0), - COUNT(DISTINCT UID) - FROM player_games_hist - WHERE team_name IS NOT NULL AND team_name != '' - GROUP BY team_name COLLATE NOCASE", - ) - .map_err(db_error)?; +fn team_summaries_for( + conn: &Connection, + team_names: &[&str], +) -> Result, ApiError> { + if team_names.is_empty() { + return Ok(HashMap::new()); + } + + let placeholders = std::iter::repeat("?") + .take(team_names.len()) + .collect::>() + .join(", "); + let sql = format!( + "SELECT + team_name, + COUNT(DISTINCT session_id), + SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END), + SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END), + COALESCE(SUM(ground_kills), 0), + COALESCE(SUM(air_kills), 0), + COALESCE(SUM(assists), 0), + COALESCE(SUM(deaths), 0), + COALESCE(SUM(score), 0), + COUNT(DISTINCT UID) + FROM player_games_hist + WHERE team_name COLLATE NOCASE IN ({placeholders}) + GROUP BY team_name COLLATE NOCASE" + ); + let mut stmt = conn.prepare(&sql).map_err(db_error)?; let summaries = stmt - .query_map([], |row| { + .query_map(rusqlite::params_from_iter(team_names.iter()), |row| { let name: String = row.get(0)?; let battles: i64 = row.get(1)?; let wins: i64 = row.get(2)?; @@ -709,6 +763,30 @@ fn team_summaries_for(conn: &Connection) -> Result, Ok(summaries) } +fn cached_leaderboard( + state: &AppState, + limit: usize, +) -> Result>, ApiError> { + let cache = state + .leaderboard_cache + .lock() + .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; + Ok(cache.as_ref().and_then(|cached| { + (cached.teams.len() >= limit).then(|| cached.teams.iter().take(limit).cloned().collect()) + })) +} + +fn cache_leaderboard(state: &AppState, teams: &[TeamLeaderboardRow]) -> Result<(), ApiError> { + let mut cache = state + .leaderboard_cache + .lock() + .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; + *cache = Some(CachedLeaderboard { + teams: teams.to_vec(), + }); + Ok(()) +} + fn player_summaries_for( teams_conn: &Connection, battles_conn: &Connection,