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:
2026-05-11 17:26:30 +02:00
parent 88ff588fb0
commit b832032cc3
5 changed files with 131 additions and 4 deletions

View File

@@ -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,