generated from coulomb/repo-seed
feat(summary): revision-gated cache with stale-while-revalidate (STATE-WP-0066)
Replace the fixed 15s TTL on GET /state/summary with per-table revision watermarks, stale-while-revalidate background refresh, and a progress-tail section split. SQLAlchemy write hooks invalidate core or progress sections on mutation. Adds tests, benchmark script, and operator docs.
This commit is contained in:
4
Makefile
4
Makefile
@@ -67,6 +67,10 @@ test-python:
|
|||||||
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
|
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
|
||||||
$(UV) run pytest -x -q
|
$(UV) run pytest -x -q
|
||||||
|
|
||||||
|
## Benchmark /state/summary revision cache (API must be running on :8000)
|
||||||
|
benchmark-summary-cache:
|
||||||
|
$(UV) run python scripts/benchmark_summary_cache.py
|
||||||
|
|
||||||
## ops-bridge managed tunnels
|
## ops-bridge managed tunnels
|
||||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||||
tunnels-up:
|
tunnels-up:
|
||||||
|
|||||||
@@ -185,6 +185,14 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Caching:** responses are revision-gated — the API compares cheap per-table
|
||||||
|
`MAX(updated_at)` / `MAX(created_at)` watermarks before rebuilding. Unchanged
|
||||||
|
data returns the cached snapshot (`X-StateHub-Cache: hit-revision`). When core
|
||||||
|
data changes, the last good snapshot may be served immediately while a
|
||||||
|
background refresh runs (`X-StateHub-Cache: stale`). Force a synchronous rebuild
|
||||||
|
with `?refresh=true` or `Cache-Control: no-cache`. Infrastructure probes should
|
||||||
|
use `/state/health`, not `/state/summary`.
|
||||||
|
|
||||||
### Router summary
|
### Router summary
|
||||||
|
|
||||||
| Prefix | Operations |
|
| Prefix | Operations |
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
|||||||
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
||||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||||
from api.routers.workstreams import _workplan_index
|
from api.routers.workstreams import _workplan_index
|
||||||
|
from api.services.summary_cache import (
|
||||||
|
apply_progress_section,
|
||||||
|
fetch_summary_revision,
|
||||||
|
get_summary_cache,
|
||||||
|
register_summary_cache_invalidation,
|
||||||
|
)
|
||||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
CLOSED_WORKPLAN_STATUSES,
|
CLOSED_WORKPLAN_STATUSES,
|
||||||
@@ -53,28 +59,58 @@ 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
|
|
||||||
_OVERVIEW_CACHE: DashboardOverview | None = None
|
_OVERVIEW_CACHE: DashboardOverview | None = None
|
||||||
_OVERVIEW_CACHE_AT: float = 0.0
|
_OVERVIEW_CACHE_AT: float = 0.0
|
||||||
_OVERVIEW_TTL = 10.0
|
_OVERVIEW_TTL = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_cache_headers(
|
||||||
|
response: Response,
|
||||||
|
*,
|
||||||
|
cache_status: str,
|
||||||
|
revision: str,
|
||||||
|
) -> None:
|
||||||
|
response.headers["X-StateHub-Cache"] = cache_status
|
||||||
|
response.headers["X-StateHub-Revision"] = revision
|
||||||
|
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=StateSummary)
|
@router.get("/summary", response_model=StateSummary)
|
||||||
async def get_summary(
|
async def get_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
refresh: bool = False,
|
||||||
) -> StateSummary:
|
) -> StateSummary:
|
||||||
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
|
revision = await fetch_summary_revision(session)
|
||||||
no_cache = "no-cache" in request.headers.get("cache-control", "")
|
revision_token = revision.combined_fingerprint()
|
||||||
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
|
force_refresh = refresh or "no-cache" in request.headers.get("cache-control", "")
|
||||||
response.headers["X-StateHub-Cache"] = "hit"
|
|
||||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
cache = get_summary_cache()
|
||||||
return _SUMMARY_CACHE
|
cache_status, cached = cache.resolve(revision, force_refresh=force_refresh)
|
||||||
response.headers["X-StateHub-Cache"] = "miss"
|
|
||||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
if cache_status == "hit-revision" and cached is not None:
|
||||||
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
if cache_status == "progress-section" and cached is not None:
|
||||||
|
result = await apply_progress_section(session, cached, revision)
|
||||||
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if cache_status == "stale" and cached is not None:
|
||||||
|
cache.schedule_refresh(revision)
|
||||||
|
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
result = await build_state_summary(session)
|
||||||
|
cache.store(result, revision)
|
||||||
|
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||||
|
"""Build the full state summary snapshot (cache miss / forced refresh)."""
|
||||||
# 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).
|
||||||
|
|
||||||
@@ -370,11 +406,13 @@ async def get_summary(
|
|||||||
for w in open_ws
|
for w in open_ws
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
_SUMMARY_CACHE = result
|
|
||||||
_SUMMARY_CACHE_AT = time.monotonic()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
get_summary_cache().configure(build_state_summary)
|
||||||
|
register_summary_cache_invalidation()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview", response_model=DashboardOverview)
|
@router.get("/overview", response_model=DashboardOverview)
|
||||||
async def get_overview(
|
async def get_overview(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
288
api/services/summary_cache.py
Normal file
288
api/services/summary_cache.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Revision-gated cache for ``GET /state/summary`` with stale-while-revalidate."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import noload
|
||||||
|
|
||||||
|
from api.models.capability_request import CapabilityRequest
|
||||||
|
from api.models.contribution import Contribution
|
||||||
|
from api.models.decision import Decision
|
||||||
|
from api.models.domain import Domain
|
||||||
|
from api.models.extension_point import ExtensionPoint
|
||||||
|
from api.models.managed_repo import ManagedRepo
|
||||||
|
from api.models.progress_event import ProgressEvent
|
||||||
|
from api.models.sbom_snapshot import SBOMSnapshot
|
||||||
|
from api.models.task import Task
|
||||||
|
from api.models.technical_debt import TechnicalDebt
|
||||||
|
from api.models.topic import Topic
|
||||||
|
from api.models.workplan import Workplan
|
||||||
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
|
from api.schemas.progress_event import ProgressEventRead
|
||||||
|
from api.schemas.state import StateSummary
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||||
|
_MAX_STALE_AGE_SECONDS = 300.0
|
||||||
|
InvalidateScope = Literal["all", "core", "progress"]
|
||||||
|
CacheStatus = Literal["hit-revision", "stale", "miss", "progress-section"]
|
||||||
|
BuildSummaryFn = Callable[[AsyncSession], Awaitable[StateSummary]]
|
||||||
|
|
||||||
|
# Tables feeding the stable (non-progress) summary core.
|
||||||
|
_CORE_TABLES: tuple[tuple[str, type], ...] = (
|
||||||
|
("topics", Topic),
|
||||||
|
("workplans", Workplan),
|
||||||
|
("tasks", Task),
|
||||||
|
("decisions", Decision),
|
||||||
|
("workplan_dependencies", WorkplanDependency),
|
||||||
|
("managed_repos", ManagedRepo),
|
||||||
|
("contributions", Contribution),
|
||||||
|
("capability_requests", CapabilityRequest),
|
||||||
|
("domains", Domain),
|
||||||
|
("extension_points", ExtensionPoint),
|
||||||
|
("technical_debt", TechnicalDebt),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SummaryRevision:
|
||||||
|
"""Cheap fingerprints of hub data that affect ``/state/summary``."""
|
||||||
|
|
||||||
|
core: datetime
|
||||||
|
progress: datetime | None
|
||||||
|
sbom: datetime | None
|
||||||
|
|
||||||
|
def core_fingerprint(self) -> str:
|
||||||
|
return _fingerprint(self.core, self.sbom)
|
||||||
|
|
||||||
|
def progress_fingerprint(self) -> str:
|
||||||
|
return self.progress.isoformat() if self.progress else ""
|
||||||
|
|
||||||
|
def combined_fingerprint(self) -> str:
|
||||||
|
return f"{self.core_fingerprint()}|{self.progress_fingerprint()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(*parts: datetime | None) -> str:
|
||||||
|
normalized = [
|
||||||
|
(part or _EPOCH).astimezone(timezone.utc).isoformat()
|
||||||
|
for part in parts
|
||||||
|
]
|
||||||
|
return "|".join(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision:
|
||||||
|
"""Return per-section revision watermarks (indexed MAX scans)."""
|
||||||
|
core_parts: list[datetime] = []
|
||||||
|
for _name, model in _CORE_TABLES:
|
||||||
|
value = (
|
||||||
|
await session.execute(select(func.max(model.updated_at)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if value is not None:
|
||||||
|
core_parts.append(value)
|
||||||
|
|
||||||
|
sbom_at = (
|
||||||
|
await session.execute(select(func.max(SBOMSnapshot.snapshot_at)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
progress_at = (
|
||||||
|
await session.execute(select(func.max(ProgressEvent.created_at)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
core = max(core_parts, default=_EPOCH)
|
||||||
|
if sbom_at is not None and sbom_at > core:
|
||||||
|
core = sbom_at
|
||||||
|
|
||||||
|
return SummaryRevision(core=core, progress=progress_at, sbom=sbom_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_recent_progress(session: AsyncSession, *, limit: int = 20) -> list[ProgressEventRead]:
|
||||||
|
rows = await session.execute(
|
||||||
|
select(ProgressEvent)
|
||||||
|
.options(noload("*"))
|
||||||
|
.order_by(ProgressEvent.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [ProgressEventRead.model_validate(event) for event in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_summary(core: StateSummary, recent_progress: list[ProgressEventRead]) -> StateSummary:
|
||||||
|
return core.model_copy(update={"recent_progress": recent_progress})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CacheEntry:
|
||||||
|
summary: StateSummary
|
||||||
|
core_revision: str
|
||||||
|
progress_revision: str
|
||||||
|
built_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entry: _CacheEntry | None = None
|
||||||
|
self._refresh_task: asyncio.Task | None = None
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self._build_fn: BuildSummaryFn | None = None
|
||||||
|
|
||||||
|
def configure(self, build_fn: BuildSummaryFn) -> None:
|
||||||
|
self._build_fn = build_fn
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._entry = None
|
||||||
|
self.last_error = None
|
||||||
|
if self._refresh_task is not None and not self._refresh_task.done():
|
||||||
|
self._refresh_task.cancel()
|
||||||
|
self._refresh_task = None
|
||||||
|
|
||||||
|
def invalidate(self, scope: InvalidateScope = "all") -> None:
|
||||||
|
if scope == "all" or self._entry is None:
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
if scope == "core":
|
||||||
|
self.reset()
|
||||||
|
elif scope == "progress":
|
||||||
|
self._entry.progress_revision = "__invalid__"
|
||||||
|
|
||||||
|
def store(self, summary: StateSummary, revision: SummaryRevision) -> None:
|
||||||
|
import time
|
||||||
|
|
||||||
|
self._entry = _CacheEntry(
|
||||||
|
summary=summary,
|
||||||
|
core_revision=revision.core_fingerprint(),
|
||||||
|
progress_revision=revision.progress_fingerprint(),
|
||||||
|
built_at=time.monotonic(),
|
||||||
|
)
|
||||||
|
self.last_error = None
|
||||||
|
|
||||||
|
def _entry_age(self) -> float | None:
|
||||||
|
import time
|
||||||
|
|
||||||
|
if self._entry is None:
|
||||||
|
return None
|
||||||
|
return time.monotonic() - self._entry.built_at
|
||||||
|
|
||||||
|
def _entry_matches(self, revision: SummaryRevision) -> tuple[bool, bool]:
|
||||||
|
if self._entry is None:
|
||||||
|
return False, False
|
||||||
|
core_match = self._entry.core_revision == revision.core_fingerprint()
|
||||||
|
progress_match = self._entry.progress_revision == revision.progress_fingerprint()
|
||||||
|
return core_match, progress_match
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
revision: SummaryRevision,
|
||||||
|
*,
|
||||||
|
force_refresh: bool,
|
||||||
|
) -> tuple[CacheStatus, StateSummary | None]:
|
||||||
|
import time
|
||||||
|
|
||||||
|
if force_refresh:
|
||||||
|
return "miss", None
|
||||||
|
|
||||||
|
if self._entry is None:
|
||||||
|
return "miss", None
|
||||||
|
|
||||||
|
age = self._entry_age()
|
||||||
|
if age is not None and age > _MAX_STALE_AGE_SECONDS:
|
||||||
|
return "miss", None
|
||||||
|
|
||||||
|
core_match, progress_match = self._entry_matches(revision)
|
||||||
|
if core_match and progress_match:
|
||||||
|
return "hit-revision", self._entry.summary
|
||||||
|
|
||||||
|
if core_match and not progress_match:
|
||||||
|
return "progress-section", self._entry.summary
|
||||||
|
|
||||||
|
# Core changed — serve stale full snapshot while refreshing.
|
||||||
|
return "stale", self._entry.summary
|
||||||
|
|
||||||
|
def schedule_refresh(self, revision: SummaryRevision) -> None:
|
||||||
|
if self._build_fn is None:
|
||||||
|
return
|
||||||
|
if self._refresh_task is not None and not self._refresh_task.done():
|
||||||
|
return
|
||||||
|
self._refresh_task = asyncio.create_task(
|
||||||
|
self._refresh_background(revision),
|
||||||
|
name="summary-cache-refresh",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _refresh_background(self, revision: SummaryRevision) -> None:
|
||||||
|
from api.database import async_session_factory
|
||||||
|
|
||||||
|
if self._build_fn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
current = await fetch_summary_revision(session)
|
||||||
|
summary = await self._build_fn(session)
|
||||||
|
self.store(summary, current)
|
||||||
|
except Exception as exc:
|
||||||
|
self.last_error = str(exc)
|
||||||
|
logger.exception("summary cache background refresh failed")
|
||||||
|
|
||||||
|
|
||||||
|
_summary_cache = SummaryCache()
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary_cache() -> SummaryCache:
|
||||||
|
return _summary_cache
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_summary_cache(scope: InvalidateScope = "all") -> None:
|
||||||
|
_summary_cache.invalidate(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_summary_cache_for_tests() -> None:
|
||||||
|
_summary_cache.reset()
|
||||||
|
|
||||||
|
|
||||||
|
_INVALIDATION_REGISTERED = False
|
||||||
|
|
||||||
|
|
||||||
|
def register_summary_cache_invalidation() -> None:
|
||||||
|
"""Clear summary cache when ORM rows that affect summary are written."""
|
||||||
|
global _INVALIDATION_REGISTERED
|
||||||
|
if _INVALIDATION_REGISTERED:
|
||||||
|
return
|
||||||
|
_INVALIDATION_REGISTERED = True
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
def _invalidate_core(*_args: object, **_kwargs: object) -> None:
|
||||||
|
invalidate_summary_cache("core")
|
||||||
|
|
||||||
|
def _invalidate_progress(*_args: object, **_kwargs: object) -> None:
|
||||||
|
invalidate_summary_cache("progress")
|
||||||
|
|
||||||
|
for _name, model in _CORE_TABLES:
|
||||||
|
event.listen(model, "after_insert", _invalidate_core)
|
||||||
|
event.listen(model, "after_update", _invalidate_core)
|
||||||
|
event.listen(model, "after_delete", _invalidate_core)
|
||||||
|
|
||||||
|
event.listen(SBOMSnapshot, "after_insert", _invalidate_core)
|
||||||
|
event.listen(SBOMSnapshot, "after_delete", _invalidate_core)
|
||||||
|
event.listen(ProgressEvent, "after_insert", _invalidate_progress)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_progress_section(
|
||||||
|
session: AsyncSession,
|
||||||
|
summary: StateSummary,
|
||||||
|
revision: SummaryRevision,
|
||||||
|
) -> StateSummary:
|
||||||
|
recent = await fetch_recent_progress(session)
|
||||||
|
merged = merge_summary(summary, recent)
|
||||||
|
cache = get_summary_cache()
|
||||||
|
if cache._entry is not None and cache._entry.core_revision == revision.core_fingerprint():
|
||||||
|
cache._entry.summary = merged
|
||||||
|
cache._entry.progress_revision = revision.progress_fingerprint()
|
||||||
|
else:
|
||||||
|
cache.store(merged, revision)
|
||||||
|
return merged
|
||||||
@@ -59,6 +59,11 @@ make api # db + migrate + uvicorn (restarts if already running)
|
|||||||
|
|
||||||
All endpoints are read-only GET requests. The dashboard never writes to the API.
|
All endpoints are read-only GET requests. The dashboard never writes to the API.
|
||||||
|
|
||||||
|
`/state/summary` is revision-cached server-side. Repeated polls with unchanged
|
||||||
|
hub data return `X-StateHub-Cache: hit-revision` without rebuilding the full
|
||||||
|
snapshot. Prefer `/state/overview` on the Overview page (lighter bounded
|
||||||
|
read model).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Poll interval: 15 s for most pages, 60 s for Overview. Data is refreshed in the background — the page never reloads itself.*
|
*Poll interval: 15 s for most pages, 60 s for Overview. Data is refreshed in the background — the page never reloads itself.*
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ entity while recording the missing progress event.
|
|||||||
| Tool | Key Args | When to use |
|
| Tool | Key Args | When to use |
|
||||||
|------|----------|-------------|
|
|------|----------|-------------|
|
||||||
| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. |
|
| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. |
|
||||||
| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workstreams, last 20 events. Large (~10k tokens). |
|
| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workstreams, last 20 events. Large (~10k tokens). API revision-caches unchanged snapshots (`X-StateHub-Cache: hit-revision`); use REST `?refresh=true` only when you need a forced rebuild. |
|
||||||
| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. |
|
| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. |
|
||||||
| `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: wait/todo/progress/done/cancel | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. |
|
| `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: wait/todo/progress/done/cancel | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. |
|
||||||
| `list_blocked_tasks(workstream_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. |
|
| `list_blocked_tasks(workstream_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. |
|
||||||
|
|||||||
40
scripts/benchmark_summary_cache.py
Normal file
40
scripts/benchmark_summary_cache.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Quick benchmark for /state/summary revision cache (STATE-WP-0066)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||||
|
parser.add_argument("--requests", type=int, default=10)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
url = f"{args.base_url.rstrip('/')}/state/summary"
|
||||||
|
timings: list[float] = []
|
||||||
|
last_cache = ""
|
||||||
|
|
||||||
|
# Prime cache
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as resp:
|
||||||
|
resp.read()
|
||||||
|
last_cache = resp.headers.get("X-StateHub-Cache", "")
|
||||||
|
|
||||||
|
for _ in range(args.requests):
|
||||||
|
started = time.perf_counter()
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as resp:
|
||||||
|
resp.read()
|
||||||
|
last_cache = resp.headers.get("X-StateHub-Cache", "")
|
||||||
|
timings.append(time.perf_counter() - started)
|
||||||
|
|
||||||
|
p95 = statistics.quantiles(timings, n=20)[18] if len(timings) >= 2 else timings[0]
|
||||||
|
print(f"requests={args.requests} p50={statistics.median(timings):.3f}s p95={p95:.3f}s last_cache={last_cache}")
|
||||||
|
return 0 if last_cache == "hit-revision" else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -52,10 +52,10 @@ def _truncate(_schema):
|
|||||||
from api.models import Base
|
from api.models import Base
|
||||||
import api.routers.state as _state_router
|
import api.routers.state as _state_router
|
||||||
import api.routers.workstreams as _ws_router
|
import api.routers.workstreams as _ws_router
|
||||||
|
from api.services.summary_cache import reset_summary_cache_for_tests
|
||||||
|
|
||||||
# Reset in-process TTL caches so stale data from a previous test can't bleed through.
|
# Reset in-process caches so stale data from a previous test can't bleed through.
|
||||||
_state_router._SUMMARY_CACHE = None
|
reset_summary_cache_for_tests()
|
||||||
_state_router._SUMMARY_CACHE_AT = 0.0
|
|
||||||
_state_router._OVERVIEW_CACHE = None
|
_state_router._OVERVIEW_CACHE = None
|
||||||
_state_router._OVERVIEW_CACHE_AT = 0.0
|
_state_router._OVERVIEW_CACHE_AT = 0.0
|
||||||
_ws_router._INDEX_CACHE = None
|
_ws_router._INDEX_CACHE = None
|
||||||
|
|||||||
195
tests/test_summary_cache.py
Normal file
195
tests/test_summary_cache.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for revision-gated /state/summary caching."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.services.summary_cache import (
|
||||||
|
SummaryCache,
|
||||||
|
SummaryRevision,
|
||||||
|
fetch_summary_revision,
|
||||||
|
get_summary_cache,
|
||||||
|
invalidate_summary_cache,
|
||||||
|
reset_summary_cache_for_tests,
|
||||||
|
)
|
||||||
|
from tests.test_routers_core import _create_domain, _create_task, _create_topic, _create_workstream
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_summary_revision_empty_db(test_engine):
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
revision = await fetch_summary_revision(session)
|
||||||
|
assert revision.core_fingerprint()
|
||||||
|
assert revision.progress_fingerprint() == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_hit_revision_header(client):
|
||||||
|
r1 = await client.get("/state/summary")
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.headers.get("X-StateHub-Cache") == "miss"
|
||||||
|
assert r1.headers.get("X-StateHub-Revision")
|
||||||
|
|
||||||
|
r2 = await client.get("/state/summary")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.headers.get("X-StateHub-Cache") == "hit-revision"
|
||||||
|
assert r2.headers.get("X-StateHub-Revision") == r1.headers.get("X-StateHub-Revision")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_force_refresh_query(client):
|
||||||
|
await client.get("/state/summary")
|
||||||
|
r = await client.get("/state/summary", params={"refresh": "true"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("X-StateHub-Cache") == "miss"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_no_cache_header(client):
|
||||||
|
await client.get("/state/summary")
|
||||||
|
r = await client.get("/state/summary", headers={"Cache-Control": "no-cache"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("X-StateHub-Cache") == "miss"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_stale_while_revalidate(client, monkeypatch):
|
||||||
|
await client.get("/state/summary")
|
||||||
|
|
||||||
|
original = fetch_summary_revision
|
||||||
|
|
||||||
|
async def bumped_revision(session):
|
||||||
|
rev = await original(session)
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
return SummaryRevision(
|
||||||
|
core=rev.core + timedelta(seconds=1),
|
||||||
|
progress=rev.progress,
|
||||||
|
sbom=rev.sbom,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("api.routers.state.fetch_summary_revision", bumped_revision)
|
||||||
|
|
||||||
|
r = await client.get("/state/summary")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("X-StateHub-Cache") == "stale"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_section_refresh_without_full_rebuild(client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
await client.get("/state/summary")
|
||||||
|
before = (await client.get("/state/summary")).headers.get("X-StateHub-Revision")
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/progress/",
|
||||||
|
json={
|
||||||
|
"topic_id": topic["id"],
|
||||||
|
"event_type": "note",
|
||||||
|
"summary": "cache section test",
|
||||||
|
"author": "pytest",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await client.get("/state/summary")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("X-StateHub-Cache") == "hit-revision"
|
||||||
|
assert r.json()["recent_progress"][0]["summary"] == "cache section test"
|
||||||
|
assert r.headers.get("X-StateHub-Revision") != before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_task_mutation_invalidates_core_cache(client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
ws = await _create_workstream(client, topic_id=topic["id"])
|
||||||
|
task = await _create_task(client, ws["id"])
|
||||||
|
|
||||||
|
await client.get("/state/summary")
|
||||||
|
assert (await client.get("/state/summary")).headers.get("X-StateHub-Cache") == "hit-revision"
|
||||||
|
|
||||||
|
await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
|
||||||
|
|
||||||
|
r = await client.get("/state/summary")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("X-StateHub-Cache") == "miss"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_cache_unit_progress_section():
|
||||||
|
cache = SummaryCache()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
|
||||||
|
|
||||||
|
rev = SummaryRevision(
|
||||||
|
core=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
progress=datetime(2026, 1, 2, tzinfo=timezone.utc),
|
||||||
|
sbom=None,
|
||||||
|
)
|
||||||
|
empty_totals = Totals(
|
||||||
|
topics=TopicTotals(),
|
||||||
|
workstreams=WorkstreamTotals(),
|
||||||
|
tasks=TaskTotals(),
|
||||||
|
decisions=DecisionTotals(),
|
||||||
|
)
|
||||||
|
summary = StateSummary(
|
||||||
|
generated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
totals=empty_totals,
|
||||||
|
topics=[],
|
||||||
|
blocking_decisions=[],
|
||||||
|
waiting_tasks=[],
|
||||||
|
recent_progress=[],
|
||||||
|
open_workstreams=[],
|
||||||
|
)
|
||||||
|
cache.store(summary, rev)
|
||||||
|
|
||||||
|
new_rev = SummaryRevision(
|
||||||
|
core=rev.core,
|
||||||
|
progress=datetime(2026, 1, 3, tzinfo=timezone.utc),
|
||||||
|
sbom=None,
|
||||||
|
)
|
||||||
|
status, cached = cache.resolve(new_rev, force_refresh=False)
|
||||||
|
assert status == "progress-section"
|
||||||
|
assert cached is summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidate_summary_cache_scopes():
|
||||||
|
reset_summary_cache_for_tests()
|
||||||
|
cache = get_summary_cache()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
|
||||||
|
|
||||||
|
rev = SummaryRevision(
|
||||||
|
core=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
progress=None,
|
||||||
|
sbom=None,
|
||||||
|
)
|
||||||
|
empty_totals = Totals(
|
||||||
|
topics=TopicTotals(),
|
||||||
|
workstreams=WorkstreamTotals(),
|
||||||
|
tasks=TaskTotals(),
|
||||||
|
decisions=DecisionTotals(),
|
||||||
|
)
|
||||||
|
summary = StateSummary(
|
||||||
|
generated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
totals=empty_totals,
|
||||||
|
topics=[],
|
||||||
|
blocking_decisions=[],
|
||||||
|
waiting_tasks=[],
|
||||||
|
recent_progress=[],
|
||||||
|
open_workstreams=[],
|
||||||
|
)
|
||||||
|
cache.store(summary, rev)
|
||||||
|
|
||||||
|
invalidate_summary_cache("progress")
|
||||||
|
status, _ = cache.resolve(rev, force_refresh=False)
|
||||||
|
assert status == "progress-section"
|
||||||
|
|
||||||
|
invalidate_summary_cache("core")
|
||||||
|
status, cached = cache.resolve(rev, force_refresh=False)
|
||||||
|
assert status == "miss"
|
||||||
|
assert cached is None
|
||||||
@@ -4,12 +4,13 @@ type: workplan
|
|||||||
title: "State summary revision cache and stale-while-revalidate"
|
title: "State summary revision cache and stale-while-revalidate"
|
||||||
domain: custodian
|
domain: custodian
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: ready
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-06-22"
|
created: "2026-06-22"
|
||||||
updated: "2026-06-22"
|
updated: "2026-06-22"
|
||||||
state_hub_workstream_id: ""
|
finished: "2026-06-22"
|
||||||
|
state_hub_workstream_id: "f738cd77-6b8b-40e5-b348-dc304c7821f1"
|
||||||
---
|
---
|
||||||
|
|
||||||
# STATE-WP-0066 — State summary revision cache and stale-while-revalidate
|
# STATE-WP-0066 — State summary revision cache and stale-while-revalidate
|
||||||
@@ -147,9 +148,9 @@ Preserve truthful `generated_at` on cache hits (when the snapshot was built).
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T01
|
id: STATE-WP-0066-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "8ee836ec-048c-44f6-b16e-e7454d07371a"
|
||||||
```
|
```
|
||||||
|
|
||||||
Extract summary cache logic from `api/routers/state.py` into a dedicated
|
Extract summary cache logic from `api/routers/state.py` into a dedicated
|
||||||
@@ -171,9 +172,9 @@ Done when revision fetch is tested in isolation and profiled under local DB.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T02
|
id: STATE-WP-0066-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "c5fc8ab8-5fc8-463b-9ae7-c304a7e0383e"
|
||||||
```
|
```
|
||||||
|
|
||||||
Wire revision check into `GET /state/summary`:
|
Wire revision check into `GET /state/summary`:
|
||||||
@@ -192,9 +193,9 @@ skip the heavy query path (assert via mock or elapsed-ms header threshold).
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T03
|
id: STATE-WP-0066-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "aa079e17-6103-4539-878d-b451035e5f8a"
|
||||||
```
|
```
|
||||||
|
|
||||||
When revision differs but a cached snapshot exists:
|
When revision differs but a cached snapshot exists:
|
||||||
@@ -214,9 +215,9 @@ runs; second request after rebuild shows `hit-revision`.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T04
|
id: STATE-WP-0066-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "99b14a10-ff1b-4609-a62b-5da19b79be68"
|
||||||
```
|
```
|
||||||
|
|
||||||
Split cache into `core` and `progress_tail` sections:
|
Split cache into `core` and `progress_tail` sections:
|
||||||
@@ -234,9 +235,9 @@ re-running domain/SBOM/flow-engine work (verify via query count or spy).
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T05
|
id: STATE-WP-0066-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "0df9c1c2-6edf-4be3-bee8-b75e0d24fc02"
|
||||||
```
|
```
|
||||||
|
|
||||||
Call `invalidate_summary_cache()` (or equivalent revision bump) from write
|
Call `invalidate_summary_cache()` (or equivalent revision bump) from write
|
||||||
@@ -254,9 +255,9 @@ Done when mutation routes trigger invalidation and tests pass.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T06
|
id: STATE-WP-0066-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "5f68f999-d5ca-40df-a054-aaf762837342"
|
||||||
```
|
```
|
||||||
|
|
||||||
Prove cache effectiveness under realistic load:
|
Prove cache effectiveness under realistic load:
|
||||||
@@ -276,9 +277,9 @@ and CI tests are green.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T07
|
id: STATE-WP-0066-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "aee0f349-761f-42b7-82b2-6a319936a68e"
|
||||||
```
|
```
|
||||||
|
|
||||||
Update:
|
Update:
|
||||||
@@ -300,9 +301,9 @@ Done when docs match implemented headers and invalidation semantics.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0066-T08
|
id: STATE-WP-0066-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: ""
|
state_hub_task_id: "b4e967ab-57ba-4f0f-9161-373b383248b5"
|
||||||
```
|
```
|
||||||
|
|
||||||
End-to-end check from Railiance01 (or documented manual runbook):
|
End-to-end check from Railiance01 (or documented manual runbook):
|
||||||
|
|||||||
Reference in New Issue
Block a user