-- Контекст: перф-фикс offer-trend §4.3 (профилирование 2026-07-04). Покрывающий
-- индекс под join+фильтр запроса market_metrics._OFFER_TREND_SQL (CTE `hist`),
-- который считает медиану цены предложения по снапшотам Объектива в радиусе.
--
-- ПРОБЛЕМА (ground-truth, prod EXPLAIN ANALYZE 2026-07-04, центр ЕКБ 66:41:0702048):
--   `hist` джойнит objective_lots_history h на 141 936 radius-лотов (плотный центр) —
--   Nested Loop с ВНУТРЕННИМ `Index Scan using objective_lots_history_lot_idx`, actual
--   loops=141936. Существующий _lot_idx = (objective_lot_id, snapshot_date DESC) БЕЗ
--   INCLUDE: он находит строки по objective_lot_id, но фильтр
--     (is_sold IS NOT TRUE) AND price_per_m2_rub BETWEEN 10000 AND 1000000
--   и проекция (snapshot_date, price_per_m2_rub) НЕ покрыты индексом → на КАЖДУЮ
--   совпавшую индекс-запись планировщик добирает строку из кучи (heap fetch).
--   Итог по этому одному узлу: Buffers shared hit=579133 read=10666 (~589 799
--   буфер-доступов ≈ 4.6 ГБ тронуто), Execution ~6.8 s тёплый / >8 s холодный —
--   пробивает 8-секундный guard ~2 раза в сутки на плотных центрах.
--
-- РЕШЕНИЕ: покрывающий индекс, у которого КЛЮЧ даёт и join-ключ, и порядок
--   DISTINCT ON запроса, а INCLUDE несёт фильтр-колонки и проекцию — тогда каждый
--   probe становится Index-Only Scan без heap fetch.
--
--   Ключ (objective_lot_id, snapshot_date, fetched_at DESC):
--     • objective_lot_id — Index Cond джойна (rl.objective_lot_id = h.objective_lot_id);
--     • snapshot_date, fetched_at DESC — ТОЧНО порядок `hist`
--         ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
--       под DISTINCT ON (objective_lot_id, snapshot_date) (дедуп задвоенного дня —
--       берём последний свип). Индекс отдаёт строки уже в этом порядке per-lot →
--       Unique/Incremental-Sort обслуживается из индекса без досортировки кучи.
--   INCLUDE (price_per_m2_rub, is_sold, status):
--     • price_per_m2_rub — и фильтр (BETWEEN), и единственная проецируемая метрика;
--     • is_sold — фильтр (is_sold IS NOT TRUE) — сильнейший по селективности;
--     • status — фильтр LOWER(COALESCE(status,'')) <> 'продан'. На проде он сейчас
--       ничего не отсекает (2 245 510/2 245 510 проходят — «продан»-состояние несёт
--       is_sold, не строка status), но колонка нужна в INCLUDE, чтобы предикат
--       вычислялся из индекса, а не из кучи; иначе Index-Only невозможен.
--
-- PARTIAL-WHERE price_per_m2_rub BETWEEN 10000 AND 1000000 — зеркало литеральных
--   _OFFER_PRICE_MIN / _OFFER_PRICE_MAX запроса (market_metrics.py). Режет индекс с
--   2 245 510 до 1 856 499 строк (≈83%, prod-замер) и заодно выкидывает NULL-цены
--   (16.9% null_frac — они и так не проходят BETWEEN). Никакой ДРУГОЙ потребитель
--   history по этому диапазону не фильтрует:
--     • триггер-писатель objective_lots_write_history (166) — SELECT … WHERE
--       objective_lot_id=? ORDER BY snapshot_date DESC LIMIT 1, БЕЗ price-фильтра →
--       остаётся на _lot_idx (18.4M scans, hot);
--     • MV mv_sales_tracker_* (164) — DISTINCT ON (lot, month) без price-фильтра →
--       тоже _lot_idx.
--   Поэтому partial-предикат безопасен: он не должен обслуживать эти пути, а
--   _lot_idx НЕ трогаем (оба индекса сосуществуют, каждый под своим запросом).
--   is_sold в partial-WHERE НЕ выносим (оставляем в INCLUDE): partial по
--   `is_sold IS NOT TRUE` был бы селективнее (39% строк), но такой индекс перестал
--   бы годиться для будущих is-sold-agnostic чтений, а выигрыш поверх price-partial
--   мал — Index-Only и так снимает heap-походы.
--
-- ЭФФЕКТ (прод-верифицирован 2026-07-04: индекс УЖЕ создан руками CONCURRENTLY
--   до мержа + зарегистрирован в _schema_migrations, деплой = no-op): внутренний `Index Scan using objective_lots_history_lot_idx` + heap
--   fetch → `Index Only Scan using objective_lots_history_offer_trend_idx`,
--   Heap Fetches ≈ 0, Buffers на history-узле падают с ~590k до ~десятков k,
--   Execution 6789 мс → 1072 мс (6.3×) на том же центре — подтверждено живым
--   EXPLAIN ANALYZE после создания.
--
-- ── РАЗМЕР / ВРЕМЯ BUILD ─────────────────────────────────────────────────────
-- Таблица objective_lots_history: 2 245 510 строк, 538 MB total (282 MB heap,
--   255 MB indexes), prod-замер 2026-07-04. Партиал-индекс: ~1 856 499 записей ×
--   (key 20B: lot_id 8 + date 4 + fetched_at 8; INCLUDE 27B: price 11 + is_sold 1
--   + status 15; + ~12B накладных) ≈ 59–64 B/запись → оценка ~120–150 MB. Для
--   сравнения _lot_idx (2 ключа, без INCLUDE, все 2.24M) = 90 MB — оценка
--   согласована. BUILD такого индекса на ~1.9M строк — ориентировочно 20–60 с
--   (heap 282 MB, одиночный сорт), НЕ гигантский, но у верхней границы 30-с эвристики.
--
-- ── LOCK-ПРОФИЛЬ / CONCURRENTLY ──────────────────────────────────────────────
-- Обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE-lock на
--   objective_lots_history — блокирует writes (INSERT снапшотов триггером +
--   недельный objective-scrape) на время сборки. CONCURRENTLY здесь НЕВОЗМОЖЕН:
--   deploy.yml прогоняет каждый .sql целиком через `psql -v ON_ERROR_STOP=on <
--   file` в ОДНОЙ транзакции (файл само-оборачивается BEGIN/COMMIT), а
--   `CREATE INDEX CONCURRENTLY` внутри транзакции PostgreSQL запрещает. Тот же
--   класс/решение, что 176 / 183 / 168 (см. их lock-комменты).
-- ⚠ Деплой ВНЕ недельного окна objective-scrape (bulk INSERT/UPDATE лотов → каскад
--   триггер-INSERT в history): миграция создаёт ~120–150 MB индекс под
--   кратковременным SHARE-lock на history (~20–60 с writes-block).
-- ⚙ ЕСЛИ build упрётся в >30 с и writes-block нежелателен — применить руками ДО
--   мержа CONCURRENTLY (вне транзакции, без блокировки writes):
--     CREATE INDEX CONCURRENTLY IF NOT EXISTS objective_lots_history_offer_trend_idx
--       ON objective_lots_history (objective_lot_id, snapshot_date, fetched_at DESC)
--       INCLUDE (price_per_m2_rub, is_sold, status)
--       WHERE price_per_m2_rub BETWEEN 10000 AND 1000000;
--     INSERT INTO _schema_migrations (filename)
--       VALUES ('186_objective_lots_history_offer_trend_covering_idx.sql')
--       ON CONFLICT DO NOTHING;
--   Тогда эта миграция при деплое станет no-op (IF NOT EXISTS + уже в трекере) —
--   файл остаётся идемпотентной фиксацией схемы. Иначе (окно тихое) — деплой
--   сам создаёт индекс штатным CREATE INDEX ниже.
--
-- Idempotent: CREATE INDEX IF NOT EXISTS. Self-wrapped BEGIN/COMMIT.
-- Deploy order: чистая СХЕМА, без зависимости от backend-кода (индекс прозрачен —
--   планировщик выбирает его по форме запроса, не по имени; market_metrics.py НЕ
--   меняется). Безопасно катить в любой момент (вне scrape-окна).
BEGIN;

CREATE INDEX IF NOT EXISTS objective_lots_history_offer_trend_idx
    ON objective_lots_history (
        objective_lot_id,
        snapshot_date,
        fetched_at DESC
    )
    INCLUDE (
        price_per_m2_rub,
        is_sold,
        status
    )
    WHERE price_per_m2_rub BETWEEN 10000 AND 1000000;

COMMENT ON INDEX objective_lots_history_offer_trend_idx IS
    'Покрывающий индекс под market_metrics._OFFER_TREND_SQL (CTE hist, §4.3). Ключ '
    '(objective_lot_id, snapshot_date, fetched_at DESC) = join-ключ + порядок '
    'DISTINCT ON запроса; INCLUDE (price_per_m2_rub, is_sold, status) покрывает '
    'фильтр и проекцию → Index-Only Scan вместо Nested-Loop heap-fetch на 141k+ '
    'лотов (было ~590k буфер-доступов / ~6.8 s тёплый). Partial-WHERE '
    'price BETWEEN 10000 AND 1000000 = литералы _OFFER_PRICE_MIN/MAX (~83% строк). '
    'НЕ обслуживает триггер-писатель (166) и MV (164) — те на _lot_idx.';

ANALYZE objective_lots_history;

COMMIT;
