make caching opt-in
Some checks failed
ci / deploy-coolify (push) Has been cancelled
ci / push-image (push) Has been cancelled
ci / test (push) Successful in 7s

This commit is contained in:
Space-Banane
2026-05-29 19:45:02 +02:00
parent 4f0ef035cc
commit d2f0dc646a
6 changed files with 103 additions and 58 deletions

View File

@@ -5,8 +5,9 @@ GITEA_BASE_URL=https://gitea.example.com
GITEA_USERNAME=octocat GITEA_USERNAME=octocat
GITEA_TOKEN= GITEA_TOKEN=
CACHE_TTL_SECONDS=3600 # Leave both unset to disable caching.
CACHE_DIR=/app/cache # CACHE_TTL_SECONDS=3600
# CACHE_DIR=/app/cache
DEFAULT_THEME=light DEFAULT_THEME=light
SERVICE_TITLE=git-activity-merge SERVICE_TITLE=git-activity-merge

View File

@@ -68,11 +68,12 @@ Optional:
- `GITHUB_TOKEN` - `GITHUB_TOKEN`
- `GITEA_TOKEN` - `GITEA_TOKEN`
- `CACHE_TTL_SECONDS` (default: `3600`) - `CACHE_TTL_SECONDS` and `CACHE_DIR` enable caching when both are set
- `CACHE_DIR` (default: `./cache` locally, `/app/cache` in container)
- `DEFAULT_THEME` (`light` or `dark`, default: `light`) - `DEFAULT_THEME` (`light` or `dark`, default: `light`)
- `SERVICE_TITLE` (default: `git-activity-merge`) - `SERVICE_TITLE` (default: `git-activity-merge`)
If you leave `CACHE_TTL_SECONDS` and `CACHE_DIR` undefined, the app skips caching entirely.
### 3. Run ### 3. Run
```bash ```bash

View File

