fix(tradein/scraper): deactivate stale yandex/cian listings with NULL segment

544 yandex + 224 cian active rows carry listing_segment=NULL (legacy rows
predating migration 011, plus a small trickle that can never self-heal since
the upsert ON CONFLICT never rewrites listing_segment on re-scrape). 94-97%
of them are frozen at ~86 days old, yet the estimator's Tier A (same-building)
and Tier C (micro-radius) anchor queries filter only is_active=true -- no
freshness column -- so these stale asking prices anchor live valuations.

deactivate_stale_yandex/_cian (migration 115) already run at TTL=30 but scope
segments=['vtorichka'] only: `= ANY(CAST(:segments AS text[]))` never matches
NULL, so the NULL bucket was invisible to both existing jobs and to
avito/domklik/n1 (which are source-blanket or vtorichka-only respectively).

Adds null_segment_only kwarg to deactivate_stale_listings() building an
explicit `listing_segment IS NULL` predicate (confirmations/revisit-floor
builders extended in parallel for correctness, though both gates are kept
off for this slice -- population too small for thresholds calibrated on a
full vtorichka sweep, would permanently skip as unhealthy). Two new
scrape_schedules rows (migration 264) run it per source, untouched
novostroyki/vtorichka jobs unaffected.

TTL=60d (vs 30d for vtorichka): revisit-floor is not computable here (rows
out of dedicated sweep scope have no revisit-gap history), so the margin is
folded into the TTL directly -- 2.26x/1.4x over the measured p99 revisit gaps
already on file for cian/yandex vtorichka (26.6d/43.0d). Bimodal age
distribution means this costs almost nothing in coverage (30d vs 60d: 744 vs
734 deactivated).

First run: 734 of 768 NULL rows deactivated (211 cian, 523 yandex), 34 remain
(fresher than 60d, still incidentally re-touched). Comparable pool
(is_active AND segment IN (NULL, vtorichka)) after: cian -2.7% (7739->7528),
yandex -10.2% (5133->4610) -- yandex crosses the 10% flag threshold. All 734
removed rows already had scraped_at frozen >60d, i.e. already excluded from
Tier S/H (which do filter freshness, 14-60d window) -- the drop is real for
is_active headcount but zero-impact there; it only prunes Tier A/C, where it
removes stale prices rather than live comps.

Separate finding (not fixed here): base.py's ON CONFLICT DO UPDATE omits
listing_segment from SET entirely, so a legacy NULL row can never heal even
though cian/yandex SERP always compute segment deterministically on re-scrape.
This commit is contained in:
bot-backend 2026-08-15 20:44:36 +03:00
parent 7def4973bd
commit cfb4c159ab
5 changed files with 410 additions and 15 deletions

View file

@ -236,6 +236,11 @@ async def _job_deactivate_stale(
revisit_floor_quantile: float = params.get( revisit_floor_quantile: float = params.get(
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
) )
# Пустой (NULL) listing_segment -- легаси-строки до миграции 011 + жертвы
# отсутствующего COALESCE в ON CONFLICT (base.py upsert никогда не перезаписывает
# listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не
# элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py.
null_segment_only: bool = params.get("null_segment_only", False)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor( await loop.run_in_executor(
@ -249,6 +254,7 @@ async def _job_deactivate_stale(
staleness_column=staleness_column, staleness_column=staleness_column,
min_confirmations=min_confirmations, min_confirmations=min_confirmations,
revisit_floor_quantile=revisit_floor_quantile, revisit_floor_quantile=revisit_floor_quantile,
null_segment_only=null_segment_only,
), ),
) )

View file

