-- 217_position_in_serp_unexpressible.sql -- listings_snapshots.position_in_serp — диагноз «механизм невыразим», шаг 1 из 2. -- -- Dependencies: 016_listings_snapshots.sql (создала колонку). -- Apply after: 216_dead_code_sweep.sql -- Идемпотентно: только COMMENT ON COLUMN (CREATE OR REPLACE-семантика). -- -- ── ЧТО НАЙДЕНО ────────────────────────────────────────────────────────────── -- upsert_listing_snapshot принимал position_in_serp, но НИ ОДИН боевой вызывающий -- его не передавал: три call-site'а (scraper_kit/base.py:801 save_listings, -- providers/cian/detail.py:382, app/tasks/avito_detail_backfill.py:454) плюс -- ingest-скрипты — везде аргумент опущен. На проде колонка пуста 0 из 395 240 строк -- (данные с 2026-05-30 по 2026-08-06). -- -- ── ПОЧЕМУ ЭТО НЕ «ПОДКЛЮЧИТЬ» ─────────────────────────────────────────────── -- Соблазнительный вывод — «парсер выдачи знает индекс карточки, передайте его». -- Он неверен: позиция есть свойство пары (объявление, конкретный прогон выдачи с -- конкретными фильтрами), а выбранная структура этого отношения не выражает. -- PRIMARY KEY (listing_id, snapshot_date) — максимум ОДНА строка на объявление в -- сутки; run_id здесь обычный атрибут, да ещё и под COALESCE в ON CONFLICT. -- Следствия на живых данных: -- * 2026-08-05: 77 прогонов и 5392 total_seen дали 4595 строк снэпшотов; за -- 2026-08-03 в таблице 13 разных run_id на одну дату. Четыре SERP-источника -- (yandex/cian/avito/domclick city_sweep) в одни сутки пишут по одному ключу. -- * Внутри одного city_sweep обход идёт по десяткам гео-якорей радиусом 1500 м с -- перекрытием, и save_listings получает `anchor_lots` — 3 страницы ОДНОГО якоря, -- а не общий ранжированный список. Одно объявление приезжает с разным индексом -- от разных якорей того же прогона. -- * Старый ON CONFLICT писал COALESCE(EXCLUDED.position_in_serp, <старое>) — в -- строке оседал бы индекс последнего писателя дня. Это не «позиция в выдаче», а -- произвольный представитель суток; хуже NULL, потому что читался бы как факт. -- Чтобы позицию можно было хранить честно, нужна отдельная таблица с ключом -- (run_id, listing_id) и сохранёнными фильтрами прогона. Такой задачи сейчас нет — -- ни один потребитель позицию не читает (0 view/matview на проде ссылаются на -- колонку), поэтому колонка удаляется, а не переносится. -- -- ── ПОЧЕМУ DROP НЕ ЗДЕСЬ ───────────────────────────────────────────────────── -- Шаг 1 (этот файл + правка кода в том же PR): upsert_listing_snapshot перестаёт -- упоминать колонку; колонка остаётся, комментарий объясняет почему. -- Шаг 2 (отдельный PR, после того как образ с шагом 1 живёт на проде): -- ALTER TABLE listings_snapshots DROP COLUMN IF EXISTS position_in_serp; -- Порядок не косметический. deploy-tradein.yml применяет data/sql/*.sql ДО -- перезапуска контейнеров («(3) Применяем SQL миграции — ДО app»), так что DROP в -- одном деплое с правкой кода оставил бы окно в несколько минут, где ещё живой -- СТАРЫЙ образ выполняет INSERT со списком колонок, включающим удалённую. Запись -- снэпшотов fault-tolerant (SAVEPOINT + warning), поэтому упало бы тихо — ровно тот -- класс потерь, который замечают через недели по дырке в истории цен. BEGIN; COMMENT ON COLUMN listings_snapshots.position_in_serp IS 'МЁРТВАЯ, удаляется следующей миграцией (#2674). Пуста 0/395240 на 2026-08-06: ' 'писатель принимал аргумент, ни один вызывающий его не передавал. Не подключена, ' 'потому что механизм невыразим в этой таблице: позиция — свойство пары ' '(объявление, конкретный прогон выдачи с конкретными фильтрами), а PK здесь ' '(listing_id, snapshot_date) — одна строка на объявление в сутки, при том что в ' 'одни сутки по этому ключу пишут до 13 прогонов и 4 разных SERP-источника, а ' 'внутри одного прогона объявление приходит с разным индексом от перекрывающихся ' 'гео-якорей. Честное хранение требует таблицы с ключом (run_id, listing_id) и ' 'сохранёнными фильтрами прогона; читателей у позиции нет ни одного.'; COMMIT;