fix(mera/estimate): коннект БД не живёт через внешний HTTP; потолок пула ≥ суммы потолков одновременности (#3083, #3408) #3444

Merged
bot-backend merged 3 commits from fix/3083-estimate-throughput into main 2026-09-11 21:33:15 +00:00
Collaborator

Часть #3083 и #3408. Заголовок #3083 («тяжёлый /estimate внутри event loop блокирует всё») на текущем коде НЕ воспроизводится — тяжёлая часть уже унесена в asyncio.to_thread (~20 мест), параллелизм ограничен семафором 4. Замер это показывает прямо, и вместо гипотезы найден другой, измеримый дефект.

Замер ДО (прод, 11.09, изнутри хоста, мимо Caddy; md5 файлов в контейнере сверен с origin/main — мерил тот код, что правлю):

проба wall БД (дельта pg_stat_statements) heartbeat /health коннекты
холостой ход, 27 с 66 мс / 179 вызовов p50 2.1 / p95 2.7 / max 18.7 мс 5
1 оценка, новый адрес 0.971 с 458 мс (47 %) max 73.2 мс 5
N=8 параллельно, новые 0.79–2.02 с, все 200 17.5 с p95 3.0 / max 159.9 мс max 10, idle-in-tx 6
N=8 параллельно, повтор 0.64–1.69 с 1.44 с p99 90.6 мс max 9, idle-in-tx 8

Event loop не голодает: при восьми одновременных оценках лёгкий запрос отвечает за 3 мс на p95, максимум удержания — 160 мс. Хвост 8.9 с в пробе N=4 — не блокировка, а честное асинхронное ожидание внешнего тира (house_metadata(overpass) exceeded 8.0s budget — degrading to None (#654)).

Настоящий дефект — удержание коннекта БД через чужой HTTP. По логу одной фоновой догрузки: proxy_pool: leased … provider=yandex 19:14:33.856 → yandex_valuation: fresh fetch saved 19:14:42.334 = 8.5 с с открытой транзакцией. Таких задач разрешено 8 (_MAX_DEFERRED_REFRESH_TASKS) при пуле 5+10. Замер подтверждает: при N=8 «повтор» 8 из 9 коннектов — idle in transaction.

Правка:

  • app/services/estimator.py:698_db_step[T]: синхронный шаг БД уходит в asyncio.to_thread и завершает транзакцию (commit, на ошибке rollback) — коннект возвращается в пул ДО внешнего HTTP. Применён на всех четырёх сайтах из #3408 п.2: :771, :851, :887, :1027, :1120.
  • app/core/db.py:30max_overflow=15 (было 10): потолок пула обязан быть не меньше суммы потолков одновременности, которые процесс сам объявляет (4 оценки + 4 подсказки + 8 фоновых = 16 против 15). pool_size не тронут.
  • app/core/db.py:37pool_timeout=5 (было 30): ждать коннект дольше бюджета источника (8 с) бессмысленно.
  • Поправлены два устаревших комментария (trade_in.py:83, estimator.py:944).

Замер ПОСЛЕ (деплой недоступен, поэтому на тех же величинах, что мерила прод-проба, переключением кода patch-файлом):

величина ДО ПОСЛЕ
loop не тикал, пока идёт SELECT кэша (300 мс «БД») 301.3 мс, тиков соседа 0 0.2 мс, тиков 23 571
ожидание коннекта соседом во время фетча (1 с, пул из 1) таймаут пула 0.1 мс

Фальсификация (origin/main): 5 failed по значению — loop простоял весь SELECT кэша: тиков всего 0, коннект удерживается транзакцией всё время внешнего HTTP, пул 15 меньше суммы потолков одновременности 16, pool_timeout 30.0с ≥ бюджета источника 8.0с. На ветке 5 passed.

Прогоны: 5744 passed, 35 skipped (rc=0); ruff чисто.

Суммарный потолок коннектов (явно): на процесс 5+15=20; app/core/db.py общий для трёх прод-процессов (tradein-backend 1 воркер, tradein-scraper, tradein-tgbot) → худший случай 3×20=60 + exporter ≈ 62 из max_connections=100 (живьём сейчас 10). Если однажды появятся воркеры uvicorn: 20×N, при N=4 это 120 > 100 — то есть многопроцессность требует одновременного решения по пулу, это не независимая ручка.

Воркеры uvicorn намеренно НЕ добавлены: замер не показывает ни дефицита CPU (12 ядер, load 1.0), ни голодания loop'а, зато размножение процессов множит два семафора, пять in-process лимитеров, реестр фоновых задач и сам пул.

Приёмка на проде (признак отсутствует в старой версии): docker exec tradein-backend python -c "from app.core.db import engine as e; print(e.pool.size(), e.pool._max_overflow, e.pool._timeout)"5 15 5.0 (на старом 5 10 30.0); повтор пробы burst8 — пик idle in transaction в хвосте ≤ числа живых запросов (было 6–8 при N=8), heartbeat не хуже ДО; за сутки ноль QueuePool limit ... timed out.

Риск отката: pool_timeout=5 действует и на scraper/tgbot (общий модуль) — исчерпанный пул теперь падает через 5 с вместо 30 с. Если батчи скрапера начнут на этом падать — откатывать только pool_timeout, max_overflow оставить.

Не сделано осознанно: #3408 п.3 (синхронный curl в админ-ручке cian_price_history.py:140) и п.4 (бюджет на ветке acquire); estimate_via_cian_valuation имеет ту же форму «сессия открыта через фетч», но она общая со скрапером, где коммит в середине менял бы семантику батча — это следующий шаг, а не забытый.

Часть #3083 и #3408. **Заголовок #3083 («тяжёлый /estimate внутри event loop блокирует всё») на текущем коде НЕ воспроизводится** — тяжёлая часть уже унесена в `asyncio.to_thread` (~20 мест), параллелизм ограничен семафором 4. Замер это показывает прямо, и вместо гипотезы найден другой, измеримый дефект. **Замер ДО (прод, 11.09, изнутри хоста, мимо Caddy; md5 файлов в контейнере сверен с origin/main — мерил тот код, что правлю):** | проба | wall | БД (дельта `pg_stat_statements`) | heartbeat `/health` | коннекты | |---|---|---|---|---| | холостой ход, 27 с | — | 66 мс / 179 вызовов | p50 2.1 / p95 2.7 / max 18.7 мс | 5 | | 1 оценка, новый адрес | 0.971 с | 458 мс (47 %) | max 73.2 мс | 5 | | N=8 параллельно, новые | 0.79–2.02 с, все 200 | 17.5 с | p95 3.0 / max 159.9 мс | max 10, **idle-in-tx 6** | | N=8 параллельно, повтор | 0.64–1.69 с | 1.44 с | p99 90.6 мс | max 9, **idle-in-tx 8** | Event loop не голодает: при восьми одновременных оценках лёгкий запрос отвечает за 3 мс на p95, максимум удержания — 160 мс. Хвост 8.9 с в пробе N=4 — не блокировка, а честное асинхронное ожидание внешнего тира (`house_metadata(overpass) exceeded 8.0s budget — degrading to None (#654)`). **Настоящий дефект — удержание коннекта БД через чужой HTTP.** По логу одной фоновой догрузки: `proxy_pool: leased … provider=yandex` 19:14:33.856 → `yandex_valuation: fresh fetch saved` 19:14:42.334 = **8.5 с с открытой транзакцией**. Таких задач разрешено 8 (`_MAX_DEFERRED_REFRESH_TASKS`) при пуле 5+10. Замер подтверждает: при N=8 «повтор» 8 из 9 коннектов — `idle in transaction`. **Правка:** - `app/services/estimator.py:698` — `_db_step[T]`: синхронный шаг БД уходит в `asyncio.to_thread` и **завершает транзакцию** (`commit`, на ошибке `rollback`) — коннект возвращается в пул ДО внешнего HTTP. Применён на всех четырёх сайтах из #3408 п.2: `:771`, `:851`, `:887`, `:1027`, `:1120`. - `app/core/db.py:30` — `max_overflow=15` (было 10): потолок пула обязан быть не меньше суммы потолков одновременности, которые процесс сам объявляет (4 оценки + 4 подсказки + 8 фоновых = 16 против 15). `pool_size` не тронут. - `app/core/db.py:37` — `pool_timeout=5` (было 30): ждать коннект дольше бюджета источника (8 с) бессмысленно. - Поправлены два устаревших комментария (`trade_in.py:83`, `estimator.py:944`). **Замер ПОСЛЕ** (деплой недоступен, поэтому на тех же величинах, что мерила прод-проба, переключением кода patch-файлом): | величина | ДО | ПОСЛЕ | |---|---|---| | loop не тикал, пока идёт SELECT кэша (300 мс «БД») | 301.3 мс, тиков соседа **0** | 0.2 мс, тиков **23 571** | | ожидание коннекта соседом во время фетча (1 с, пул из 1) | **таймаут пула** | 0.1 мс | **Фальсификация** (origin/main): 5 failed по значению — `loop простоял весь SELECT кэша: тиков всего 0`, `коннект удерживается транзакцией всё время внешнего HTTP`, `пул 15 меньше суммы потолков одновременности 16`, `pool_timeout 30.0с ≥ бюджета источника 8.0с`. На ветке 5 passed. **Прогоны:** `5744 passed, 35 skipped` (rc=0); ruff чисто. **Суммарный потолок коннектов (явно):** на процесс 5+15=**20**; `app/core/db.py` общий для трёх прод-процессов (`tradein-backend` 1 воркер, `tradein-scraper`, `tradein-tgbot`) → худший случай 3×20=60 + exporter ≈ **62 из `max_connections=100`** (живьём сейчас 10). Если однажды появятся воркеры uvicorn: 20×N, при N=4 это 120 > 100 — то есть многопроцессность требует одновременного решения по пулу, это не независимая ручка. **Воркеры uvicorn намеренно НЕ добавлены:** замер не показывает ни дефицита CPU (12 ядер, load 1.0), ни голодания loop'а, зато размножение процессов множит два семафора, пять in-process лимитеров, реестр фоновых задач и сам пул. **Приёмка на проде** (признак отсутствует в старой версии): `docker exec tradein-backend python -c "from app.core.db import engine as e; print(e.pool.size(), e.pool._max_overflow, e.pool._timeout)"` → `5 15 5.0` (на старом `5 10 30.0`); повтор пробы `burst8` — пик `idle in transaction` в хвосте ≤ числа живых запросов (было 6–8 при N=8), heartbeat не хуже ДО; за сутки ноль `QueuePool limit ... timed out`. **Риск отката:** `pool_timeout=5` действует и на scraper/tgbot (общий модуль) — исчерпанный пул теперь падает через 5 с вместо 30 с. Если батчи скрапера начнут на этом падать — откатывать **только** `pool_timeout`, `max_overflow` оставить. **Не сделано осознанно:** #3408 п.3 (синхронный curl в админ-ручке `cian_price_history.py:140`) и п.4 (бюджет на ветке acquire); `estimate_via_cian_valuation` имеет ту же форму «сессия открыта через фетч», но она общая со скрапером, где коммит в середине менял бы семантику батча — это следующий шаг, а не забытый.
bot-backend added 1 commit 2026-09-11 20:09:44 +00:00
fix(mera): sync-БД источников эстиматора — с event loop в поток и не через фетч
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
9f696299de
Замер на проде 11.09 (изнутри хоста, тот же контейнер):
- одна оценка 0.44 с (повтор адреса) / 0.97 с (новый адрес), из них БД 252/458 мс;
- N=8 параллельных — все 200, heartbeat /health p95 5-7 мс, max 113-160 мс:
  loop сегодня НЕ голодает, «добавить воркеров uvicorn» замером не подтверждается
  (и умножило бы на N оба семафора, пять in-process лимитеров и пул);
- зато одна фоновая догрузка Яндекса держала коннект пула 8.5 с (лиз прокси
  33.856 → запись 42.334), а таких задач разрешено 8 при пуле 15.

Правки:
- estimator `_db_step`: SELECT/UPSERT кэша источников уходят в `asyncio.to_thread`
  и завершают транзакцию — коннект возвращается в пул ДО внешнего HTTP;
- core/db: max_overflow 10→15 (потолок 20 на процесс ≥ 4+4+8 объявленных
  потолков одновременности) и pool_timeout 30→5 с (короче бюджета источника 8 с,
  иначе занятый пул съедает и бюджет запроса, и поток to_thread).

Локальный замер ДО/ПОСЛЕ на тех же величинах: loop стоял 301 мс (0 тиков соседней
корутины) → 0.2 мс (23.5k тиков); ожидание коннекта соседом во время фетча —
таймаут пула → 0.1 мс.

Refs #3083, #3408
Light1YT added 2 commits 2026-09-11 21:27:23 +00:00
Ревью PR #3444, M1. `_with_budget` — это `asyncio.wait_for`, а `asyncio.to_thread`
отменить нельзя: снимается только ожидание со стороны loop'а. Корутина умирает,
поток продолжает работать с ТОЙ ЖЕ `Session`, а вызывающий тем временем идёт
дальше по своим шагам ПО ТОЙ ЖЕ сессии — следующий источник,
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной сессии
дают «another operation is in progress» / InvalidRequestError на следующем шаге
БД: у источников её глушит `except` вокруг вызова, у персиста оценки не глушит
ничего — 500 и потерянная оценка клиента, ровно под нагрузкой, ради которой PR и
делается.

`_db_step` теперь пробрасывает отмену ПОСЛЕ того, как поток отпустил сессию
(`asyncio.shield` + ожидание шага). Цена — бюджет источника переезжает на длину
ОДНОГО шага БД, а не на длину фетча, ради которой бюджет заведён.

Почему не `threading.Lock` на сессию (вариант из ревью): лок внутри `_db_step`
сериализует только шаги, которые через `_db_step` и проходят, — а названный
пострадавший `_persist_estimate_and_commit` (estimator.py:5203) это ГОЛЫЙ
`asyncio.to_thread(db...)`, как и ещё 17 мест эстиматора; лока они не берут, и
дыра осталась бы открытой ровно там, где она стоит 500. Ожидание же в точке
отмены закрывает ВСЕХ последующих потребителей сессии разом и не заводит
глобального состояния (`WeakKeyDictionary`). Гейт по значению —
tests/test_3408_db_step_cancel_orphan.py: следующий шаг (голый `to_thread`, как
персист) не входит в сессию, пока сирота не закончил. Семантика проверена на
питоне прода (3.12): `wait_for` по-прежнему отдаёт TimeoutError, источник
деградирует в None.

Остальное из ревью:
- M2: комментарий у `_MAX_DEFERRED_REFRESH_TASKS` обещал за ОБА фоновых
  источника, а верен только для Яндекса. Циан держит коннект весь фетч (до 25 с):
  транзакцию открывают `_load_from_cache`/`load_session`, закрывает `db.commit()`
  в конце (scraper_kit .../cian/valuation.py:163,176,595). Формулировка сужена,
  остаток назван явно: функция общая со скраппером (cian_history_backfill.py:458),
  где коммит в середине менял бы семантику батча, — нужен отдельный опт-ин путь.
  На ПОТОЛОК пула остаток не влияет (коннект на задачу один независимо от того,
  как долго держится), только на среднюю занятость.
- L1: `db.rollback()` после упавшего `_db_step` (estimator.py:1186) удалён —
  откат уже сделан в потоке, а на loop'е это блокирующий вызов.
- L4: в core/db.py записано, что «пул >= суммы объявленных потолков» — ПОЛ, а не
  гарантия: коннект держит и любая ручка с `Depends(get_db)`, а глобального
  обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет (проверено:
  единственный handler — RequestValidationError, core/http_errors.py:59).
- L3: гейт пула больше не читает `pool._timeout` и не молчит при переименовании
  `_max_overflow` — публичный `pool.size()` + приватное поле за явным assert'ом.

`pool_timeout` из этого коммита УБРАН намеренно: это единственная правка, которая
меняет режим отказа с «медленно» на «быстро с ошибкой», и она едет во все сервисы
образа (backend, scraper, tgbot). Возвращается отдельным коммитом в конце ветки —
чтобы ветку можно было смержить без него или откатить одной командой.

Refs #3083, #3408
fix(mera): pool_timeout 30→5 с — отдельным коммитом, с триггером отката
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
ae6d28d5e2
Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула:
было «медленно» (ждём коннект до 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
bot-backend merged commit 4efcb712e4 into main 2026-09-11 21:33:15 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3444
No description provided.