From ae6d28d5e2a315fa55898683340c5ebe01187c03 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 02:23:09 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera):=20pool=5Ftimeout=2030=E2=86=925=20?= =?UTF-8?q?=D1=81=20=E2=80=94=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=D0=BE=D0=BC?= =?UTF-8?q?,=20=D1=81=20=D1=82=D1=80=D0=B8=D0=B3=D0=B3=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула: было «медленно» (ждём коннект до 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 --- tradein-mvp/backend/app/core/db.py | 16 +++++++++++++++ .../backend/tests/test_3408_pool_ceiling.py | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tradein-mvp/backend/app/core/db.py b/tradein-mvp/backend/app/core/db.py index 408d47ba..1214a3eb 100644 --- a/tradein-mvp/backend/app/core/db.py +++ b/tradein-mvp/backend/app/core/db.py @@ -35,6 +35,22 @@ engine = create_engine( # которые пул закрывает сам. Потолок процесса: 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) diff --git a/tradein-mvp/backend/tests/test_3408_pool_ceiling.py b/tradein-mvp/backend/tests/test_3408_pool_ceiling.py index 254340db..2c220834 100644 --- a/tradein-mvp/backend/tests/test_3408_pool_ceiling.py +++ b/tradein-mvp/backend/tests/test_3408_pool_ceiling.py @@ -49,3 +49,23 @@ def test_pool_ceiling_covers_declared_concurrency() -> None: 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}с" + )