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:
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
|
||||
Reference in New Issue
Block a user