ai generated solutions to our ai generated problems

This commit is contained in:
Heidi
2026-06-15 08:11:16 +01:00
parent 760e49f401
commit c94a09f46c
2 changed files with 107 additions and 29 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ axum = "0.8"
rusqlite = { version = "0.37", features = ["bundled"] } rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+94 -16
View File
@@ -13,7 +13,8 @@ use std::{
env, fs, env, fs,
net::{IpAddr, Ipv4Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, SocketAddr},
path::{Path as FsPath, PathBuf}, path::{Path as FsPath, PathBuf},
sync::Arc, sync::{Arc, Mutex},
time::Duration,
}; };
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::{ use tower_http::{
@@ -22,11 +23,16 @@ use tower_http::{
}; };
const MAX_TEAM_NAME_LENGTH: usize = 80; const MAX_TEAM_NAME_LENGTH: usize = 80;
const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300);
#[derive(Clone)]
struct AppState { struct AppState {
battles_db: PathBuf, battles_db: PathBuf,
teams_db: PathBuf, teams_db: PathBuf,
leaderboard_cache: Mutex<Option<CachedLeaderboard>>,
}
struct CachedLeaderboard {
teams: Vec<TeamLeaderboardRow>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -92,7 +98,7 @@ struct HealthResponse {
databases: BTreeMap<&'static str, bool>, databases: BTreeMap<&'static str, bool>,
} }
#[derive(Serialize)] #[derive(Clone, Serialize)]
struct LeaderboardResponse { struct LeaderboardResponse {
teams: Vec<TeamLeaderboardRow>, teams: Vec<TeamLeaderboardRow>,
} }
@@ -120,7 +126,7 @@ struct TeamSearchRow {
members: i64, members: i64,
} }
#[derive(Serialize)] #[derive(Clone, Serialize)]
struct TeamLeaderboardRow { struct TeamLeaderboardRow {
team_id: i64, team_id: i64,
name: String, name: String,
@@ -288,7 +294,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let state = Arc::new(AppState { let state = Arc::new(AppState {
battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"), battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.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() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
@@ -373,7 +381,17 @@ async fn leaderboard(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(query): Query<LimitQuery>, Query(query): Query<LimitQuery>,
) -> ApiResult<LeaderboardResponse> { ) -> ApiResult<LeaderboardResponse> {
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<Vec<TeamLeaderboardRow>, ApiError> {
let teams_conn = open_db(&state.teams_db)?; let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?; let battles_conn = open_db(&state.battles_db)?;
// Deduplicate teams by name across tournaments — pick the highest team_id // Deduplicate teams by name across tournaments — pick the highest team_id
@@ -389,11 +407,15 @@ async fn leaderboard(
.map_err(db_error)?; .map_err(db_error)?;
let teams = stmt 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)? .map_err(db_error)?
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map_err(db_error)?; .map_err(db_error)?;
let mut summaries = team_summaries_for(&battles_conn)?; let team_names = teams
.iter()
.map(|team| team.name.as_str())
.collect::<Vec<_>>();
let mut summaries = team_summaries_for(&battles_conn, &team_names)?;
let mut rows = Vec::with_capacity(teams.len()); let mut rows = Vec::with_capacity(teams.len());
for team in teams { for team in teams {
@@ -412,7 +434,29 @@ async fn leaderboard(
}); });
} }
Ok(Json(LeaderboardResponse { teams: rows })) Ok(rows)
}
fn spawn_leaderboard_refresh(state: Arc<AppState>) {
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( async fn recent_games(
@@ -655,9 +699,19 @@ fn team_summary_for(conn: &Connection, team_name: &str) -> Result<TeamSummary, A
.map_err(db_error) .map_err(db_error)
} }
fn team_summaries_for(conn: &Connection) -> Result<HashMap<String, TeamSummary>, ApiError> { fn team_summaries_for(
let mut stmt = conn conn: &Connection,
.prepare( team_names: &[&str],
) -> Result<HashMap<String, TeamSummary>, ApiError> {
if team_names.is_empty() {
return Ok(HashMap::new());
}
let placeholders = std::iter::repeat("?")
.take(team_names.len())
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT "SELECT
team_name, team_name,
COUNT(DISTINCT session_id), COUNT(DISTINCT session_id),
@@ -670,13 +724,13 @@ fn team_summaries_for(conn: &Connection) -> Result<HashMap<String, TeamSummary>,
COALESCE(SUM(score), 0), COALESCE(SUM(score), 0),
COUNT(DISTINCT UID) COUNT(DISTINCT UID)
FROM player_games_hist FROM player_games_hist
WHERE team_name IS NOT NULL AND team_name != '' WHERE team_name COLLATE NOCASE IN ({placeholders})
GROUP BY team_name COLLATE NOCASE", GROUP BY team_name COLLATE NOCASE"
) );
.map_err(db_error)?; let mut stmt = conn.prepare(&sql).map_err(db_error)?;
let summaries = stmt let summaries = stmt
.query_map([], |row| { .query_map(rusqlite::params_from_iter(team_names.iter()), |row| {
let name: String = row.get(0)?; let name: String = row.get(0)?;
let battles: i64 = row.get(1)?; let battles: i64 = row.get(1)?;
let wins: i64 = row.get(2)?; let wins: i64 = row.get(2)?;
@@ -709,6 +763,30 @@ fn team_summaries_for(conn: &Connection) -> Result<HashMap<String, TeamSummary>,
Ok(summaries) Ok(summaries)
} }
fn cached_leaderboard(
state: &AppState,
limit: usize,
) -> Result<Option<Vec<TeamLeaderboardRow>>, 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( fn player_summaries_for(
teams_conn: &Connection, teams_conn: &Connection,
battles_conn: &Connection, battles_conn: &Connection,