diff --git a/Makefile b/Makefile index aec86d7..caf48d9 100644 --- a/Makefile +++ b/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: diff --git a/README.md b/README.md index 46e21ee..1bf290f 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/api/routers/state.py b/api/routers/state.py index a29abaf..72c3a10 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -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, diff --git a/api/services/summary_cache.py b/api/services/summary_cache.py new file mode 100644 index 0000000..4061f55 --- /dev/null +++ b/api/services/summary_cache.py @@ -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 \ No newline at end of file diff --git a/dashboard/src/docs/live-data.md b/dashboard/src/docs/live-data.md index 2ec98e0..b4950c6 100644 --- a/dashboard/src/docs/live-data.md +++ b/dashboard/src/docs/live-data.md @@ -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.* diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 0629790..9b61f13 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -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. | diff --git a/scripts/benchmark_summary_cache.py b/scripts/benchmark_summary_cache.py new file mode 100644 index 0000000..4bcd060 --- /dev/null +++ b/scripts/benchmark_summary_cache.py @@ -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()) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 72848ca..0b7a18e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_summary_cache.py b/tests/test_summary_cache.py new file mode 100644 index 0000000..e08f9b2 --- /dev/null +++ b/tests/test_summary_cache.py @@ -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 \ No newline at end of file diff --git a/workplans/STATE-WP-0066-state-summary-revision-cache.md b/workplans/STATE-WP-0066-state-summary-revision-cache.md index 576d77f..8686ae6 100644 --- a/workplans/STATE-WP-0066-state-summary-revision-cache.md +++ b/workplans/STATE-WP-0066-state-summary-revision-cache.md @@ -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):