fix(tradein/deactivate-stale): пол переобхода — LATERAL вместо equality-join по дате #3056

Merged
lekss361 merged 1 commit from fix/revisit-floor-lateral-lookup into main 2026-08-23 14:37:24 +00:00
Owner

Первый из четырёх PR по переходу listing_source_snapshots на модель «строка на изменение» (развилка #2993, решение владельца 2026-08-23). Порядок жёсткий: читатель раньше писателя, обратный даёт зелёный деплой и обвал на четвёртые сутки.

Зачем

_build_revisit_floor_sql вычисляет минимальный TTL по парам «предыдущее наблюдение → текущее». Джойн шёл по равенству даты снимка с якорем. Это ломается двумя независимыми способами.

Будущий. В change-only модели строка пишется только при изменении. На 2026-08-20 «изменениями» являются 4 458 строк из 101 795 — equality-join теряет 95.6 % пар, выборка схлопывается до n=3–4, percentile_disc(0.99) вырождается в максимум из трёх чисел. Обвал пола по срезам: domklik 28 → 14 (под снятие сразу 128 активных строк), yandex/vtorichka 44 → 30, avito 13 → 10.

Уже сейчас. Дыры в суточной истории дают n_pairs=0 → floor_days=NULL → TTL как задан, молча. На проде такие дыры есть: 03–14.06 (двенадцать суток подряд), 03–04.07, 12.07, 26.07, 30–31.07, 01.08 — то есть пол не считался ровно в моменты разбора завалов.

Что меняется

1. Равенство по дате → последняя строка не позже якоря. JOIN LATERAL (… ORDER BY snapshot_date DESC LIMIT 1). Семантика сохраняется точно: между изменениями last_seen_at по определению постоянен.

2. Глобальный max(snapshot_date) → максимум внутри источника. Старый подзапрос брал максимум по всей таблице, не скоупленный по listing_source_id — источник, чья история короче общей, выпадал из выборки целиком. Это отдельный фикс, а не побочный эффект, и назван здесь намеренно.

3. floor_n_pairs в counters — наблюдательность под будущий гейт деградации пола (PR-B). Сейчас ничего не блокирует.

FROM..WHERE вынесен в _revisit_floor_from_where_sql, чтобы count(*) и percentile_disc гарантированно шли по одному срезу, а не разошлись при будущей независимой правке одного из двух.

Замер на живом проде, 2026-08-23

health_window_days=3, обе формы запроса рядом:

источник старый n / пол новый n / пол
avito 765 / 49.7 765 / 49.7
cian 1226 / 81.6 1226 / 81.6
domklik 565 / 35.7 565 / 35.7
yandex 3856 / 87.3 3856 / 87.3

Совпадение побитовое. Сегодня суточная джоба пишет строку для каждого источника каждый день, поэтому глобальный максимум совпадает с максимумом каждого источника — пункт 2 является no-op на текущих данных. Расхождение проявится только на дырах и после перехода на change-only.

Иначе говоря, на прод это выкатывается с нулевым изменением поведения, а защищает от обвала, который наступил бы при PR-C.

Тесты

Новый tests/test_revisit_floor_lateral_lookup.py: три live-теста против настоящего Postgres и четыре чистых SQL-теста без БД.

Ключевой — test_equality_join_loses_gapped_pairs_and_gives_a_smaller_floor: на одних и тех же трёх парах (одна со снимком ровно на дату якоря, две с гэпом) LATERAL видит все три (n_pairs=3, пол 65.0), воспроизведённый старый equality-join — только одну (n_pairs=1, пол 5.5).

Live-тесты используют uuid-суффикс в source, поэтому независимы от порядка выполнения и данных соседей; очистка — чистый rollback(), без коммитов.

Прогон: 160/160 в целевом наборе (новый файл + все пять существующих test_deactivate_stale_*).

Границы

Писатель listing_source_snapshot.py не тронут, миграций нет, гейты и предохранители не добавлены — это PR-B и PR-C.

Refs #2993, #2659

Первый из четырёх PR по переходу `listing_source_snapshots` на модель «строка на изменение» (развилка #2993, решение владельца 2026-08-23). Порядок жёсткий: **читатель раньше писателя**, обратный даёт зелёный деплой и обвал на четвёртые сутки. ## Зачем `_build_revisit_floor_sql` вычисляет минимальный TTL по парам «предыдущее наблюдение → текущее». Джойн шёл **по равенству даты снимка** с якорем. Это ломается двумя независимыми способами. **Будущий.** В change-only модели строка пишется только при изменении. На 2026-08-20 «изменениями» являются 4 458 строк из 101 795 — equality-join теряет **95.6 % пар**, выборка схлопывается до n=3–4, `percentile_disc(0.99)` вырождается в максимум из трёх чисел. Обвал пола по срезам: domklik 28 → 14 (под снятие сразу 128 активных строк), yandex/vtorichka 44 → 30, avito 13 → 10. **Уже сейчас.** Дыры в суточной истории дают `n_pairs=0 → floor_days=NULL → TTL как задан`, молча. На проде такие дыры есть: 03–14.06 (двенадцать суток подряд), 03–04.07, 12.07, 26.07, 30–31.07, 01.08 — то есть пол не считался ровно в моменты разбора завалов. ## Что меняется **1. Равенство по дате → последняя строка не позже якоря.** `JOIN LATERAL (… ORDER BY snapshot_date DESC LIMIT 1)`. Семантика сохраняется точно: между изменениями `last_seen_at` по определению постоянен. **2. Глобальный `max(snapshot_date)` → максимум внутри источника.** Старый подзапрос брал максимум по **всей таблице**, не скоупленный по `listing_source_id` — источник, чья история короче общей, выпадал из выборки целиком. Это отдельный фикс, а не побочный эффект, и назван здесь намеренно. **3. `floor_n_pairs` в counters** — наблюдательность под будущий гейт деградации пола (PR-B). Сейчас ничего не блокирует. `FROM..WHERE` вынесен в `_revisit_floor_from_where_sql`, чтобы `count(*)` и `percentile_disc` гарантированно шли по одному срезу, а не разошлись при будущей независимой правке одного из двух. ## Замер на живом проде, 2026-08-23 `health_window_days=3`, обе формы запроса рядом: | источник | старый n / пол | новый n / пол | |---|---|---| | avito | 765 / 49.7 | 765 / 49.7 | | cian | 1226 / 81.6 | 1226 / 81.6 | | domklik | 565 / 35.7 | 565 / 35.7 | | yandex | 3856 / 87.3 | 3856 / 87.3 | **Совпадение побитовое.** Сегодня суточная джоба пишет строку для каждого источника каждый день, поэтому глобальный максимум совпадает с максимумом каждого источника — пункт 2 является no-op на текущих данных. Расхождение проявится только на дырах и после перехода на change-only. Иначе говоря, на прод это выкатывается **с нулевым изменением поведения**, а защищает от обвала, который наступил бы при PR-C. ## Тесты Новый `tests/test_revisit_floor_lateral_lookup.py`: три live-теста против настоящего Postgres и четыре чистых SQL-теста без БД. Ключевой — `test_equality_join_loses_gapped_pairs_and_gives_a_smaller_floor`: на одних и тех же трёх парах (одна со снимком ровно на дату якоря, две с гэпом) LATERAL видит все три (`n_pairs=3`, пол 65.0), воспроизведённый старый equality-join — только одну (`n_pairs=1`, пол 5.5). Live-тесты используют `uuid`-суффикс в `source`, поэтому независимы от порядка выполнения и данных соседей; очистка — чистый `rollback()`, без коммитов. Прогон: 160/160 в целевом наборе (новый файл + все пять существующих `test_deactivate_stale_*`). ## Границы Писатель `listing_source_snapshot.py` не тронут, миграций нет, гейты и предохранители не добавлены — это PR-B и PR-C. Refs #2993, #2659
lekss361 added 1 commit 2026-08-23 12:46:37 +00:00
fix(tradein/deactivate-stale): пол переобхода — LATERAL вместо equality-join по дате
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 4m41s
fb38d657ad
Правка меняет две вещи разом, обе намеренно.

1. Равенство по дате -> «последняя строка не позже якоря».
   listing_source_snapshots переходит на модель «строка на изменение».
   В ней equality-join теряет 95.6% пар (на 2026-08-20 изменениями являются
   4458 строк из 101795): выборка для percentile_disc схлопывается до n=3-4,
   квантиль вырождается в максимум из трёх чисел, TTL обваливается —
   domklik 28->14 (под снятие сразу 128 активных строк), yandex 44->30,
   avito 13->10.
   Дыры в суточной истории ломали equality-join и до перехода: на проде
   03-14.06 (12 суток подряд), 03-04.07, 12.07, 26.07, 30-31.07, 01.08 —
   там n_pairs=0, floor_days=NULL и TTL молча оставался как задан.

2. Глобальный max(snapshot_date) -> максимум внутри источника.
   Старый подзапрос брал максимум по ВСЕЙ таблице, не скоупленный по
   listing_source_id: источник, чья история короче общей, выпадал из
   выборки целиком. LATERAL ищет предшественника по строке.

Замер на живом проде 2026-08-23 (health_window_days=3): обе формы дают
побитово одинаковый результат на всех четырёх источниках —
avito n=765 пол=49.7, cian n=1226 пол=81.6, domklik n=565 пол=35.7,
yandex n=3856 пол=87.3. Сегодня суточная джоба пишет строку для каждого
источника каждый день, поэтому глобальный максимум совпадает с максимумом
каждого источника, и пункт 2 — no-op на текущих данных. Расхождение
проявится только на дырах и после перехода на change-only.

Плюс floor_n_pairs в counters — наблюдательность для будущего гейта
деградации пола, сейчас ничего не блокирует.

FROM..WHERE вынесен в _revisit_floor_from_where_sql, чтобы count(*) и
percentile_disc гарантированно шли по одному срезу.
lekss361 merged commit 38e38e662c into main 2026-08-23 14:37:24 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3056
No description provided.