From cd862103b8ae7fab09885b04569ff3bcf20a8be6 Mon Sep 17 00:00:00 2001 From: IK Date: Tue, 22 Sep 2026 20:11:02 +0700 Subject: [PATCH] perf: cache media manifest revisions --- apps/api/app/media_catalog.py | 26 +++++++++++++++++++++----- apps/api/app/routers/media.py | 7 +++++-- apps/api/tests/test_media_catalog.py | 16 ++++++++++++++++ docs/ROADMAP.md | 2 +- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index 22a0a4e..e50907f 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -11,6 +11,22 @@ WATERBODY_MEDIA_ROLES = {"waterbody_cover", "waterbody_map", "waterbody_depth_ma TACKLE_MEDIA_ROLES = {"tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"} KNOWN_MEDIA_ROLES = WATERBODY_MEDIA_ROLES | TACKLE_MEDIA_ROLES MEDIA_ROLES_BY_ENTITY = {"waterbody": WATERBODY_MEDIA_ROLES, "tackle": TACKLE_MEDIA_ROLES} +_manifest_cache: tuple[Path, int, int, dict] | None = None + + +def _read_manifest() -> dict: + """Read the manifest once per file revision in this API process.""" + global _manifest_cache + path = (MEDIA_ROOT / "manifest.json").resolve() + stat = path.stat() + marker = (path, stat.st_mtime_ns, stat.st_size) + if _manifest_cache and _manifest_cache[:3] == marker: + return _manifest_cache[3] + manifest = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise ValueError("media manifest must be an object") + _manifest_cache = (*marker, manifest) + return manifest def _source_system(source_page: object) -> str: @@ -30,7 +46,7 @@ def _source_system(source_page: object) -> str: def media_manifest_version() -> int: - manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + manifest = _read_manifest() return max(1, int(manifest.get("version", 1))) @@ -47,7 +63,7 @@ def _public_role_allowed(entity_type: str | None, role: object) -> bool: def published_assets(entity_type: str | None = None, media_role: str | None = None) -> list[dict]: - manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + manifest = _read_manifest() result = [] for item in manifest.get("assets", []): if item.get("status") != "approved" or not item.get("sha256") or not item.get("local_path"): @@ -90,7 +106,7 @@ def published_assets(entity_type: str | None = None, media_role: str | None = No def published_file(digest: str) -> tuple[Path, str] | None: if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): return None - manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + manifest = _read_manifest() item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None) media_type = None local_path = None @@ -118,7 +134,7 @@ def review_assets( entity_type: str | None = None, status: str | None = None, media_role: str | None = None, query: str | None = None, ) -> list[dict]: - manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + manifest = _read_manifest() result = [] for item in manifest.get("assets", []): item_status = str(item.get("status") or "") @@ -161,7 +177,7 @@ def review_assets( def review_file(digest: str) -> tuple[Path, str] | None: if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): return None - manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + manifest = _read_manifest() item = next((row for row in manifest.get("assets", []) if row.get("sha256") == digest and row.get("status") in {"approved", "upgrade_queued", "upgrade_stored"}), None) if not item or not item.get("local_path"): return None diff --git a/apps/api/app/routers/media.py b/apps/api/app/routers/media.py index 14ed838..0a4994d 100644 --- a/apps/api/app/routers/media.py +++ b/apps/api/app/routers/media.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Response from fastapi.responses import FileResponse -from ..media_catalog import KNOWN_MEDIA_ROLES, published_assets, published_file +from ..media_catalog import KNOWN_MEDIA_ROLES, media_manifest_version, published_assets, published_file router = APIRouter() @@ -9,6 +9,7 @@ router = APIRouter() @router.get("/api/v1/media/catalog") def media_catalog( + response: Response, entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"), media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"), ) -> list[dict]: @@ -16,6 +17,8 @@ def media_catalog( raise HTTPException(status_code=422, detail="media_role requires waterbody or tackle entity_type") if media_role and media_role not in KNOWN_MEDIA_ROLES: raise HTTPException(status_code=422, detail="unknown media role") + response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=60" + response.headers["X-Media-Manifest-Version"] = str(media_manifest_version()) return published_assets(entity_type, media_role) diff --git a/apps/api/tests/test_media_catalog.py b/apps/api/tests/test_media_catalog.py index 3c8f839..cdf8ca0 100644 --- a/apps/api/tests/test_media_catalog.py +++ b/apps/api/tests/test_media_catalog.py @@ -32,3 +32,19 @@ def test_media_catalog_does_not_infer_official_source_from_url_substrings(tmp_pa assert rows["e" * 64]["source_system"] == "unknown" assert rows["f" * 64]["source_system"] == "rf4db" + + +def test_media_manifest_cache_invalidates_after_revision(tmp_path, monkeypatch) -> None: + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps({"version": 1, "assets": [ + {"status": "approved", "sha256": "a" * 64, "local_path": "one.webp", "entity_type": "fish", "entity_key": "pike"}, + ]}), encoding="utf-8") + monkeypatch.setattr(media_catalog, "MEDIA_ROOT", tmp_path) + assert [row["id"] for row in media_catalog.published_assets()] == ["a" * 64] + + manifest_path.write_text(json.dumps({"version": 2, "assets": [ + {"status": "approved", "sha256": "b" * 64, "local_path": "two.webp", "entity_type": "fish", "entity_key": "pike"}, + ]}), encoding="utf-8") + + assert media_catalog.media_manifest_version() == 2 + assert [row["id"] for row in media_catalog.published_assets()] == ["b" * 64] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 98183c9..fe16ce9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -37,7 +37,7 @@ R-пункты уточняют критерии существующих B/G/U/ - [x] **R11 · P2 · Честное качество и происхождение media (B25).** API использует точный hostname allowlist для `rf4db`/`rf4map`/`rf4-stat`, неизвестные и malformed URL получают `unknown`, а не ложный `rf4-official`. UI различает низкое разрешение и наличие проверенной card-версии, не утверждая отсутствие альтернатив без данных manifest. Cache/revocation и production visual acceptance остаются отдельными gates. - [x] **R14 · P2 · Подтверждённый каталог и demo seed.** `SEED_DEMO_DATA=false` больше не создаёт редакционные описания тестовых точек; demo-уловы имеют отдельный `seed:` namespace и catalog audit считает их отдельно от подтверждённых reports. Loaded canonical counts и external staging остаются раздельными, media count не используется как catalog count. - [x] **R15 · P2 · Восстановление пользовательских действий.** Report UI теперь различает idempotency-конфликт и обычную ошибку, восстанавливает draft и предлагает повтор с новым ключом; plan получил восьмисекундный undo после удаления с обработкой storage errors. Draft сохраняется при 422/429/timeout, повтор не требует заново заполнять весь улов. -- [ ] **R16 · P2 · Производительность реальных endpoints.** Измерить ORM/RSS/SQL и чтение manifest, затем bounded aggregation и digest/mtime cache по результатам. Критерий: воспроизводимый endpoint budget на крупных fixtures; Redis заранее не добавлять. +- [ ] **R16 · P2 · Производительность реальных endpoints.** Первый шаг выполнен: public media catalog использует process-local digest/mtime cache manifest с инвалидированием при изменении файла, API отдаёт короткий `Cache-Control` и manifest version. Осталось измерить ORM/RSS/SQL на крупных fixtures, завершить bounded aggregation и зафиксировать воспроизводимый endpoint budget; Redis заранее не добавлять. - [x] **R17 · P2 · Навигация и SEO снастей.** Sitemap теперь включает `/tackle`, все пагинированные canonical item/rig detail URL и не добавляет фильтры/analytics; detail layout оставляет missing/unavailable/404 страницы noindex, а каталог ведёт в canonical карточки. При недоступном API сохраняется последний корректный sitemap. - [ ] **R12 · P2 · Визуальная приёмка наполненных состояний (D07/A06/U06/G09).** Изолированные данные: длинные карточки, компоненты, конфликты, ошибки, authenticated admin; light/dark mobile/desktop, keyboard/contrast и screenshots с revision. Критерий: подтверждены действия и читаемость, а не только DOM/overflow. - [ ] **R13 · P2 · Зелёные проверки и CI artifacts.** Обновить два устаревших контракта, адресные regressions исправленных дефектов, успешные visual artifacts, Node/Python baseline и отдельный dependency audit. Критерий: suites зелёные, skip объяснён, отчёт связан с commit. Известные падения учитывать при каждом feature-коммите.