МЕРА: у запросов к БД появился потолок по времени и по ожиданию блокировки (#3463) #3508
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3508
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/3463-db-statement-timeout"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #3463
У движка
app/core/db.pyне былоconnect_argsвовсе, а на боевой БДstatement_timeout,lock_timeoutиidle_in_transaction_session_timeout= 0 (сверено 12.09). После #3444/#3449 шаги БД на пути/estimateидут через обёртку, которая при отмене по бюджету дожидается своего потока — ожидание верное (иначе поток остаётся сиротой в общейSession), но его верхняя граница равна длительности самого запроса, а у запроса границы не было. ОдинACCESS EXCLUSIVEна таблице → четыре повисших запроса →_ESTIMATE_CONCURRENCY = 4исчерпан →/estimateотдаёт 429 всем остальным.Потолок стоит на коннекте (libpq
options), а не в обёртке: таймаут в обёртке вернул бы ровно ту сироту, ради которой писался #3449.Откуда величины
statement_timeout/estimate(20 с —estimate_avito_imv_timeout_s; дальше 12 с геокод, 8 с Yandex/Cian/house_meta). ~3× от самого длинного замеренного set-based statement'а через движок (матч ГАР→houses, 6.46 с). Выше самой долгой чисто-БД задачи планировщика (9.7 с целиком). Сверху ограничен тестом:<= 2 ×объявленного бюджета.lock_timeoutSET LOCAL lock_timeout = '5s'вdata/sql/250,251,260,272,277…), снизу ограниченаdeadlock_timeout= 1 с на проде. Именно этот потолок закрывает сценарий #3463: подACCESS EXCLUSIVEзапрос ждёт лок, а не считает.idle_in_transaction_session_timeoutservices/tgbot/bridge.pyдержит транзакцию открытой поверх long-poll Telegram (замер 12.09, 3 пробы с шагом 7 с — одна и та же сессия,SELECT value FROM tg_support_state …, возраст транзакции циклически растёт до ~29 с). Сессионный потолок на простой ронял бы long-poll каждый цикл, гарантированно.Замер на боевой БД (только SELECT, до правки)
Основная опора —
scrape_runs(длительности целых прогонов; вытеснению не подвержены): самая долгая чисто-БД задача за 14 суток —listing_source_snapshot, 9.7 с целиком, весь остальной чисто-БД планировщик ≤ 8.8 с.Самый длинный set-based statement через движок замерен отдельно — матч ГАР→houses (
services/gar_flats_loader._MATCH_SQL, 919 341 строкаgar_house_flats→ 10 854 канона): 2.07 с с городским фильтром и 6.46 с без него (city_filter=None, доступен флагом CLI). Это CLI-задача, вscrape_runsеё нет. Запас до потолка ~3×, и это считающий запрос, а не ждущий блокировку.pg_stat_statementsопорой по планировщику не является (правка после ревью): приmax = 5000он вытесняет редкие записи — проверено вживую 12.09,deallocвырос 1020→1021 за десять минут, и из топа пропали все записи сcalls = 1, включаяREFRESH MATERIALIZED VIEW CONCURRENTLY(30.85 с) и KNNcadastral_geo_match(2.45 с). Суточная задача до следующих суток там не доживает. Поэтому «самый долгий запрос через движок 4.27 с» верно только для высокочастотных запросов — это и всё, за что этот источник отвечает:REFRESH MATERIALIZED VIEW CONCURRENTLY30.85 с — сыроеpsycopg.connect();COPY _stg19.67 с —psqlиз ручногоscripts/local-avito-msk/collect.py);WITH wd AS (… deals …), 401 вызов).Задачи планировщика — по каждой вердикт
Движок один и тот же:
scheduler_main.py→RealSessionFactory→app.core.db.SessionLocal. Потолок накрывает и продуктовый путь, и планировщик, и все три сервиса образа (backend / scraper / tgbot).listing_source_snapshotSET LOCAL—statement_timeout = 900000(tasks/listing_source_snapshot.py:288). Перекрытие сессионного потолка ПРОВЕРЕНО тестом, а не предположено. Сверх того укладывается: 9.7 с на прогон.proxy_pool.attribute_run_proxySET LOCAL—lock_timeout = '2s'(services/proxy_pool.py:513), строже нового потолка.refresh_search_matviewpsycopg.connect(dsn, autocommit=True). 30.85 с замерены,connect_argsего не касается.data/sql/*.sqlpsqlизdeploy-tradein.yml, у DDL свойSET LOCAL lock_timeout = '5s'.*_detail_backfill,geocode_missing_listings,proxy_healthcheck,newbuilding_enrich,house_imv_backfillstatement_timeoutдействует НА STATEMENT, а не на транзакцию. Единственное, что их убило бы, —idle_in_transaction_session_timeout, и его мы не ставим.rosreestr_dkp_import/_77batch_size), 428 statement'ов, max 629 мс; прогон 102 с целиком.cadastral_geo_matchTRUNCATE cad_buildings_local23 мс,INSERT … FROMFDW 748 мс, KNN-матч 2.45 с.osm_poi_ekb_refreshTRUNCATE23 мс,INSERT22 мс.house_dedup_mergedeactivate_stale_*(6 шт.)UPDATE listingsmax 177 мс, прогон ≤ 0.6 с.asking_to_sold_ratio_refresh,deal_city_price_bands_refresh,landing_stats_refresh,deals_freshness_monitor,sber_freshness_monitor,sber_index_pull,rosreestr_quarter_poll,house_coords_from_listings,geoportal_coords_backfilldomrf_kapremont_loadpurge_expired_trade_in_datagar_flats_load,zhkh_flats_load,frt_mkd_load,fns_opendata_load,dtp_stat_refresh,msk_raw_import,ekb_geoportal_ingeststatement_timeoutдействует на statement внутри чанка. Отдельно замерен единственный крупный set-based statement — матч ГАР→houses (gar_flats_loader._MATCH_SQL, 919 341 строка → 10 854 канона): 2.07 с с городским фильтром, 6.46 с без него. Самый длинный statement через движок из известных; запас до потолка ~3×.Своего
SET LOCALне потребовалось никому.Второй движок —
app/core/auth_db.py(находка ревью, HIGH)Он тоже строил коннекты без потолка, и мой первый вердикт «на проде реестр не сконфигурирован» был ложным (я прочитал устаревший докстринг как факт о текущем проде). Сверено
printenvв контейнерах 12.09:IDENTITY_STORE=authиAUTH_DB_PASSWORDзадан во всех трёх сервисах образа; в БДauth14 пользователей и живые сессии.Путь горячий:
core/rbac.pyрезолвит session-cookie в middleware, синхронно прямо на event loop'е, на каждом запросе с cookie. Без потолкаACCESS EXCLUSIVEнаauth.sessions(миграцияdata/sql/auth/00*.sqlилиVACUUM FULL) вешал бы не четыре слота/estimate, а весь uvicorn-воркер (он один, без--workers) — включая/health.Потолок отдан той же константой
DB_CONNECT_ARGS: одна на оба движка, а не защита на одном и мина на втором. Срабатывание безопасно — вызов уже подexcept Exceptionс фолбэком на заголовочную аутентификацию, то есть отмена даёт тот же путь, что и любой другой сбой реестра, а не 500.Класс, которого не было в таблице (находка ревью)
lock_timeoutдействует и на строчные блокировки, поэтому массовые UPDATE в одно окно со свипами (listing_source_snapshot01-02,house_dedup_merge04-05,deactivate_stale_*06-08) могут отвалитьсяLockNotAvailableчерез 5 с. Риск низкий, отказ громкий и попадает под критерий приёмки №2 — но в первую ночь смотреть именно за этим. Мержить не перед ночным окном сбора.Тесты по значению —
tradein-mvp/backend/tests/test_3463_db_timeouts.pyБез БД (идут на обоих лэйнах):
cparamsзамыканияpool._creator, а не константа — иначе тест пережил бы снятиеconnect_args; наружу отдаётся толькоoptions, вcparamsпароль). Сравнение на равенство, а не черезin: подстрочная проверка пропускала испорченный хвост (…=30000zzсодержит…=30000), а Postgres на такое отвечаетFATAL: invalid value for parameter— ни одного коннекта ни в одном из трёх сервисов, полный отказ продукта;statement_timeoutстрого между полом (самый длинный объявленный бюджет/estimate) и крышей (<= 2 ×от него),lock_timeout<statement_timeout.Против живой Postgres (в
ci-tradein.ymlидут по-настоящему, на машине без БД пропускаются — записаны вtests/skip_allowlist.txt). Пропуск теперь отделён от отказа:_live_engineсначала пробивает коннект БЕЗconnect_args— сервера нет это пропуск, а «сервер есть, нашиoptionsон не принял» это падение, а неskip:statement_timeout = 30sиlock_timeout = 5s;SELECT pg_sleep(…)длиннее потолка обрываетсяQueryCanceledза отведённое время, а не висит;ACCESS EXCLUSIVEчитатель отваливаетсяLockNotAvailableпоlock_timeout, а не ждёт вечно;SET LOCAL statement_timeoutне обрезается сессионным потолком (запрос длиннее сессионного потолка доходит до конца);SET LOCALне течёт за свою транзакцию — коннект не возвращается в пул без потолка.Мутации (все красные,
git stashне использовался)connect_args=DB_CONNECT_ARGSснятoptionsиспорчен (statement_timeout=30000zz)_STATEMENT_TIMEOUT_MS = 300_000(пять минут)До правок по ревью вторая мутация давала 6 passed, 1 skipped — зелёный прогон при полном отказе продукта. Теперь:
Снятый
connect_args:Вернул →
7 passed.Прогоны
tests/test_3463_db_timeouts.pyпротив живой Postgres — 7 passed, 5.17s;test_3463_db_timeouts.py+test_auth_dsn_from_parts.py+test_identity_store.pyпротив живой БД (второй движок) — 56 passed;DATABASE_URL=postgresql+psycopg://test:test@localhost:5432/test, как вdeploy-tradein.yml) — 6046 passed, 40 skipped,PYTEST_RC=0;ruff check app tests—All checks passed!(rc 0);ruff format --checkпо изменённым файлам —3 files already formatted(rc 0).Приёмка после включения
Первый критерий из issue («ноль запросов дольше потолка в
pg_stat_activityдляstate='active'») заменён — он тавтологичен: после включенияstatement_timeout = 30000активного запроса длиннее 30 с существовать не может, сервер его убивает. Зелено по построению, и «работает» от «работает слишком жёстко» не отличает. Плюс на продеlog_min_duration_statement = -1иlog_lock_waits = off, так что следа отменённых в логах Postgres не останется.За сутки после деплоя:
QueryCanceled/LockNotAvailableв логахtradein-backendиtradein-scraper— по ВСЕМ путям, включая те, что в таблице вердиктов выше. Ошибка на «ожидаемом» пути означала бы ровно то, что потолок стал биндящим, и обязана вызывать откат наравне с неожиданной.zombie/failedвscrape_runsпротив предыдущих суток.Триггер отката: срабатывание любого из двух критериев. Мержить не перед ночным окном сбора; в первую ночь отдельно смотреть за
LockNotAvailableна массовых UPDATE (см. «класс, которого не было в таблице»).Что осталось за рамками (честно)
refresh_search_matview— своё сырое psycopg-соединение мимо движка, 30.85 с замерены и потолка у него по-прежнему нет. Это отдельный класс (как и сказано в комментарии #3194 про «сырые psycopg-подключения мимо движков»), в #3463 не входит.pg_stat_statementsвзяты из deep-ревью: сам я замерил только read-сторону матча (859 мс), аUPDATEна проде не гонял — это запись.Ответ на deep-ревью — всё принято, правки в
70bb5a3a.IDENTITY_STORE=auth+AUTH_DB_PASSWORDво всех трёх контейнерах.connect_args=DB_CONNECT_ARGSвauth_db.py— одна константа на оба движка. Ложное утверждение из «за рамками» убрано.optionsзеленеетin;_live_engineпробивает коннект безconnect_argsи отличает «сервера нет» (skip) от «сервер не принял наши options» (fail). Мутация теперь даётFATAL: invalid value for parameter "statement_timeout": "30000zz", а неskip.QueryCanceled/LockNotAvailableв логах backend+scraper по всем путям, включая таблицу вердиктов. Триггер отката переписан.<= 2 ×объявленного бюджета; мутация300_000краснеет на обеих лэйнах.db.pyи в теле PR: область замера (pg_stat_statementsвытесняетcalls=1→ опораscrape_runs), ГАР-матч 2.07/6.46 с вместо 859 мс read-стороны,idle in transaction= tgbot long-poll (подтвердил тремя пробами: один pid,tg_support_state), а не свипы.Мутационная таблица (все три красные на обеих лэйнах) и числа прогонов — в теле PR.