generated from coulomb/repo-seed
feat(api): dashboard poll optimisation — T1, T2, T3
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user