@@ -43,7 +43,9 @@ class ActivityResult:
app = FastAPI(title=os.getenv("SERVICE_TITLE", "git-activity-merge")) app = FastAPI(title=os.getenv("SERVICE_TITLE", "git-activity-merge"))
def get_cache(settings: Settings = Depends(get_settings)) -> FileCache: def get_cache(settings: Settings = Depends(get_settings)) -> FileCache | None:
if settings.cache_dir is None or settings.cache_ttl_seconds is None:
return None
return FileCache(cache_dir=settings.cache_dir, default_ttl_seconds=settings.cache_ttl_seconds) return FileCache(cache_dir=settings.cache_dir, default_ttl_seconds=settings.cache_ttl_seconds)
@@ -88,10 +90,14 @@ def _date_keys(from_date: date, to_date: date) -> list[str]:
async def _fetch_with_cache( async def _fetch_with_cache(
cache: FileCache, cache: FileCache | None,
key: str, key: str,
fetcher, fetcher,
) -> tuple[dict[str, int], bool]: ) -> tuple[dict[str, int], bool]:
if cache is None:
fresh = await fetcher()
return fresh, False
cached = cache.get_json(key, allow_stale=True) cached = cache.get_json(key, allow_stale=True)
if cached and not cached.stale: if cached and not cached.stale:
return {k: int(v) for k, v in cached.value.items()}, False return {k: int(v) for k, v in cached.value.items()}, False
@@ -109,7 +115,7 @@ async def _fetch_with_cache(
async def collect_merged_activity( async def collect_merged_activity(
settings: Settings, settings: Settings,
cache: FileCache, cache: FileCache | None,
options: QueryOptions, options: QueryOptions,
) -> ActivityResult: ) -> ActivityResult:
from_date, to_date, days_count = compute_date_range(options) from_date, to_date, days_count = compute_date_range(options)
@@ -178,7 +184,7 @@ async def health(settings: Settings = Depends(get_settings)) -> dict[str, str]:
@app.get("/activity.json") @app.get("/activity.json")
async def activity_json( async def activity_json(
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
cache: FileCache = Depends(get_cache), cache: FileCache | None = Depends(get_cache),
options: QueryOptions = Depends(get_query_options), options: QueryOptions = Depends(get_query_options),
) -> JSONResponse: ) -> JSONResponse:
try: try:
@@ -203,7 +209,7 @@ async def activity_json(
@app.get("/activity.svg") @app.get("/activity.svg")
async def activity_svg( async def activity_svg(
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
cache: FileCache = Depends(get_cache), cache: FileCache | None = Depends(get_cache),
options: QueryOptions = Depends(get_query_options), options: QueryOptions = Depends(get_query_options),
) -> Response: ) -> Response:
try: try:
@@ -213,9 +219,10 @@ async def activity_svg(
raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc
cache_key = _image_cache_key("svg", options, result) cache_key = _image_cache_key("svg", options, result)
cached = cache.get_json(cache_key) if cache is not None:
if cached is not None: cached = cache.get_json(cache_key)
return Response(content=str(cached.value), media_type="image/svg+xml") if cached is not None:
return Response(content=str(cached.value), media_type="image/svg+xml")
daily_totals = _daily_totals(result.merged) daily_totals = _daily_totals(result.merged)
total = sum(daily_totals.values()) total = sum(daily_totals.values())
@@ -227,14 +234,15 @@ async def activity_svg(
total_contributions=total, total_contributions=total,
theme=options.theme, theme=options.theme,
) )
cache.set_json(cache_key, svg) if cache is not None:
cache.set_json(cache_key, svg)
return Response(content=svg, media_type="image/svg+xml") return Response(content=svg, media_type="image/svg+xml")
@app.get("/activity.png") @app.get("/activity.png")
async def activity_png( async def activity_png(
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
cache: FileCache = Depends(get_cache), cache: FileCache | None = Depends(get_cache),
options: QueryOptions = Depends(get_query_options), options: QueryOptions = Depends(get_query_options),
) -> Response: ) -> Response:
try: try:
@@ -244,10 +252,11 @@ async def activity_png(
raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc raise HTTPException(status_code=502, detail="Failed to fetch activity data") from exc
cache_key = _image_cache_key("png", options, result) cache_key = _image_cache_key("png", options, result)
cached = cache.get_json(cache_key) if cache is not None:
if cached is not None: cached = cache.get_json(cache_key)
png_data = bytes.fromhex(str(cached.value)) if cached is not None:
return Response(content=png_data, media_type="image/png") png_data = bytes.fromhex(str(cached.value))
return Response(content=png_data, media_type="image/png")
daily_totals = _daily_totals(result.merged) daily_totals = _daily_totals(result.merged)
total = sum(daily_totals.values()) total = sum(daily_totals.values())
@@ -264,5 +273,6 @@ async def activity_png(
except Exception as exc: except Exception as exc:
logger.exception("failed to render png") logger.exception("failed to render png")
raise HTTPException(status_code=500, detail="PNG rendering failed") from exc raise HTTPException(status_code=500, detail="PNG rendering failed") from exc
cache.set_json(cache_key, png_data.hex()) if cache is not None:
cache.set_json(cache_key, png_data.hex())
return Response(content=png_data, media_type="image/png") return Response(content=png_data, media_type="image/png")

View File

@@ -14,8 +14,8 @@ class Settings(BaseSettings):
gitea_username: str = Field(alias="GITEA_USERNAME") gitea_username: str = Field(alias="GITEA_USERNAME")
gitea_token: str | None = Field(default=None, alias="GITEA_TOKEN") gitea_token: str | None = Field(default=None, alias="GITEA_TOKEN")
cache_ttl_seconds: int = Field(default=3600, alias="CACHE_TTL_SECONDS") cache_ttl_seconds: int | None = Field(default=None, alias="CACHE_TTL_SECONDS")
cache_dir: str = Field(default="./cache", alias="CACHE_DIR") cache_dir: str | None = Field(default=None, alias="CACHE_DIR")
default_theme: str = Field(default="light", alias="DEFAULT_THEME") default_theme: str = Field(default="light", alias="DEFAULT_THEME")
service_title: str = Field(default="git-activity-merge", alias="SERVICE_TITLE") service_title: str = Field(default="git-activity-merge", alias="SERVICE_TITLE")

View File

@@ -1,28 +1,58 @@
import time import time
import tempfile
from pathlib import Path
from app.cache import FileCache from app.cache import FileCache
from app.main import get_cache
from app.settings import Settings
def test_cache_returns_fresh_entry(tmp_path) -> None: def test_cache_returns_fresh_entry() -> None:
cache = FileCache(cache_dir=str(tmp_path), default_ttl_seconds=10) with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir:
cache.set_json("sample", {"x": 1}) cache = FileCache(cache_dir=temp_dir, default_ttl_seconds=10)
cache.set_json("sample", {"x": 1})
entry = cache.get_json("sample") entry = cache.get_json("sample")
assert entry is not None assert entry is not None
assert entry.stale is False assert entry.stale is False
assert entry.value == {"x": 1} assert entry.value == {"x": 1}
def test_cache_can_serve_stale_when_requested(tmp_path) -> None: def test_cache_can_serve_stale_when_requested() -> None:
cache = FileCache(cache_dir=str(tmp_path), default_ttl_seconds=0) with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir:
cache.set_json("sample", {"x": 1}) cache = FileCache(cache_dir=temp_dir, default_ttl_seconds=0)
time.sleep(0.01) cache.set_json("sample", {"x": 1})
time.sleep(0.01)
fresh = cache.get_json("sample") fresh = cache.get_json("sample")
stale = cache.get_json("sample", allow_stale=True) stale = cache.get_json("sample", allow_stale=True)
assert fresh is None assert fresh is None
assert stale is not None assert stale is not None
assert stale.stale is True assert stale.stale is True
assert stale.value == {"x": 1} assert stale.value == {"x": 1}
def test_cache_is_disabled_when_not_configured() -> None:
settings = Settings(
_env_file=None,
GITHUB_USERNAME="octocat",
GITEA_BASE_URL="https://gitea.example.com",
GITEA_USERNAME="octocat",
)
cache = get_cache(settings)
assert cache is None
def test_cache_works_in_workspace_tempdir() -> None:
with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir:
cache = FileCache(cache_dir=temp_dir, default_ttl_seconds=10)
cache.set_json("sample", {"x": 1})
entry = cache.get_json("sample")
assert entry is not None
assert entry.value == {"x": 1}

View File

@@ -1,3 +1,5 @@
import tempfile
from pathlib import Path
from datetime import date from datetime import date
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -6,30 +8,31 @@ from app.main import ActivityResult, app
from app.settings import get_settings from app.settings import get_settings
def test_activity_svg_returns_svg_content_type(monkeypatch, tmp_path) -> None: def test_activity_svg_returns_svg_content_type(monkeypatch) -> None:
monkeypatch.setenv("GITHUB_USERNAME", "octocat") monkeypatch.setenv("GITHUB_USERNAME", "octocat")
monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com") monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_USERNAME", "octocat") monkeypatch.setenv("GITEA_USERNAME", "octocat")
monkeypatch.setenv("CACHE_DIR", str(tmp_path)) with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp_dir:
get_settings.cache_clear() monkeypatch.setenv("CACHE_DIR", temp_dir)
get_settings.cache_clear()
async def fake_collect_merged_activity(settings, cache, options): async def fake_collect_merged_activity(settings, cache, options):
return ActivityResult( return ActivityResult(
merged={ merged={
"2026-01-01": {"github": 1, "gitea": 2, "total": 3}, "2026-01-01": {"github": 1, "gitea": 2, "total": 3},
"2026-01-02": {"github": 0, "gitea": 0, "total": 0}, "2026-01-02": {"github": 0, "gitea": 0, "total": 0},
}, },
stale=False, stale=False,
from_date=date(2026, 1, 1), from_date=date(2026, 1, 1),
to_date=date(2026, 1, 2), to_date=date(2026, 1, 2),
days_count=2, days_count=2,
) )
monkeypatch.setattr("app.main.collect_merged_activity", fake_collect_merged_activity) monkeypatch.setattr("app.main.collect_merged_activity", fake_collect_merged_activity)
client = TestClient(app) client = TestClient(app)
response = client.get("/activity.svg") response = client.get("/activity.svg")
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["content-type"].startswith("image/svg+xml") assert response.headers["content-type"].startswith("image/svg+xml")
assert "<svg" in response.text assert "<svg" in response.text