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
168 lines
8.4 KiB
Python
168 lines
8.4 KiB
Python
"""#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() не закоммичен"
|