gendesign/tradein-mvp/backend/tests/test_revisit_floor_lateral_lookup.py
bot-backend fb38d657ad
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
fix(tradein/deactivate-stale): пол переобхода — LATERAL вместо equality-join по дате
Правка меняет две вещи разом, обе намеренно.

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 гарантированно шли по одному срезу.
2026-08-23 15:45:53 +03:00

428 lines
21 KiB
Python
Raw Permalink 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.

"""LATERAL-поиск предшественника в поле переобхода (#2659 продолжение, PR-A).
`_build_revisit_floor_sql` джойнил историю переобхода ПО РАВЕНСТВУ ДАТЫ:
`prev.snapshot_date = (SELECT max(snapshot_date) FROM listing_source_snapshots
WHERE snapshot_date <= CURRENT_DATE - health_window_days)`. Это ломается ДВАЖДЫ:
1. listing_source_snapshots переходит на модель «строка на изменение» (пишется
только когда значение отличается от предыдущего снимка) -- в ней у подавляющего
большинства listing_source_id на конкретную календарную дату строки просто нет.
На 2026-08-20 «изменениями» являются 4 458 строк из 101 795 (95.6% пар теряются).
2. Дыры в истории ломали equality-join и раньше, до перехода на change-only (на
проде: 03-14.06 -- 12 суток подряд, 03-04.07, 12.07, 26.07, 30-31.07, 01.08).
Правка меняет equality-join на `JOIN LATERAL (... ORDER BY snapshot_date DESC
LIMIT 1) prev ON true` -- предшественник ищется ПО СТРОКЕ (per listing_source_id),
не по единой глобальной дате, и гэпы в истории для него прозрачны. Семантика не
меняется: между изменениями last_seen_at по определению постоянен, поэтому
«последний снимок не позже якоря» и «снимок ровно на дату якоря» при СПЛОШНОЙ
ежедневной истории дают одно и то же число -- разница проявляется только там, где
equality-join терял пары.
Тесты ниже:
- test_lateral_ignores_gaps_and_matches_dense_daily_history -- LATERAL на дырявой
(change-only) истории даёт ТОТ ЖЕ floor_days, что дала бы сплошная суточная
история для того же слушателя.
- test_equality_join_loses_gapped_pairs_and_gives_a_smaller_floor -- на ОДНИХ И ТЕХ
ЖЕ данных equality-join (воспроизведён буквально -- см. _OLD_EQUALITY_JOIN_FLOOR_SQL
ниже, это ровно то условие, что было в _build_revisit_floor_sql до этого PR)
теряет строки без снимка ровно на дату якоря и даёт МЕНЬШИЙ пол.
- Две pure-SQL проверки без БД (форма LATERAL-запроса, "count(*)" из того же среза).
Живая БД: см. `_live_session()` -- self-skip без реального Postgres, как соседние
live-тесты в этом каталоге (test_2992_upsert_unchanged_gate.py и т.д.). В CI Trade-In
есть postgres-сервис (ci-tradein.yml), локально без БД эти проверки skip'аются, а
чисто-SQL тесты внизу файла бегут всегда.
"""
from __future__ import annotations
import os
import re
import uuid
from datetime import UTC, date, datetime, timedelta
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import text
from app.tasks import deactivate_stale_avito as task_mod
_HEALTH_WINDOW_DAYS = 5
def _live_session() -> Any | None:
"""Тот же контракт, что у соседних live-тестов (test_2992_upsert_unchanged_gate.py)."""
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
# ── Воспроизведение СТАРОГО equality-join (буквально то, что было в
# _build_revisit_floor_sql до этой правки) -- код удалён из app/, поэтому для
# сравнения "до/после" запрос встроен сюда как литерал. Единственное упрощение:
# вместо подзапроса `(SELECT max(snapshot_date) FROM listing_source_snapshots
# WHERE snapshot_date <= CURRENT_DATE - :N)` (глобальный по ВСЕЙ таблице, не
# скоупленный per-listing_source_id -- и в этом половина исходного бага) якорная
# дата передаётся явно предвычисленным `CURRENT_DATE - :health_window_days`.
# Само по себе равенство по дате -- ровно та семантика, что менялась в этом PR;
# зависимость от глобального max() по ВСЕЙ таблице сделала бы этот тест хрупким
# к данным других тестов в той же живой БД, никак не проверяя суть правки.
_OLD_EQUALITY_JOIN_FLOOR_SQL = text(
"""
SELECT percentile_disc(CAST(:revisit_quantile AS double precision))
WITHIN GROUP (
ORDER BY EXTRACT(epoch FROM (l.last_seen_at - prev.last_seen_at)) / 86400.0
)
FROM listings l
JOIN listing_sources ls
ON ls.listing_id = l.id
AND ls.ext_source = l.source
JOIN listing_source_snapshots prev
ON prev.listing_source_id = ls.id
AND prev.snapshot_date = CURRENT_DATE - CAST(:health_window_days AS integer)
WHERE l.source = :listing_source
AND l.last_seen_at > NOW() - CAST(:health_window_days || ' days' AS interval)
AND l.last_seen_at > prev.last_seen_at
"""
)
_OLD_EQUALITY_JOIN_COUNT_SQL = text(
"""
SELECT count(*)
FROM listings l
JOIN listing_sources ls
ON ls.listing_id = l.id
AND ls.ext_source = l.source
JOIN listing_source_snapshots prev
ON prev.listing_source_id = ls.id
AND prev.snapshot_date = CURRENT_DATE - CAST(:health_window_days AS integer)
WHERE l.source = :listing_source
AND l.last_seen_at > NOW() - CAST(:health_window_days || ' days' AS interval)
AND l.last_seen_at > prev.last_seen_at
"""
)
def _insert_pair(
db: Any,
*,
source: str,
ext_id: str,
listing_last_seen_at: datetime,
snapshots: list[tuple[date, datetime]],
) -> int:
"""Вставляет listings + listing_sources + N снимков listing_source_snapshots.
БЕЗ commit -- вся синтетика живёт в ОДНОЙ транзакции теста и видна собственным
же SELECT'ам (same-session read-your-writes), очистка -- rollback в конце теста,
отдельного DELETE не нужно.
"""
listing_id = db.execute(
text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, price_rub,
is_active, scraped_at, last_seen_at)
VALUES
(:source, :url, :ext_id, :dedup_hash, 5000000,
true, NOW(), :last_seen_at)
RETURNING id
"""
),
{
"source": source,
"url": f"https://example.test/pr2659-lateral/{ext_id}",
"ext_id": ext_id,
"dedup_hash": f"pr2659-lateral-{ext_id}",
"last_seen_at": listing_last_seen_at,
},
).scalar_one()
listing_source_id = db.execute(
text(
"""
INSERT INTO listing_sources
(listing_id, ext_source, ext_id, confidence, matched_method)
VALUES
(:listing_id, :source, :ext_id, 1.0, 'test')
RETURNING id
"""
),
{"listing_id": listing_id, "source": source, "ext_id": ext_id},
).scalar_one()
for snapshot_date, snapshot_last_seen_at in snapshots:
db.execute(
text(
"""
INSERT INTO listing_source_snapshots
(listing_source_id, snapshot_date, is_active, last_seen_at)
VALUES
(:lsid, :snapshot_date, true, :last_seen_at)
"""
),
{
"lsid": listing_source_id,
"snapshot_date": snapshot_date,
"last_seen_at": snapshot_last_seen_at,
},
)
return int(listing_source_id)
def _floor(db: Any, *, source: str, quantile: float = 1.0) -> float | None:
result = db.execute(
task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False),
{
"listing_source": source,
"health_window_days": _HEALTH_WINDOW_DAYS,
"revisit_quantile": quantile,
},
).scalar()
return float(result) if result is not None else None
def _n_pairs(db: Any, *, source: str) -> int:
result = db.execute(
task_mod._build_revisit_floor_pairs_count_sql("last_seen_at", with_segments=False),
{"listing_source": source, "health_window_days": _HEALTH_WINDOW_DAYS},
).scalar()
return int(result or 0)
def _old_floor(db: Any, *, source: str, quantile: float = 1.0) -> float | None:
result = db.execute(
_OLD_EQUALITY_JOIN_FLOOR_SQL,
{
"listing_source": source,
"health_window_days": _HEALTH_WINDOW_DAYS,
"revisit_quantile": quantile,
},
).scalar()
return float(result) if result is not None else None
def _old_n_pairs(db: Any, *, source: str) -> int:
result = db.execute(
_OLD_EQUALITY_JOIN_COUNT_SQL,
{"listing_source": source, "health_window_days": _HEALTH_WINDOW_DAYS},
).scalar()
return int(result or 0)
# ── Живые тесты (реальный Postgres, транзакция + rollback) ────────────────────
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_lateral_ignores_gaps_and_matches_dense_daily_history() -> None:
"""Дырявая (change-only) история даёт ТОТ ЖЕ floor, что дала бы сплошная суточная.
Между изменениями last_seen_at по определению постоянен -- поэтому у одного и
того же listing_source_id снимок «ровно на дату якоря» и «последний снимок не
позже якоря» несут ОДНО И ТО ЖЕ last_seen_at, если между ними ничего не менялось.
Сначала считаем пол на СПЛОШНОЙ суточной истории (якорная дата присутствует),
затем удаляем все снимки, кроме самого раннего (change-only модель — сутки без
изменений не пишутся), и проверяем, что LATERAL даёт то же число.
"""
db = _live_session()
source = f"zzzlat_dense_{uuid.uuid4().hex[:8]}"
now = datetime.now(UTC)
anchor = date.today() - timedelta(days=_HEALTH_WINDOW_DAYS)
# last_seen_at не меняется все эти сутки -- то самое "между изменениями постоянен".
unchanged_last_seen_at = now - timedelta(days=_HEALTH_WINDOW_DAYS + 3)
try:
db.execute(text("BEGIN"))
lsid = _insert_pair(
db,
source=source,
ext_id="dense",
listing_last_seen_at=now,
snapshots=[
(anchor - timedelta(days=3), unchanged_last_seen_at),
(anchor - timedelta(days=2), unchanged_last_seen_at),
(anchor - timedelta(days=1), unchanged_last_seen_at),
(anchor, unchanged_last_seen_at), # якорная дата ПРИСУТСТВУЕТ
],
)
floor_dense = _floor(db, source=source)
assert floor_dense is not None
assert _n_pairs(db, source=source) == 1
# Change-only модель: сутки без изменений не пишутся -- удаляем всё, кроме
# самого раннего снимка (якорная дата больше НЕ присутствует ровно).
db.execute(
text(
"DELETE FROM listing_source_snapshots "
"WHERE listing_source_id = :lsid AND snapshot_date > :keep_date"
),
{"lsid": lsid, "keep_date": anchor - timedelta(days=3)},
)
floor_sparse = _floor(db, source=source)
assert floor_sparse is not None
assert _n_pairs(db, source=source) == 1
assert floor_sparse == pytest.approx(floor_dense, abs=0.01), (
f"LATERAL обязан игнорировать гэп: сплошная история дала {floor_dense}, "
f"дырявая -- {floor_sparse}, а между изменениями last_seen_at постоянен"
)
# Возраст известен точно (постоянный last_seen_at, N+3 суток разрыва) --
# пиним не только "совпадают", но и КАКОЕ именно число.
assert floor_dense == pytest.approx(_HEALTH_WINDOW_DAYS + 3, abs=0.01)
finally:
db.rollback()
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_equality_join_loses_gapped_pairs_and_gives_a_smaller_floor() -> None:
"""На ОДНИХ И ТЕХ ЖЕ данных equality-join теряет пары и даёт МЕНЬШИЙ пол -- это
и есть суть правки PR-A: 3 строки, только у одной снимок ровно на дату якоря.
Group A -- снимок РОВНО на дату якоря, возраст ~5.5 сут (выживает в ОБЕИХ моделях).
Group B, C -- снимок ТОЛЬКО раньше якоря (change-only гэп ровно на дату якоря),
возраст 65 и 30 сут -- выживают ТОЛЬКО в LATERAL.
revisit_quantile=1.0 (максимум) -- детерминированно и без интерполяции percentile_disc
на маленькой выборке: LATERAL обязан дать max(5.5, 65, 30) = 65, equality-join --
только 5.5 (единственная сохранившаяся пара).
"""
db = _live_session()
source = f"zzzlat_gap_{uuid.uuid4().hex[:8]}"
now = datetime.now(UTC)
anchor = date.today() - timedelta(days=_HEALTH_WINDOW_DAYS)
try:
db.execute(text("BEGIN"))
# Group A: снимок ровно на дату якоря -- переживает equality-join.
_insert_pair(
db,
source=source,
ext_id="group-a-on-anchor",
listing_last_seen_at=now,
snapshots=[(anchor, now - timedelta(days=5, hours=12))], # возраст 5.5 сут
)
# Group B: снимок только на anchor-60 -- дыра ровно на дату якоря
# (типичный change-only разрыв: ничего не менялось 55+ суток подряд).
_insert_pair(
db,
source=source,
ext_id="group-b-gap-60d",
listing_last_seen_at=now,
snapshots=[(anchor - timedelta(days=60), now - timedelta(days=65))],
)
# Group C: тот же класс гэпа, гэп короче (30 сут) -- контроль, что LATERAL
# берёт максимум по ВСЕЙ выборке, а не просто "последнюю вставленную пару".
_insert_pair(
db,
source=source,
ext_id="group-c-gap-25d",
listing_last_seen_at=now,
snapshots=[(anchor - timedelta(days=25), now - timedelta(days=30))],
)
lateral_floor = _floor(db, source=source)
lateral_n_pairs = _n_pairs(db, source=source)
old_floor = _old_floor(db, source=source)
old_n_pairs = _old_n_pairs(db, source=source)
assert lateral_n_pairs == 3, "LATERAL обязан видеть все 3 пары, гэпы не теряют строк"
assert old_n_pairs == 1, "equality-join теряет Group B и C -- у них нет снимка на якоре"
assert lateral_floor == pytest.approx(65.0, abs=0.01), (
f"LATERAL max должен взять Group B (65 сут), получили {lateral_floor}"
)
assert old_floor == pytest.approx(5.5, abs=0.01), (
f"equality-join должен остаться только с Group A (5.5 сут), получили {old_floor}"
)
assert lateral_floor > old_floor, (
"равенство по дате даёт МЕНЬШИЙ пол на тех же данных -- это и есть регрессия, "
"которую эта правка чинит"
)
finally:
db.rollback()
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_missing_history_before_anchor_still_yields_no_floor_via_lateral() -> None:
"""Контроль: если у listing_source_id вообще нет снимка не позже якоря (совсем
свежая строка), LATERAL закономерно не находит пару -- НЕ ломается на NULL/пусто,
а просто исключает строку (как и раньше исключал equality-join, если совсем
нет истории). Это НЕ регрессия, а ожидаемое поведение при отсутствии данных."""
db = _live_session()
source = f"zzzlat_nohist_{uuid.uuid4().hex[:8]}"
now = datetime.now(UTC)
anchor = date.today() - timedelta(days=_HEALTH_WINDOW_DAYS)
try:
db.execute(text("BEGIN"))
_insert_pair(
db,
source=source,
ext_id="only-future-snapshot",
listing_last_seen_at=now,
# Снимок ЕСТЬ, но он ПОЗЖЕ якоря -- LATERAL (snapshot_date <= якорь)
# его не видит, что и требуется: свежая строка без прошлого не должна
# выдумывать пол из снимка, который сам моложе окна здоровья.
snapshots=[(anchor + timedelta(days=1), now - timedelta(days=1))],
)
assert _floor(db, source=source) is None
assert _n_pairs(db, source=source) == 0
finally:
db.rollback()
db.close()
# ── Pure-SQL проверки (без БД) -- форма LATERAL-запроса и общего среза ────────
def test_lateral_sql_has_no_equality_join_on_snapshot_date() -> None:
"""Ключевой негативный инвариант правки: РАВЕНСТВО по дате запрещено."""
sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text)
assert "JOIN LATERAL" in sql
assert "ORDER BY s.snapshot_date DESC" in sql
assert "LIMIT 1" in sql
assert not re.search(r"prev\.snapshot_date\s*=", sql), (
"equality-join по snapshot_date должен быть полностью удалён из пола переобхода"
)
def test_lateral_sql_still_psycopg_v3_safe() -> None:
sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text)
assert "CAST(:revisit_quantile AS double precision)" in sql
assert "CAST(:health_window_days AS integer)" in sql
assert not re.search(r":\w+::", sql)
def test_pairs_count_sql_shares_the_exact_same_slice_as_the_floor_sql() -> None:
"""count(*) и percentile_disc обязаны идти по ОДНОМУ И ТОМУ ЖЕ FROM..WHERE --
иначе floor_n_pairs в counters не описывает реальную выборку percentile_disc."""
floor_sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text)
count_sql = str(
task_mod._build_revisit_floor_pairs_count_sql("last_seen_at", with_segments=True).text
)
floor_from_where = floor_sql.split("FROM listings l", 1)[1]
count_from_where = count_sql.split("FROM listings l", 1)[1]
assert floor_from_where == count_from_where, (
"FROM..WHERE percentile_disc-запроса и count(*)-запроса разошлись -- "
"floor_n_pairs больше не описывает реальную выборку пола"
)
def test_comparison_predicate_against_prev_last_seen_at_is_untouched() -> None:
"""Предикат сравнения (l.<col> > prev.last_seen_at) -- НЕ часть этой правки,
LATERAL меняет только то, КАК ищется prev, а не что с ним сравнивается."""
sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text)
assert "l.last_seen_at > prev.last_seen_at" in sql