gendesign/backend/tests/test_2464c_photos_session_release.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

168 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#2464-C: сессия БД не должна держаться на время внешнего HTTP-фетча.
`GET /api/v1/photos/{obj}/{file}` берёт сессию через `Depends(get_db)`, делает
SELECT — и (SQLAlchemy открывает транзакцию на первом запросе) ДЕРЖИТ соединение
пула всё время синхронного похода к ДОМ.РФ.
Цена на проде, замер 19.08.2026 по `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 соединений на весь бэкенд. Страница отчёта тянет
несколько картинок разом — пятнадцать таких запросов занимают пул целиком, и
за ними встают ВСЕ остальные ручки.
Хуже, чем просто занятое соединение: транзакция открыта и висит
idle-in-transaction, что мешает vacuum'у.
Тест проверяет ФАКТ отпускания соединения в момент фетча, а не наличие
`db.close()` в тексте — иначе он бы фиксировал реализацию, а не свойство.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
@pytest.fixture
def _session_with_photo_row(tmp_path: Path):
"""Настоящая сессия SQLAlchemy (SQLite) с одной строкой фотографии.
Настоящая, а не MagicMock: проверяется свойство сессии (`in_transaction`),
и на моке оно было бы выдумкой.
"""
engine = create_engine(f"sqlite:///{tmp_path / 'photos.db'}", future=True)
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE domrf_kn_photos ("
" obj_id INTEGER, obj_file_id TEXT, local_path TEXT,"
" thumb_path TEXT, photo_url TEXT, photo_name TEXT, size_bytes INTEGER)"
)
)
conn.execute(
text(
"INSERT INTO domrf_kn_photos VALUES"
" (1, 'f1', NULL, NULL, 'https://upstream.example/p.jpg', 'p.jpg', 100)"
)
)
session = Session(engine, future=True)
yield session
session.close()
engine.dispose()
def test_connection_released_before_upstream_fetch(_session_with_photo_row, monkeypatch) -> None:
"""В момент похода наружу транзакция закрыта — соединение вернулось в пул.
На main: SELECT открыл транзакцию, она висит все 8 с фетча.
"""
from app.api.v1 import photos
seen: dict[str, bool] = {}
def _spy_fetch(url: str):
seen["in_transaction"] = _session_with_photo_row.in_transaction()
return None # не ходим наружу: ДОМ.РФ трогать нельзя
monkeypatch.setattr(photos, "_fetch_upstream", _spy_fetch)
resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb")
assert seen.get("in_transaction") is False, (
"во время внешнего фетча сессия держит открытую транзакцию — "
"соединение пула занято, и при пуле в 15 штук страница отчёта его исчерпает"
)
# Поведение не изменилось: картинки локально нет → редирект на upstream.
assert resp.status_code == 302
def test_still_serves_cached_thumb(_session_with_photo_row, tmp_path: Path) -> None:
"""Контроль: закешированная миниатюра по-прежнему отдаётся с диска.
Зелёный с обеих сторон — доказывает, что раннее закрытие сессии не сломало
быстрый путь (98.9% запросов идут не им, но именно он — цель кеша).
"""
from app.api.v1 import photos
thumb = tmp_path / "t.webp"
thumb.write_bytes(b"webp")
_session_with_photo_row.execute(
text("UPDATE domrf_kn_photos SET thumb_path = :t"), {"t": str(thumb)}
)
_session_with_photo_row.commit()
resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb")
assert getattr(resp, "path", None) == str(thumb)
def test_missing_row_still_404(_session_with_photo_row) -> None:
"""Контроль: незарегистрированная фотография по-прежнему 404, а не 500."""
from fastapi import HTTPException
from app.api.v1 import photos
with pytest.raises(HTTPException) as exc:
photos.get_photo(db=_session_with_photo_row, obj_id=999, file_id="nope", size="thumb")
assert exc.value.status_code == 404
def test_session_usable_after_close_for_thumb_update(
_session_with_photo_row, tmp_path: Path, monkeypatch
) -> None:
"""Сессия переиспользуется ПОСЛЕ close(): UPDATE thumb_path + commit доезжают.
Это самый рискованный участок #2928: `db.close()` стоит выше по коду, а ниже
сессия ещё раз работает с БД. Поведение штатное (`close()` возвращает
соединение в пул, следующий execute берёт новое и открывает свою транзакцию),
но на проде эта ветка сегодня не исполняется НИ РАЗУ: замер 19.08.2026 —
0 строк `domrf_kn_photos` с непустым local_path и пустым thumb_path (все 1 889
закешированных уже с миниатюрами). То есть первый же ленивый фетч новой
фотографии пойдёт по коду, который не проверял никто.
Дополнительно фиксируем, что генерация миниатюры (медленная работа) идёт уже
без открытой транзакции — то же свойство, что и для внешнего фетча.
"""
from app.api.v1 import photos
original = tmp_path / "orig.jpg"
original.write_bytes(b"jpeg")
_session_with_photo_row.execute(
text("UPDATE domrf_kn_photos SET local_path = :p"), {"p": str(original)}
)
_session_with_photo_row.commit()
generated = tmp_path / "orig.webp"
seen: dict[str, bool] = {}
def _fake_make_thumbnail(src: Path):
seen["in_transaction"] = _session_with_photo_row.in_transaction()
generated.write_bytes(b"webp")
return generated
monkeypatch.setattr(photos, "make_thumbnail", _fake_make_thumbnail)
resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb")
assert seen.get("in_transaction") is False, (
"миниатюра генерируется при открытой транзакции — соединение пула занято"
)
assert getattr(resp, "path", None) == str(generated)
# Главное: запись ПОСЛЕ close() действительно доехала до БД — читаем ОТДЕЛЬНЫМ
# соединением. Через ту же сессию проверять нельзя: она видит собственную
# незакоммиченную транзакцию, и пропажа commit() прошла бы незамеченной.
with _session_with_photo_row.get_bind().connect() as fresh:
stored = fresh.execute(
text("SELECT thumb_path FROM domrf_kn_photos WHERE obj_id = 1")
).scalar_one()
assert stored == str(generated), "UPDATE после close() не закоммичен"