Files
state-hub/tests/test_summary_cache.py
tegwick 94c7817339 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.
2026-06-22 16:27:32 +02:00

195 lines
6.1 KiB
Python

"""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