generated from coulomb/repo-seed
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.
195 lines
6.1 KiB
Python
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 |