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"] }
|
||||
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"] }
|
||||
|
||||
+94
-16
@@ -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<Option<CachedLeaderboard>>,
|
||||
}
|
||||
|
||||
struct CachedLeaderboard {
|
||||
teams: Vec<TeamLeaderboardRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -92,7 +98,7 @@ struct HealthResponse {
|
||||
databases: BTreeMap<&'static str, bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LeaderboardResponse {
|
||||
teams: Vec<TeamLeaderboardRow>,
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<Arc<AppState>>,
|
||||
Query(query): Query<LimitQuery>,
|
||||
) -> 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 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::<Result<Vec<_>, _>>()
|
||||
.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());
|
||||
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(
|
||||
@@ -655,9 +699,19 @@ fn team_summary_for(conn: &Connection, team_name: &str) -> Result<TeamSummary, A
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
fn team_summaries_for(conn: &Connection) -> Result<HashMap<String, TeamSummary>, ApiError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
fn team_summaries_for(
|
||||
conn: &Connection,
|
||||
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
|
||||
team_name,
|
||||
COUNT(DISTINCT session_id),
|
||||
@@ -670,13 +724,13 @@ fn team_summaries_for(conn: &Connection) -> Result<HashMap<String, TeamSummary>,
|
||||
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)?;
|
||||
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<HashMap<String, TeamSummary>,
|
||||
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(
|
||||
teams_conn: &Connection,
|
||||
battles_conn: &Connection,
|
||||
|
||||
Reference in New Issue
Block a user