feat(forgejo-metrics): adjust line stats caching strategy and improve retry logic for contributor stats

This commit is contained in:
null 2026-05-22 16:19:15 -05:00
parent c04ab6ac8b
commit dd9681925e
3 changed files with 29 additions and 41 deletions

View File

@ -36,7 +36,8 @@ if TYPE_CHECKING:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_line_stats_cache: dict[str, tuple[float, int, int, bool]] = {} _line_stats_cache: dict[str, tuple[float, int, int, bool]] = {}
_line_stats_fetching: set[str] = set() _line_stats_fetching: set[str] = set()
_LINE_STATS_TTL = 300 # seconds before a re-fetch is triggered _LINE_STATS_TTL_HIT = 300 # 5 min — re-fetch cadence once real data is cached
_LINE_STATS_TTL_MISS = 30 # 30 s — retry cadence while Forgejo is still computing
async def _bg_fetch_line_stats( async def _bg_fetch_line_stats(
@ -383,8 +384,9 @@ async def get_forgejo_heatmap(
cached = _line_stats_cache.get(cache_key) cached = _line_stats_cache.get(cache_key)
now = _time.monotonic() now = _time.monotonic()
ttl = _LINE_STATS_TTL_HIT if (cached and cached[3]) else _LINE_STATS_TTL_MISS
if cache_key not in _line_stats_fetching and ( if cache_key not in _line_stats_fetching and (
cached is None or now - cached[0] > _LINE_STATS_TTL cached is None or now - cached[0] > ttl
): ):
# Normalise base_url the same way get_forgejo_client() does, eagerly, # Normalise base_url the same way get_forgejo_client() does, eagerly,
# so the background task never touches a potentially-closed session. # so the background task never touches a potentially-closed session.

View File

@ -353,28 +353,22 @@ class ForgejoAPIClient:
async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]: async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]:
"""Fetch per-contributor weekly stats for a repository. """Fetch per-contributor weekly stats for a repository.
Returns (contributors, has_data). On the first call Forgejo may return Returns (contributors, has_data). Forgejo returns HTTP 202 while it
HTTP 202 ("computing") we wait 2 s and retry once so the stats are computes stats that is not an error, it just means the caller should
available on the next dashboard load even if not this one. retry later. has_data=False signals the 202 case; the application layer
uses a short retry TTL so it probes again within ~30 s.
Each contributor has a ``weeks`` array with ``w`` (Unix timestamp of Each contributor dict has a ``weeks`` array with ``w`` (Unix timestamp),
week start), ``a`` (additions), and ``d`` (deletions). ``a`` (additions), and ``d`` (deletions).
""" """
import asyncio as _asyncio
client = await self._get_client() client = await self._get_client()
url = f"/api/v1/repos/{owner}/{repo}/stats/contributors" response = await client.get(
response = await client.get(url) f"/api/v1/repos/{owner}/{repo}/stats/contributors"
)
if response.status_code == 202: if response.status_code == 202:
# Forgejo is computing — wait briefly then try once more return [], False # still computing — caller will retry shortly
await _asyncio.sleep(2)
response = await client.get(url)
if response.status_code == 202:
return [], False # still computing after retry
if response.status_code == 404: if response.status_code == 404:
return [], True # no data, but not a 202 — treat as "has_data" return [], True # repo exists but no commits; treat as data-present
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return (data if isinstance(data, list) else []), True return (data if isinstance(data, list) else []), True

View File

@ -12,12 +12,12 @@ interface ForgejoHeatmapProps {
isLoading?: boolean; isLoading?: boolean;
} }
const CELL = 13; const CELL = 16;
const GAP = 3; const GAP = 4;
const STRIDE = CELL + GAP; const STRIDE = CELL + GAP;
const WEEKS = 27; const WEEKS = 27;
const LEFT = 28; const LEFT = 32;
const TOP = 18; const TOP = 20;
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
@ -33,7 +33,7 @@ const LEVEL_FILL = [
const VIOLET = "rgba(139,92,246,1)"; const VIOLET = "rgba(139,92,246,1)";
const GREEN = "rgba(52,211,153,1)"; const GREEN = "rgba(52,211,153,1)";
const RED = "rgba(248,113,113,1)"; const RED = "rgba(248,113,113,1)";
const LABEL_CLR = "rgba(139,92,246,0.65)"; const LABEL_CLR = "rgba(255,255,255,0.70)"; // white labels
function toLevel(count: number): number { function toLevel(count: number): number {
if (count === 0) return 0; if (count === 0) return 0;
@ -166,23 +166,15 @@ export function ForgejoHeatmap({
<span>More</span> <span>More</span>
</div> </div>
{/* ── Contributions badge ──────────────────────────────────── */} {/* ── Contributions summary ────────────────────────────────── */}
<div className="flex justify-center"> <p className="text-center">
<div <span className="text-2xl font-bold tabular-nums" style={{ color: VIOLET }}>
className="inline-flex items-baseline gap-2 rounded-full px-5 py-2" {totalEvents.toLocaleString()}
style={{ </span>
background: "rgba(139,92,246,0.10)", <span className="ml-1.5 text-sm" style={{ color: "rgba(255,255,255,0.70)" }}>
border: "1px solid rgba(139,92,246,0.25)", contributions across all tracked repositories in the last 6 months
}} </span>
> </p>
<span className="text-2xl font-bold tabular-nums" style={{ color: VIOLET }}>
{totalEvents.toLocaleString()}
</span>
<span className="text-sm font-medium" style={{ color: LABEL_CLR }}>
contributions across all tracked repositories in the last 6 months
</span>
</div>
</div>
{/* ── Line stats ──────────────────────────────────────────── */} {/* ── Line stats ──────────────────────────────────────────── */}
{hasLineStats ? ( {hasLineStats ? (