fix(ptica): соединение БД отпускается до похода за фотографией наружу (#2464-C) #2928

Merged
bot-backend merged 1 commit from fix/2464c-photos-session-hold into main 2026-08-19 11:18:01 +00:00
Collaborator

Последний пункт кластера C. В отличие от #2927 это другой дефект — не про executor,
поэтому отдельным PR.

Что происходит

GET /api/v1/photos/{obj}/{file} берёт сессию через Depends(get_db), делает SELECT
и держит соединение пула всё время синхронного фетча к ДОМ.РФ. SQLAlchemy открывает
транзакцию на первом запросе, поэтому она ещё и висит idle-in-transaction.

Насколько это горит — замер, а не рассуждение

domrf_kn_photos, 19.08.2026:

всего фотографий          165 208
закешировано локально       1 889   (1.1%)
пойдут «ленивым» путём    163 319   (98.9%)

То есть почти каждый запрос картинки — удержание соединения на время внешнего похода
(_UPSTREAM_TIMEOUT = 8 с, connect 4 с).

Пул при этом дефолтный: create_engine(settings.database_url, pool_pre_ping=True) в
app/core/db.py — без pool_size, значит 5 + 10 overflow = 15 соединений на весь
бэкенд. Страница отчёта тянет картинки пачкой; пятнадцать таких запросов занимают пул
целиком, и за ними встают все остальные ручки, включая /analyze.

Правка

db.close() сразу после того, как значения строки разложены по локальным переменным — до
фетча и до генерации миниатюры. close() не делает сессию непригодной: следующий
db.execute прозрачно берёт новое соединение и открывает свою транзакцию.

Одна строка, но место выбрано не наугад: до неё — только SELECT и распаковка, после — вся
медленная работа.

Тест проверяет свойство, а не текст

Проверяется факт отпускания — in_transaction() в момент фетча, — а не наличие
db.close() в исходнике. Иначе тест фиксировал бы реализацию: любая другая корректная
правка (отдельная сессия под запись, вынос фетча наружу) сделала бы его красным без причины.

Сессия в тесте настоящая (SQLite in-memory), не MagicMock: на моке in_transaction
был бы выдумкой, а проверяется именно поведение сессии.

Наружу тест не ходит — _fetch_upstream подменён шпионом. ДОМ.РФ трогать нельзя, там
жёсткий WAF-бан.

Против кода из main:

FAILED test_connection_released_before_upstream_fetch
passed test_still_serves_cached_thumb    ← контроль
passed test_missing_row_still_404        ← контроль
после правки: 3 passed
  • pytest tests/test_2464c_photos_session_release.py — 3 passed
  • pytest tests/api/v1 — зелёный (rc=0)
  • ruff check — clean

Что этот PR не делает

Не трогает размер пула. 15 соединений на весь бэкенд — отдельный разговор: правильное
число зависит от max_connections Postgres и от того, сколько процессов ходит в ту же
базу (backend, worker, beat). Менять его наугад значит переносить дефицит в другое место.
Здесь устранена причина, по которой пул выедался запросами картинок.

Не чинит 98.9% промахов кеша. Это отдельная задача — прогрев или фоновая загрузка;
сейчас каждый первый показ картинки идёт наружу, и это по-прежнему так.

Refs #2464

Последний пункт кластера C. В отличие от #2927 это **другой** дефект — не про executor, поэтому отдельным PR. ## Что происходит `GET /api/v1/photos/{obj}/{file}` берёт сессию через `Depends(get_db)`, делает `SELECT` — и держит соединение пула всё время синхронного фетча к ДОМ.РФ. SQLAlchemy открывает транзакцию на первом запросе, поэтому она ещё и висит `idle-in-transaction`. ## Насколько это горит — замер, а не рассуждение `domrf_kn_photos`, 19.08.2026: ``` всего фотографий 165 208 закешировано локально 1 889 (1.1%) пойдут «ленивым» путём 163 319 (98.9%) ``` То есть почти **каждый** запрос картинки — удержание соединения на время внешнего похода (`_UPSTREAM_TIMEOUT` = 8 с, connect 4 с). Пул при этом дефолтный: `create_engine(settings.database_url, pool_pre_ping=True)` в `app/core/db.py` — без `pool_size`, значит **5 + 10 overflow = 15** соединений на весь бэкенд. Страница отчёта тянет картинки пачкой; пятнадцать таких запросов занимают пул целиком, и за ними встают **все** остальные ручки, включая `/analyze`. ## Правка `db.close()` сразу после того, как значения строки разложены по локальным переменным — до фетча и до генерации миниатюры. `close()` не делает сессию непригодной: следующий `db.execute` прозрачно берёт новое соединение и открывает свою транзакцию. Одна строка, но место выбрано не наугад: до неё — только `SELECT` и распаковка, после — вся медленная работа. ## Тест проверяет свойство, а не текст Проверяется **факт** отпускания — `in_transaction()` в момент фетча, — а не наличие `db.close()` в исходнике. Иначе тест фиксировал бы реализацию: любая другая корректная правка (отдельная сессия под запись, вынос фетча наружу) сделала бы его красным без причины. Сессия в тесте **настоящая** (SQLite in-memory), не `MagicMock`: на моке `in_transaction` был бы выдумкой, а проверяется именно поведение сессии. Наружу тест не ходит — `_fetch_upstream` подменён шпионом. ДОМ.РФ трогать нельзя, там жёсткий WAF-бан. Против кода из main: ``` FAILED test_connection_released_before_upstream_fetch passed test_still_serves_cached_thumb ← контроль passed test_missing_row_still_404 ← контроль после правки: 3 passed ``` - [x] `pytest tests/test_2464c_photos_session_release.py` — 3 passed - [x] `pytest tests/api/v1` — зелёный (rc=0) - [x] `ruff check` — clean ## Что этот PR не делает **Не трогает размер пула.** 15 соединений на весь бэкенд — отдельный разговор: правильное число зависит от `max_connections` Postgres и от того, сколько процессов ходит в ту же базу (backend, worker, beat). Менять его наугад значит переносить дефицит в другое место. Здесь устранена причина, по которой пул выедался запросами картинок. **Не чинит 98.9% промахов кеша.** Это отдельная задача — прогрев или фоновая загрузка; сейчас каждый первый показ картинки идёт наружу, и это по-прежнему так. Refs #2464
bot-backend added 1 commit 2026-08-19 10:38:13 +00:00
fix(ptica): соединение БД отпускается до похода за фотографией наружу
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
80b38fccaa
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
bot-backend merged commit c3ea0364e7 into main 2026-08-19 11:18:01 +00:00
bot-backend deleted branch fix/2464c-photos-session-hold 2026-08-19 11:18:01 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2928
No description provided.