From 214b328cd2d1e3aec19dc1c47710d87bdc0674ee Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 7 Sep 2026 18:54:58 +0700 Subject: [PATCH] perf: cache public activity aggregates --- .env.example | 1 + .env.production.example | 1 + README.md | 2 +- apps/api/app/config.py | 1 + apps/api/app/main.py | 15 ++++++++++++-- apps/api/app/public_cache.py | 32 +++++++++++++++++++++++++++++ apps/api/tests/test_public_cache.py | 11 ++++++++++ compose.production.yaml | 1 + deploy/README.md | 2 ++ docs/ROADMAP.md | 2 +- 10 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 apps/api/app/public_cache.py create mode 100644 apps/api/tests/test_public_cache.py diff --git a/.env.example b/.env.example index 7b9a50c..fadb006 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ OFFICIAL_RECORDS_CATEGORY=records OFFICIAL_IMPORT_REQUIRED=false IMPORT_INTERVAL_SECONDS=3600 COMMUNITY_IMPORT_INTERVAL_SECONDS=1800 +PUBLIC_CACHE_SECONDS=20 RF4MAP_POINT_URL=https://rf4map.ru/points/275 RF4POSTS_SPOT_URL=https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee RATE_LIMIT_SECRET=change-rate-limit-secret diff --git a/.env.production.example b/.env.production.example index 8709b2c..2efb29a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -10,6 +10,7 @@ POSTGRES_PASSWORD=replace-with-long-random-value DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter APP_VERSION=0.1.0 APP_REVISION=replace-with-git-commit-sha +PUBLIC_CACHE_SECONDS=20 ADMIN_TOKEN=replace-with-at-least-32-random-characters RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters diff --git a/README.md b/README.md index ce29c51..2bac82f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ docker compose up --build Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным. -API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров. +API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд и очищается после публикации, модерации или удаления; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров. Остановка: diff --git a/apps/api/app/config.py b/apps/api/app/config.py index ff8a8e7..4f24315 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): retention_published_payload_days: int = Field(default=365, ge=90) import_interval_seconds: int = Field(default=3600, ge=3600) community_import_interval_seconds: int = Field(default=1800, ge=1800) + public_cache_seconds: int = Field(default=20, ge=1, le=300) rf4map_point_url: str = "https://rf4map.ru/points/275" rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee" rate_limit_secret: str = "change-rate-limit-secret" diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 04511e3..064e0b0 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -26,6 +26,7 @@ from .importer import ImportAlreadyRunning, ImportSourceError, import_records, n from .logging_config import configure_logging from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .readiness import readiness_report +from .public_cache import public_cache from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot @@ -110,7 +111,7 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, @app.get("/api/v1/activity", response_model=list[ActivityOut]) def activity( - db: Db, hours: int = Query(24), + db: Db, response: Response, hours: int = Query(24), waterbody: str | None = None, fish: str | None = None, method: str | None = None, sort: Literal["activity", "confidence", "freshness"] = "activity", @@ -118,6 +119,12 @@ def activity( ) -> list[ActivityOut]: if hours not in {6, 12, 24, 72}: raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72") + response.headers["Cache-Control"] = f"public, max-age={settings.public_cache_seconds}" + cache_key = ("activity", hours, waterbody, fish, method, sort, limit, offset) + cached = public_cache.get(cache_key, settings.public_cache_seconds) + if cached is not None: + response.headers["X-Cache"] = "HIT" + return cached rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method) keys = { "activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)), @@ -125,7 +132,8 @@ def activity( "freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)), } rows.sort(key=keys[sort], reverse=True) - return rows[offset:offset + limit] + response.headers["X-Cache"] = "MISS" + return public_cache.set(cache_key, rows[offset:offset + limit]) def _spot_or_404(db: Session, spot_id: UUID) -> Spot: @@ -372,6 +380,7 @@ def admin_publish_external_observation( report = publish_observation(db, observation) except ExternalReviewError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + public_cache.invalidate() return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status) @@ -454,6 +463,7 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato report.moderation_status = ModerationStatus(payload.status) db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason)) db.commit() + public_cache.invalidate() return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value) @@ -476,6 +486,7 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad report.raw_payload = None db.add(ModerationEvent(catch_report=report, created_at=report.deleted_at, previous_status=previous, new_status=ModerationStatus.rejected, moderator=moderator, reason="user report deleted and anonymized")) db.commit() + public_cache.invalidate() return Response(status_code=204) diff --git a/apps/api/app/public_cache.py b/apps/api/app/public_cache.py new file mode 100644 index 0000000..4fa46bc --- /dev/null +++ b/apps/api/app/public_cache.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from copy import deepcopy +from threading import Lock +from time import monotonic +from typing import Any + + +class PublicResponseCache: + def __init__(self) -> None: + self._items: dict[tuple[Any, ...], tuple[float, Any]] = {} + self._lock = Lock() + + def get(self, key: tuple[Any, ...], ttl_seconds: int) -> Any | None: + with self._lock: + item = self._items.get(key) + if item is None or monotonic() - item[0] >= ttl_seconds: + self._items.pop(key, None) + return None + return deepcopy(item[1]) + + def set(self, key: tuple[Any, ...], value: Any) -> Any: + with self._lock: + self._items[key] = (monotonic(), deepcopy(value)) + return value + + def invalidate(self) -> None: + with self._lock: + self._items.clear() + + +public_cache = PublicResponseCache() diff --git a/apps/api/tests/test_public_cache.py b/apps/api/tests/test_public_cache.py new file mode 100644 index 0000000..a61fa01 --- /dev/null +++ b/apps/api/tests/test_public_cache.py @@ -0,0 +1,11 @@ +from app.public_cache import PublicResponseCache + + +def test_cache_copies_values_and_invalidates() -> None: + cache = PublicResponseCache() + original = [{"score": 10}] + cache.set(("activity",), original) + original[0]["score"] = 99 + assert cache.get(("activity",), 20) == [{"score": 10}] + cache.invalidate() + assert cache.get(("activity",), 20) is None diff --git a/compose.production.yaml b/compose.production.yaml index 92c6696..b256507 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -121,6 +121,7 @@ services: OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false} SEED_DEMO_DATA: "false" IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600} + PUBLIC_CACHE_SECONDS: ${PUBLIC_CACHE_SECONDS:-20} RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET} RETENTION_SUBMISSION_DAYS: ${RETENTION_SUBMISSION_DAYS:-1} RETENTION_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30} diff --git a/deploy/README.md b/deploy/README.md index f4d8dd8..ea50d93 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -33,6 +33,8 @@ docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛ Перед сборкой запишите текущий `git rev-parse --short HEAD` в `APP_REVISION` файла `.env.production`, чтобы `/ready` однозначно показывал развёрнутый commit. +`PUBLIC_CACHE_SECONDS` задаёт короткий in-process кэш публичного агрегата активности (по умолчанию 20 секунд). Не увеличивайте его без повторной проверки свежести после публикации. + ```bash docker compose --env-file .env.production -f compose.production.yaml config --quiet docker compose --env-file .env.production -f compose.production.yaml build diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0d5c66d..bb9ebb6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -141,7 +141,7 @@ - [x] Добавить scheduler всех пяти разрешённых community-парсеров с устойчивым cooldown ≥30 минут, PostgreSQL lock, журналом запусков и opt-in локальным профилем; backoff после повторных ошибок остаётся отдельным улучшением (7 сентября 2026). - [x] Добавить экспоненциальный backoff community scheduler после повторных ошибок от 30 минут до 24 часов со сбросом после успеха (7 сентября 2026). - [x] Показывать на `/status` безопасное состояние источников: актуален, устарел, временно ограничен, изменился DOM, ожидает запуска или выключен (7 сентября 2026). -- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации. +- [x] Добавить 20-секундный серверный кэш агрегата активности с явной инвалидацией после публикации, модерации и удаления (7 сентября 2026). - [x] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon (7 сентября 2026). - [x] Добавить серверное «Показать ещё» для публичной ленты полевых сигналов с сохранением фильтров и пределом 48 записей (7 сентября 2026). - [x] Объединять одинаковые полевые сигналы в сюжеты без потери уникальных ссылок provenance и показывать число совпадений (7 сентября 2026).