make caching opt-in
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
22
app/main.py
22
app/main.py
@@ -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,6 +219,7 @@ 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)
|
||||||
|
if cache is not None:
|
||||||
cached = cache.get_json(cache_key)
|
cached = cache.get_json(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return Response(content=str(cached.value), media_type="image/svg+xml")
|
return Response(content=str(cached.value), media_type="image/svg+xml")
|
||||||
@@ -227,6 +234,7 @@ async def activity_svg(
|
|||||||
total_contributions=total,
|
total_contributions=total,
|
||||||
theme=options.theme,
|
theme=options.theme,
|
||||||
)
|
)
|
||||||
|
if cache is not None:
|
||||||
cache.set_json(cache_key, svg)
|
cache.set_json(cache_key, svg)
|
||||||
return Response(content=svg, media_type="image/svg+xml")
|
return Response(content=svg, media_type="image/svg+xml")
|
||||||
|
|
||||||
@@ -234,7 +242,7 @@ async def activity_svg(
|
|||||||
@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,6 +252,7 @@ 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)
|
||||||
|
if cache is not None:
|
||||||
cached = cache.get_json(cache_key)
|
cached = cache.get_json(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
png_data = bytes.fromhex(str(cached.value))
|
png_data = bytes.fromhex(str(cached.value))
|
||||||
@@ -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
|
||||||
|
if cache is not None:
|
||||||
cache.set_json(cache_key, png_data.hex())
|
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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
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 = FileCache(cache_dir=temp_dir, default_ttl_seconds=10)
|
||||||
cache.set_json("sample", {"x": 1})
|
cache.set_json("sample", {"x": 1})
|
||||||
|
|
||||||
entry = cache.get_json("sample")
|
entry = cache.get_json("sample")
|
||||||
@@ -14,8 +20,9 @@ def test_cache_returns_fresh_entry(tmp_path) -> None:
|
|||||||
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 = FileCache(cache_dir=temp_dir, default_ttl_seconds=0)
|
||||||
cache.set_json("sample", {"x": 1})
|
cache.set_json("sample", {"x": 1})
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
@@ -26,3 +33,26 @@ def test_cache_can_serve_stale_when_requested(tmp_path) -> 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}
|
||||||
|
|||||||
@@ -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,11 +8,12 @@ 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:
|
||||||
|
monkeypatch.setenv("CACHE_DIR", temp_dir)
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
async def fake_collect_merged_activity(settings, cache, options):
|
async def fake_collect_merged_activity(settings, cache, options):
|
||||||
|
|||||||
Reference in New Issue
Block a user