tradein-backend: у запросов к БД нет потолка ни по времени, ни по ожиданию блокировки — одна заблокированная таблица вешает /estimate для всех #3463

Closed
opened 2026-09-12 08:58:17 +00:00 by bot-backend · 1 comment
Collaborator

Найдено при deep-ревью PR #3460 (#3449). Уже на main, PR #3460 этого не вносит и не чинит.

Факт

На боевой БД (SELECT name, setting FROM pg_settings, 2026-09-12):

statement_timeout                   | 0
lock_timeout                        | 0
idle_in_transaction_session_timeout | 0

connect_args у движка (app/core/db.py) не задан вовсе — грепом по app/ единственные потолки это SET LOCAL внутри отдельных задач планировщика (listing_source_snapshot.py:288, proxy_pool.py:513). Ручки продукта не покрыты ничем.

Это ровно п.2 закрытого #2607 («server/role-level statement_timeout — отдельное решение»), который так и не был принят.

Почему это стало важно сейчас

После #3444 и #3460 все шаги БД на пути /estimate идут через обёртку, которая при отмене по бюджету ждёт завершения потока (asyncio.wait([step]) без timeout) — иначе поток остаётся сиротой в сессии запроса и следующий шаг ловит «another operation is in progress». Ожидание корректное, но его верхняя граница = время самого запроса к БД, а у запроса границы нет.

Сценарий отказа (вход → результат)

  1. На geocode_cache берётся ACCESS EXCLUSIVE — миграция, VACUUM FULL или зависший ALTER.
  2. POST /estimate_with_budget(geocode(...), 12 с) → шаг БД блокируется на блокировке.
  3. Бюджет истекает, обёртка уходит ждать поток — без потолка.
  4. Запрос не завершается никогда. Слот _estimate_slots не возвращается (trade_in.py:94, релиз в finally, до которого не доходит).
  5. _ESTIMATE_CONCURRENCY = 4 → четыре таких запроса, и /estimate отдаёт 429 всем остальным (_ESTIMATE_SLOT_WAIT_S = 5.0) до снятия блокировки. Клиент видит 504 от Caddy, бэкенд считает запрос живым.