@ -6,8 +6,17 @@
Ключевые решения: Ключевые решения:
- Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой - Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой
инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka', инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka',
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. TTL=30. novostroyki (9659 активных первичных строк) не трогаем.
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
- NULL-сегмент (легаси-строки до миграции 011 + жертвы бага в ON CONFLICT -- upsert
никогда не пишет listing_segment повторно, поэтому раз рождённая NULL-строка сама
себя не чинит даже при живой ежедневной досдаче) деактивируется ОТДЕЛЬНОЙ джобой per
source (null_segment_only=True, миграция 264): явный `listing_segment IS NULL`
предикат, а не ANY(:segments) -- этот оператор NULL никогда не матчит. Гейт
здоровья/пол переобхода для этой джобы выключены (min_confirmations=0,
revisit_floor_quantile=0) -- население нерепрезентативно мало (единицы подтверждений
в сутки против сотен-тысяч у обычного vtorichka-среза), калиброванный под vtorichka
порог держал бы джобу вечно skipped_unhealthy.
- Строки НЕ удаляются -- история нужна для бэктеста (#667). - Строки НЕ удаляются -- история нужна для бэктеста (#667).
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом - #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка 'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
@ -127,6 +136,11 @@ DEFAULT_MIN_CONFIRMATIONS = 500
_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))" _CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))"
# NULL-сегмент: `= ANY(...)` НИКОГДА не матчит NULL (SQL, не баг), поэтому
# для null_segment_only-режима нужен отдельный явный предикат IS NULL, а не элемент
# в :segments. См. _build_null_segment_sql ниже -- тот же принцип для самого UPDATE.
_CONFIRMATIONS_NULL_SEGMENT_FILTER = "\n AND listing_segment IS NULL"
# ── Пол TTL по измеренному циклу переобхода (#2659) ─────────────────────────── # ── Пол TTL по измеренному циклу переобхода (#2659) ───────────────────────────
# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на # Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на
@ -188,9 +202,12 @@ _CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:seg
DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99
_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))" _REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))"
_REVISIT_FLOOR_NULL_SEGMENT_FILTER = "\n AND l.listing_segment IS NULL"
def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any: def _build_revisit_floor_sql(
staleness_column: str, *, with_segments: bool, null_segment_only: bool = False
) -> Any:
"""Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива. """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива.
Пара «предыдущее наблюдение (снимок) текущее наблюдение (listings)» даёт Пара «предыдущее наблюдение (снимок) текущее наблюдение (listings)» даёт
@ -198,10 +215,20 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A
Только строки, у которых свежесть реально сдвинулась, то есть выжившие, Только строки, у которых свежесть реально сдвинулась, то есть выжившие,
а не «мы к ним не приходили». а не «мы к ним не приходили».
null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments)
(ANY никогда не матчит NULL). На практике для null_segment_only-джобы этот запрос
не строится вовсе (revisit_floor_quantile=0 -- см. модульный докстринг), но вариант
нужен для корректности, если порог когда-нибудь включат.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
Значения param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type). Значения param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type).
""" """
segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else "" if null_segment_only:
segment_filter = _REVISIT_FLOOR_NULL_SEGMENT_FILTER
elif with_segments:
segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER
else:
segment_filter = ""
return text( return text(
f""" f"""
SELECT percentile_disc(CAST(:revisit_quantile AS double precision)) SELECT percentile_disc(CAST(:revisit_quantile AS double precision))
@ -229,14 +256,25 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A
) )
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any: def _build_confirmations_sql(
staleness_column: str, *, with_segments: bool, null_segment_only: bool = False
) -> Any:
"""SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE. """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments).
Для null_segment_only-джобы min_confirmations=0 по умолчанию (см. модульный
докстринг), так что на практике этот путь не строится -- оставлен для корректности.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
Значения (:listing_source, :health_window_days, :segments) param-binding, Значения (:listing_source, :health_window_days, :segments) param-binding,
psycopg v3 safe (CAST(... AS ...), никаких :param::type). psycopg v3 safe (CAST(... AS ...), никаких :param::type).
""" """
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else "" if null_segment_only:
segment_filter = _CONFIRMATIONS_NULL_SEGMENT_FILTER
elif with_segments:
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER
else:
segment_filter = ""
return text( return text(
f""" f"""
SELECT count(*) SELECT count(*)
@ -292,6 +330,33 @@ def _build_segments_sql(staleness_column: str) -> Any:
) )
def _build_null_segment_sql(staleness_column: str) -> Any:
"""UPDATE строго по listing_segment IS NULL (null_segment_only=True).
НЕ переиспользует _build_segments_sql: `= ANY(CAST(:segments AS text[]))` никогда
не матчит NULL (SQL-семантика, не баг -- та же ловушка задокументирована выше у
novostroyki-гарда), поэтому NULL-сегмент не выразить через список segments и нужен
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе.
"""
return text(
f"""
WITH stale AS (
UPDATE listings
SET is_active = false
WHERE source = :listing_source
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment IS NULL
RETURNING id, price_rub
)
{_STALE_SNAPSHOT_TAIL}
"""
)
# Дефолтные (last_seen_at) варианты SQL -- сохранены как модульные константы для # Дефолтные (last_seen_at) варианты SQL -- сохранены как модульные константы для
# обратной совместимости (тесты читают .text, product_handlers/scheduler не менялись). # обратной совместимости (тесты читают .text, product_handlers/scheduler не менялись).
_DEACTIVATE_SQL_ALL_SEGMENTS = _build_all_segments_sql("last_seen_at") _DEACTIVATE_SQL_ALL_SEGMENTS = _build_all_segments_sql("last_seen_at")
@ -312,6 +377,7 @@ def deactivate_stale_listings(
min_confirmations: int = 0, min_confirmations: int = 0,
health_window_days: int = _HEALTH_WINDOW_DAYS, health_window_days: int = _HEALTH_WINDOW_DAYS,
revisit_floor_quantile: float = 0.0, revisit_floor_quantile: float = 0.0,
null_segment_only: bool = False,
) -> dict[str, int]: ) -> dict[str, int]:
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней. """Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
@ -321,6 +387,7 @@ def deactivate_stale_listings(
ttl_days: количество дней TTL; объявления старше этого порога деактивируются. ttl_days: количество дней TTL; объявления старше этого порога деактивируются.
segments: если задан -- деактивировать только объявления с указанными segments: если задан -- деактивировать только объявления с указанными
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию). listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
Несовместимо с null_segment_only=True (см. ниже).
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
{"last_seen_at", "scraped_at"} иначе ValueError ДО любого SQL. Дефолт {"last_seen_at", "scraped_at"} иначе ValueError ДО любого SQL. Дефолт
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
@ -337,6 +404,12 @@ def deactivate_stale_listings(
эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так
вызывают старые тесты и совместимая обёртка), рабочее значение вызывают старые тесты и совместимая обёртка), рабочее значение
DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше. DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше.
null_segment_only: True -> WHERE фильтрует `listing_segment IS NULL` вместо
ANY(:segments). Требует segments=None (иначе ValueError -- смешивать
бессмысленно, это два непересекающихся среза). Для этого среза гейт/пол
обычно держат выключенными (min_confirmations=0, revisit_floor_quantile=0,
см. миграцию 264 и модульный докстринг) -- население слишком мало для
откалиброванных под полноценный vtorichka-свип порогов.
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
@ -349,7 +422,8 @@ def deactivate_stale_listings(
Raises: Raises:
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
никакой интерполяции пользовательского ввода в запрос). никакой интерполяции пользовательского ввода в запрос), либо если заданы
одновременно null_segment_only=True и segments (взаимоисключающие срезы).
""" """
counters: dict[str, int] = {"deactivated": 0} counters: dict[str, int] = {"deactivated": 0}
try: try:
@ -362,6 +436,11 @@ def deactivate_stale_listings(
f"invalid staleness_column={staleness_column!r}; " f"invalid staleness_column={staleness_column!r}; "
f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}" f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}"
) )
# null_segment_only + segments одновременно -- неоднозначный запрос:
# IS NULL и ANY(:segments) -- разные, непересекающиеся предикаты, а не
# композиция. Явный ValueError лучше молчаливого выбора одного из двух.
if null_segment_only and segments is not None:
raise ValueError("null_segment_only=True несовместимо с заданным segments")
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима # Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
# на практике (вернуть «живость» может только повторный сбор), поэтому # на практике (вернуть «живость» может только повторный сбор), поэтому
@ -375,7 +454,11 @@ def deactivate_stale_listings(
health_params["segments"] = segments health_params["segments"] = segments
confirmations = ( confirmations = (
db.execute( db.execute(
_build_confirmations_sql(staleness_column, with_segments=segments is not None), _build_confirmations_sql(
staleness_column,
with_segments=segments is not None,
null_segment_only=null_segment_only,
),
health_params, health_params,
).scalar() ).scalar()
or 0 or 0
@ -390,13 +473,15 @@ def deactivate_stale_listings(
logger.warning( logger.warning(
"deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — " "deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — "
"подтверждений за %d сут %d < порога %d " "подтверждений за %d сут %d < порога %d "
"(segments=%r, staleness_column=%s); ни одна строка не тронута", "(segments=%r, null_segment_only=%s, staleness_column=%s); "
"ни одна строка не тронута",
listing_source, listing_source,
run_id, run_id,
health_window_days, health_window_days,
confirmations, confirmations,
min_confirmations, min_confirmations,
segments, segments,
null_segment_only,
staleness_column, staleness_column,
) )
return counters return counters
@ -413,7 +498,11 @@ def deactivate_stale_listings(
if segments is not None: if segments is not None:
floor_params["segments"] = segments floor_params["segments"] = segments
floor_days = db.execute( floor_days = db.execute(
_build_revisit_floor_sql(staleness_column, with_segments=segments is not None), _build_revisit_floor_sql(
staleness_column,
with_segments=segments is not None,
null_segment_only=null_segment_only,
),
floor_params, floor_params,
).scalar() ).scalar()
# NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках). # NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках).
@ -439,12 +528,21 @@ def deactivate_stale_listings(
ttl_days, ttl_days,
) )
# segments is None -> все сегменты (поведение avito). segments=[...] -> только # null_segment_only -> IS NULL, отдельный явный предикат (ANY(:segments)
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] # никогда не матчит NULL). segments is None -> все сегменты (поведение avito).
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), # segments=[...] -> только перечисленные сегменты. Используем `is not None`
# а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник. # (НЕ truthy): пустой список [] означает "ни один сегмент" (= ANY(ARRAY[])
if segments is not None: # ничего не матчит, деактивирует 0), а НЕ "все сегменты" — иначе случайный []
# стёр бы весь источник.
if null_segment_only:
params: dict[str, Any] = { params: dict[str, Any] = {
"listing_source": listing_source,
"ttl_days": effective_ttl_days,
"run_id": run_id,
}
result = db.execute(_build_null_segment_sql(staleness_column), params)
elif segments is not None:
params = {
"listing_source": listing_source, "listing_source": listing_source,
"ttl_days": effective_ttl_days, "ttl_days": effective_ttl_days,
"segments": segments, "segments": segments,
@ -465,13 +563,15 @@ def deactivate_stale_listings(
runs_mod.mark_done(db, run_id, counters) runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info(
"deactivate_stale source=%s run_id=%d done: deactivated=%d " "deactivate_stale source=%s run_id=%d done: deactivated=%d "
"(ttl_days=%d эффективный, задан %d, segments=%r, staleness_column=%s)", "(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, "
"staleness_column=%s)",
listing_source, listing_source,
run_id, run_id,
counters["deactivated"], counters["deactivated"],
effective_ttl_days, effective_ttl_days,
ttl_days, ttl_days,
segments, segments,
null_segment_only,
staleness_column, staleness_column,
) )
return counters return counters

View file

@ -0,0 +1,109 @@
-- 264_seed_deactivate_stale_null_segment_yandex_cian.sql
-- Деактивация протухших yandex/cian объявлений с ПУСТЫМ listing_segment.
--
-- Замер на проде 2026-08-15 (is_active=true, listing_segment IS NULL):
-- source | активных | старше 30 сут | макс возраст
-- yandex | 544 | 533 | 86.3 сут
-- cian | 224 | 211 | 86.3 сут
-- 97% / 94% этих строк протухли, вплоть до 86 суток. При этом estimator их
-- ИСПОЛЬЗУЕТ как comps без freshness-фильтра (Tier A "тот же дом" / Tier C
-- micro-radius в app/services/estimator.py фильтруют только is_active=true,
-- без scraped_at-фильтра свежести — в отличие от Tier S/H, у которых он есть).
--
-- ПОЧЕМУ NULL, А НЕ ANY(:segments). deactivate_stale_yandex / deactivate_stale_cian
-- (миграция 115) уже деактивируют segments=['vtorichka'] — пустой сегмент они НЕ видят:
-- `listing_segment = ANY(CAST(:segments AS text[]))` в SQL никогда не матчит NULL
-- (задокументировано в 115 у novostroyki-гарда). Нужен отдельный явный предикат
-- IS NULL — app/tasks/deactivate_stale_avito.py получил kwarg null_segment_only=True,
-- строящий `... AND listing_segment IS NULL` вместо ANY(:segments).
--
-- ПОЧЕМУ ОТДЕЛЬНАЯ ДЖОБА, А НЕ РАСШИРЕНИЕ deactivate_stale_yandex/_cian. Гейт
-- здоровья сбора (#2659, migration 219) и пол переобхода (#2659) откалиброваны под
-- полноценный vtorichka-свип (сотни-тысячи подтверждений в сутки, см. 219). У
-- NULL-сегмента подтверждений на 2-3 порядка меньше (замер того же дня: 9 cian +
-- 4 yandex строк с last_seen_at < 7 суток) — с общим min_confirmations джоба
-- вечно давала бы skipped_unhealthy и никогда не деактивировала бы ни строки.
-- Отдельная джоба с собственными (выключенными) порогами не трогает работающие
-- deactivate_stale_yandex/_cian и их пол/гейт.
--
-- НЕ ЗАТРАГИВАЕТ novostroyki: null_segment_only-предикат — строго `IS NULL`, ни
-- 'novostroyki', ни 'vtorichka' в него не попадают ни при каких условиях (в отличие
-- от паушального TTL по всему source, который снёс бы все ~22,5к первичных строк).
--
-- TTL=60 суток — консервативный, обоснование числом:
-- Строки этого среза по определению не переобходятся систематически (иначе у них
-- был бы сегмент — свежий обход cian/yandex SERP всегда вычисляет listing_segment
-- детерминированно, см. providers/cian/serp.py:955-958, providers/yandex/serp.py:177).
-- Значит «пол переобхода» (revisit_floor, #2659) здесь измерять нечем: он квантиль
-- разрывов НАБЛЮДАЕМОГО повторного обхода, а для строки вне скоупа обхода такого
-- ряда нет — вычислять его было бы фикцией. Поэтому revisit_floor_quantile=0 явно
-- (выключен), а весь запас закладываем в сам TTL:
-- deactivate_stale_avito.py документирует измеренные p99 разрывов переобхода
-- vtorichka (тот же тип строк, тот же source, разница только в сегменте):
-- cian/vtorichka p99 = 26.6 сут
-- yandex/vtorichka p99 = 43.0 сут
-- TTL=60 даёт запас 2.26x над cian p99 и 1.4x над yandex p99 — комфортный отступ
-- без специального замера под null-сегмент (население слишком мало для устойчивого
-- перцентиля). При этом бимодальность выборки (замер 2026-08-15: gt30d/gt45d/gt60d
-- почти не меняются — 211/211/211 cian, 533/525/523 yandex) означает, что более
-- консервативный TTL стоит ПОЧТИ НИЧЕГО в охвате: первый прогон снимет 734 из 768
-- строк (95.6%) вместо 744 при TTL=30 — разница 10 строк, зато вдвое больший
-- защитный запас над измеренным хвостом обхода.
--
-- min_confirmations=0, revisit_floor_quantile=0 — оба гейта ВЫКЛЮЧЕНЫ явно (не через
-- умолчание product_handlers.py, которое иначе подставило бы DEFAULT_MIN_CONFIRMATIONS
-- = 500 и DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 — оба откалиброваны под другую шкалу
-- популяции и держали бы эту джобу в вечном skipped_unhealthy, см. выше).
--
-- Schedule window 07:00-08:00 UTC — тот же слот, что и deactivate_stale_yandex/_cian
-- (migration 115) и deactivate_stale_domklik/_n1 (migration 160): после ночных sweep'ов
-- (02:00-05:00 UTC), так что реально переобойдённые строки не деактивируются.
--
-- next_run_at bootstrapped на завтра 07:00 UTC — тот же паттерн, что 090/115/160,
-- чтобы не сработать сразу на деплое.
--
-- Идемпотентно: ON CONFLICT (source) DO NOTHING — безопасно при повторном применении.
--
-- Dependencies:
-- 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- listings.listing_segment (011_listings_alter.sql).
-- 115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql (соседние джобы, тот же слот).
-- app/tasks/deactivate_stale_avito.py — null_segment_only kwarg.
-- app/services/product_handlers.py — _job_deactivate_stale читает null_segment_only
-- из default_params и пробрасывает в deactivate_stale_listings.
--
-- Deploy order: применять ПОСЛЕ деплоя backend-кода (null_segment_only kwarg), иначе
-- первый прогон свалится с TypeError на неизвестный параметр default_params.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'deactivate_stale_yandex_null_segment',
true, -- SAFE: pure internal DB UPDATE, no ext calls
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"listing_source":"yandex","ttl_days":60,"null_segment_only":true,'
'"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb
),
(
'deactivate_stale_cian_null_segment',
true, -- SAFE: pure internal DB UPDATE, no ext calls
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"listing_source":"cian","ttl_days":60,"null_segment_only":true,'
'"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -252,3 +252,4 @@
261_listings_search_mv_drop_placeholder_columns.sql 261_listings_search_mv_drop_placeholder_columns.sql
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
263_scrape_schedules_wave2_cian_newbuilding_only_false.sql 263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
264_seed_deactivate_stale_null_segment_yandex_cian.sql

View file

@ -424,3 +424,182 @@ def test_migration_160_is_transactional() -> None:
def test_migration_160_no_psycopg_trap() -> None: def test_migration_160_no_psycopg_trap() -> None:
sql = _MIGRATION_160.read_text("utf-8") sql = _MIGRATION_160.read_text("utf-8")
assert not re.search(r":\w+::", sql) assert not re.search(r":\w+::", sql)
# ── null_segment_only (пустой listing_segment yandex/cian, никогда не переобходится) ──
def test_null_segment_sql_uses_is_null_not_any() -> None:
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
assert "listing_segment IS NULL" in sql
assert "ANY(CAST(:segments AS text[]))" not in sql
assert ":segments" not in sql
def test_null_segment_sql_filters_is_active_and_source() -> None:
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
assert "is_active = true" in sql
assert ":listing_source" in sql
assert "SET is_active = false" in sql
assert "DELETE" not in sql.upper()
def test_null_segment_sql_no_psycopg_trap() -> None:
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
assert not re.search(r":\w+::", sql)
assert "CAST(:ttl_days || ' days' AS interval)" in sql
def test_null_segment_only_and_segments_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""null_segment_only=True + segments заданы -- неоднозначный запрос, ValueError."""
failed: dict[str, Any] = {}
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_failed",
lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err),
)
db = _FakeDB(rowcount=0)
with pytest.raises(ValueError, match="null_segment_only"):
task_mod.deactivate_stale_listings(
db,
run_id=20,
listing_source="cian",
ttl_days=60,
segments=["vtorichka"],
null_segment_only=True,
) # type: ignore[arg-type]
assert db.executed == []
assert failed["run_id"] == 20
def test_null_segment_only_deactivates_via_is_null(monkeypatch: pytest.MonkeyPatch) -> None:
marked: dict[str, Any] = {}
monkeypatch.setattr(
task_mod.runs_mod,
"mark_done",
lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)),
)
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
db = _FakeDB(rowcount=211)
out = task_mod.deactivate_stale_listings(
db,
run_id=21,
listing_source="cian",
ttl_days=60,
null_segment_only=True,
) # type: ignore[arg-type]
assert out == {"deactivated": 211}
assert db.committed is True
stmt, params = db.executed[0]
sql = _sql_text(stmt)
assert "listing_segment IS NULL" in sql
assert params is not None
assert "segments" not in params
assert params["listing_source"] == "cian"
assert params["ttl_days"] == 60
assert marked["counters"] == {"deactivated": 211}
def test_null_segment_only_confirmations_sql_uses_is_null() -> None:
sql = str(
task_mod._build_confirmations_sql(
"last_seen_at", with_segments=False, null_segment_only=True
).text
)
assert "listing_segment IS NULL" in sql
assert ":segments" not in sql
def test_null_segment_only_revisit_floor_sql_uses_is_null() -> None:
sql = str(
task_mod._build_revisit_floor_sql(
"last_seen_at", with_segments=False, null_segment_only=True
).text
)
assert "l.listing_segment IS NULL" in sql
assert ":segments" not in sql
def test_null_segment_only_default_is_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""Обратная совместимость: старые вызовы без null_segment_only ведут себя как раньше."""
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
db = _FakeDB(rowcount=3)
task_mod.deactivate_stale_listings(
db, run_id=22, listing_source="avito", ttl_days=10, segments=None
) # type: ignore[arg-type]
stmt, _params = db.executed[0]
sql = _sql_text(stmt)
assert "listing_segment IS NULL" not in sql
# ── Migration 264 (deactivate_stale_yandex_null_segment / _cian_null_segment) ───────
_MIGRATION_264 = _SQL_DIR / "264_seed_deactivate_stale_null_segment_yandex_cian.sql"
def test_migration_264_exists() -> None:
assert _MIGRATION_264.is_file(), f"missing migration: {_MIGRATION_264}"
def test_migration_264_seeds_yandex_and_cian_null_segment() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert "'deactivate_stale_yandex_null_segment'" in sql
assert "'deactivate_stale_cian_null_segment'" in sql
def test_migration_264_null_segment_only_true() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert '"null_segment_only":true' in sql
def test_migration_264_ttl_60_days() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert '"ttl_days":60' in sql
def test_migration_264_gates_disabled() -> None:
"""min_confirmations/revisit_floor_quantile выключены явно -- население слишком
мало для порогов, откалиброванных под полноценный vtorichka-свип (см. файл)."""
sql = _MIGRATION_264.read_text("utf-8")
assert '"min_confirmations":0' in sql
assert '"revisit_floor_quantile":0' in sql
def test_migration_264_is_idempotent() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert "ON CONFLICT (source) DO NOTHING" in sql
def test_migration_264_is_transactional() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_264_enabled_true() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert "true" in sql
def test_migration_264_window_7_to_8_utc() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert re.search(r"\b7\b", sql), "window_start_hour 7 missing"
assert re.search(r"\b8\b", sql), "window_end_hour 8 missing"
def test_migration_264_no_psycopg_trap() -> None:
sql = _MIGRATION_264.read_text("utf-8")
assert not re.search(r":\w+::", sql)
def test_handler_wires_null_segment_only_from_schedule_params() -> None:
"""Читаем исходник файлом (как test_handler_wires_revisit_floor_from_schedule_params):
product_handlers тянет scraper_kit, которого в юнит-окружении может не быть."""
handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
src = handlers.read_text("utf-8")
job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
flat = " ".join(job.split())
assert 'params.get("null_segment_only", False)' in flat
assert "null_segment_only=null_segment_only" in job