26 203 мёртвых объявления числятся активными: deactivate_stale_* читают last_seen_at, который лжёт #2994

Open
opened 2026-08-20 17:48:59 +00:00 by lekss361 · 3 comments
Owner

Эпик: #2989 · Корневая причина выше по течению, чем #2656 и #2659.

Находка

Истина про живость уже лежит в базе. Сегодняшний снапшот помечает мёртвыми 26 203 строки, у которых listings.is_active = true.

Разрез по источникам на 2026-08-20 (listings.is_active против подтверждённых в снапшоте за сегодня):

Источник Числится активными Подтверждено живыми Фантомов
cian 19 300 6 110 13 190 (68 %)
yandex 16 046 6 044 10 002 (62 %)
avito 13 228 9 184 4 044
domklik 1 028 545 483

Всего v_data_quality.listings_active = 49 602 против 21 883 подтверждённых.

Задачи deactivate_stale_avito / _cian / _yandex / _domklik все включены, ежедневно завершаются со статусом done — и деактивируют ноль строк. Причина: они читают не снапшот, а listings.last_seen_at, который в скрапере трогается bulk-апдейтом и потому лжёт. Замер: у domklik last_seen_at обновился у 0 из 6954 строк, у cian — у 324 из 23 843.

Что делать — с двумя поправками проверяющего

Флаг не убирать, убрать неверного писателя флага. Идея «живость — чистая вьюха над снапшотами» была отвергнута: предикат обязан оставаться однотабличным и индексируемым, иначе выборка аналогов при 330–377 тыс. активных лотов (московский объём) станет дорогой.

Рабочая модель: на горячей строке держать денормализованные closed_at timestamptz NULL и live_until timestamptz, которые пишет реконсилятор из listing_source_snapshots. Предикат closed_at IS NULL AND live_until > now() остаётся однотабличным.

Вторая поправка: джоба помимо UPDATE пишет хвост _STALE_SNAPSHOT_TAIL в listings_snapshots (:63-70) — событие закрытия в дневной истории. Новый реконсилятор обязан писать то же событие, иначе история потеряет закрытия.

Почему это важнее, чем кажется

Оценка квартиры считается по предложению, половина которого снята с продажи. При выходе в Москву искажение вырастет пропорционально объёму — то есть ×6,6 к ошибке, а не к качеству.

Acceptance

  • Реконсилятор живости пишет closed_at / live_until из listing_source_snapshots
  • Событие закрытия по-прежнему попадает в listings_snapshots
  • Старые deactivate_stale_* заменены, а не просто удалены
  • Замер после: расхождение is_active со снапшотом за сегодня не более пары процентов
  • Тест, закрепляющий инвариант — иначе регрессия вернётся молча

Смежное: #2656 (якорь дома читает is_active без фильтра свежести), #2659 (паушальный TTL выкосил 9033 живых), #1781 (cian-первичка, фантомы не гаснут).

Scope: backend/app/tasks/deactivate_stale_*.py, backend/app/tasks/listing_source_snapshot.py.

