fix(tradein/snapshot): починить зависающий запрос снапшотов и бюджет времени (#2607) #2618
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2618
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/tradein-snapshot-query-perf"
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?
Summary
Issue #2607 —
listing_source_snapshot(ежедневный per-source price-history snapshot) висел ночь за ночью минимум с 19 июля:scrape_runsкаждый раз добирал до статусаzombieровно через 6h (порог zombie-детектора), а backend в Postgres продолжал реально выполнять запрос ещё сутками (46h/22h/31h/7h — снимал рукамиpg_terminate_backend, но появлялись новые). Данные не писались (max(snapshot_date)застрял на 2026-07-29), а осиротевшие транзакции держалиbackend_xmin, блокируя autovacuum наlistings/listing_sources(14-16% мёртвых кортежей).Root cause (EXPLAIN на проде, planner-only, БЕЗ ANALYZE)
Event-diff CTE (
app/tasks/listing_source_snapshot.py) джойнилtoday(снимок заCURRENT_DATE) сprior—DISTINCT ON (listing_source_id) ... FROM listing_source_snapshots WHERE snapshot_date < CURRENT_DATEпо всей таблице (~2.6-2.8M строк, 81 387 distinctlisting_source_id) — обычнымJOIN.Планировщик оценивает
snapshot_date = CURRENT_DATEв 1 строку (свежевставленные в ЭТОЙ ЖЕ транзакции строки статистика ANALYZE ещё не видела —CURRENT_DATEвсегда за пределами гистограммы) → выбираетNested LoopбезMaterializeна внутренней стороне:Реально
today— это ~80-140k строк (весьlisting_sources), а не 1. Каждая строкаtodayзаново пересчитываетDISTINCT ONпо всей таблице (Index Scan + Unique над 2.67M строк, cost≈298 627) — экспоненциальный разгон, который никогда не завершался ни за 6h, ни за сутки.listing_source_snapshots: 370 MB (table only) + ~265 MB индексов, 2 784 049 строк / 81 387 distinct sources,work_mem=4MB(не трогал — вне scope).Fix
app/tasks/listing_source_snapshot.py—prior-CTE переписан наJOIN LATERAL (... WHERE s.listing_source_id = t.listing_source_id AND s.snapshot_date < CURRENT_DATE ORDER BY s.snapshot_date DESC LIMIT 1) ON true. Форсирует per-row индексный point-lookup черезidx_lss_source_date (listing_source_id, snapshot_date DESC)вместо полного скана таблицы. Семантика идентична (PK(listing_source_id, snapshot_date)исключает дубликаты — "последний снимок до сегодня" тот же самый). EXPLAIN на проде после фикса:Cost внутреннего подзапроса упал с ~298 627 до ~4.4 за строку today — при ~80-140k строк это несколько сотен тысяч cost-юнитов суммарно на чистых индексных lookup'ах (не полных сканах), т.е. ожидаемое время выполнения — низкие единицы-десятки секунд вместо суток. Не гонял ANALYZE на проде (запрещено правилами задачи — реальный запрос может повесить базу), оценка построена по planner-cost и характеру плана (индексные point-lookup вместо full-table Unique-скана).
budget_sec →
SET LOCAL statement_timeout(defense-in-depth, по образцуrun_geocode_missing_listings/budget_sec). Задача не батчится Python-циклом (два set-based statement'а), поэтому единственный надёжный способ прервать зависший statement — Postgres-нативныйstatement_timeout, выставленныйSET LOCAL(per-transaction scope, не трогает server/role-level — issue #2607 п.2 остаётся отдельным решением).SETне принимает bind-параметр ($1/:nameдаётsyntax error— проверено вживую), поэтому значение подставляется как provalidated/clamp'нутый ([30, 3600]сек, default 900) int, источник —scrape_schedules.default_params, не user input.data/sql/202_listing_source_snapshot_budget_sec.sql—UPDATE scrape_schedules SET default_params = default_params || '{"budget_sec": 900}'::jsonb WHERE source = 'listing_source_snapshot'. Идемпотентно, dry-run синтаксис проверен на проде внутриBEGIN; ... ROLLBACK;(без побочных эффектов).app/services/product_handlers.py—_job_listing_source_snapshotтеперь прокидываетparamsвsnapshot_listing_sources(раньше игнорировались).Зомби-детектор (issue #2607 п.3-4) — ОТЛОЖЕНО, не в этом PR
reap_zombies(scraper_kit/orchestration/scheduler.py) только помечаетscrape_runs.status='zombie'— не убивает backend в Postgres. Корень симптомов 3-4 (осиротевшие backend'ы живут сутками, держатbackend_xmin, блокируют autovacuum). Добавлениеpg_terminate_backendпотребовало бы хранитьpid/application_nameпрогона — этого нет в схемеscrape_runs(проверено:\d scrape_runsна проде, нет соответствующих колонок). Это кросс-катный (затрагивает_dispatch/_claim_run/create_run/reap_zombies— общий код ВСЕХ job-хендлеров, не только этого), требует pid-reuse-safe дизайна (сверкаbackend_starttimestamp, иначе можно убить не тот backend) — отдельный follow-up PR, не раздуваю эту задачу. Теоретически можно обойтись без ALTER TABLE (pid можно писать в уже существующуюcounters/paramsjsonb), но сам объём изменений — за рамками этого фикса.С этим PR риск снижается сам по себе: запрос теперь укладывается в секунды, а budget_sec гарантирует честный
mark_failedвместо зависания, даже если что-то ещё разрегрессирует план.Test plan
pytest tradein-mvp/backend— 3059 passed, 9 skipped, 1 pre-existing fail (test_search_api.py::test_search_cache_hit, 401 RBAC — известен, не в scope)ruff checkна изменённые файлы — чистоBEGIN;...ROLLBACK;— синтаксис и результат проверены, без побочных эффектовscrape_runsследующей ночью (окно 01:00-02:00 UTC) —status='done', неzombie;listing_source_snapshots.max(snapshot_date)продвинулсяRefs #2607