Тот же класс закрывает и синхронные пути, у которых обёртки нет вовсе (house_metadata.py:244 ходит в БД прямо на loop'е).

Как чинить

Потолок ставится на движке, а не в обёртке (таймаут в обёртке вернул бы сироту — то, ради чего #3449 и писался):

connect_args={"options": "-c statement_timeout=20000 -c lock_timeout=5000"}

Величины подобрать по фактическим бюджетам: самый длинный объявленный бюджет на /estimate — 20 с (IMV), геокод 12 с, остальные 8 с. lock_timeout заведомо меньше — ждать блокировку дольше секунд смысла нет, лучше деградировать.

Осторожно с задачами планировщика: у них есть заведомо длинные транзакции (listing_source_snapshotSET LOCAL statement_timeout = 900000). SET LOCAL перекрывает сессионное значение, так что они не пострадают, но это надо ПРОВЕРИТЬ, а не предположить: пройти по всем задачам, у которых транзакция может быть длиннее 20 с, и убедиться, что у каждой либо свой SET LOCAL, либо она укладывается.

Тест по значению

Запрос, который заведомо превышает потолок (SELECT pg_sleep(...)), обязан упасть с QueryCanceled за отведённое время, а не висеть; и параллельно — задача со своим SET LOCAL не должна обрываться новым сессионным потолком. Фальсификация: снять connect_args → первый тест краснеет.

Приёмка

За сутки после включения: ноль запросов к tradein дольше statement_timeout в pg_stat_activity (замер по max(now() - query_start) для state='active'), и ноль новых zombie/failed в scrape_runs по сравнению с предыдущими сутками.

Refs #3449, PR #3460, #2607 (п.2), #3408.

Найдено при deep-ревью PR #3460 (#3449). **Уже на `main`**, PR #3460 этого не вносит и не чинит. ## Факт На боевой БД (`SELECT name, setting FROM pg_settings`, 2026-09-12): ``` statement_timeout | 0 lock_timeout | 0 idle_in_transaction_session_timeout | 0 ``` `connect_args` у движка (`app/core/db.py`) не задан вовсе — грепом по `app/` единственные потолки это `SET LOCAL` внутри отдельных задач планировщика (`listing_source_snapshot.py:288`, `proxy_pool.py:513`). Ручки продукта не покрыты ничем. Это ровно п.2 закрытого #2607 («server/role-level statement_timeout — отдельное решение»), который так и не был принят. ## Почему это стало важно сейчас После #3444 и #3460 все шаги БД на пути `/estimate` идут через обёртку, которая при отмене по бюджету **ждёт завершения потока** (`asyncio.wait([step])` без `timeout`) — иначе поток остаётся сиротой в сессии запроса и следующий шаг ловит «another operation is in progress». Ожидание корректное, но его верхняя граница = время самого запроса к БД, а у запроса границы нет. ## Сценарий отказа (вход → результат) 1. На `geocode_cache` берётся `ACCESS EXCLUSIVE` — миграция, `VACUUM FULL` или зависший `ALTER`. 2. `POST /estimate` → `_with_budget(geocode(...), 12 с)` → шаг БД блокируется на блокировке. 3. Бюджет истекает, обёртка уходит ждать поток — **без потолка**. 4. Запрос не завершается никогда. Слот `_estimate_slots` не возвращается (`trade_in.py:94`, релиз в `finally`, до которого не доходит). 5. `_ESTIMATE_CONCURRENCY = 4` → четыре таких запроса, и `/estimate` отдаёт **429 всем остальным** (`_ESTIMATE_SLOT_WAIT_S = 5.0`) до снятия блокировки. Клиент видит 504 от Caddy, бэкенд считает запрос живым. Тот же класс закрывает и синхронные пути, у которых обёртки нет вовсе (`house_metadata.py:244` ходит в БД прямо на loop'е). ## Как чинить Потолок ставится на движке, а не в обёртке (таймаут в обёртке вернул бы сироту — то, ради чего #3449 и писался): ```python connect_args={"options": "-c statement_timeout=20000 -c lock_timeout=5000"} ``` Величины подобрать по фактическим бюджетам: самый длинный объявленный бюджет на `/estimate` — 20 с (IMV), геокод 12 с, остальные 8 с. `lock_timeout` заведомо меньше — ждать блокировку дольше секунд смысла нет, лучше деградировать. **Осторожно с задачами планировщика:** у них есть заведомо длинные транзакции (`listing_source_snapshot` — `SET LOCAL statement_timeout = 900000`). `SET LOCAL` перекрывает сессионное значение, так что они не пострадают, но это надо ПРОВЕРИТЬ, а не предположить: пройти по всем задачам, у которых транзакция может быть длиннее 20 с, и убедиться, что у каждой либо свой `SET LOCAL`, либо она укладывается. ## Тест по значению Запрос, который заведомо превышает потолок (`SELECT pg_sleep(...)`), обязан упасть с `QueryCanceled` за отведённое время, а не висеть; и параллельно — задача со своим `SET LOCAL` не должна обрываться новым сессионным потолком. Фальсификация: снять `connect_args` → первый тест краснеет. ## Приёмка За сутки после включения: ноль запросов к `tradein` дольше `statement_timeout` в `pg_stat_activity` (замер по `max(now() - query_start)` для `state='active'`), и ноль новых `zombie`/`failed` в `scrape_runs` по сравнению с предыдущими сутками. Refs #3449, PR #3460, #2607 (п.2), #3408.
Author
Collaborator

Приёмка на проде — 2026-09-12

PR #3508 смержен. Проверял не статусом джобы, а живым коннектом через тот же движок, которым ходит продукт:

connect_args движка: {'options': '-c statement_timeout=30000 -c lock_timeout=5000'}
statement_timeout = 30s
lock_timeout      = 5s

И отдельно второй движок (app/core/auth_db.py) — тот самый, про который в первой редакции PR стояло «на проде не сконфигурирован», а на деле IDENTITY_STORE=auth во всех трёх контейнерах и вызов висит синхронно в middleware на каждом запросе с сессионной кукой:

auth statement_timeout = 30s
auth lock_timeout      = 5s

Что проверить дальше — с датой

Приёмка из issue сегодня невыполнима: контейнер пересоздан деплоем, окно логов — минуты. Не раньше 2026-09-13 18:00 UTC (то есть после первого полного ночного окна сбора):

ssh poincare 'docker inspect tradein-backend --format "{{.State.StartedAt}}";   for C in tradein-backend tradein-scraper; do     echo "== $C"; docker logs --since 24h $C 2>&1 | wc -l;     docker logs --since 24h $C 2>&1 | grep -c -E "QueryCanceled|LockNotAvailable"; done'

Если StartedAt моложе суток — окно неполное, число недействительно.

Отдельно посмотреть глазами первые прогоны, где новый lock_timeout может дать первый честный отказ: listing_source_snapshot (01–02 UTC) и house_dedup_merge (04–05 UTC) — по scrape_runs.status.

Триггер отката (один коммит, состояние БД не меняется): рост failed/zombie в scrape_runs против предыдущих суток либо появление QueryCanceled/LockNotAvailable на путях, признанных в PR укладывающимися.

## Приёмка на проде — 2026-09-12 PR #3508 смержен. Проверял не статусом джобы, а живым коннектом через тот же движок, которым ходит продукт: ``` connect_args движка: {'options': '-c statement_timeout=30000 -c lock_timeout=5000'} statement_timeout = 30s lock_timeout = 5s ``` И отдельно второй движок (`app/core/auth_db.py`) — тот самый, про который в первой редакции PR стояло «на проде не сконфигурирован», а на деле `IDENTITY_STORE=auth` во всех трёх контейнерах и вызов висит синхронно в middleware на каждом запросе с сессионной кукой: ``` auth statement_timeout = 30s auth lock_timeout = 5s ``` ## Что проверить дальше — с датой Приёмка из issue сегодня невыполнима: контейнер пересоздан деплоем, окно логов — минуты. **Не раньше 2026-09-13 18:00 UTC** (то есть после первого полного ночного окна сбора): ```bash ssh poincare 'docker inspect tradein-backend --format "{{.State.StartedAt}}"; for C in tradein-backend tradein-scraper; do echo "== $C"; docker logs --since 24h $C 2>&1 | wc -l; docker logs --since 24h $C 2>&1 | grep -c -E "QueryCanceled|LockNotAvailable"; done' ``` Если `StartedAt` моложе суток — окно неполное, число недействительно. Отдельно посмотреть глазами первые прогоны, где новый `lock_timeout` может дать первый честный отказ: `listing_source_snapshot` (01–02 UTC) и `house_dedup_merge` (04–05 UTC) — по `scrape_runs.status`. Триггер отката (один коммит, состояние БД не меняется): рост `failed`/`zombie` в `scrape_runs` против предыдущих суток **либо** появление `QueryCanceled`/`LockNotAvailable` на путях, признанных в PR укладывающимися.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#3463
No description provided.