ai generated solutions to our ai generated problems
This commit is contained in:
+1
-1
@@ -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"] }
|
||||||
|
|||||||
+106
-28
@@ -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,28 +699,38 @@ 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],
|
||||||
"SELECT
|
) -> Result<HashMap<String, TeamSummary>, ApiError> {
|
||||||
team_name,
|
if team_names.is_empty() {
|
||||||
COUNT(DISTINCT session_id),
|
return Ok(HashMap::new());
|
||||||
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),
|
let placeholders = std::iter::repeat("?")
|
||||||
COALESCE(SUM(air_kills), 0),
|
.take(team_names.len())
|
||||||
COALESCE(SUM(assists), 0),
|
.collect::<Vec<_>>()
|
||||||
COALESCE(SUM(deaths), 0),
|
.join(", ");
|
||||||
COALESCE(SUM(score), 0),
|
let sql = format!(
|
||||||
COUNT(DISTINCT UID)
|
"SELECT
|
||||||
FROM player_games_hist
|
team_name,
|
||||||
WHERE team_name IS NOT NULL AND team_name != ''
|
COUNT(DISTINCT session_id),
|
||||||
GROUP BY team_name COLLATE NOCASE",
|
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
|
||||||
)
|
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
|
||||||
.map_err(db_error)?;
|
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
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user