From b832032cc3ed056b32a34732b647a0f1442e1021 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 11 May 2026 17:26:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20dashboard=20poll=20optimisation=20?= =?UTF-8?q?=E2=80=94=20T1,=20T2,=20T3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1: Cache-Control max-age=60 on /topics/, /repos/, /domains/ list endpoints so repeated dashboard polls within a minute are served from browser cache. T2: ETag middleware (md5 hash) on all JSON GET responses with conditional-GET (304 Not Modified) support; If-None-Match and ETag added to CORS headers. ETag registered inside CORS so 304s automatically carry CORS headers. T3: GET /state/deps — lightweight dep-graph endpoint returning open workstreams with depends_on/blocks edges only, skipping the 10-table full-summary query. Prerequisite for T4 (switching workstreams.md and dependencies.md off /state/summary). Workplan: CUST-WP-0039-dashboard-poll-optimization.md Co-Authored-By: Claude Sonnet 4.6 --- api/main.py | 42 +++++++++++++++++++++- api/routers/domains.py | 4 ++- api/routers/repos.py | 4 ++- api/routers/state.py | 81 ++++++++++++++++++++++++++++++++++++++++++ api/routers/topics.py | 4 ++- 5 files changed, 131 insertions(+), 4 deletions(-) diff --git a/api/main.py b/api/main.py index 9d35136..d7cdffd 100644 --- a/api/main.py +++ b/api/main.py @@ -1,8 +1,12 @@ +import hashlib import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response as StarletteResponse from api.database import engine from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies @@ -12,6 +16,40 @@ from api.routers import interface_changes from api.routers import flows +class ETagMiddleware(BaseHTTPMiddleware): + """Add ETag + conditional-GET (304) support to all JSON GET responses.""" + + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + if request.method != "GET": + return response + if "application/json" not in response.headers.get("content-type", ""): + return response + + body_parts = [] + async for chunk in response.body_iterator: + body_parts.append(chunk) + body = b"".join(body_parts) + + etag = '"' + hashlib.md5(body, usedforsecurity=False).hexdigest() + '"' + if request.headers.get("if-none-match") == etag: + return StarletteResponse( + status_code=304, + headers={"ETag": etag, "Cache-Control": "no-cache"}, + ) + + headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"} + headers["ETag"] = etag + if not any(k.lower() == "cache-control" for k in headers): + headers["Cache-Control"] = "no-cache" + return StarletteResponse( + content=body, + status_code=response.status_code, + headers=headers, + media_type=response.media_type, + ) + + @asynccontextmanager async def lifespan(app: FastAPI): yield @@ -28,11 +66,13 @@ app = FastAPI( _cors_env = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000") _cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()] +app.add_middleware(ETagMiddleware) app.add_middleware( CORSMiddleware, allow_origins=_cors_origins, allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"], - allow_headers=["Content-Type"], + allow_headers=["Content-Type", "If-None-Match"], + expose_headers=["ETag"], ) app.include_router(domains.router) diff --git a/api/routers/domains.py b/api/routers/domains.py index 274b989..ac62af9 100644 --- a/api/routers/domains.py +++ b/api/routers/domains.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,9 +18,11 @@ router = APIRouter(prefix="/domains", tags=["domains"]) @router.get("/", response_model=list[DomainRead]) async def list_domains( + response: Response, status: str | None = Query(None, description="active | archived | all"), 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) if status and status != "all": q = q.where(Domain.status == status) diff --git a/api/routers/repos.py b/api/routers/repos.py index 5000d94..1b14c99 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -9,7 +9,7 @@ import uuid from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -50,9 +50,11 @@ router = APIRouter(prefix="/repos", tags=["repos"]) @router.get("/", response_model=list[RepoRead]) async def list_repos( + response: Response, domain: str | None = None, 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) if domain: domain_row = await session.execute(select(Domain).where(Domain.slug == domain)) diff --git a/api/routers/state.py b/api/routers/state.py index 01a8a10..d66170c 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -379,6 +379,87 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: ] +@router.get("/deps", response_model=list[WorkstreamWithDeps]) +async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]: + """Lightweight dep-graph endpoint: open workstreams with their dependency edges only. + + Returns the same structure as open_workstreams in /state/summary but skips + the 10-table full-summary computation. Task counts are omitted (all zero). + Used by workstreams.md and dependencies.md which only need dep edges. + """ + open_ws_rows = await session.execute( + select(Workstream) + .options(noload("*")) + .where(Workstream.status.in_(["active", "blocked"])) + .order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at) + ) + open_ws = list(open_ws_rows.scalars().all()) + + open_ws_ids = [w.id for w in open_ws] + dep_rows = [] + if open_ws_ids: + dep_result = await session.execute( + select(WorkstreamDependency).where( + (WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) + | (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) + ) + ) + dep_rows = list(dep_result.scalars().all()) + + dep_ws_ids: set = set() + dep_task_ids: set = set() + for d in dep_rows: + dep_ws_ids.add(d.from_workstream_id) + if d.to_workstream_id: + dep_ws_ids.add(d.to_workstream_id) + if d.to_task_id: + dep_task_ids.add(d.to_task_id) + + ws_lookup: dict = {w.id: w for w in open_ws} + extra_ids = dep_ws_ids - set(ws_lookup.keys()) + if extra_ids: + extra_rows = await session.execute( + select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids)) + ) + for w in extra_rows.scalars(): + ws_lookup[w.id] = w + + task_lookup: dict = {} + if dep_task_ids: + task_rows = await session.execute(select(Task).options(noload("*")).where(Task.id.in_(dep_task_ids))) + task_lookup = {t.id: t for t in task_rows.scalars().all()} + + dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} + for d in dep_rows: + from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id + if from_id in dep_index and to_id and to_id in ws_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type, + workstream_id=to_id, workstream_slug=ws_lookup[to_id].slug, + workstream_title=ws_lookup[to_id].title, description=d.description, + )) + if from_id in dep_index and task_id and task_id in task_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, target_type="task", relationship_type=d.relationship_type, + task_id=task_id, task_title=task_lookup[task_id].title, description=d.description, + )) + if to_id and to_id in dep_index and from_id in ws_lookup: + dep_index[to_id]["blocks"].append(WorkstreamDepStub( + dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type, + workstream_id=from_id, workstream_slug=ws_lookup[from_id].slug, + workstream_title=ws_lookup[from_id].title, description=d.description, + )) + + return [ + WorkstreamWithDeps( + **WorkstreamRead.model_validate(w).model_dump(), + depends_on=dep_index[w.id]["depends_on"], + blocks=dep_index[w.id]["blocks"], + ) + for w in open_ws + ] + + _PRIORITY_RANK = { TaskPriority.critical: 0, TaskPriority.high: 1, diff --git a/api/routers/topics.py b/api/routers/topics.py index f26bfea..17e3cbe 100644 --- a/api/routers/topics.py +++ b/api/routers/topics.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -23,9 +23,11 @@ async def _resolve_domain_id(domain_slug: str, session: AsyncSession) -> uuid.UU @router.get("/", response_model=list[TopicRead]) async def list_topics( + response: Response, status: TopicStatus | None = None, session: AsyncSession = Depends(get_session), ) -> list[Topic]: + response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" q = select(Topic) if status: q = q.where(Topic.status == status)