Эпик: #2989 · Корневая причина выше по течению, чем #2656 и #2659. ## Находка Истина про живость **уже лежит в базе**. Сегодняшний снапшот помечает мёртвыми **26 203 строки**, у которых `listings.is_active = true`. Разрез по источникам на 2026-08-20 (`listings.is_active` против подтверждённых в снапшоте за сегодня): | Источник | Числится активными | Подтверждено живыми | Фантомов | |---|---|---|---| | cian | 19 300 | 6 110 | **13 190 (68 %)** | | yandex | 16 046 | 6 044 | 10 002 (62 %) | | avito | 13 228 | 9 184 | 4 044 | | domklik | 1 028 | 545 | 483 | Всего `v_data_quality.listings_active` = 49 602 против 21 883 подтверждённых. Задачи `deactivate_stale_avito` / `_cian` / `_yandex` / `_domklik` **все включены**, ежедневно завершаются со статусом `done` — и деактивируют **ноль строк**. Причина: они читают не снапшот, а `listings.last_seen_at`, который в скрапере трогается bulk-апдейтом и потому лжёт. Замер: у domklik `last_seen_at` обновился у 0 из 6954 строк, у cian — у 324 из 23 843. ## Что делать — с двумя поправками проверяющего **Флаг не убирать, убрать неверного писателя флага.** Идея «живость — чистая вьюха над снапшотами» была отвергнута: предикат обязан оставаться однотабличным и индексируемым, иначе выборка аналогов при 330–377 тыс. активных лотов (московский объём) станет дорогой. Рабочая модель: на горячей строке держать денормализованные `closed_at timestamptz NULL` и `live_until timestamptz`, которые пишет **реконсилятор** из `listing_source_snapshots`. Предикат `closed_at IS NULL AND live_until > now()` остаётся однотабличным. **Вторая поправка:** джоба помимо `UPDATE` пишет хвост `_STALE_SNAPSHOT_TAIL` в `listings_snapshots` (`:63-70`) — событие закрытия в дневной истории. Новый реконсилятор обязан писать то же событие, иначе история потеряет закрытия. ## Почему это важнее, чем кажется Оценка квартиры считается по предложению, половина которого снята с продажи. При выходе в Москву искажение вырастет пропорционально объёму — то есть ×6,6 к ошибке, а не к качеству. ## Acceptance - [ ] Реконсилятор живости пишет `closed_at` / `live_until` из `listing_source_snapshots` - [ ] Событие закрытия по-прежнему попадает в `listings_snapshots` - [ ] Старые `deactivate_stale_*` заменены, а не просто удалены - [ ] Замер после: расхождение `is_active` со снапшотом за сегодня не более пары процентов - [ ] Тест, закрепляющий инвариант — иначе регрессия вернётся молча Смежное: #2656 (якорь дома читает `is_active` без фильтра свежести), #2659 (паушальный TTL выкосил 9033 живых), #1781 (cian-первичка, фантомы не гаснут). Scope: `backend/app/tasks/deactivate_stale_*.py`, `backend/app/tasks/listing_source_snapshot.py`.
lekss361 added the
bug
data
priority/p1
scope/backend
tradein
labels 2026-08-20 17:50:58 +00:00
Collaborator

Диагноз в теле задачи не подтвердился. Причина другая, и она проще

Проверил всё сам на проде 21.08. Числа находки воспроизвелись в точности (cian 19300 активных, avito 13228, domklik 1028; подтверждённых 6110 / 6044 / 9184 / 545). А вот объяснение — нет.

1. last_seen_at не лжёт относительно снапшота

Утверждение «джобы читают listings.last_seen_at, который лжёт, а истина лежит в снапшоте» проверяется одним запросом. На одинаковом окне 7 суток:

источник is_active listings.last_seen_at свежих 7д listing_sources.last_seen_at свежих 7д
cian 17 985 6 121 6 121
yandex 16 235 6 018 6 018
avito 13 228 9 144 9 144
domklik 1 028 583 583

Совпадение точное, не приблизительное. Причина видна в коде снапшота:

-- listing_source_snapshot.py:91
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active

is_active снимка — это не независимое наблюдение, а TTL-производная от listing_sources.last_seen_at с окном FRESHNESS_WINDOW_DAYS = 7. Модуль сам это документирует на строке 123. Мой первый замер (123 свежих у cian) сравнивал окно 1 сутки с окном 7 — сравнение окон, а не колонок; исправил.

Следствие: предложенный реконсилятор, пишущий closed_at/live_until из listing_source_snapshots, взял бы тот же сигнал. Он не изменил бы ни одной строки. Это работа, которая ничего бы не починила.

2. Джобы не «деактивируют ноль» — им нечего деактивировать в своём срезе

Оба защитных гейта пройдены, а не сработали:

min_confirmations (окно 3 суток):
  domklik  502 ≥ 200 ✓     yandex/vtorichka 2230 ≥ 500 ✓
  avito   1642 ≥ 1500 ✓    cian/vtorichka   3384 ≥ 500 ✓

пол переобхода (квантиль 0.99, тот же запрос модуля):
  cian/vtorichka 7.9 сут  → эффективный TTL = max(30, 7.9) = 30, пол НЕ блокирует
  yandex/vtorichka 38.0   → 38
  domklik/vtorichka 32.6  → 32.6

Сколько строк подпадает под деактивацию при эффективном TTL в настроенных срезах:

cian/vtorichka   TTL=30    0
yandex/vtorichka TTL=38    0
domklik          TTL=33    0
cian  null-segment TTL=60  0
yandex null-segment TTL=60 0

