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