All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Successful in 16m10s
GET /api/v1/photos/{obj}/{file} берёт сессию через Depends(get_db), делает
SELECT — и держит соединение пула всё время синхронного фетча к ДОМ.РФ.
SQLAlchemy открывает транзакцию на первом запросе, поэтому она ещё и висит
idle-in-transaction.
ЗАМЕР 19.08 по domrf_kn_photos:
всего фотографий 165 208
закешировано локально 1 889 (1.1%)
пойдут ленивым путём 163 319 (98.9%)
То есть почти каждый запрос картинки — удержание соединения на время внешнего
похода (_UPSTREAM_TIMEOUT 8 с, connect 4 с). Пул при этом дефолтный:
create_engine в app/core/db.py без pool_size, значит 5 + 10 overflow = 15
соединений на весь бэкенд. Страница отчёта тянет картинки пачкой — пятнадцать
таких запросов занимают пул целиком, и за ними встают ВСЕ остальные ручки.
Правка: db.close() сразу после того, как значения строки разложены по
локальным переменным, — до фетча и до генерации миниатюры. close() не делает
сессию непригодной: следующий db.execute прозрачно берёт новое соединение.
Тест проверяет ФАКТ отпускания (in_transaction() в момент фетча), а не наличие
db.close() в тексте — иначе он фиксировал бы реализацию, а не свойство. Сессия
в тесте настоящая (SQLite), не мок: на моке in_transaction был бы выдумкой.
Наружу тест не ходит — _fetch_upstream подменён, ДОМ.РФ трогать нельзя.
Двусторонний: против main падает ровно проверка отпускания; два контроля —
«закешированная миниатюра всё ещё отдаётся с диска» и «незарегистрированная
фотография всё ещё 404» — зелёные с обеих сторон.
Хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864).
Refs #2464
165 lines
7.5 KiB
Python
165 lines
7.5 KiB
Python
"""Serve cached DOM.РФ photos.
|
||
|
||
GET /api/v1/photos/{obj_id}/{file_id}?size=thumb|full
|
||
|
||
- size=thumb (default): WebP 320×240 from local cache. If neither the thumb
|
||
nor the original exists locally, lazily fetch the original from DOM.РФ via
|
||
httpx + Basic auth, save it, generate the thumb, and serve. Subsequent
|
||
requests hit the disk cache and return in single-digit ms.
|
||
- size=full: original PNG/JPG if cached locally, else 302 redirect upstream.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Annotated, Literal
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.services.photos.thumbs import make_thumbnail
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter()
|
||
|
||
# Same Basic auth used by the kn-API scraper (1:qwe). Photo CDN accepts it
|
||
# without the TLS-fingerprint challenge that the JSON API enforces.
|
||
_UPSTREAM_AUTH = "Basic MTpxd2U="
|
||
_UPSTREAM_TIMEOUT = httpx.Timeout(8.0, connect=4.0)
|
||
_PHOTOS_DIR = Path("data/raw/domrf_photos")
|
||
|
||
_PHOTO_LOOKUP_SQL = text(
|
||
"""
|
||
SELECT local_path, thumb_path, photo_url, photo_name, size_bytes
|
||
FROM domrf_kn_photos
|
||
WHERE obj_id = :obj AND obj_file_id = :fid
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
|
||
|
||
def _ext_from_name(name: str | None) -> str:
|
||
n = (name or "").lower()
|
||
return ".jpg" if n.endswith((".jpg", ".jpeg")) else ".png"
|
||
|
||
|
||
def _fetch_upstream(url: str) -> bytes | None:
|
||
"""Fetch upstream image bytes. Returns None on any error (logged)."""
|
||
try:
|
||
with httpx.Client(timeout=_UPSTREAM_TIMEOUT, follow_redirects=True) as c:
|
||
r = c.get(url, headers={"Authorization": _UPSTREAM_AUTH})
|
||
if r.status_code == 200 and r.content:
|
||
return r.content
|
||
logger.warning("upstream photo %s status=%s len=%s", url, r.status_code, len(r.content))
|
||
except Exception as e:
|
||
logger.warning("upstream photo %s failed: %s", url, e)
|
||
return None
|
||
|
||
|
||
def _persist_original(obj_id: int, file_id: str, name: str | None, data: bytes) -> Path:
|
||
obj_dir = _PHOTOS_DIR / str(obj_id)
|
||
obj_dir.mkdir(parents=True, exist_ok=True)
|
||
dst = obj_dir / f"{file_id}{_ext_from_name(name)}"
|
||
dst.write_bytes(data)
|
||
return dst
|
||
|
||
|
||
@router.get("/{obj_id}/{file_id}")
|
||
def get_photo(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
file_id: str,
|
||
size: Annotated[Literal["thumb", "full"], Query()] = "thumb",
|
||
) -> Response:
|
||
row = db.execute(_PHOTO_LOOKUP_SQL, {"obj": obj_id, "fid": file_id}).mappings().first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="photo not registered")
|
||
|
||
local_path = row["local_path"]
|
||
thumb_path = row["thumb_path"]
|
||
upstream = row["photo_url"]
|
||
photo_name = row["photo_name"]
|
||
|
||
# #2464-C: отпускаем соединение ДО любой медленной работы — внешнего фетча
|
||
# (до 8 с) и генерации миниатюры. SELECT выше открыл транзакцию (SQLAlchemy
|
||
# начинает её на первом запросе), и без этого она висела бы idle-in-transaction
|
||
# всё это время, занимая соединение пула.
|
||
#
|
||
# Почему это важно именно здесь: закешировано локально 1 889 фотографий из
|
||
# 165 208 (замер 19.08.2026), то есть 98.9% запросов идут «ленивым» путём с
|
||
# походом наружу. Пул дефолтный — `create_engine` в app/core/db.py без
|
||
# pool_size, значит 5 + 10 overflow = 15 соединений на весь бэкенд. Страница
|
||
# отчёта тянет картинки пачкой, и пятнадцать таких запросов занимают пул
|
||
# целиком, а за ними встают ВСЕ остальные ручки.
|
||
#
|
||
# `close()` не делает сессию непригодной: следующий `db.execute` ниже
|
||
# прозрачно возьмёт новое соединение и откроет свою транзакцию. Значения из
|
||
# `row` уже разложены по локальным переменным выше — после закрытия они
|
||
# остаются доступны.
|
||
db.close()
|
||
|
||
headers = {"Cache-Control": "public, max-age=604800, immutable"}
|
||
|
||
# ── size=thumb ──────────────────────────────────────────────────────────
|
||
if size == "thumb":
|
||
if thumb_path and Path(thumb_path).exists():
|
||
return FileResponse(thumb_path, media_type="image/webp", headers=headers)
|
||
|
||
# Generate from existing local original.
|
||
if local_path and Path(local_path).exists():
|
||
generated = make_thumbnail(Path(local_path))
|
||
if generated and generated.exists():
|
||
db.execute(
|
||
text(
|
||
"UPDATE domrf_kn_photos SET thumb_path = :tp"
|
||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||
),
|
||
{"tp": str(generated), "o": obj_id, "f": file_id},
|
||
)
|
||
db.commit()
|
||
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
||
|
||
# Lazy: fetch upstream, persist original + thumb, then serve.
|
||
if upstream:
|
||
data = _fetch_upstream(upstream)
|
||
if data:
|
||
src = _persist_original(obj_id, file_id, photo_name, data)
|
||
# Record the original immediately so a failed thumbnail does not
|
||
# orphan the on-disk file and trigger an eternal re-fetch.
|
||
db.execute(
|
||
text(
|
||
"UPDATE domrf_kn_photos"
|
||
" SET local_path = :lp, downloaded_at = NOW()"
|
||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||
),
|
||
{"lp": str(src), "o": obj_id, "f": file_id},
|
||
)
|
||
db.commit()
|
||
generated = make_thumbnail(src)
|
||
if generated and generated.exists():
|
||
db.execute(
|
||
text(
|
||
"UPDATE domrf_kn_photos SET thumb_path = :tp"
|
||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||
),
|
||
{"tp": str(generated), "o": obj_id, "f": file_id},
|
||
)
|
||
db.commit()
|
||
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
||
|
||
# Last resort — redirect to upstream so the page still renders.
|
||
if upstream:
|
||
return RedirectResponse(upstream, status_code=302)
|
||
raise HTTPException(status_code=404, detail="photo unavailable")
|
||
|
||
# ── size=full ───────────────────────────────────────────────────────────
|
||
if local_path and Path(local_path).exists():
|
||
return FileResponse(local_path, headers=headers)
|
||
if upstream:
|
||
return RedirectResponse(upstream, status_code=302)
|
||
raise HTTPException(status_code=404, detail="full photo unavailable")
|