From 32cc7095311035e7a138e97795068dce539f18df Mon Sep 17 00:00:00 2001 From: Hao Li Date: Mon, 26 Jan 2026 22:31:35 -0800 Subject: [PATCH 1/2] Further optimize prod image --- Dockerfile | 9 ++++++- server/session_store.py | 54 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 00f02fc..4cdc7bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,14 @@ FROM python:3.11-slim AS runtime WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + APP_ENV=production \ + SESSION_TTL_SECONDS=3600 \ + OMP_NUM_THREADS=1 \ + MKL_NUM_THREADS=1 \ + OPENBLAS_NUM_THREADS=1 \ + NUMEXPR_NUM_THREADS=1 \ + MALLOC_ARENA_MAX=2 COPY requirements.prod.txt . RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu torch==2.10.0 diff --git a/server/session_store.py b/server/session_store.py index 8d33d0b..84cb0be 100644 --- a/server/session_store.py +++ b/server/session_store.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import os +from datetime import timedelta from dataclasses import dataclass from datetime import datetime from typing import Callable, Dict, Optional, Protocol @@ -29,6 +31,17 @@ def choose_two_cards_from_hand(self, hand: list) -> list: ... except ImportError: # pragma: no cover - defensive for limited environments RLPlayer = None # type: ignore[assignment] +_rl_player_singleton: Optional[AIPlayerProtocol] = None + + +def _get_rl_player() -> AIPlayerProtocol: + global _rl_player_singleton + if RLPlayer is None: + raise ValueError("RL AI is not available") + if _rl_player_singleton is None: + _rl_player_singleton = RLPlayer() + return _rl_player_singleton + @dataclass class GameSession: @@ -49,6 +62,34 @@ class SessionStore: def __init__(self) -> None: self._sessions: Dict[str, GameSession] = {} self._lock: Optional[asyncio.Lock] = None + self._session_ttl = self._load_session_ttl() + + def _load_session_ttl(self) -> Optional[timedelta]: + env = os.getenv("APP_ENV", "").lower() + if env not in {"production", "prod"}: + return None + ttl_value = os.getenv("SESSION_TTL_SECONDS") + if not ttl_value: + return None + try: + seconds = int(ttl_value) + except ValueError: + return None + if seconds <= 0: + return None + return timedelta(seconds=seconds) + + async def _cleanup_expired_sessions(self) -> None: + if self._session_ttl is None: + return + cutoff = datetime.utcnow() - self._session_ttl + expired_ids = [ + session_id + for session_id, session in self._sessions.items() + if session.updated_at < cutoff + ] + for session_id in expired_ids: + self._sessions.pop(session_id, None) async def _get_lock(self) -> asyncio.Lock: if self._lock is None: @@ -66,6 +107,7 @@ async def create_session( """Create and store a new session.""" lock = await self._get_lock() async with lock: + await self._cleanup_expired_sessions() session_id = uuid4().hex ai_player = None if use_ai: @@ -76,9 +118,7 @@ async def create_session( raise ValueError("LLM AI is not available") ai_player = LLMPlayer() elif ai_type == "rl": - if RLPlayer is None: - raise ValueError("RL AI is not available") - ai_player = RLPlayer() + ai_player = _get_rl_player() else: raise ValueError(f"Unknown ai_type: {ai_type}") game = Game( @@ -103,16 +143,22 @@ async def get_session(self, session_id: str) -> Optional[GameSession]: """Fetch a session by id.""" lock = await self._get_lock() async with lock: - return self._sessions.get(session_id) + await self._cleanup_expired_sessions() + session = self._sessions.get(session_id) + if session is not None: + session.updated_at = datetime.utcnow() + return session async def delete_session(self, session_id: str) -> bool: """Delete a session by id.""" lock = await self._get_lock() async with lock: + await self._cleanup_expired_sessions() return self._sessions.pop(session_id, None) is not None async def session_count(self) -> int: """Return number of active sessions.""" lock = await self._get_lock() async with lock: + await self._cleanup_expired_sessions() return len(self._sessions) From 92f3d5edb028ed00027194d01c72adfd44b6b92f Mon Sep 17 00:00:00 2001 From: Hao Li Date: Sat, 31 Jan 2026 20:19:40 -0800 Subject: [PATCH 2/2] update five one-off desc --- web/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1c597fe..28310b0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ -import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react' import { Club, Diamond, Heart, Spade } from 'lucide-react' +import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react' import './App.css' import { useGameSession } from './api/hooks' import type { ActionView, AiType, CardView } from './api/types' @@ -633,7 +633,7 @@ const oneOffDescriptions: Record = { TWO: 'Scrap a target royal or Glasses Eight.', THREE: 'Take one card from the scrap pile.', FOUR: 'Opponent discards two cards of their choice.', - FIVE: 'Discard one card, then draw up to three (max 8 in hand).', + FIVE: 'Draw up to two cards from deck (max 8 in hand).', SIX: 'Destroy all face cards (royals) on the field.', SEVEN: 'Reveal the top two cards of the deck; choose one to play.', NINE: "Return an opponent's field card to their hand (cannot play it next turn).",