Ноль — потому что в этих срезах просроченных действительно нет. Механизм исправен.

3. Настоящая причина: сегмент novostroyki не покрыт ни одной джобой

deactivate_stale_cian     →  "segments": ["vtorichka"]
deactivate_stale_yandex   →  "segments": ["vtorichka"]
deactivate_stale_*_null_segment → только listing_segment IS NULL (12 и 17 строк)

novostroyki проваливается в щель между этими двумя джобами. Состав активных:

источник vtorichka novostroyki NULL
cian 7 295 11 993 12
yandex 4 584 11 638 17
avito 11 356 1 870 2

Возраст непокрытых:

источник/сегмент активных свежих 7д старше 30 сут медианный возраст
cian/novostroyki 11 993 614 10 585 81 сутки
yandex/novostroyki 11 638 3 222 5 518 19.1 суток
avito/novostroyki 1 870 1 066 0 4.6 суток

Контрольная группа нашлась сама

avito/novostroyki — 0 просроченных. У джобы avito в параметрах сегментов нет вовсе ({"cap_mult": 6, "min_confirmations": 1500}), поэтому она покрывает и novostroyki. Там, где джоба покрывает сегмент, просроченных ноль; там, где не покрывает, — 16 103. Это тот самый разрез, который отличает «механизм сломан» от «механизм не туда направлен».

Что это меняет в плане работ

  • Отпадает: реконсилятор из снапшотов, колонки closed_at/live_until, замена deactivate_stale_*. Сигнал в снапшоте тот же самый.
  • Остаётся настоящая задача: покрыть novostroyki у cian и yandex.

И к ней нужна осторожность, а не скорость. Пол переобхода у cian/novostroyki — 29.0 суток против 7.9 у vtorichka: новостройки переобходятся принципиально реже. При TTL=30 под снятие разом попадут 10 585 строк. Это ровно масштаб #2659, где паушальный TTL выкосил 9033 живых. У джобы есть cap_mult именно для этого — включать надо с капом и замером доли ложных снятий на первом прогоне, а не одним движением.

Могу подготовить конфигурацию и PR, если решение «покрывать novostroyki» подтверждается.

