From 7b2d2e460d0e4e4a03f2d1c0f004b26eabf1bdab Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 2 Jul 2026 11:53:41 -0700 Subject: [PATCH] feat(stats): cache staleness/age fields for dep-scan + linear caches (TAP-4558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified tapps_stats.caches surface (TAP-4561) reported raw hit/miss counters only for the dependency-scan and Linear-snapshot caches. Add age_seconds + stale telemetry to both, following the existing age/stale shape the code-graph cache surface already exposes — closing AC1 (hit-rate PLUS staleness) and AC4 (a committed real-cache test, not synthetic registry providers, asserting staleness fields for >=2 caches). - dependency_scan: provider computes age/staleness of the freshest cache entry from its monotonic timestamps against the TTL. - linear_snapshot: record the newest write timestamp on _cache_write; provider derives age + staleness vs the open-bucket TTL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tapps_mcp/server_linear_tools.py | 32 +++++- .../tapps_mcp/tools/dependency_scan_cache.py | 24 +++- .../tests/unit/test_cache_staleness_stats.py | 104 ++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 packages/tapps-mcp/tests/unit/test_cache_staleness_stats.py diff --git a/packages/tapps-mcp/src/tapps_mcp/server_linear_tools.py b/packages/tapps-mcp/src/tapps_mcp/server_linear_tools.py index e3834d9e..2f5fa4cd 100644 --- a/packages/tapps-mcp/src/tapps_mcp/server_linear_tools.py +++ b/packages/tapps-mcp/src/tapps_mcp/server_linear_tools.py @@ -36,7 +36,7 @@ import structlog -from tapps_core.cache import AtomicJsonCache, register_cache_stats +from tapps_core.cache import AtomicJsonCache, TTLStaleness, register_cache_stats from tapps_core.config.settings import load_settings from tapps_mcp.mcp_register import register_tool from tapps_mcp.server_helpers import error_response, success_response @@ -146,7 +146,33 @@ def _ttl_for_state(state: str | None, ttl_open: int, ttl_closed: int) -> int: # ADR-0029 / TAP-4561: unified cache-stats counters (snapshot reads/writes). _snapshot_stats: dict[str, int] = {"hits": 0, "misses": 0, "writes": 0} -register_cache_stats("linear_snapshot", lambda: dict(_snapshot_stats)) +# TAP-4558: wall-clock timestamp of the most recent snapshot write (0 == never). +_snapshot_last_write_ts: float = 0.0 + + +def _linear_snapshot_stats() -> dict[str, Any]: + """Stats provider: counters + staleness/age of the freshest write (TAP-4558). + + ``age_seconds`` is the age of the most-recently written snapshot (``None`` + until the first write); ``stale`` reports whether that freshest write has + already aged past the open-bucket TTL — the conservative (shorter) bound, so + a freshest snapshot older than it is definitively stale. This gives the + unified ``tapps_stats.caches`` surface the same age/staleness signal the + code-graph cache already exposes. + """ + out: dict[str, Any] = dict(_snapshot_stats) + if _snapshot_last_write_ts <= 0: + out["age_seconds"] = None + out["stale"] = None + return out + ttl_open = load_settings().linear_cache_ttl_open_seconds + age = time.time() - _snapshot_last_write_ts + out["age_seconds"] = round(age, 1) + out["stale"] = TTLStaleness(float(ttl_open)).is_stale(_snapshot_last_write_ts) + return out + + +register_cache_stats("linear_snapshot", _linear_snapshot_stats) def _cache_read(cache_dir: Path, cache_key: str) -> dict[str, Any] | None: @@ -179,6 +205,8 @@ def _cache_write( # indent=None keeps the compact json.dumps byte layout from before. AtomicJsonCache.write_json(cache_file, payload, indent=None) _snapshot_stats["writes"] += 1 + global _snapshot_last_write_ts + _snapshot_last_write_ts = time.time() except OSError as exc: logger.debug("linear_cache_write_failed", key=cache_key, exc=str(exc)) diff --git a/packages/tapps-mcp/src/tapps_mcp/tools/dependency_scan_cache.py b/packages/tapps-mcp/src/tapps_mcp/tools/dependency_scan_cache.py index 1b43582b..1951f8cc 100644 --- a/packages/tapps-mcp/src/tapps_mcp/tools/dependency_scan_cache.py +++ b/packages/tapps-mcp/src/tapps_mcp/tools/dependency_scan_cache.py @@ -19,7 +19,29 @@ # ADR-0029 / TAP-4561: unified cache-stats counters. _stats: dict[str, int] = {"hits": 0, "misses": 0} -register_cache_stats("dependency_scan", lambda: {**_stats, "size": len(_cache)}) + + +def _dependency_scan_stats() -> dict[str, object]: + """Stats provider: counters + staleness/age of the freshest entry (TAP-4558). + + ``age_seconds`` is the age of the most-recently populated entry (or ``None`` + when the cache is empty); ``stale`` reports whether that freshest entry has + already aged past the TTL. This gives the unified ``tapps_stats.caches`` + surface the same age/staleness signal the code-graph cache already exposes. + """ + out: dict[str, object] = {**_stats, "size": len(_cache)} + if _cache: + newest_ts = max(ts for _, ts in _cache.values()) + age = time.monotonic() - newest_ts + out["age_seconds"] = round(age, 1) + out["stale"] = TTLStaleness(float(_CACHE_TTL), now_fn=time.monotonic).is_stale(newest_ts) + else: + out["age_seconds"] = None + out["stale"] = None + return out + + +register_cache_stats("dependency_scan", _dependency_scan_stats) def set_dependency_findings(project_root: str, findings: list[VulnerabilityFinding]) -> None: diff --git a/packages/tapps-mcp/tests/unit/test_cache_staleness_stats.py b/packages/tapps-mcp/tests/unit/test_cache_staleness_stats.py new file mode 100644 index 00000000..d8b90c23 --- /dev/null +++ b/packages/tapps-mcp/tests/unit/test_cache_staleness_stats.py @@ -0,0 +1,104 @@ +"""Real-cache staleness telemetry in the unified stats surface (TAP-4558). + +The unified ``tapps_stats.caches`` surface (TAP-4561) reported raw hit/miss +counters only. TAP-4558 adds ``age_seconds`` + ``stale`` for the dependency-scan +and Linear-snapshot caches so hit-rate *and* staleness health are visible. + +These tests drive the **real** cache modules (not synthetic registry providers) +and assert the staleness fields surface through ``collect_cache_stats`` for at +least two distinct caches — the AC4 requirement the prior synthetic tests missed. +""" + +from __future__ import annotations + +from pathlib import Path + +from tapps_core.cache import collect_cache_stats +from tapps_mcp import server_linear_tools +from tapps_mcp.tools import dependency_scan_cache +from tapps_mcp.tools.dependency_scan_cache import ( + clear_dependency_cache, + set_dependency_findings, +) + + +class TestDependencyScanStaleness: + def test_empty_cache_reports_null_age_and_staleness(self) -> None: + clear_dependency_cache() + stats = dependency_scan_cache._dependency_scan_stats() + assert stats["size"] == 0 + assert stats["age_seconds"] is None + assert stats["stale"] is None + + def test_fresh_entry_reports_age_and_not_stale(self) -> None: + clear_dependency_cache() + set_dependency_findings("/proj-fresh", []) + stats = dependency_scan_cache._dependency_scan_stats() + assert stats["size"] == 1 + assert isinstance(stats["age_seconds"], float) + assert stats["age_seconds"] >= 0 + # Just-written entry is well within the 300 s TTL. + assert stats["stale"] is False + clear_dependency_cache() + + def test_old_entry_reports_stale(self) -> None: + clear_dependency_cache() + # Plant an entry timestamped far in the past (ts=0.0 is past any TTL). + dependency_scan_cache._cache["/proj-old"] = ([], 0.0) + stats = dependency_scan_cache._dependency_scan_stats() + assert stats["stale"] is True + assert isinstance(stats["age_seconds"], float) + clear_dependency_cache() + + +class TestLinearSnapshotStaleness: + def _reset(self) -> None: + server_linear_tools._snapshot_last_write_ts = 0.0 + + def test_no_write_reports_null_age_and_staleness(self) -> None: + self._reset() + stats = server_linear_tools._linear_snapshot_stats() + assert stats["age_seconds"] is None + assert stats["stale"] is None + + def test_write_records_age_and_freshness(self, tmp_path: Path) -> None: + self._reset() + cache_dir = tmp_path / "linear-snapshots" + cache_dir.mkdir(parents=True, exist_ok=True) + server_linear_tools._cache_write( + cache_dir, "team__proj__any__abc123", {"issues": [], "expires_at": 0} + ) + stats = server_linear_tools._linear_snapshot_stats() + assert stats["writes"] >= 1 + assert isinstance(stats["age_seconds"], float) + assert stats["age_seconds"] >= 0 + # Freshest write is within the default 300 s open-bucket TTL. + assert stats["stale"] is False + self._reset() + + +def test_staleness_fields_surface_for_at_least_two_real_caches(tmp_path: Path) -> None: + """AC4: unified surface reports age/staleness for >=2 distinct real caches.""" + clear_dependency_cache() + server_linear_tools._snapshot_last_write_ts = 0.0 + + # Drive both real caches so each has a populated entry / recorded write. + set_dependency_findings("/proj-ac4", []) + cache_dir = tmp_path / "linear-snapshots" + cache_dir.mkdir(parents=True, exist_ok=True) + server_linear_tools._cache_write( + cache_dir, "team__proj__any__ac4", {"issues": [], "expires_at": 0} + ) + + caches = collect_cache_stats() + + for name in ("dependency_scan", "linear_snapshot"): + assert name in caches, f"{name} missing from unified surface" + entry = caches[name] + assert "age_seconds" in entry, f"{name} lacks age_seconds" + assert "stale" in entry, f"{name} lacks stale" + assert entry["age_seconds"] is not None + assert entry["stale"] is False + + clear_dependency_cache() + server_linear_tools._snapshot_last_write_ts = 0.0