perf(api): CUST-WP-0041 — DB indexes, TTL caches, noload on list endpoints

- Migration t7o8p9q0r1s2: indexes on tasks.status, tasks(workstream_id,status),
  workstreams.status, sbom_snapshots(repo_id,snapshot_at)
- workplan-index: 30 s TTL cache + ?refresh param (4171 ms → 16 ms on hit)
- /state/summary: 15 s TTL cache, bypassed on Cache-Control: no-cache
- /topics/: noload(workstreams, decisions, progress_events) (2382 ms → 115 ms)
- /domains/: noload(topics, repos, goals) (2252 ms → 39 ms)
- /repos/: noload(goals) (2222 ms → 599 ms first / fast on repeat)
- conftest: reset TTL caches between tests to prevent bleed-through

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 11:12:17 +02:00
parent 90c5ea50f7
commit 619fb72a78
7 changed files with 84 additions and 9 deletions

View File

@@ -2,6 +2,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
@@ -23,7 +24,11 @@ async def list_domains(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Domain]: ) -> list[Domain]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Domain).order_by(Domain.name) q = select(Domain).options(
noload(Domain.topics),
noload(Domain.repos),
noload(Domain.goals),
).order_by(Domain.name)
if status and status != "all": if status and status != "all":
q = q.where(Domain.status == status) q = q.where(Domain.status == status)
elif status is None: elif status is None:

View File

@@ -11,6 +11,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings from api.config import settings
@@ -55,7 +56,7 @@ async def list_repos(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[ManagedRepo]: ) -> list[ManagedRepo]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(ManagedRepo).order_by(ManagedRepo.name) q = select(ManagedRepo).options(noload(ManagedRepo.goals)).order_by(ManagedRepo.name)
if domain: if domain:
domain_row = await session.execute(select(Domain).where(Domain.slug == domain)) domain_row = await session.execute(select(Domain).where(Domain.slug == domain))
domain_obj = domain_row.scalar_one_or_none() domain_obj = domain_row.scalar_one_or_none()

View File

@@ -1,6 +1,7 @@
import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import func, select, text from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -41,9 +42,20 @@ from task_flow_engine import FlowEngine
router = APIRouter(prefix="/state", tags=["state"]) router = APIRouter(prefix="/state", tags=["state"])
_SUMMARY_CACHE: StateSummary | None = None
_SUMMARY_CACHE_AT: float = 0.0
_SUMMARY_TTL = 15.0
@router.get("/summary", response_model=StateSummary) @router.get("/summary", response_model=StateSummary)
async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSummary: async def get_summary(
request: Request,
session: AsyncSession = Depends(get_session),
) -> StateSummary:
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
no_cache = "no-cache" in request.headers.get("cache-control", "")
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
return _SUMMARY_CACHE
# Run all queries sequentially on one session. # Run all queries sequentially on one session.
# AsyncSession does not support concurrent operations (no gather on same session). # AsyncSession does not support concurrent operations (no gather on same session).
@@ -294,7 +306,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
) )
)).scalar() or 0 )).scalar() or 0
return StateSummary( result = StateSummary(
generated_at=datetime.now(tz=timezone.utc), generated_at=datetime.now(tz=timezone.utc),
totals=totals, totals=totals,
topics=[ topics=[
@@ -330,6 +342,9 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
for w in open_ws for w in open_ws
], ],
) )
_SUMMARY_CACHE = result
_SUMMARY_CACHE_AT = time.monotonic()
return result
async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:

View File

@@ -2,6 +2,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
@@ -28,7 +29,11 @@ async def list_topics(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Topic]: ) -> list[Topic]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Topic) q = select(Topic).options(
noload(Topic.workstreams),
noload(Topic.decisions),
noload(Topic.progress_events),
)
if status: if status:
q = q.where(Topic.status == status) q = q.where(Topic.status == status)
q = q.order_by(Topic.created_at) q = q.order_by(Topic.created_at)

View File

@@ -1,9 +1,10 @@
import uuid import uuid
import socket import socket
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,6 +20,10 @@ from api.schemas.workstream import (
router = APIRouter(prefix="/workstreams", tags=["workstreams"]) router = APIRouter(prefix="/workstreams", tags=["workstreams"])
_INDEX_CACHE: dict[str, Any] | None = None
_INDEX_CACHE_AT: float = 0.0
_INDEX_TTL = 30.0
def _repo_path(repo: ManagedRepo) -> Path | None: def _repo_path(repo: ManagedRepo) -> Path | None:
hostname = socket.gethostname() hostname = socket.gethostname()
@@ -92,8 +97,15 @@ async def list_workstreams(
@router.get("/workplan-index") @router.get("/workplan-index")
async def workplan_index(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: async def workplan_index(
refresh: bool = Query(False, description="Force cache invalidation"),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""Map file-backed workstream ids to their local workplan filenames.""" """Map file-backed workstream ids to their local workplan filenames."""
global _INDEX_CACHE, _INDEX_CACHE_AT
if not refresh and _INDEX_CACHE is not None and (time.monotonic() - _INDEX_CACHE_AT) < _INDEX_TTL:
return _INDEX_CACHE
result = await session.execute( result = await session.execute(
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug) select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
) )
@@ -119,7 +131,9 @@ async def workplan_index(session: AsyncSession = Depends(get_session)) -> dict[s
"repo_slug": repo.slug, "repo_slug": repo.slug,
"archived": archived, "archived": archived,
} }
return {"workstreams": index} _INDEX_CACHE = {"workstreams": index}
_INDEX_CACHE_AT = time.monotonic()
return _INDEX_CACHE
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)

View File

@@ -0,0 +1,27 @@
"""perf: add missing status and composite indexes
Revision ID: t7o8p9q0r1s2
Revises: s6n7o8p9q0r1
Create Date: 2026-05-15
"""
from alembic import op
revision = "t7o8p9q0r1s2"
down_revision = "s6n7o8p9q0r1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index("ix_tasks_status", "tasks", ["status"])
op.create_index("ix_tasks_workstream_status", "tasks", ["workstream_id", "status"])
op.create_index("ix_workstreams_status", "workstreams", ["status"])
op.create_index("ix_sbom_snapshots_repo_at", "sbom_snapshots", ["repo_id", "snapshot_at"])
def downgrade() -> None:
op.drop_index("ix_sbom_snapshots_repo_at", table_name="sbom_snapshots")
op.drop_index("ix_workstreams_status", table_name="workstreams")
op.drop_index("ix_tasks_workstream_status", table_name="tasks")
op.drop_index("ix_tasks_status", table_name="tasks")

View File

@@ -50,6 +50,14 @@ def _schema():
def _truncate(_schema): def _truncate(_schema):
"""Truncate all tables after each test for isolation.""" """Truncate all tables after each test for isolation."""
from api.models import Base from api.models import Base
import api.routers.state as _state_router
import api.routers.workstreams as _ws_router
# Reset in-process TTL caches so stale data from a previous test can't bleed through.
_state_router._SUMMARY_CACHE = None
_state_router._SUMMARY_CACHE_AT = 0.0
_ws_router._INDEX_CACHE = None
_ws_router._INDEX_CACHE_AT = 0.0
yield yield
engine = sqlalchemy.create_engine(_SYNC_URL) engine = sqlalchemy.create_engine(_SYNC_URL)