gendesign/tradein-mvp/scripts/sql/286_dryrun.sql
bot-backend 721ceb9876
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
fix(tradein): выборка миграции 286 повторяет гейт 1:1, правило первой точки (#3376)
Ревью нашло, что миграция и код ловили РАЗНОЕ. Миграция брала базой предыдущую
СЫРУЮ строку (lag), гейт — предыдущую ОСТАВЛЕННУЮ. На 1M→10M→1M→10M (цена 1M)
lag-версия удаляла честную точку, на 1M→10M→1.05M→9.9M — не была идемпотентной
(второй прогон доедал 9.9M). Теперь кандидаты выбирает PL/pgSQL-цикл, пошагово
повторяющий drop_decimal_slips, а правило первой точки — отдельным INSERT..SELECT
уже по ОСТАВШИМСЯ строкам.

Правило первой точки — из прод-разбора: 12 из 20 остатков domklik это серии вида
330 000 → 3 300 000 (текущая цена 3 300 000) и 420 000 → 4 200 000 → 4 500 000,
где дефектная точка ПЕРВАЯ и базы слева у неё нет. Свидетелей по-прежнему два:
×10 ко второй точке И подтверждение второй третьей-или-текущей-ценой. Решение по
первой точке принимается по kept-серии, а не по сырой, — иначе гейт теряет
идемпотентность (перебор ловит 1122 таких прогона).

Идемпотентность доказана НА ГЕЙТЕ: property-тест gate(gate(s)) == gate(s) по всем
сериям длины 2-6 (19 525 серий × 3 текущие цены). Фальсифицирован обеими
поломками — сырая база даёт 136 красных прогонов, сырые соседи первой точки 1122.
Раз SQL зеркалит гейт, свойство переносится на миграцию.

Ещё в 286: третий свидетель ПРОТИВ удаления (цена подтверждена триггерной строкой
того же объявления — значит она реально наблюдалась в listings.price_rub) и
финальный шаг |diff_percent| > 100 → NULL по всем источникам, то же правило, что
validate_diff_percent на записи. Ожидаемое число удалений в шапке — 35 + ~12 из
двухсвидетельского предзамера, а не 76 (то была односвидетельская цифра).
Прогон обеих фаз дважды с ROLLBACK — tradein-mvp/scripts/sql/286_dryrun.sql.

yandex: проводка гейта снята как мёртвая. На том пути серия из двух точек, а
свидетель последней — текущая цена лота, то есть она же сама: ветка по построению
не могла выбросить ничего. Оставлен честный комментарий-потолок и ссылка на
follow-up (отлов требует DELETE на следующем наблюдении).

cian: после выброса точки соседу пересчитывается diff_percent (было только у
domclick). domclick: цена листинга берётся RETURNING'ом у UPDATE вместо отдельного
SELECT по PK, пересчёт вынесен в общий recompute_diff_percent с гейтом на пустую
цену (ручной ingest кладёт price_changes из JSONL без валидации).
2026-09-06 03:53:08 +05:00

252 lines
10 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 286_dryrun.sql — прогон миграции 286 на проде БЕЗ записи (#3376).
--
-- ЗАЧЕМ. Миграция 286 удаляет строки из offer_price_history. Прежде чем этому
-- уехать на деплой, надо увидеть на БОЕВЫХ данных: сколько строк она заберёт по
-- источникам, сколько соседей пересчитает, сколько процентов обнулит — и что
-- ВТОРОЙ прогон подряд не делает уже ничего (идемпотентность).
--
-- КАК ЗАПУСКАТЬ (ничего не остаётся, в конце ROLLBACK):
-- ssh poincare
-- docker exec -i tradein-postgres psql -U <user> -d tradein -v ON_ERROR_STOP=1 \
-- -f - < 286_dryrun.sql
-- (или скопировать файл в контейнер и psql -f). Смотреть строки NOTICE.
--
-- ЧЕМ ОТЛИЧАЕТСЯ ОТ 286 — ровно тремя вещами, всё остальное скопировано дословно:
-- 1. вся работа обёрнута в FOR pass_no IN 1..2 — два прохода подряд в ОДНОЙ
-- транзакции; второй обязан дать deleted=0 recomputed=0 nulled=0;
-- 2. порог 200 даёт WARNING, а не EXCEPTION: на dry-run важнее увидеть число и
-- добежать до второго прохода, чем оборвать транзакцию;
-- 3. ROLLBACK вместо COMMIT.
-- ФАЙЛ РАЗОВЫЙ и живёт вне data/sql (автоприменение его не подхватывает). Правишь
-- 286 — правь и здесь, иначе сверять будет нечего.
BEGIN;
SET LOCAL lock_timeout = '5s';
-- ── ПРЕДЗАМЕР ОДНИМ SELECT (для сверки с числами в шапке 286) ────────────────
-- Правило внутри серии пошаговое (база = предыдущая ОСТАВЛЕННАЯ точка) и одним
-- SELECT не выражается — его считает DO-блок ниже. А эти два выражаются.
-- (а) кандидаты правила ПЕРВОЙ точки по СЫРЫМ строкам, до удалений фазы 1.
-- Ожидание из шапки 286: ~12, из них домклик большинство.
SELECT 'предзамер: первая точка (сырые строки)' AS metric, k.source, count(*)
FROM (
SELECT oph.source,
oph.price_rub,
row_number() OVER w AS rn,
lead(oph.price_rub) OVER w AS second_price,
lead(oph.price_rub, 2) OVER w AS third_price,
l.price_rub AS listing_price
FROM offer_price_history oph
LEFT JOIN listings l ON l.id = oph.listing_id
WHERE oph.change_time <> oph.recorded_at
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
) k
WHERE k.rn = 1
AND k.price_rub > 0
AND k.second_price > 0
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
AND COALESCE(k.third_price, k.listing_price) > 0
AND COALESCE(k.third_price, k.listing_price) / k.second_price BETWEEN 0.9 AND 1.1
GROUP BY k.source
ORDER BY k.source;
-- (б) сколько строк заберёт финальный шаг |diff_percent| > 100 → NULL.
SELECT 'предзамер: |diff_percent| > 100' AS metric, source, count(*)
FROM offer_price_history
WHERE change_time <> recorded_at
AND abs(diff_percent) > 100
GROUP BY source
ORDER BY source;
-- ── ДВА ПРОХОДА ЛОГИКИ 286 ───────────────────────────────────────────────────
CREATE TEMP TABLE oph_decimal_slips (
id bigint PRIMARY KEY,
listing_id bigint NOT NULL,
next_id bigint,
source text NOT NULL
) ON COMMIT DROP;
DO $$
DECLARE
pass_no int;
r record;
cur_listing bigint;
base numeric;
witness numeric;
confirmed boolean;
to_delete bigint;
per_source text;
deleted_rows bigint;
updated_rows bigint;
nulled_rows bigint;
nulled_src text;
BEGIN
FOR pass_no IN 1..2 LOOP
DELETE FROM oph_decimal_slips;
cur_listing := NULL;
base := NULL;
-- ФАЗА 1 — правило внутри серии, пошагово как в гейте.
FOR r IN
SELECT oph.id,
oph.listing_id,
oph.source,
oph.price_rub,
lead(oph.price_rub) OVER w AS next_price,
lead(oph.id) OVER w AS next_id,
l.price_rub AS listing_price
FROM offer_price_history oph
LEFT JOIN listings l ON l.id = oph.listing_id
WHERE oph.change_time <> oph.recorded_at
AND oph.listing_id IN (
SELECT listing_id
FROM offer_price_history
WHERE change_time <> recorded_at
GROUP BY listing_id
HAVING count(*) >= 2
)
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
ORDER BY oph.listing_id, oph.change_time, oph.id
LOOP
IF cur_listing IS DISTINCT FROM r.listing_id THEN
cur_listing := r.listing_id;
base := NULL;
END IF;
witness := COALESCE(r.next_price, r.listing_price);
IF base IS NOT NULL
AND base > 0 AND r.price_rub > 0 AND witness > 0
AND (r.price_rub / base BETWEEN 9.5 AND 10.5
OR base / r.price_rub BETWEEN 9.5 AND 10.5)
AND witness / base BETWEEN 0.9 AND 1.1
THEN
SELECT EXISTS (
SELECT 1
FROM offer_price_history t
WHERE t.listing_id = r.listing_id
AND t.change_time = t.recorded_at
AND t.price_rub = r.price_rub
)
INTO confirmed;
IF NOT confirmed THEN
INSERT INTO oph_decimal_slips (id, listing_id, next_id, source)
VALUES (r.id, r.listing_id, r.next_id, r.source);
CONTINUE;
END IF;
END IF;
IF r.price_rub > 0 THEN
base := r.price_rub;
END IF;
END LOOP;
-- ФАЗА 2 — правило первой точки, по оставшимся строкам.
INSERT INTO oph_decimal_slips (id, listing_id, next_id, source)
WITH kept AS (
SELECT oph.id,
oph.listing_id,
oph.source,
oph.price_rub,
row_number() OVER w AS rn,
lead(oph.id) OVER w AS second_id,
lead(oph.price_rub) OVER w AS second_price,
lead(oph.price_rub, 2) OVER w AS third_price,
l.price_rub AS listing_price
FROM offer_price_history oph
LEFT JOIN listings l ON l.id = oph.listing_id
WHERE oph.change_time <> oph.recorded_at
AND NOT EXISTS (SELECT 1 FROM oph_decimal_slips s WHERE s.id = oph.id)
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
)
SELECT k.id, k.listing_id, k.second_id, k.source
FROM kept k
WHERE k.rn = 1
AND k.price_rub > 0
AND k.second_price > 0
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
AND COALESCE(k.third_price, k.listing_price) > 0
AND COALESCE(k.third_price, k.listing_price) / k.second_price BETWEEN 0.9 AND 1.1
AND NOT EXISTS (
SELECT 1
FROM offer_price_history t
WHERE t.listing_id = k.listing_id
AND t.change_time = t.recorded_at
AND t.price_rub = k.price_rub
);
SELECT count(*) INTO to_delete FROM oph_decimal_slips;
SELECT string_agg(source || '=' || cnt, ', ' ORDER BY source)
INTO per_source
FROM (
SELECT source, count(*) AS cnt
FROM oph_decimal_slips
GROUP BY source
) s;
-- Отличие 2 от 286: здесь WARNING, а не EXCEPTION.
IF to_delete > 200 THEN
RAISE WARNING 'pass%: кандидатов % > порога 200 — на проде 286 здесь УПАЛА БЫ',
pass_no, to_delete;
END IF;
DELETE FROM offer_price_history oph
USING oph_decimal_slips s
WHERE oph.id = s.id;
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
WITH neighbours AS (
SELECT id,
price_rub,
lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id)
AS prev_price
FROM offer_price_history
WHERE change_time <> recorded_at
AND listing_id IN (SELECT DISTINCT listing_id FROM oph_decimal_slips)
),
recomputed AS (
SELECT n.id,
CASE
WHEN n.prev_price > 0
AND abs((n.price_rub - n.prev_price) / n.prev_price * 100) <= 100
THEN round((n.price_rub - n.prev_price) / n.prev_price * 100, 2)
END AS new_diff
FROM neighbours n
WHERE n.id IN (SELECT next_id FROM oph_decimal_slips WHERE next_id IS NOT NULL)
)
UPDATE offer_price_history oph
SET diff_percent = rc.new_diff
FROM recomputed rc
WHERE oph.id = rc.id
AND oph.diff_percent IS DISTINCT FROM rc.new_diff;
GET DIAGNOSTICS updated_rows = ROW_COUNT;
SELECT string_agg(source || '=' || cnt, ', ' ORDER BY source)
INTO nulled_src
FROM (
SELECT source, count(*) AS cnt
FROM offer_price_history
WHERE change_time <> recorded_at
AND abs(diff_percent) > 100
GROUP BY source
) s;
UPDATE offer_price_history
SET diff_percent = NULL
WHERE change_time <> recorded_at
AND abs(diff_percent) > 100;
GET DIAGNOSTICS nulled_rows = ROW_COUNT;
RAISE NOTICE 'pass%: deleted=% (%) recomputed=% nulled=% (%)',
pass_no,
deleted_rows, COALESCE(per_source, 'ни одного'),
updated_rows,
nulled_rows, COALESCE(nulled_src, 'ни одной');
END LOOP;
END $$;
ROLLBACK;