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
Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула: было «медленно» (ждём коннект до 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
67 lines
4.6 KiB
Python
67 lines
4.6 KiB
Python
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()
|