## Диагноз в теле задачи не подтвердился. Причина другая, и она проще Проверил всё сам на проде 21.08. Числа находки воспроизвелись в точности (cian 19300 активных, avito 13228, domklik 1028; подтверждённых 6110 / 6044 / 9184 / 545). А вот объяснение — нет. ### 1. `last_seen_at` не лжёт относительно снапшота Утверждение «джобы читают `listings.last_seen_at`, который лжёт, а истина лежит в снапшоте» проверяется одним запросом. На **одинаковом** окне 7 суток: | источник | `is_active` | `listings.last_seen_at` свежих 7д | `listing_sources.last_seen_at` свежих 7д | |---|---|---|---| | cian | 17 985 | 6 121 | **6 121** | | yandex | 16 235 | 6 018 | **6 018** | | avito | 13 228 | 9 144 | **9 144** | | domklik | 1 028 | 583 | **583** | Совпадение **точное**, не приблизительное. Причина видна в коде снапшота: ```sql -- listing_source_snapshot.py:91 (last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active ``` `is_active` снимка — это **не независимое наблюдение**, а TTL-производная от `listing_sources.last_seen_at` с окном `FRESHNESS_WINDOW_DAYS = 7`. Модуль сам это документирует на строке 123. Мой первый замер (123 свежих у cian) сравнивал окно 1 сутки с окном 7 — сравнение окон, а не колонок; исправил. **Следствие:** предложенный реконсилятор, пишущий `closed_at`/`live_until` из `listing_source_snapshots`, взял бы тот же сигнал. Он не изменил бы ни одной строки. Это работа, которая ничего бы не починила. ### 2. Джобы не «деактивируют ноль» — им нечего деактивировать в своём срезе Оба защитных гейта пройдены, а не сработали: ``` min_confirmations (окно 3 суток): domklik 502 ≥ 200 ✓ yandex/vtorichka 2230 ≥ 500 ✓ avito 1642 ≥ 1500 ✓ cian/vtorichka 3384 ≥ 500 ✓ пол переобхода (квантиль 0.99, тот же запрос модуля): cian/vtorichka 7.9 сут → эффективный TTL = max(30, 7.9) = 30, пол НЕ блокирует yandex/vtorichka 38.0 → 38 domklik/vtorichka 32.6 → 32.6 ``` Сколько строк подпадает под деактивацию при эффективном TTL в настроенных срезах: ``` cian/vtorichka TTL=30 0 yandex/vtorichka TTL=38 0 domklik TTL=33 0 cian null-segment TTL=60 0 yandex null-segment TTL=60 0 ``` Ноль — потому что в этих срезах просроченных **действительно нет**. Механизм исправен. ### 3. Настоящая причина: сегмент `novostroyki` не покрыт ни одной джобой ``` deactivate_stale_cian → "segments": ["vtorichka"] deactivate_stale_yandex → "segments": ["vtorichka"] deactivate_stale_*_null_segment → только listing_segment IS NULL (12 и 17 строк) ``` `novostroyki` проваливается в щель между этими двумя джобами. Состав активных: | источник | vtorichka | **novostroyki** | NULL | |---|---|---|---| | cian | 7 295 | **11 993** | 12 | | yandex | 4 584 | **11 638** | 17 | | avito | 11 356 | 1 870 | 2 | Возраст непокрытых: | источник/сегмент | активных | свежих 7д | **старше 30 сут** | медианный возраст | |---|---|---|---|---| | cian/novostroyki | 11 993 | 614 | **10 585** | **81 сутки** | | yandex/novostroyki | 11 638 | 3 222 | **5 518** | 19.1 суток | | avito/novostroyki | 1 870 | 1 066 | **0** | 4.6 суток | ### Контрольная группа нашлась сама **avito/novostroyki — 0 просроченных.** У джобы avito в параметрах сегментов нет вовсе (`{"cap_mult": 6, "min_confirmations": 1500}`), поэтому она покрывает и novostroyki. Там, где джоба покрывает сегмент, просроченных ноль; там, где не покрывает, — 16 103. Это тот самый разрез, который отличает «механизм сломан» от «механизм не туда направлен». ## Что это меняет в плане работ - **Отпадает:** реконсилятор из снапшотов, колонки `closed_at`/`live_until`, замена `deactivate_stale_*`. Сигнал в снапшоте тот же самый. - **Остаётся настоящая задача:** покрыть `novostroyki` у cian и yandex. И к ней нужна осторожность, а не скорость. Пол переобхода у cian/novostroyki — **29.0 суток** против 7.9 у vtorichka: новостройки переобходятся принципиально реже. При TTL=30 под снятие разом попадут 10 585 строк. Это ровно масштаб #2659, где паушальный TTL выкосил 9033 живых. У джобы есть `cap_mult` именно для этого — включать надо с капом и замером доли ложных снятий на первом прогоне, а не одним движением. Могу подготовить конфигурацию и PR, если решение «покрывать novostroyki» подтверждается.
Collaborator

Ответ на предусловие, которое модуль сам поставил

В докстроке deactivate_stale_avito.py:10-20 пробел с novostroyki описан и намеренно отложен с условием:

расширение скоупа туда отдельная задача (нужно сперва выяснить, поддерживает ли cian/yandex full-coverage sweep для novostroyki СЕЙЧАС, иначе паушальный TTL повторит инцидент, ради которого этот DECISION и принят)

Замерил. Доля активного инвентаря сегмента, которую свип подтверждает своим приходом:

источник / сегмент охват за неделю охват за месяц джоба деактивации
avito / vtorichka 71.1 % 100.0 % есть
avito / novostroyki 57.0 % 100.0 % есть
cian / vtorichka 75.4 % 100.0 % есть
yandex / vtorichka 61.0 % 90.0 % есть
cian / novostroyki 5.1 % 11.7 % нет
yandex / novostroyki 27.7 % 52.6 % нет

Ответ: нет. Каждый сегмент, где джоба деактивации работает, имеет 90–100 % месячного охвата — то есть свип действительно полный. Два непокрытых сегмента — 11.7 % и 52.6 %.

Для cian/novostroyki это означает: TTL=30 снял бы примерно 88 % инвентаря просто потому, что свип туда не доходил. Ровно механизм #2659, только вдвое крупнее. Расширять туда скоуп нельзя, и правка, которую я собирался готовить, была бы вредной.

Что это меняет в постановке #2994

