gendesign/tradein-mvp/backend/tests/test_3408_pool_ceiling.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

71 lines
4.1 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.

"""Пул коннектов не меньше суммы потолков одновременности процесса (#3408 п.1).
Процесс сам объявляет, сколько одновременной работы он допускает: 4 оценки, 4
подсказки, 8 фоновых догрузок. Каждая из этих единиц работы держит СВОЮ сессию.
Если сумма больше пула, исчерпать пул можно штатной нагрузкой — и упереться не в
ресурс, а в `QueuePool limit ... timed out`, причём на публичной ручке.
На origin/main сумма 16 против дефолта SQLAlchemy 5+10=15 — тест красный.
Гейт нужен не ради текущих чисел, а ради будущих: поднять `_ESTIMATE_CONCURRENCY`
или `_MAX_DEFERRED_REFRESH_TASKS`, не поднимая пула, станет видно здесь.
Добавится воркер uvicorn (#3083) — числа per-process не меняются, но суммарный
потолок коннектов умножается на число воркеров; это отдельное решение, не это.
"""
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.api.public.mera import _SUGGEST_CONCURRENCY
from app.api.v1.trade_in import _ESTIMATE_CONCURRENCY
from app.core.db import engine
from app.services.estimator import _MAX_DEFERRED_REFRESH_TASKS
def _max_overflow(pool: Any) -> int:
"""`max_overflow` у QueuePool публичного геттера не имеет (в отличие от
`size()` / `timeout()`), поэтому читаем приватное поле — но с явным падением:
молча переименуется в новой SQLAlchemy → гейт бы просто перестал что-либо
проверять (AttributeError упал бы ошибкой теста, а вот подмена значения на
дефолт — нет).
"""
assert hasattr(pool, "_max_overflow"), (
f"{type(pool).__name__} больше не хранит `_max_overflow` — SQLAlchemy сменила "
"API пула, потолок коннектов читать больше нечем: почини гейт, а не удаляй его"
)
return int(pool._max_overflow)
def test_pool_ceiling_covers_declared_concurrency() -> None:
declared = _ESTIMATE_CONCURRENCY + _SUGGEST_CONCURRENCY + _MAX_DEFERRED_REFRESH_TASKS
ceiling = engine.pool.size() + _max_overflow(engine.pool)
assert ceiling >= declared, (
f"пул {ceiling} меньше суммы потолков одновременности {declared} "
f"({_ESTIMATE_CONCURRENCY} оценок + {_SUGGEST_CONCURRENCY} подсказок + "
f"{_MAX_DEFERRED_REFRESH_TASKS} фоновых догрузок) — штатная работа исчерпает пул"
)
def test_pool_checkout_wait_shorter_than_source_budget() -> None:
"""Ожидание коннекта короче бюджета внешнего источника.
Иначе занятый пул съедает весь бюджет запроса (и поток `asyncio.to_thread`,
которых тоже конечное число) вместо того, чтобы деградировать один источник.
Сравниваем с самым КОРОТКИМ бюджетом (8 с Yandex/Cian): geocode 12 с и IMV
20 с длиннее, их этот же потолок покрывает с запасом.
"""
from app.core.config import settings
budget = min(
settings.estimate_yandex_valuation_timeout_s,
settings.estimate_cian_valuation_timeout_s,
)
assert engine.pool.timeout() < budget, (
f"pool_timeout {engine.pool.timeout()}с >= бюджета источника {budget}с"
)