From 21baba4b6d49b86b87c29f8e147b5c426b2b9ed2 Mon Sep 17 00:00:00 2001 From: Yad Konrad Date: Mon, 4 May 2026 23:36:01 -0400 Subject: [PATCH] fix: provider/model query bindings + non-blocking startup ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues blocked v0.6.1 multi-provider from working in real use: 1. Filter query params silently ignored on 5 routes. FastAPI requires ``Query()`` (or ``Annotated[..., Query()]``) to bind repeated list-typed query parameters. Without it the param stays ``None`` regardless of what the URL contains. The routes added in PR #66 used the bare ``list[str] | None = None`` shape, so every ``?provider=...`` / ``?model=...`` filter from FilterBar was being dropped on the server side. Verified live before fix: /api/projects?provider=cursor → 187 projects (all 7 providers) /api/projects?provider=claude → 187 projects (all 7 providers) After fix: /api/projects → 187 projects /api/projects?provider=cursor → 7 projects /api/projects?provider=cursor+cline → 8 projects Used ``Annotated[list[str] | None, Query()] = None`` so direct in-process calls (existing test pattern) keep getting ``None`` as the default while HTTP-bound calls go through FastAPI's normal parameter binding. 9 bindings touched across projects, sessions, cost, data (dashboard-data + messages), optimize. 2. Startup blocked HTTP for the entire reindex (~90s today, 30+min on cold 188-project store). The ``_lifespan`` handler ran ``run_ingest()`` synchronously before ``yield``, so the HTTP server didn't actually start serving until ingest finished — even though the CLI wrapper had already printed the misleading "live at..." line. Schema apply still runs sync (cheap, needed by every route), but ``run_ingest`` now runs in a daemon background thread so HTTP starts serving in <1s. The "Ingest complete" log line still fires when the thread finishes; any error in the thread degrades to a single ``logger.error`` call, matching the previous behaviour. 1341 backend tests pass. No version bump. Co-Authored-By: Claude Opus 4.7 (1M context) --- stackunderflow/routes/cost.py | 6 +++--- stackunderflow/routes/data.py | 11 +++++----- stackunderflow/routes/optimize.py | 8 ++++--- stackunderflow/routes/projects.py | 5 +++-- stackunderflow/routes/sessions.py | 5 +++-- stackunderflow/server.py | 36 ++++++++++++++++++++++++------- 6 files changed, 48 insertions(+), 23 deletions(-) diff --git a/stackunderflow/routes/cost.py b/stackunderflow/routes/cost.py index fb55b34..e0431c8 100644 --- a/stackunderflow/routes/cost.py +++ b/stackunderflow/routes/cost.py @@ -16,9 +16,9 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query import stackunderflow.deps as deps from stackunderflow.infra.currency import active_currency_payload @@ -153,7 +153,7 @@ async def get_cost_data(log_path: str | None = None, timezone_offset: int = 0): @router.get("/api/cost-data/by-provider") async def get_cost_by_provider( period: str = "month", - provider: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, ): """Return total cost / message count / session count grouped by provider. diff --git a/stackunderflow/routes/data.py b/stackunderflow/routes/data.py index 911f4fd..eea6ced 100644 --- a/stackunderflow/routes/data.py +++ b/stackunderflow/routes/data.py @@ -5,8 +5,9 @@ import threading import time from pathlib import Path +from typing import Annotated -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import JSONResponse import stackunderflow.deps as deps @@ -113,8 +114,8 @@ async def get_stats(timezone_offset: int = 0): @router.get("/api/dashboard-data") async def get_dashboard_data( timezone_offset: int = 0, - provider: list[str] | None = None, - model: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, + model: Annotated[list[str] | None, Query()] = None, ): """Get optimized data for initial dashboard load. @@ -278,8 +279,8 @@ def _apply_currency_to_stats(stats: dict) -> dict: async def get_messages( limit: int | None = None, timezone_offset: int = 0, - provider: list[str] | None = None, - model: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, + model: Annotated[list[str] | None, Query()] = None, ): """Get messages for the current project. diff --git a/stackunderflow/routes/optimize.py b/stackunderflow/routes/optimize.py index bd5d058..8db917a 100644 --- a/stackunderflow/routes/optimize.py +++ b/stackunderflow/routes/optimize.py @@ -14,7 +14,9 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query import stackunderflow.deps as deps from stackunderflow.reports.optimize import find_patterns, find_waste @@ -30,8 +32,8 @@ @router.get("/api/optimize") async def get_optimize_report( period: str = "30days", - project: list[str] | None = None, - exclude: list[str] | None = None, + project: Annotated[list[str] | None, Query()] = None, + exclude: Annotated[list[str] | None, Query()] = None, ): """Run waste + structural-pattern detection over *period*. diff --git a/stackunderflow/routes/projects.py b/stackunderflow/routes/projects.py index 9457941..12c6329 100644 --- a/stackunderflow/routes/projects.py +++ b/stackunderflow/routes/projects.py @@ -3,8 +3,9 @@ import os from collections import defaultdict from pathlib import Path +from typing import Annotated -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import JSONResponse import stackunderflow.deps as deps @@ -155,7 +156,7 @@ async def get_projects( sort_by: str = "last_modified", limit: int | None = None, offset: int = 0, - provider: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, ): """ Get all available Claude projects with metadata. diff --git a/stackunderflow/routes/sessions.py b/stackunderflow/routes/sessions.py index dd4e69d..d0f47e1 100644 --- a/stackunderflow/routes/sessions.py +++ b/stackunderflow/routes/sessions.py @@ -3,8 +3,9 @@ import json from datetime import datetime from pathlib import Path +from typing import Annotated -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import JSONResponse import stackunderflow.deps as deps @@ -38,7 +39,7 @@ def _duration_minutes(first: str | None, last: str | None) -> float | None: @router.get("/api/jsonl-files") async def get_jsonl_files( project: str | None = None, - provider: list[str] | None = None, + provider: Annotated[list[str] | None, Query()] = None, ): """Get list of JSONL files for a project with metadata. diff --git a/stackunderflow/server.py b/stackunderflow/server.py index b80dc9e..44f28d9 100644 --- a/stackunderflow/server.py +++ b/stackunderflow/server.py @@ -89,20 +89,40 @@ async def _lifespan(_app: FastAPI): if failed: logger.warning(f"Failed services: {', '.join(failed)}") - # Initialise the session store and run one ingest pass. + # Initialise the session store schema synchronously (cheap), then + # run the ingest in a background thread so HTTP starts serving + # immediately. Without this, the lifespan blocks the bind for the + # full duration of the reindex (~90s on 7 small projects, 30+min + # on a cold 188-project store) — and the "live at..." line that + # already printed from the CLI wrapper is misleading because the + # HTTP server hasn't actually started yet. + import threading from stackunderflow.adapters import registered from stackunderflow.ingest import run_ingest from stackunderflow.store import db, schema try: - store_conn = db.connect(deps.store_path) - schema.apply(store_conn) - counts = run_ingest(store_conn, registered()) - logger.info("Ingest complete: %s", counts) - store_conn.close() - _maybe_clean_cold_cache() + _schema_conn = db.connect(deps.store_path) + schema.apply(_schema_conn) + _schema_conn.close() except Exception as e: - logger.error("Ingest failed at startup: %s", e) + logger.error("Schema apply failed at startup: %s", e) + + def _background_ingest() -> None: + try: + conn = db.connect(deps.store_path) + counts = run_ingest(conn, registered()) + logger.info("Ingest complete: %s", counts) + conn.close() + _maybe_clean_cold_cache() + except Exception as exc: # noqa: BLE001 — top of background thread + logger.error("Background ingest failed: %s", exc) + + threading.Thread( + target=_background_ingest, + name="stackunderflow-ingest", + daemon=True, + ).start() yield