Разрез «есть джоба → 100 % охвата → 0 просроченных» против «нет джобы → 11.7 % охвата → 10 585 просроченных» показывает, что причина не в бухгалтерии is_active:

26 203 «фантома» — это тень несобираемого инвентаря, а не отказ деактиватора. Деактиватор исправен и в своём скоупе находит ноль просроченных. Сегмент cian/novostroyki просто не обходится: 11 993 активных строки, из них за месяц подтверждено 1 408, медианный возраст 81 сутки.

Отсюда порядок работ переворачивается:

  1. Сначала сбор. Починить обход cian/novostroyki (смежное #1781 — «cian-первичка, фантомы не гаснут»). Пока охват 11.7 %, любая работа с флагом живости — это выбор между «показывать мёртвое» и «убивать живое».
  2. Потом скоуп деактивации. Когда месячный охват выйдет на уровень покрытых сегментов (ориентир — 90 %+, как у yandex/vtorichka), расширение джобы на novostroyki станет безопасным, и предусловие из докстроки будет выполнено фактически, а не на глаз.
  3. Реконсилятор из снапшотов не нужен ни на одном из этих шагов — см. предыдущий комментарий: сигнал в снапшоте тот же самый.

yandex/novostroyki (52.6 % за месяц) — промежуточный случай: не 11 %, но и не 90 %. Отдельного решения по нему без замера ложных снятий на ограниченном прогоне я предлагать не стал бы.

Что я НЕ делал и почему

Не готовил PR с расширением скоупа, хотя в прошлом комментарии предлагал. Замер, который для этого понадобился, сам же и показал, что делать этого нельзя. Оставляю задачу открытой с уточнённой постановкой вместо правки, которая выглядела бы выполнением acceptance-критерия и при этом снесла бы живой инвентарь.

## Ответ на предусловие, которое модуль сам поставил В докстроке `deactivate_stale_avito.py:10-20` пробел с novostroyki описан и **намеренно отложен** с условием: > расширение скоупа туда отдельная задача (нужно сперва выяснить, **поддерживает ли cian/yandex full-coverage sweep для novostroyki СЕЙЧАС**, иначе паушальный TTL повторит инцидент, ради которого этот DECISION и принят) Замерил. Доля активного инвентаря сегмента, которую свип подтверждает своим приходом: | источник / сегмент | охват за неделю | **охват за месяц** | джоба деактивации | |---|---|---|---| | avito / vtorichka | 71.1 % | **100.0 %** | есть | | avito / novostroyki | 57.0 % | **100.0 %** | есть | | cian / vtorichka | 75.4 % | **100.0 %** | есть | | yandex / vtorichka | 61.0 % | **90.0 %** | есть | | **cian / novostroyki** | **5.1 %** | **11.7 %** | **нет** | | **yandex / novostroyki** | **27.7 %** | **52.6 %** | **нет** | **Ответ: нет.** Каждый сегмент, где джоба деактивации работает, имеет 90–100 % месячного охвата — то есть свип действительно полный. Два непокрытых сегмента — 11.7 % и 52.6 %. Для cian/novostroyki это означает: TTL=30 снял бы примерно **88 % инвентаря просто потому, что свип туда не доходил**. Ровно механизм #2659, только вдвое крупнее. Расширять туда скоуп **нельзя**, и правка, которую я собирался готовить, была бы вредной. ## Что это меняет в постановке #2994 Разрез «есть джоба → 100 % охвата → 0 просроченных» против «нет джобы → 11.7 % охвата → 10 585 просроченных» показывает, что причина не в бухгалтерии `is_active`: **26 203 «фантома» — это тень несобираемого инвентаря, а не отказ деактиватора.** Деактиватор исправен и в своём скоупе находит ноль просроченных. Сегмент cian/novostroyki просто не обходится: 11 993 активных строки, из них за месяц подтверждено 1 408, медианный возраст 81 сутки. Отсюда порядок работ переворачивается: 1. **Сначала сбор.** Починить обход cian/novostroyki (смежное #1781 — «cian-первичка, фантомы не гаснут»). Пока охват 11.7 %, любая работа с флагом живости — это выбор между «показывать мёртвое» и «убивать живое». 2. **Потом скоуп деактивации.** Когда месячный охват выйдет на уровень покрытых сегментов (ориентир — 90 %+, как у yandex/vtorichka), расширение джобы на novostroyki станет безопасным, и предусловие из докстроки будет выполнено фактически, а не на глаз. 3. **Реконсилятор из снапшотов не нужен** ни на одном из этих шагов — см. предыдущий комментарий: сигнал в снапшоте тот же самый. yandex/novostroyki (52.6 % за месяц) — промежуточный случай: не 11 %, но и не 90 %. Отдельного решения по нему без замера ложных снятий на ограниченном прогоне я предлагать не стал бы. ## Что я НЕ делал и почему Не готовил PR с расширением скоупа, хотя в прошлом комментарии предлагал. Замер, который для этого понадобился, сам же и показал, что делать этого нельзя. Оставляю задачу открытой с уточнённой постановкой вместо правки, которая выглядела бы выполнением acceptance-критерия и при этом снесла бы живой инвентарь.
Collaborator

Посылка тикета не подтвердилась: снапшот — не независимая истина, а тот же сигнал

Прежде чем строить реконсилятор, проверил утверждение «истина про живость уже лежит в базе». Она там не лежит.

1. listing_source_snapshots.is_active вычисляется из того же last_seen_at

tradein-mvp/backend/app/tasks/listing_source_snapshot.py:91:

(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active

Это не наблюдение, а функция от той же колонки. Снапшот не знает про живость ничего сверх того, что знает last_seen_at, — он лишь применяет к ней окно в 7 суток вместо TTL деактиваторов в 25-75 суток.

2. Замер: обе колонки дают одно и то же, строка в строку

SELECT l.source, count(*) AS src_rows,
       count(*) FILTER (WHERE ls.last_seen_at > now() - make_interval(days => 1)) AS src_fresh_24h,
       count(*) FILTER (WHERE l.last_seen_at  > now() - make_interval(days => 1)) AS lst_fresh_24h
FROM listing_sources ls JOIN listings l ON l.id = ls.listing_id GROUP BY l.source;
source строк listing_sources.last_seen_at свежих 24ч listings.last_seen_at свежих 24ч
avito 54 768 150 150
cian 22 628 1 232 1 232
yandex 21 164 3 071 3 071
domklik 8 065 0 0
n1 377 0 0

Совпадение точное по всем источникам. Версия «listings.last_seen_at лжёт, а снапшот честен» не выдерживает замера: это одна и та же величина.

3. Разница 50 332 против 21 883 — не «правда против лжи», а два окна на одних часах

Реконсилятор из снапшотов закрыл бы 36 656 строк из 50 332 (72.8 %) — по тому самому сигналу, который тикет называет лжецом, только с более коротким окном. Это буквально сценарий #2659, где паушальный TTL выкосил 9 033 живых и не меньше 1 270 вернулись.

Что колонка на самом деле показывает

Свежесть за 30 суток: avito 16 834 / 54 768, cian 8 226 / 22 628, yandex 12 044 / 21 164, domklik 3 011 / 8 065, n1 0 / 377 (мёртв с 16.06).

Сборы при этом идут: сегодня yandex_city_sweep 8 231, cian_city_sweep 3 332, domclick_city_sweep 2 050, avito_city_sweep 150 (по avito душит QRATOR — #3045). То есть две трети инвентаря просто не попадают в обход, а не «сняты с продажи». Отличить одно от другого last_seen_at не способен по построению.

Отдельно: утверждение тикета «у domklik last_seen_at обновился у 0 из 6954» верно только в окне 24 часов — за 7 суток обновилось 2 522 из 8 065, последняя запись 26.08. Систематического отказа записи нет.

Что из этого следует

Закрывать по устареванию нельзя ни из listings, ни из снапшота — сигнал один и тот же, и он измеряет охват обхода, а не жизнь объявления.

Закрытие требует положительного свидетельства непокрытия: «сегмент обходился, объявление в выдаче не встретилось N раз подряд». Это принципиально другой источник данных — состав обхода (scrape_runs + множество увиденного за прогон), а не отметка времени на строке.

Модель closed_at/live_until при этом остаётся верной: однотабличный индексируемый предикат никуда не девается. Меняется только то, кто и по какому свидетельству их пишет.

Требуется решение владельца

Переписывать ли #2994 под «закрытие по непокрытию в обходе» (нужен разбор, фиксируется ли состав прогона — у снапшота есть run_id, но это run самой снапшот-задачи, не скрапера), или держать тикет до восстановления сбора (#3045 avito, #3118 domklik) — потому что при охвате в треть инвентаря любое закрытие будет угадыванием.

Реконсилятор в прежней постановке не строю: он воспроизвёл бы #2659 на порядок большем объёме.

## Посылка тикета не подтвердилась: снапшот — не независимая истина, а тот же сигнал Прежде чем строить реконсилятор, проверил утверждение «истина про живость **уже лежит в базе**». Она там не лежит. ### 1. `listing_source_snapshots.is_active` вычисляется из того же `last_seen_at` `tradein-mvp/backend/app/tasks/listing_source_snapshot.py:91`: ```sql (last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active ``` Это не наблюдение, а функция от той же колонки. Снапшот не знает про живость ничего сверх того, что знает `last_seen_at`, — он лишь применяет к ней окно в 7 суток вместо TTL деактиваторов в 25-75 суток. ### 2. Замер: обе колонки дают одно и то же, строка в строку ```sql SELECT l.source, count(*) AS src_rows, count(*) FILTER (WHERE ls.last_seen_at > now() - make_interval(days => 1)) AS src_fresh_24h, count(*) FILTER (WHERE l.last_seen_at > now() - make_interval(days => 1)) AS lst_fresh_24h FROM listing_sources ls JOIN listings l ON l.id = ls.listing_id GROUP BY l.source; ``` | source | строк | `listing_sources.last_seen_at` свежих 24ч | `listings.last_seen_at` свежих 24ч | |---|---|---|---| | avito | 54 768 | 150 | **150** | | cian | 22 628 | 1 232 | **1 232** | | yandex | 21 164 | 3 071 | **3 071** | | domklik | 8 065 | 0 | **0** | | n1 | 377 | 0 | **0** | Совпадение точное по всем источникам. Версия «`listings.last_seen_at` лжёт, а снапшот честен» не выдерживает замера: это одна и та же величина. ### 3. Разница 50 332 против 21 883 — не «правда против лжи», а два окна на одних часах Реконсилятор из снапшотов закрыл бы 36 656 строк из 50 332 (72.8 %) — по тому самому сигналу, который тикет называет лжецом, только с более коротким окном. Это буквально сценарий #2659, где паушальный TTL выкосил 9 033 живых и не меньше 1 270 вернулись. ### Что колонка на самом деле показывает Свежесть за 30 суток: avito 16 834 / 54 768, cian 8 226 / 22 628, yandex 12 044 / 21 164, domklik 3 011 / 8 065, n1 0 / 377 (мёртв с 16.06). Сборы при этом идут: сегодня `yandex_city_sweep` 8 231, `cian_city_sweep` 3 332, `domclick_city_sweep` 2 050, `avito_city_sweep` 150 (по avito душит QRATOR — #3045). То есть две трети инвентаря просто **не попадают в обход**, а не «сняты с продажи». Отличить одно от другого `last_seen_at` не способен по построению. *Отдельно: утверждение тикета «у domklik `last_seen_at` обновился у 0 из 6954» верно только в окне 24 часов — за 7 суток обновилось 2 522 из 8 065, последняя запись 26.08. Систематического отказа записи нет.* ## Что из этого следует Закрывать по устареванию нельзя ни из `listings`, ни из снапшота — сигнал один и тот же, и он измеряет охват обхода, а не жизнь объявления. Закрытие требует **положительного свидетельства непокрытия**: «сегмент обходился, объявление в выдаче не встретилось N раз подряд». Это принципиально другой источник данных — состав обхода (`scrape_runs` + множество увиденного за прогон), а не отметка времени на строке. Модель `closed_at`/`live_until` при этом остаётся верной: однотабличный индексируемый предикат никуда не девается. Меняется только то, **кто и по какому свидетельству их пишет**. ## Требуется решение владельца Переписывать ли #2994 под «закрытие по непокрытию в обходе» (нужен разбор, фиксируется ли состав прогона — у снапшота есть `run_id`, но это run самой снапшот-задачи, не скрапера), или держать тикет до восстановления сбора (#3045 avito, #3118 domklik) — потому что при охвате в треть инвентаря любое закрытие будет угадыванием. Реконсилятор в прежней постановке не строю: он воспроизвёл бы #2659 на порядок большем объёме.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#2994
No description provided.