diff --git a/web/app_factory.py b/web/app_factory.py index 3f141c96..ba87a0cf 100644 --- a/web/app_factory.py +++ b/web/app_factory.py @@ -16,6 +16,7 @@ from web.routers.payments import router as payments_router from web.routers.scan import router as scan_router from web.routers.system import router as system_router from web.routes import router as legacy_router +from web.scan_terminal_service import start_scan_terminal_prewarm _ROUTES_REGISTERED_FLAG = "_polyweather_routes_registered" @@ -32,4 +33,5 @@ def create_app() -> FastAPI: core_app.include_router(ops_router) core_app.include_router(legacy_router) setattr(core_app.state, _ROUTES_REGISTERED_FLAG, True) + start_scan_terminal_prewarm() return core_app diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index f0554702..c0a82994 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -1544,3 +1544,58 @@ def build_scan_terminal_ai_payload( reason=str(exc), duration_ms=duration_ms, ) + + +_SCAN_PREWARM_STARTED = False +_SCAN_PREWARM_LOCK = threading.Lock() + + +def start_scan_terminal_prewarm() -> None: + """Warm analysis caches for all cities at startup so the first terminal + scan returns quickly instead of forcing every city through a cold + _analyze() path. + + Runs once per process. Safe to call from any thread and at any point + during the server lifecycle. + """ + global _SCAN_PREWARM_STARTED + with _SCAN_PREWARM_LOCK: + if _SCAN_PREWARM_STARTED: + return + _SCAN_PREWARM_STARTED = True + + city_names = list(CITIES.keys()) + logger.info( + "scan terminal pre-warm starting cities={} workers={}", + len(city_names), + SCAN_TERMINAL_MAX_WORKERS, + ) + + def _warm_one(city: str) -> str: + try: + _analyze(city, force_refresh=False, detail_mode="panel") + except Exception: + pass + return city + + def _run(): + started = time.time() + ok = 0 + workers = max(1, min(SCAN_TERMINAL_MAX_WORKERS, len(city_names))) + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = {ex.submit(_warm_one, c): c for c in city_names} + for f in as_completed(futures): + try: + f.result() + ok += 1 + except Exception: + pass + elapsed = int(time.time() - started) + logger.info( + "scan terminal pre-warm finished ok={}/{} elapsed={}s", + ok, + len(city_names), + elapsed, + ) + + threading.Thread(target=_run, name="scan-prewarm", daemon=True).start()