[P2][tradein][backend] avito listings не деактивируются (is_active=true навсегда) → stale раздувает знаменатель coverage #759

Closed
opened 2026-05-30 16:06:09 +00:00 by lekss361 · 6 comments
Owner

🔍 Аналитик-рефайн 2026-05-31 (human-proxy): тело было «нужен план / 2 варианта» (нарушение no-ambiguity gate). Решено однозначно, verified на live tradein-БД + коде.

Проблема

save_listings (tradein-mvp/backend/app/services/scrapers/base.py:214; ON CONFLICT на стр.280-281: SET last_seen_at=NOW(), is_active=true) реактивирует каждую ре-увиденную строку, но НЕТ механизма ставить is_active=false для avito-листингов, которые перестали появляться в выдаче (проданы/сняты). У cian скоуп через snapshot — у avito нет.

Live-факт (2026-05-31, tradein-БД): listings WHERE source='avito' = 9765, все is_active=true; не виделись >7 дней — 5113 (52%). last_seen_at заполнен 100%, обновляется (свежий 2026-05-30). Раздутый знаменатель искажает coverage (#726) и медиану аналогов в estimator (мёртвые лоты в выборке).

Решение (DECIDED — воркер НЕ выбирает)

TTL по last_seen_at. Вариант «run_id sweep-scoped» ОТКЛОНЁН: колонки run_id в listings НЕТ (проверено information_schema.columns) — инфраструктуры под него нет. last_seen_at есть и живой → TTL единственно реализуемый.

Порог N = 10 дней. Обоснование: avito_city_sweep (#814) покрывает 5 анкоров EKB за ~2-3 ночи → 10 дней заведомо больше полного цикла покрытия города ⇒ нет ложных деактиваций из-за частичного sweep'а одним анкором.

Files

  • tradein-mvp/backend/app/services/scheduler.py — добавить scheduled task deactivate_stale_avito_listings(), окно после ночного avito_city_sweep (напр. 06:00 UTC). Паттерн регистрации — как у существующих задач в этом файле.
  • SQL внутри задачи (psycopg v3 — CAST(... AS interval), НЕ ::interval):
    UPDATE listings SET is_active = false
    WHERE source = 'avito' AND is_active = true
      AND last_seen_at < NOW() - CAST(:ttl_days || ' days' AS interval)
    
  • (опц.) вынести AVITO_STALE_TTL_DAYS = 10 в tradein-mvp/backend/app/core/config.py.

Definition of Done (бинарно проверяемо)

  • Task deactivate_stale_avito_listings зарегистрирован в scheduler.py, запускается после avito-sweep.
  • После прогона: SELECT count(*) FILTER (WHERE is_active) FROM listings WHERE source='avito' строго меньше total (деактивированы не-виденные >10д).
  • НЕ затрагивает иные source: запрос строго WHERE source='avito'; SELECT count(*) FILTER (WHERE NOT is_active) FROM listings WHERE source<>'avito' не изменился.
  • uv run pytest (tradein backend) зелёный; uv run ruff check на изменённых файлах clean.

Не делать (out of scope)

  • НЕ трогать cian/yandex деактивацию — у них свой snapshot-механизм.
  • НЕ DELETE строки — только is_active=false (история нужна для backtest #667).

Refs #726, #814.

> 🔍 Аналитик-рефайн 2026-05-31 (human-proxy): тело было «нужен план / 2 варианта» (нарушение no-ambiguity gate). Решено однозначно, verified на live tradein-БД + коде. ## Проблема `save_listings` (`tradein-mvp/backend/app/services/scrapers/base.py:214`; ON CONFLICT на стр.280-281: `SET last_seen_at=NOW(), is_active=true`) реактивирует каждую ре-увиденную строку, но НЕТ механизма ставить `is_active=false` для avito-листингов, которые перестали появляться в выдаче (проданы/сняты). У cian скоуп через snapshot — у avito нет. **Live-факт (2026-05-31, tradein-БД):** `listings WHERE source='avito'` = 9765, **все is_active=true**; не виделись >7 дней — 5113 (52%). `last_seen_at` заполнен 100%, обновляется (свежий 2026-05-30). Раздутый знаменатель искажает coverage (#726) и медиану аналогов в estimator (мёртвые лоты в выборке). ## Решение (DECIDED — воркер НЕ выбирает) **TTL по `last_seen_at`.** Вариант «run_id sweep-scoped» ОТКЛОНЁН: колонки `run_id` в `listings` НЕТ (проверено `information_schema.columns`) — инфраструктуры под него нет. `last_seen_at` есть и живой → TTL единственно реализуемый. Порог **N = 10 дней.** Обоснование: avito_city_sweep (#814) покрывает 5 анкоров EKB за ~2-3 ночи → 10 дней заведомо больше полного цикла покрытия города ⇒ нет ложных деактиваций из-за частичного sweep'а одним анкором. ## Files - `tradein-mvp/backend/app/services/scheduler.py` — добавить scheduled task `deactivate_stale_avito_listings()`, окно после ночного avito_city_sweep (напр. 06:00 UTC). Паттерн регистрации — как у существующих задач в этом файле. - SQL внутри задачи (psycopg v3 — `CAST(... AS interval)`, НЕ `::interval`): ```sql UPDATE listings SET is_active = false WHERE source = 'avito' AND is_active = true AND last_seen_at < NOW() - CAST(:ttl_days || ' days' AS interval) ``` - (опц.) вынести `AVITO_STALE_TTL_DAYS = 10` в `tradein-mvp/backend/app/core/config.py`. ## Definition of Done (бинарно проверяемо) - [ ] Task `deactivate_stale_avito_listings` зарегистрирован в `scheduler.py`, запускается после avito-sweep. - [ ] После прогона: `SELECT count(*) FILTER (WHERE is_active) FROM listings WHERE source='avito'` строго меньше total (деактивированы не-виденные >10д). - [ ] НЕ затрагивает иные source: запрос строго `WHERE source='avito'`; `SELECT count(*) FILTER (WHERE NOT is_active) FROM listings WHERE source<>'avito'` не изменился. - [ ] `uv run pytest` (tradein backend) зелёный; `uv run ruff check` на изменённых файлах clean. ## Не делать (out of scope) - НЕ трогать cian/yandex деактивацию — у них свой snapshot-механизм. - НЕ DELETE строки — только `is_active=false` (история нужна для backtest #667). Refs #726, #814.
lekss361 added the
scope/backend
priority/p2
status/ready
tradein
labels 2026-05-30 16:06:09 +00:00
lekss361 added this to the Praktika demo — Mon 2026-06-01 milestone 2026-05-30 17:21:09 +00:00
lekss361 modified the milestone from Praktika demo — Mon 2026-06-01 to Night batch — 2026-05-31 2026-05-30 19:39:06 +00:00
Author
Owner

🔍 Рефайн аналитика: тело переписано в однозначный спек (TTL по last_seen_at, N=10д решено; run_id-вариант отклонён — колонки нет). Verified на live tradein-БД (9765 avito, все is_active, 52% stale >7д). Готово к работе.

🔍 Рефайн аналитика: тело переписано в однозначный спек (TTL по last_seen_at, N=10д решено; run_id-вариант отклонён — колонки нет). Verified на live tradein-БД (9765 avito, все is_active, 52% stale >7д). Готово к работе.
bot-backend self-assigned this 2026-05-31 07:01:54 +00:00
bot-backend added
status/wip
and removed
status/ready
labels 2026-05-31 07:02:01 +00:00
bot-backend added
status/review
and removed
status/wip
labels 2026-05-31 07:12:14 +00:00
Collaborator

Working on this in PR #862 (status/review). TTL-деактивация по last_seen_at >10д, source='avito' only, is_active=false (no DELETE), scheduler task + seed migration 090. 32 tests pass, ruff clean. Live "после прогона" проверки — post-deploy (qa).

Working on this in PR #862 (status/review). TTL-деактивация по `last_seen_at` >10д, source='avito' only, is_active=false (no DELETE), scheduler task + seed migration 090. 32 tests pass, ruff clean. Live "после прогона" проверки — post-deploy (qa).
bot-reviewer added
status/qa
and removed
status/review
labels 2026-05-31 07:19:50 +00:00
Collaborator

QA: code-уровень verified против merged diff (PR #862, merged 07:19:37), runtime — deploy+scheduled-fire+tradein-DB gated.

Code DoD (проверено по диффу 42007e9b):

  1. Task deactivate_stale_avito_listings зарегистрирован: новый app/tasks/deactivate_stale_avito.py + trigger_deactivate_stale_avito_run + dispatch-ветка elif source == "deactivate_stale_avito" в scheduler_loop. Окно seed-migration 090 = 06:00–07:00 UTC, после avito_city_sweep (02:00–05:00). next_run_at = завтра 06:00 UTC ⇒ НЕ стреляет на деплое (нет преждевременной деактивации по неполному sweep'у). ✓
  2. (code) НЕ трогает иные source: SQL строго WHERE source='avito' AND is_active=true; unit-тест test_sql_targets_only_avito_source это фиксирует. ✓
  3. SQL psycopg-v3-safe: CAST(:ttl_days || ' days' AS interval) (bind-param, никакого :x::type — regex-guard в тестах), нет DELETE (только is_active=false, история для #667 цела), commit/rollback + mark_done/mark_failed. 20 unit-тестов в диффе (SQL shape, CAST-discipline, scheduler wiring, migration idempotency ON CONFLICT(source) DO NOTHING, behavioural fake-db rowcount→counter). PR: 32 passed, ruff clean. ✓

Runtime DoD — НЕ оценимо из QA-окна (by design + tooling-limit):

  • DoD-2 count(*) FILTER (WHERE is_active) < total для avito и DoD-3-live non-avito is_active unchanged — требуют (а) деплоя миграции 090 + scheduler.py, (б) первого прогона задачи 06:00 UTC завтра, (в) доступа к tradein-БД, которого у QA-окна нет.

→ оставляю status/qa. Re-QA после первого 06:00 UTC fire (у вас есть live tradein-доступ — вы уже мерили 9765 avito / 52% stale в рефайне):

-- ожидаем: deactivated > 0, всего avito без изменений
SELECT count(*) FILTER (WHERE is_active) AS active,
       count(*)                          AS total
FROM listings WHERE source='avito';
-- контроль: иные source не затронуты
SELECT source, count(*) FILTER (WHERE NOT is_active) AS inactive
FROM listings WHERE source<>'avito' GROUP BY source;
-- прогон состоялся:
SELECT id, status, finished_at, result
FROM scrape_runs WHERE source='deactivate_stale_avito' ORDER BY id DESC LIMIT 1;

Если после fire active == total (0 деактивировано) при наличии >10д-stale строк — классифицировать как scheduler-dispatch регресс (needs-fix, assignee bot-backend), НЕ infra.

Refs #726, #814.

⏳ **QA: code-уровень verified против merged diff (PR #862, merged 07:19:37), runtime — deploy+scheduled-fire+tradein-DB gated.** **✅ Code DoD (проверено по диффу `42007e9b`):** 1. Task `deactivate_stale_avito_listings` зарегистрирован: новый `app/tasks/deactivate_stale_avito.py` + `trigger_deactivate_stale_avito_run` + dispatch-ветка `elif source == "deactivate_stale_avito"` в `scheduler_loop`. Окно seed-migration 090 = **06:00–07:00 UTC, после** avito_city_sweep (02:00–05:00). `next_run_at` = завтра 06:00 UTC ⇒ НЕ стреляет на деплое (нет преждевременной деактивации по неполному sweep'у). ✓ 3. (code) НЕ трогает иные source: SQL строго `WHERE source='avito' AND is_active=true`; unit-тест `test_sql_targets_only_avito_source` это фиксирует. ✓ 4. SQL psycopg-v3-safe: `CAST(:ttl_days || ' days' AS interval)` (bind-param, никакого `:x::type` — regex-guard в тестах), **нет DELETE** (только `is_active=false`, история для #667 цела), commit/rollback + mark_done/mark_failed. 20 unit-тестов в диффе (SQL shape, CAST-discipline, scheduler wiring, migration idempotency `ON CONFLICT(source) DO NOTHING`, behavioural fake-db rowcount→counter). PR: 32 passed, ruff clean. ✓ **⏳ Runtime DoD — НЕ оценимо из QA-окна (by design + tooling-limit):** - DoD-2 `count(*) FILTER (WHERE is_active) < total для avito` и DoD-3-live `non-avito is_active unchanged` — требуют (а) деплоя миграции 090 + scheduler.py, (б) **первого прогона задачи 06:00 UTC завтра**, (в) доступа к **tradein-БД**, которого у QA-окна нет. → оставляю `status/qa`. **Re-QA после первого 06:00 UTC fire** (у вас есть live tradein-доступ — вы уже мерили 9765 avito / 52% stale в рефайне): ```sql -- ожидаем: deactivated > 0, всего avito без изменений SELECT count(*) FILTER (WHERE is_active) AS active, count(*) AS total FROM listings WHERE source='avito'; -- контроль: иные source не затронуты SELECT source, count(*) FILTER (WHERE NOT is_active) AS inactive FROM listings WHERE source<>'avito' GROUP BY source; -- прогон состоялся: SELECT id, status, finished_at, result FROM scrape_runs WHERE source='deactivate_stale_avito' ORDER BY id DESC LIMIT 1; ``` Если после fire `active == total` (0 деактивировано) при наличии >10д-stale строк — классифицировать как scheduler-dispatch регресс (needs-fix, assignee bot-backend), НЕ infra. Refs #726, #814.
Collaborator

🔎 QA update — read-only prod-БД проверка (теперь есть docker exec tradein-postgres psql).

Подтвердил вживую на tradein-БД (2026-05-31 ~08:00 UTC):

source='avito': active_now=9783, total=9783, eligible_stale(>10d)=0, non-avito inactive=0

Код + schedule : задача deactivate_stale_avito зарегистрирована, schedule id=111 живой на проде (enabled, next_run 2026-06-01 06:00 UTC) — verified в #865 smoke. SQL корректен (WHERE source='avito' AND is_active AND last_seen_at < NOW()-CAST(:ttl_days||' days' AS interval), no DELETE).

⚠️ Но runtime-DoD «после прогона count<total» сейчас НЕ демонстрируем: 0 строк превышают 10-дневный TTL. Tension с постановкой issue: тело говорит «5113 (52%) не виделись >7 дней», но порог задачи = 10 дней, и эти 5113 сейчас в полосе [7,10)д ⇒ ближайший fire (06-01 06:00) деактивирует 0. Плюс avito_city_sweep запланирован 06-01 02:00 (ДО deactivate) — если он переувидит часть строк, их last_seen_at обновится и они вообще не состарятся до 10д.

→ Это не дефект (10д выбран намеренно против ложных деактиваций), но значит: (а) count<total не подтвердится на ближайшем прогоне; (б) для фактической чистки 5113-раздутия denominatorّа порог 10д сработает только когда строки доживут до >10д без переувидения. Оставляю status/qa, re-verify после 06-01 06:00 fire (доберу через psql). Если к тому моменту eligible всё ещё ~0 — возможно стоит пересмотреть, решает ли 10д-TTL исходную проблему inflated-denominator (#726), или нужен порог ниже / другой механизм для «7-10д» полосы. Это уже product-вопрос → тебе.

Refs #726, #814.

🔎 **QA update — read-only prod-БД проверка (теперь есть `docker exec tradein-postgres psql`).** Подтвердил вживую на tradein-БД (2026-05-31 ~08:00 UTC): ``` source='avito': active_now=9783, total=9783, eligible_stale(>10d)=0, non-avito inactive=0 ``` **Код + schedule ✅:** задача `deactivate_stale_avito` зарегистрирована, schedule id=111 живой на проде (enabled, next_run **2026-06-01 06:00 UTC**) — verified в #865 smoke. SQL корректен (`WHERE source='avito' AND is_active AND last_seen_at < NOW()-CAST(:ttl_days||' days' AS interval)`, no DELETE). **⚠️ Но runtime-DoD «после прогона count<total» сейчас НЕ демонстрируем:** **0 строк** превышают 10-дневный TTL. Tension с постановкой issue: тело говорит «5113 (52%) не виделись >7 дней», но порог задачи = **10 дней**, и эти 5113 сейчас в полосе [7,10)д ⇒ ближайший fire (06-01 06:00) деактивирует **0**. Плюс avito_city_sweep запланирован 06-01 02:00 (ДО deactivate) — если он переувидит часть строк, их last_seen_at обновится и они вообще не состарятся до 10д. → Это **не дефект** (10д выбран намеренно против ложных деактиваций), но значит: (а) `count<total` не подтвердится на ближайшем прогоне; (б) для фактической чистки 5113-раздутия denominatorّа порог 10д сработает только когда строки доживут до >10д без переувидения. Оставляю `status/qa`, **re-verify после 06-01 06:00 fire** (доберу через psql). Если к тому моменту eligible всё ещё ~0 — возможно стоит пересмотреть, решает ли 10д-TTL исходную проблему inflated-denominator (#726), или нужен порог ниже / другой механизм для «7-10д» полосы. Это уже product-вопрос → тебе. Refs #726, #814.
Author
Owner

QA INCONCLUSIVE → needs-fix (06.06) — код заведён, но в проде не работает:

Код PASS: task deactivate_stale_avito_listings зарегистрирован (scheduler.py:573,1000, dispatch ok), SQL avito-scoped + psycopg-v3-safe (tasks/deactivate_stale_avito.py:33-41), TTL из AVITO_STALE_TTL_DAYS (default 10).
Scope guard PASS: деактивация строго avito; cian/yandex/n1 не затронуты.

Прод-блокер: scrape_schedules id=111 (deactivate_stale_avito) — enabled=false, last_run_id=NULL, next_run_at=2026-06-01 (в прошлом). Задача ни разу не отработала. scrape_runs WHERE source ILIKE '%deactiv%' = 0 строк. 1247 inactive avito (7.9%) не атрибутируются этой задаче (вероятно более ранний ручной UPDATE).

Fix: либо UPDATE scrape_schedules SET enabled=true WHERE id=111 на проде, либо миграция, вставляющая строку с enabled=true (docstring говорит «enabled by default» — расходится с фактом). status/qastatus/needs-fix.

**QA INCONCLUSIVE → needs-fix (06.06)** — код заведён, но в проде не работает: ✅ **Код PASS:** task `deactivate_stale_avito_listings` зарегистрирован (`scheduler.py:573,1000`, dispatch ok), SQL avito-scoped + psycopg-v3-safe (`tasks/deactivate_stale_avito.py:33-41`), TTL из `AVITO_STALE_TTL_DAYS` (default 10). ✅ **Scope guard PASS:** деактивация строго avito; cian/yandex/n1 не затронуты. ❌ **Прод-блокер:** `scrape_schedules` id=111 (`deactivate_stale_avito`) — **`enabled=false`**, `last_run_id=NULL`, `next_run_at=2026-06-01` (в прошлом). Задача **ни разу не отработала**. `scrape_runs WHERE source ILIKE '%deactiv%'` = 0 строк. 1247 inactive avito (7.9%) не атрибутируются этой задаче (вероятно более ранний ручной UPDATE). **Fix:** либо `UPDATE scrape_schedules SET enabled=true WHERE id=111` на проде, либо миграция, вставляющая строку с `enabled=true` (docstring говорит «enabled by default» — расходится с фактом). `status/qa` → `status/needs-fix`.
lekss361 added
status/needs-fix
and removed
status/qa
labels 2026-06-06 18:51:10 +00:00
lekss361 added
status/review
and removed
status/needs-fix
labels 2026-06-06 19:01:47 +00:00
Author
Owner

Fix готов → PR #1096. Root cause: миграция 090 (ON CONFLICT DO NOTHING) пропустила существующую прод-строку enabled=false. Добавлена идемпотентная миграция 100_enable_deactivate_stale_avito.sql (UPDATE enabled=true + next_run_at=NOW(), guard enabled=false) + 7 тестов (39 passed, ruff clean). needs-fixreview.

Fix готов → PR #1096. Root cause: миграция `090` (`ON CONFLICT DO NOTHING`) пропустила существующую прод-строку `enabled=false`. Добавлена идемпотентная миграция `100_enable_deactivate_stale_avito.sql` (UPDATE enabled=true + next_run_at=NOW(), guard `enabled=false`) + 7 тестов (39 passed, ruff clean). `needs-fix` → `review`.
Sign in to join this conversation.
No project
No assignees
3 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#759
No description provided.