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 \
|
||||
$(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
|
||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||
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
|
||||
|
||||
| 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_dependency import WorkstreamDepStub
|
||||
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.workplan_status import (
|
||||
CLOSED_WORKPLAN_STATUSES,
|
||||
@@ -53,28 +59,58 @@ 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
|
||||
_OVERVIEW_CACHE: DashboardOverview | None = None
|
||||
_OVERVIEW_CACHE_AT: float = 0.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)
|
||||
async def get_summary(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
refresh: bool = False,
|
||||
) -> 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:
|
||||
response.headers["X-StateHub-Cache"] = "hit"
|
||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
||||
return _SUMMARY_CACHE
|
||||
response.headers["X-StateHub-Cache"] = "miss"
|
||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
||||
revision = await fetch_summary_revision(session)
|
||||
revision_token = revision.combined_fingerprint()
|
||||
force_refresh = refresh or "no-cache" in request.headers.get("cache-control", "")
|
||||
|
||||
cache = get_summary_cache()
|
||||
cache_status, cached = cache.resolve(revision, force_refresh=force_refresh)
|
||||
|
||||
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.
|
||||
# AsyncSession does not support concurrent operations (no gather on same session).
|
||||
|
||||
@@ -370,11 +406,13 @@ async def get_summary(
|
||||
for w in open_ws
|
||||
],
|
||||
)
|
||||
_SUMMARY_CACHE = result
|
||||
_SUMMARY_CACHE_AT = time.monotonic()
|
||||
return result
|
||||
|
||||
|
||||
get_summary_cache().configure(build_state_summary)
|
||||
register_summary_cache_invalidation()
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverview)
|
||||
async def get_overview(
|
||||
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.
|
||||
|
||||
`/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.*
|
||||
|
||||
@@ -89,7 +89,7 @@ entity while recording the missing progress event.
|
||||
| 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_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. |
|
||||
| `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. |
|
||||
|
||||
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
|
||||
import api.routers.state as _state_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.
|
||||
_state_router._SUMMARY_CACHE = None
|
||||
_state_router._SUMMARY_CACHE_AT = 0.0
|
||||
# Reset in-process caches so stale data from a previous test can't bleed through.
|
||||
reset_summary_cache_for_tests()
|
||||
_state_router._OVERVIEW_CACHE = None
|
||||
_state_router._OVERVIEW_CACHE_AT = 0.0
|
||||
_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"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: ready
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "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
|
||||
@@ -147,9 +148,9 @@ Preserve truthful `generated_at` on cache hits (when the snapshot was built).
|
||||
|
||||
```task
|
||||
id: STATE-WP-0066-T01
|
||||
status: todo
|
||||
status: done
|
||||
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
|
||||
@@ -171,9 +172,9 @@ Done when revision fetch is tested in isolation and profiled under local DB.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0066-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: ""
|
||||
state_hub_task_id: "c5fc8ab8-5fc8-463b-9ae7-c304a7e0383e"
|
||||
```
|
||||
|
||||
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
|
||||
id: STATE-WP-0066-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: ""
|
||||
state_hub_task_id: "aa079e17-6103-4539-878d-b451035e5f8a"
|
||||
```
|
||||
|
||||
When revision differs but a cached snapshot exists:
|
||||
@@ -214,9 +215,9 @@ runs; second request after rebuild shows `hit-revision`.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0066-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: ""
|
||||
state_hub_task_id: "99b14a10-ff1b-4609-a62b-5da19b79be68"
|
||||
```
|
||||
|
||||
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
|
||||
id: STATE-WP-0066-T05
|
||||
status: todo
|
||||
status: done
|
||||
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
|
||||
@@ -254,9 +255,9 @@ Done when mutation routes trigger invalidation and tests pass.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0066-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: ""
|
||||
state_hub_task_id: "5f68f999-d5ca-40df-a054-aaf762837342"
|
||||
```
|
||||
|
||||
Prove cache effectiveness under realistic load:
|
||||
@@ -276,9 +277,9 @@ and CI tests are green.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0066-T07
|
||||
status: todo
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: ""
|
||||
state_hub_task_id: "aee0f349-761f-42b7-82b2-6a319936a68e"
|
||||
```
|
||||
|
||||
Update:
|
||||
@@ -300,9 +301,9 @@ Done when docs match implemented headers and invalidation semantics.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0066-T08
|
||||
status: todo
|
||||
status: done
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user