gendesign/tradein-mvp/backend/app/core/db.py
bot-backend ae6d28d5e2
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
fix(mera): pool_timeout 30→5 с — отдельным коммитом, с триггером отката
Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула:
было «медленно» (ждём коннект до 30 с), стало «быстро с ошибкой» (5 с и
`sqlalchemy.exc.TimeoutError` → 500, глобального обработчика в app/main.py нет).
И едет она во все сервисы образа — backend, scraper, tgbot
(tradein-mvp/docker-compose.prod.yml), для скраппера и бота обоснования в коде
нет: за 29 ч логов исчерпания пула не было ни разу, проверить новое значение на
проде пока не на чем.

Поэтому коммит последний в ветке: ветку можно мержить без него, а на проде —
откатить одной командой (`git revert`).

Обоснование самого значения: чекаут коннекта нельзя прервать `asyncio.wait_for`,
он занимает поток `asyncio.to_thread` целиком, а пул потоков конечен
(min(32, cpu+4)) — исчерпанный пул коннектов превращается в исчерпанный пул
потоков. 5 с короче самого короткого бюджета источника (8 с Yandex/Cian/
house_meta; geocode 12 с, IMV 20 с — длиннее): занятый пул деградирует ОДИН
источник, а не весь запрос.

ТРИГГЕР ОТКАТА (вернуть 30 с) записан в комментарии рядом со значением: любое
`QueuePool limit ... timed out` в логах бэкенда ЛИБО рост failed+zombie в
`scrape_runs` после деплоя.

Правка комментария по ревью (L2): «вчетверо больше любого бюджета внешнего
источника (8 с)» было неточно — бюджеты 8 / 12 / 20 с, перечислены явно.
Гейт `test_pool_checkout_wait_shorter_than_source_budget` переехал сюда же (в
коммите без `pool_timeout` он был бы красным) и читает публичный
`engine.pool.timeout()` вместо приватного `pool._timeout`.

Refs #3083, #3408
2026-09-12 02:23:09 +05:00

67 lines
4.6 KiB
Python
Raw 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.

from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
future=True,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
# сайты вызова разом, включая будущие.
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
# #3408 п.1. Потолок пула обязан быть НЕ МЕНЬШЕ суммы потолков одновременности,
# которые сам же процесс и объявляет: 4 оценки (`api/v1/trade_in`
# `_ESTIMATE_CONCURRENCY`) + 4 подсказки (`api/public/mera` `_SUGGEST_CONCURRENCY`)
# + 8 фоновых догрузок (`services/estimator` `_MAX_DEFERRED_REFRESH_TASKS`) = 16.
# Дефолт SQLAlchemy 5+10=15 меньше этой суммы, то есть исчерпать пул можно
# штатной работой, не абузом. Гейт — tests/test_3408_pool_ceiling.py.
#
# Инвариант «пул >= суммы объявленных потолков» — это ПОЛ, а не гарантия:
# сумма считает по одному коннекту на объявленную единицу работы, а коннект
# держит и всё остальное — любая ручка с `Depends(get_db)` тоже. Глобального
# обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет, поэтому на
# исчерпанном пуле соседние ручки отдают 500 (после ожидания `pool_timeout`),
# а не медленный 200.
#
# pool_size оставлен дефолтным (5): это ПОСТОЯННО открытые коннекты, а в покое
# прод держит 5-6 (замер 11.09). Растёт только overflow — коннекты пика,
# которые пул закрывает сам. Потолок процесса: 5 + 15 = 20; воркер один
# (docker-compose.prod.yml, uvicorn без --workers), Postgres max_connections=100.
max_overflow=15,
# #3408 п.2. Дефолтные 30 с ожидания коннекта длиннее ЛЮБОГО бюджета внешнего
# источника в эстиматоре: 8 с (Yandex / Cian / house_meta), 12 с (geocode),
# 20 с (Avito IMV — `estimate_avito_imv_timeout_s`, config.py:852). Сам чекаут
# прервать `asyncio.wait_for` не может: он занимает поток `asyncio.to_thread`
# целиком, а пул потоков конечен (min(32, cpu+4)) — исчерпанный пул коннектов
# так превращается в исчерпанный пул потоков. 5 с короче самого КОРОТКОГО
# бюджета: занятый пул деградирует ОДИН источник, а не весь запрос.
#
# Отдельный коммит в конце ветки намеренно (ревью PR #3444): это единственная
# правка, которая меняет режим отказа с «медленно» на «быстро с ошибкой», и
# едет она во ВСЕ сервисы образа — backend, scraper, tgbot
# (tradein-mvp/docker-compose.prod.yml). За 29 ч логов исчерпания пула не было
# ни разу, то есть новое значение на проде пока не на чем проверить.
# ТРИГГЕР ОТКАТА на 30 с: любое `QueuePool limit ... timed out` в логах
# бэкенда ЛИБО рост failed+zombie в `scrape_runs` после деплоя.
pool_timeout=5,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()