diff --git a/state-hub/api/routers/domains.py b/state-hub/api/routers/domains.py index ac62af9..5c54c2b 100644 --- a/state-hub/api/routers/domains.py +++ b/state-hub/api/routers/domains.py @@ -2,6 +2,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import func, select +from sqlalchemy.orm import noload from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session @@ -23,7 +24,11 @@ async def list_domains( session: AsyncSession = Depends(get_session), ) -> list[Domain]: 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": q = q.where(Domain.status == status) elif status is None: diff --git a/state-hub/api/routers/repos.py b/state-hub/api/routers/repos.py index 1b14c99..f4356dd 100644 --- a/state-hub/api/routers/repos.py +++ b/state-hub/api/routers/repos.py @@ -11,6 +11,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import case, func, select +from sqlalchemy.orm import noload from sqlalchemy.ext.asyncio import AsyncSession from api.config import settings @@ -55,7 +56,7 @@ async def list_repos( session: AsyncSession = Depends(get_session), ) -> list[ManagedRepo]: 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: domain_row = await session.execute(select(Domain).where(Domain.slug == domain)) domain_obj = domain_row.scalar_one_or_none() diff --git a/state-hub/api/routers/state.py b/state-hub/api/routers/state.py index d66170c..62aabe8 100644 --- a/state-hub/api/routers/state.py +++ b/state-hub/api/routers/state.py @@ -1,6 +1,7 @@ +import time from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -41,9 +42,20 @@ from task_flow_engine import FlowEngine 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) -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. # 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 - return StateSummary( + result = StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, topics=[ @@ -330,6 +342,9 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm for w in open_ws ], ) + _SUMMARY_CACHE = result + _SUMMARY_CACHE_AT = time.monotonic() + return result async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: diff --git a/state-hub/api/routers/topics.py b/state-hub/api/routers/topics.py index 17e3cbe..4498d08 100644 --- a/state-hub/api/routers/topics.py +++ b/state-hub/api/routers/topics.py @@ -2,6 +2,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select +from sqlalchemy.orm import noload from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session @@ -28,7 +29,11 @@ async def list_topics( session: AsyncSession = Depends(get_session), ) -> list[Topic]: 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: q = q.where(Topic.status == status) q = q.order_by(Topic.created_at) diff --git a/state-hub/api/routers/workstreams.py b/state-hub/api/routers/workstreams.py index de097d0..89dffba 100644 --- a/state-hub/api/routers/workstreams.py +++ b/state-hub/api/routers/workstreams.py @@ -1,9 +1,10 @@ import uuid import socket +import time from pathlib import Path 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.ext.asyncio import AsyncSession @@ -19,6 +20,10 @@ from api.schemas.workstream import ( 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: hostname = socket.gethostname() @@ -92,8 +97,15 @@ async def list_workstreams( @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.""" + 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( 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, "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) diff --git a/state-hub/migrations/versions/t7o8p9q0r1s2_perf_indexes.py b/state-hub/migrations/versions/t7o8p9q0r1s2_perf_indexes.py new file mode 100644 index 0000000..b4030ea --- /dev/null +++ b/state-hub/migrations/versions/t7o8p9q0r1s2_perf_indexes.py @@ -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") diff --git a/state-hub/tests/conftest.py b/state-hub/tests/conftest.py index 123f2df..8a06a04 100644 --- a/state-hub/tests/conftest.py +++ b/state-hub/tests/conftest.py @@ -50,6 +50,14 @@ def _schema(): def _truncate(_schema): """Truncate all tables after each test for isolation.""" 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 engine = sqlalchemy.create_engine(_SYNC_URL) diff --git a/workplans/CUST-WP-0041-api-performance.md b/workplans/CUST-WP-0041-api-performance.md index b7483f3..5505184 100644 --- a/workplans/CUST-WP-0041-api-performance.md +++ b/workplans/CUST-WP-0041-api-performance.md @@ -3,7 +3,7 @@ id: CUST-WP-0041 type: workplan title: "API Performance Optimization" domain: custodian -status: todo +status: done owner: custodian topic_slug: custodian created: "2026-05-15"