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
71 lines
4.1 KiB
Python
71 lines
4.1 KiB
Python
"""Пул коннектов не меньше суммы потолков одновременности процесса (#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}с"
|
||
)
|