fix(tradein/scraper): строка с пустым сегментом самочинится при пересборе #2911

Merged
lekss361 merged 1 commit from fix/tradein-upsert-segment-selfheal into main 2026-08-15 19:37:17 +00:00
Owner

Причина, а не симптом

Upsert объявлений (ON CONFLICT (dedup_hash) DO UPDATE) не обновлял listing_segment. Строка, единожды родившаяся с пустым сегментом, оставалась такой навсегда — даже при живой ежедневной досдаче, потому что повторный сбор шёл по ветке UPDATE, а этой колонки в SET не было.

Отсюда пул «активных» строк без сегмента, который якорил оценку. Миграция 266 и джобы deactivate_stale_{cian,yandex}_null_segment (#2908) лечили симптом — снимали протухшие строки с пустым сегментом. Этот PR закрывает причину: строка чинит себя сама при следующем же сборе.

Замер на проде 15.08.2026 — активных строк с пустым сегментом: cian 224, yandex 544. Из них старше 60 суток (то есть подпадающих под деактивацию) — 211 и 523; остальные 34 живые и после этой правки получат сегмент вместо того, чтобы дожидаться TTL.

Правка

Два места, обе — tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py, функция save_listings():

  1. base.py:648 — основной upsert:
    listing_segment = COALESCE(EXCLUDED.listing_segment, listings.listing_segment)
    
  2. base.py:756 — reconcile-UPDATE (запасной путь при дрейфе dedup_hash, когда ловится UniqueViolation по (source, source_id)):
    listing_segment = COALESCE(:listing_segment, listing_segment)
    

Второе место формально за рамками «ON CONFLICT DO UPDATE», но болезнь та же: строки, прошедшие через reconcile, остались бы незалеченными.

COALESCE, а не голый EXCLUDED: если сбор пришёл без сегмента, уже известное значение обязано выжить. Тот же идиом, что рядом применён к городу.

Проверка

  • Новый тест tradein-mvp/backend/tests/test_listing_segment_upsert_selfheal.py — 5 тестов: INSERT-путь, обе стороны COALESCE в upsert (сегмент проставляется / известный не затирается), reconcile-UPDATE. Все проходят.
  • Полный backend-suite: 4551 passed, 23 skipped, 0 падений.
  • ruff check чисто; grep -nE ':[a-z_]+::[a-z]' пуст (ловушка приведения типов psycopg v3 не задета).

Отдельно, не чинилось

В том же upsert только при INSERT пишутся и никогда не обновляются: lat, lon, region_code, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, house_source, house_ext_id, house_url, photo_urls, raw_payload, geo_precision. То есть исправление параметров площадкой и свежие фото после первой вставки теряются. Это тот же класс, но отдельная задача — трогать здесь не стал.

Критерий приёмки на проде

После деплоя и следующего свипа cian/yandex число активных строк с listing_segment IS NULL и last_seen_at свежее 7 суток должно упасть с 13 (cian 9 + yandex 4) до 0.

Refs #2908

## Причина, а не симптом Upsert объявлений (`ON CONFLICT (dedup_hash) DO UPDATE`) не обновлял `listing_segment`. Строка, единожды родившаяся с пустым сегментом, оставалась такой навсегда — даже при живой ежедневной досдаче, потому что повторный сбор шёл по ветке UPDATE, а этой колонки в SET не было. Отсюда пул «активных» строк без сегмента, который якорил оценку. Миграция 266 и джобы `deactivate_stale_{cian,yandex}_null_segment` (#2908) лечили **симптом** — снимали протухшие строки с пустым сегментом. Этот PR закрывает **причину**: строка чинит себя сама при следующем же сборе. Замер на проде 15.08.2026 — активных строк с пустым сегментом: cian 224, yandex 544. Из них старше 60 суток (то есть подпадающих под деактивацию) — 211 и 523; остальные 34 живые и после этой правки получат сегмент вместо того, чтобы дожидаться TTL. ## Правка Два места, обе — `tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py`, функция `save_listings()`: 1. `base.py:648` — основной upsert: ```sql listing_segment = COALESCE(EXCLUDED.listing_segment, listings.listing_segment) ``` 2. `base.py:756` — reconcile-UPDATE (запасной путь при дрейфе `dedup_hash`, когда ловится `UniqueViolation` по `(source, source_id)`): ```sql listing_segment = COALESCE(:listing_segment, listing_segment) ``` Второе место формально за рамками «ON CONFLICT DO UPDATE», но болезнь та же: строки, прошедшие через reconcile, остались бы незалеченными. `COALESCE`, а не голый `EXCLUDED`: если сбор пришёл без сегмента, уже известное значение обязано выжить. Тот же идиом, что рядом применён к городу. ## Проверка - Новый тест `tradein-mvp/backend/tests/test_listing_segment_upsert_selfheal.py` — 5 тестов: INSERT-путь, обе стороны COALESCE в upsert (сегмент проставляется / известный не затирается), reconcile-UPDATE. Все проходят. - Полный backend-suite: **4551 passed, 23 skipped**, 0 падений. - `ruff check` чисто; `grep -nE ':[a-z_]+::[a-z]'` пуст (ловушка приведения типов psycopg v3 не задета). ## Отдельно, не чинилось В том же upsert только при INSERT пишутся и никогда не обновляются: `lat`, `lon`, `region_code`, `rooms`, `floor`, `total_floors`, `year_built`, `house_type`, `repair_state`, `has_balcony`, `house_source`, `house_ext_id`, `house_url`, `photo_urls`, `raw_payload`, `geo_precision`. То есть исправление параметров площадкой и свежие фото после первой вставки теряются. Это тот же класс, но отдельная задача — трогать здесь не стал. ## Критерий приёмки на проде После деплоя и следующего свипа cian/yandex число активных строк с `listing_segment IS NULL` и `last_seen_at` свежее 7 суток должно упасть с 13 (cian 9 + yandex 4) до 0. Refs #2908
lekss361 added 1 commit 2026-08-15 19:29:17 +00:00
fix(tradein/scraper): listing_segment самочинится при upsert вместо вечного NULL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 5m1s
aee0d36e7a
Раньше listing_segment писался ТОЛЬКО при INSERT в save_listings — строка,
единожды родившаяся с NULL-сегментом (классификатор не сработал на первом
скрейпе), никогда не получала сегмент на повторных сборах, даже когда он
становился определим. Симптом лечила отдельная джоба деактивации
(deactivate_stale_{cian,yandex}_null_segment, миграция 266, PR #2908) — чистит
мусор, но не саму запись.

Добавлен listing_segment = COALESCE(EXCLUDED.listing_segment,
listings.listing_segment) в ON CONFLICT DO UPDATE, и тот же идиом в
reconcile-UPDATE (dedup_hash-drift fallback path) — иначе self-heal был бы
неполным для строк, прошедших через этот путь. COALESCE, а не голый EXCLUDED,
чтобы повторный скрейп без определённого сегмента не затирал уже известное
значение пустым (тот же паттерн, что уже применён для city/kitchen/ceiling).

Тесты: tests/test_listing_segment_upsert_selfheal.py — INSERT-путь,
ON CONFLICT COALESCE (обе стороны), reconcile-UPDATE COALESCE.
lekss361 merged commit d0153f657b into main 2026-08-15 19:37:17 +00:00
lekss361 deleted branch fix/tradein-upsert-segment-selfheal 2026-08-15 19:37:18 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2911
No description provided.