feat(perf): boucles par dépôt parallélisées et cache stale-while-revalidate (chantier A)
- _parallel_map : asyncio.gather borné (16) pour dashboard, /health, /forecast, /triage et le mur — le patron de /focus, généralisé. Avant : la page attendait la somme des allers- retours Gitea de tous les dépôts, en séquentiel. - Client Gitea : les listes (dépôts, issues, jalons, étoiles) passent en stale-while-revalidate — une entrée périmée est servie immédiatement et rafraîchie en tâche de fond. Les mutations vident le cache et bumpent une génération qui désarme les rafraîchissements en vol. - /forecast calcule des lignes de jalons tous projets (écart ETA/échéance) ; /health trie par sévérité et expose la pastille ; le détail santé reçoit la liste des projets.
This commit is contained in:
+79
-44
@@ -17,8 +17,10 @@ Changes from the 0.1.0 client:
|
||||
- .env loading targets the project root only (no hardcoded home path).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -60,6 +62,8 @@ class GiteaClient:
|
||||
)
|
||||
self._user: dict | None = None
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
self._refreshing: set[str] = set()
|
||||
self._cache_gen = 0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
@@ -105,24 +109,62 @@ class GiteaClient:
|
||||
page += 1
|
||||
return items
|
||||
|
||||
def _cache_get(self, key: str) -> Any | None:
|
||||
if self.cache_ttl <= 0:
|
||||
return None
|
||||
hit = self._cache.get(key)
|
||||
if hit and (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
return None
|
||||
|
||||
def _cache_put(self, key: str, value: Any) -> None:
|
||||
if self.cache_ttl > 0:
|
||||
self._cache[key] = (time.monotonic(), value)
|
||||
|
||||
def _cache_clear(self) -> None:
|
||||
self._cache.clear()
|
||||
# Invalidate in-flight background refreshes: a refresh that
|
||||
# started before a mutation must not resurrect stale data.
|
||||
self._cache_gen += 1
|
||||
|
||||
async def _cached_list(
|
||||
self, key: str, fetcher: Callable[[], Awaitable[Any]]
|
||||
) -> Any:
|
||||
"""Stale-while-revalidate cache for list endpoints (0.19.0).
|
||||
|
||||
Fresh (< TTL): serve the cache. Stale: serve the cached
|
||||
value IMMEDIATELY and refresh it in the background (once
|
||||
per key) — the user never waits on Gitea for a page when
|
||||
any earlier answer exists. Absent: fetch and wait.
|
||||
Mutations clear the cache (see _cache_clear), so writes are
|
||||
never hidden by a stale entry.
|
||||
"""
|
||||
if self.cache_ttl <= 0:
|
||||
return await fetcher()
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None:
|
||||
if (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
if key not in self._refreshing:
|
||||
self._refreshing.add(key)
|
||||
asyncio.create_task(
|
||||
self._refresh_entry(key, fetcher, self._cache_gen)
|
||||
)
|
||||
return hit[1]
|
||||
value = await fetcher()
|
||||
self._cache_put(key, value)
|
||||
return value
|
||||
|
||||
async def _refresh_entry(
|
||||
self,
|
||||
key: str,
|
||||
fetcher: Callable[[], Awaitable[Any]],
|
||||
generation: int,
|
||||
) -> None:
|
||||
try:
|
||||
value = await fetcher()
|
||||
if generation == self._cache_gen:
|
||||
self._cache_put(key, value)
|
||||
except Exception as exc:
|
||||
logger.debug("background refresh failed for %s: %s", key, exc)
|
||||
finally:
|
||||
self._refreshing.discard(key)
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Drop every cached list response (called on webhook events)."""
|
||||
self._cache.clear()
|
||||
self._cache_clear()
|
||||
|
||||
# ── API ────────────────────────────────────────────────
|
||||
|
||||
@@ -141,20 +183,21 @@ class GiteaClient:
|
||||
return self._user
|
||||
|
||||
async def search_repos(self) -> list[dict]:
|
||||
"""All repos visible to the token owner (paginated, cached)."""
|
||||
cached = self._cache_get("search_repos")
|
||||
if cached is not None:
|
||||
return cached
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug("get_user failed, falling back to public search: %s", exc)
|
||||
repos = await self._get_all_pages("/repos/search", **params)
|
||||
self._cache_put("search_repos", repos)
|
||||
return repos
|
||||
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
||||
|
||||
async def _fetch() -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
return await self._cached_list("search_repos", _fetch)
|
||||
|
||||
async def get_repo(self, owner: str, repo: str) -> dict:
|
||||
return await self._get(f"/repos/{owner}/{repo}")
|
||||
@@ -178,14 +221,12 @@ class GiteaClient:
|
||||
(see app.main._is_issue).
|
||||
"""
|
||||
key = f"issues:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
issues = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, issues)
|
||||
return issues
|
||||
|
||||
async def get_all_issues(
|
||||
self, repos: list[dict], state: str = "open"
|
||||
@@ -308,25 +349,19 @@ class GiteaClient:
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[dict]:
|
||||
key = f"milestones:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
milestones = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, milestones)
|
||||
return milestones
|
||||
|
||||
async def list_starred(self) -> list[dict]:
|
||||
"""Repos starred by the token user (issue #20: pinning rides
|
||||
on Gitea stars — the forge stays the source of truth)."""
|
||||
key = "user:starred"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
repos = await self._get_all_pages("/user/starred")
|
||||
self._cache_put(key, repos)
|
||||
return repos
|
||||
return await self._cached_list(
|
||||
"user:starred", lambda: self._get_all_pages("/user/starred")
|
||||
)
|
||||
|
||||
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
|
||||
"""Star / unstar a repo for the token user."""
|
||||
|
||||
Reference in New Issue
Block a user