All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
Утверждение «формула триггера = формула миграции» было ложным по ИСТОЧНИКУ базы: record_listing_price_change (131:76-86) считает процент от listings.OLD.price_rub, а не от предыдущей строки offer_price_history. Пересчёт по lag() портил честные значения: у листинга, чья история начинается с триггерной строки, lag() = NULL → −0.82 уходил в NULL; у триггерной строки с соседом-строкой загрузчика база чужая. Теперь пересчитываем и берём как базу ТОЛЬКО строки загрузчика (change_time <> recorded_at — триггер ставит обе метки одним now()). Это ровно то, что делает починенный код: процент внутри истории самой карточки. Окно lag() сужено до листингов с domklik-строками — иначе оконная функция шла по всей таблице под lock_timeout = 5s и валила деплой. Признак закреплён тестом на INSERT загрузчика (recorded_at не указан → DEFAULT NOW()); при добавлении recorded_at в INSERT тест краснеет — проверено. В шапке миграции отмечена асимметрия: старые cian-строки с |x| > 100 не чиним.
132 lines
9.1 KiB
PL/PgSQL
132 lines
9.1 KiB
PL/PgSQL
-- 285_domklik_diff_percent_recompute.sql
|
||
-- Пересчитать offer_price_history.diff_percent у domklik из соседних price_rub (#3225).
|
||
--
|
||
-- ЧТО БЫЛО НЕ ТАК
|
||
-- Загрузчик карточки Домклика клал в diff_percent поле источника priceHistory.diff,
|
||
-- а там РУБЛИ, не проценты. Замер на проде 29.08.2026: 8900 из 11 075 непустых
|
||
-- значений с |diff| > 50; перцентили p05 = −600 000, p50 = −50 010, p95 = +300 000.
|
||
-- У одного и того же listing_id 406163 в колонке соседствуют −200000.00 (рубли, из
|
||
-- загрузчика) и −0.82 (настоящий процент, из триггера record_listing_price_change).
|
||
-- Код починен в том же PR (providers/domclick/detail.py + offer_price_history.py:
|
||
-- процент считается из соседних цен, неправдоподобное значение отвергается, а не
|
||
-- зажимается). Эта миграция отрабатывает задним числом по уже собранным строкам.
|
||
--
|
||
-- ТРОГАЕМ ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
|
||
-- В таблице два писателя с РАЗНОЙ базой отсчёта, и смешивать их нельзя:
|
||
-- • загрузчик (domclick/detail.py) считает процент внутри истории самой карточки —
|
||
-- база это предыдущая запись priceHistory, то есть предыдущая строка загрузчика;
|
||
-- • триггер record_listing_price_change (131_fix_diff_percent_overflow.sql:76-86)
|
||
-- считает от listings.OLD.price_rub — от цены В КАРТОЧКЕ ЛИСТИНГА на момент
|
||
-- upsert'а. Эта база в offer_price_history может вообще не лежать отдельной
|
||
-- строкой.
|
||
-- Поэтому раннее утверждение «формула триггера = формула миграции, пересчёт даст то
|
||
-- же значение» ЛОЖНО, и опереться на признак происхождения как раз нужно. Без него
|
||
-- миграция портит честные данные двумя способами: (1) у листинга, чья история
|
||
-- начинается с триггерной строки, lag() = NULL → честный −0.82 перезаписывается в
|
||
-- NULL; (2) у триггерной строки, соседом которой по lag() оказалась строка
|
||
-- загрузчика, честный процент пересчитывается от чужой базы.
|
||
-- Признак точный, а не эвристический: триггер подставляет now() и в change_time, и
|
||
-- в recorded_at ОДНИМ INSERT'ом, а now() стабилен внутри транзакции → у триггерной
|
||
-- строки метки равны побайтово. Загрузчик пишет в change_time дату источника, а
|
||
-- recorded_at не указывает вовсе (DEFAULT NOW(), 023_offer_price_history.sql:19) →
|
||
-- расхождение в месяцы. Совпадение исторической даты с моментом вставки с точностью
|
||
-- до микросекунды недостижимо. Признак закреплён тестом
|
||
-- test_save_detail_enrichment_leaves_recorded_at_to_default.
|
||
--
|
||
-- ОКНО lag() — ТОЖЕ ТОЛЬКО ПО СТРОКАМ ЗАГРУЗЧИКА
|
||
-- Триггерные строки не переписываем И не используем как базу. Иначе строка
|
||
-- загрузчика получила бы базой триггерную строку, которой в истории карточки нет, —
|
||
-- и результат разошёлся бы с тем, что теперь пишет починенный код. Задача миграции
|
||
-- ровно в том, чтобы задним числом дать те же значения, что даёт код: prev — это
|
||
-- предыдущая запись priceHistory карточки. По source окно не сужаем: «предыдущая
|
||
-- цена» — это предыдущая запись листинга, а у листинга со смешанными источниками
|
||
-- фильтр по source подсунул бы не ту строку.
|
||
--
|
||
-- ФИЛЬТР ПО |x| > 100 НЕ БЕРЁМ
|
||
-- Он пропустил бы испорченные строки с мелким рублёвым diff (−50 рублей выглядит
|
||
-- как правдоподобные −50%).
|
||
--
|
||
-- АСИММЕТРИЯ: СТАРЫЕ CIAN-СТРОКИ НЕ ЧИНИМ
|
||
-- Гейт validate_diff_percent общий для всех источников (|x| > 100 → NULL + warning),
|
||
-- а миграция — только про domklik. Уже лежащие в таблице cian-строки с |x| > 100
|
||
-- остаются как есть: их история приходит из cian_price_history.py со своим полем и
|
||
-- своей базой, отдельным замером не подтверждена, а править вслепую по чужому
|
||
-- источнику — это второй #3225, а не его починка. Отдельная задача.
|
||
--
|
||
-- САМАЯ РАННЯЯ ЗАПИСЬ ЛИСТИНГА → NULL, НЕ 0
|
||
-- Предыдущей цены нет — процента не существует. Ноль здесь читался бы как «цена не
|
||
-- менялась», то есть как измерение, которого не было. Так же ведёт себя и код.
|
||
--
|
||
-- ИДЕМПОТЕНТНОСТЬ
|
||
-- Пересчёт детерминирован (те же строки → те же значения), а UPDATE ограничен
|
||
-- `IS DISTINCT FROM` — повторный прогон трогает 0 строк. Новых объектов схемы нет.
|
||
BEGIN;
|
||
-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках
|
||
-- offer_price_history и без lock_timeout встанет в очередь за чужой сессией,
|
||
-- утащив за собой запросы приложения.
|
||
SET LOCAL lock_timeout = '5s';
|
||
|
||
DO $$
|
||
DECLARE
|
||
before_notnull bigint;
|
||
before_bad bigint;
|
||
after_notnull bigint;
|
||
after_bad bigint;
|
||
touched bigint;
|
||
BEGIN
|
||
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
|
||
count(*) FILTER (WHERE abs(diff_percent) > 100)
|
||
INTO before_notnull, before_bad
|
||
FROM offer_price_history
|
||
WHERE source = 'domklik'
|
||
AND change_time <> recorded_at;
|
||
|
||
RAISE NOTICE 'domklik (строки загрузчика) ДО: diff_percent непустых = %, из них |x| > 100 = %',
|
||
before_notnull, before_bad;
|
||
|
||
WITH neighbours AS (
|
||
-- Окно только по листингам, у которых есть domklik-строки: без этого сужения
|
||
-- lag() прогоняется по ВСЕЙ таблице, и под lock_timeout = 5s деплой падает.
|
||
-- Оба скана идут по индексам 023: oph_source_time_idx (source, change_time)
|
||
-- для подзапроса и oph_listing_time_idx (listing_id, change_time) для окна.
|
||
SELECT id,
|
||
source,
|
||
price_rub,
|
||
lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id)
|
||
AS prev_price
|
||
FROM offer_price_history
|
||
WHERE listing_id IN (
|
||
SELECT DISTINCT listing_id
|
||
FROM offer_price_history
|
||
WHERE source = 'domklik'
|
||
)
|
||
AND change_time <> recorded_at
|
||
),
|
||
recomputed AS (
|
||
SELECT id,
|
||
CASE WHEN prev_price > 0
|
||
THEN round((price_rub - prev_price) / prev_price * 100, 2)
|
||
END AS new_diff
|
||
FROM neighbours
|
||
WHERE source = 'domklik'
|
||
)
|
||
UPDATE offer_price_history oph
|
||
SET diff_percent = r.new_diff
|
||
FROM recomputed r
|
||
WHERE oph.id = r.id
|
||
AND oph.diff_percent IS DISTINCT FROM r.new_diff;
|
||
|
||
GET DIAGNOSTICS touched = ROW_COUNT;
|
||
|
||
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
|
||
count(*) FILTER (WHERE abs(diff_percent) > 100)
|
||
INTO after_notnull, after_bad
|
||
FROM offer_price_history
|
||
WHERE source = 'domklik'
|
||
AND change_time <> recorded_at;
|
||
|
||
RAISE NOTICE 'domklik (строки загрузчика) ПОСЛЕ: diff_percent непустых = % (было %), |x| > 100 = % (было %), переписано строк = %',
|
||
after_notnull, before_notnull, after_bad, before_bad, touched;
|
||
END $$;
|
||
|
||
COMMIT;
|