fix(tradein): писатели наконец пишут то, что обещает схема — фото подсказок, статус «снято», события объявлений (#2674) #2682

Merged
bot-backend merged 3 commits from fix/2674-writers-honor-schema into main 2026-08-05 22:12:30 +00:00
Collaborator

Три находки из #2674 одного класса: колонка есть, писатель есть, тест на писателя зелёный — а данные не появляются. Тестами это не ловится по построению, только сверкой с продом. Числа — с tradein-postgres на 2026-08-05/06.

Обновлено после ревью (коммит ab01f7cc): два события убраны как невыводимые, статус TTL-пути разведён с 404-путём, починена дедупликация журнала между прогонами и разбор заголовков студий. Подробности в разделах ниже и в конце.

Миграция: только 213_listings_snapshots_status_vocab.sql — обновление COMMENT'а словаря статусов. Все колонки и CHECK уже существуют.


1. Потерянные фотографии подсказок Avito IMV

Сегодня: house_suggestions — 25 055 строк, image_link заполнен у 0. Парсер выбрасывал imageLink (_parse_suggestion фильтровал его из raw_payload, а отдельного поля не было), и колонка не входила в INSERT. Живёт с миграции 064, ~74 дня.

Рядом нашлось ещё: area_m2 / rooms / floor / total_floors — тоже 0 из 25 055. Колонки объявлены в 064, но ни в INSERT, ни в модели их не было. Метрики выводятся из title тем же _parse_title, которым уже пользуется placementHistory. Больше из ответа площадки не теряется ничего: единственное непереложенное поле — isFavorite, у него нет колонки и смысла.

Из ревью: разбор заголовка требовал комнатности, а 1991 заголовок из 25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.». Площадь и этажность там есть, но обязательная группа роняла match целиком и обнуляла все четыре поля. Группа стала необязательной — один регексп чинит обоих писателей сразу (у соседней house_placement_history тот же потолок: 8.8% строк без площади). Студия → rooms=0 по конвенции kit'а (base.py: «0 = студия»), а не None: «студия» и «комнатность неизвестна» — разные факты.

Как проверить на проде:

SELECT count(*) AS total,
       count(image_link) AS img,
       count(area_m2) AS area,
       count(*) FILTER (WHERE rooms = 0) AS studios,
       count(*) FILTER (WHERE fetched_at > now() - interval '2 days') AS fresh
FROM house_suggestions;

Было total=25055, img=0, area=0, studios=0. Растут вместе с fresh по мере обхода домов. Обратной засыпки нет — raw_payload старых строк уже без imageLink.


2. «Снято» и «протухло» в дневной истории объявлений

Сегодня: listings_snapshots.status = 'active' у всех 394 704 строк при 55 448 неактивных объявлениях. Оба места с литералом 'active' честны — там объявление действительно видели. Ветку «не видим» не писал никто.

Из ревью — статусов два, а не один. TTL-задача не знает, что объявление снято; она знает, что МЫ его N суток не видели. Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата продажи» одной датой. Поэтому:

  • 'closed'только путь 404 (avito_detail_backfill): площадка ответила «нет». Жёсткий факт.
  • 'stale' — путь TTL (deactivate_stale_listings, общий писатель для всех 4 источников): «мы N суток не смотрели». Догадка, помеченная как догадка.

Дата фиксируется в обоих случаях, читатель их отличает. CHECK на колонке нет — миграция 213 обновляет только COMMENT (жёсткий словарь на историческую таблицу из 394 704 строк — деструктивный риск ради нуля).

Механика не изменилась: снимок пишется в той же транзакции, что и снятие флага. Для TTL — один statement, data-modifying CTE (WITH stale AS (UPDATE … RETURNING id, price_rub) INSERT …), rowcount остаётся равен числу деактивированных (1:1, ON CONFLICT DO UPDATE считается в affected rows). Для 404 — тот же SAVEPOINT и существующий upsert_listing_snapshot.

Как проверить на проде — после ночных прогонов (deactivate_stale_*, окна 06:47–07:42 UTC):

SELECT status, count(*) FROM listings_snapshots
WHERE snapshot_date = CURRENT_DATE GROUP BY 1;

Было ровно active | …. Ожидается строка staleclosed, когда 404-путь что-то найдёт). Число stale за дату должно сойтись с суммой counters->>'deactivated' прогонов deactivate_stale_* за тот же день:

SELECT sum((counters->>'deactivated')::int) FROM scrape_runs
WHERE source LIKE 'deactivate_stale%' AND started_at::date = CURRENT_DATE;

(Прежний вариант сверки — «снимок закрыт, а флаг активен → 0» — из описания убран: он бы не дал ноль. Обход выставляет is_active обратно, а снимок за тот день остаётся навсегда; плюс при неизменившемся хеше карточки запись снимка пропускается. Спасибо за ловлю.)


3. Журнал событий объявлений: три типа из пяти, а не пять

Сегодня: listing_source_events8288 строк, все price_change. Остальные четыре — ноль за всё время.

Изначально в PR были дописаны все четыре. Ревью показало, что два из них — неправда, и это подтвердилось моими собственными запросами, местами жёстче заявленного.

is_active в снимке — derived-признак «last_seen_at свежее 7 дней», то есть «мы видели», а не «есть на площадке». Контрольная группа в наших же данных, 14–18.07 — один и тот же ежедневный обход, разница только в покрытии:

источник покрытие снятий/сутки возвратов/сутки
domklik 99.9–100% 1 / 2 / 0 / 2 / 4 0 / 0 / 0 / 0 / 0
yandex 34–43% 343–433 0–155
cian 21–27% 325–858 4–292
avito 1.6–3.4% 0–530 0–1

При полном покрытии возвратов ровно ноль за все пять суток, а реальный отток — 1–4 объявления в сутки на 6456 источников. Событие рождается не тем, что объявление вернулось, а тем, что скрейпер снова до него дошёл.

Подтверждения на всплесках:

  • avito 13.07, день остановки обхода — 3023 «снятия» за сутки против контрольной ставки 1–4. Точность события ≈ 4%.
  • 4705 «возвратов» из 5493 за 12 дней (85.7%) — это 2 и 3 августа, два дня после возобновления обхода (2502 + 2203).

Сузить окно свежести не помогло бы — стало бы хуже (больше флапаний); окно шире максимального интервала повторного визита обессмысливает само событие. Убрал обе ветки и оба счётчика. is_active из запроса убран целиком, чтобы ветка не вернулась незаметно. Диф стал короче, чем был.

Остаются три события — они утверждают факты о наших собственных строках, а не о поведении площадки: first_seen («появился новый источник»), edited («хеш изменился при той же цене»), price_change («цена другая»). Аргумент «price_change так же выводим» был про наличие читателя, а не про истинность — принято, это разные оси.

Прочее в этом же запросе:

  • JOINLEFT JOIN LATERAL: без этого first_seen недостижим по построению.
  • NULLIF(p.price_rub, 0) на знаменателе: выражения VALUES вычисляются до фильтра WHERE e.fires, поэтому предикат p.price_rub <> 0 не защищает само деление.
  • change_time усечён до суток (date_trunc('day', now()), из ревью). С now() уникальность UNIQUE(listing_source_id, change_time, event_type) работала только внутри прогона: второй прогон в те же сутки перезаписывал снимок, предикаты срабатывали заново с другим временем и давали дубли — 2 августа таких прогонов было два. Теперь заявленная идемпотентность действительно работает, а суточная гранулярность честнее для суточного же сравнения.
  • Счётчики по типам, все три ключа всегда присутствуют. Счётчиков невыводимых типов нет намеренно: вечный 0 читался бы как «событий не было», а не как «мы это сознательно не пишем».

План не деградировал (регрессия #2607): EXPLAIN прогнан на живом проде дважды, до и после правок ревью — Index Scan using idx_lss_source_date + Limit внутри LATERAL на месте.

Как проверить на проде — после прогона listing_source_snapshot (01:00–02:00 UTC):

SELECT event_type, count(*), max(change_time) FROM listing_source_events
GROUP BY 1 ORDER BY 2 DESC;

Было одна строка price_change | 8288. Ожидаются три строки; delisted/relisted не должны появиться никогда. max(change_time) обязан быть полуночью, а не временем прогона. Счётчики:

SELECT started_at, status, counters FROM scrape_runs
WHERE source = 'listing_source_snapshot' ORDER BY id DESC LIMIT 1;

Тесты

tradein-mvp/backend/tests/test_2674_writers_honor_schema.py — 22 теста. Гейты сверяют писателя со схемой, а не с копией его же списка:

  • test_house_suggestions_insert_covers_every_declared_column — колонки INSERT против CREATE TABLE в 064;
  • test_event_writer_covers_every_derivable_schema_event_type — типы против CHECK в 079, минус явный список невыводимых.

И отдельный класс — гейты на то, что писатель НЕ пишет (иначе следующий проход «дополнит для полноты»):

  • test_not_derivable_events_are_never_writtendelisted/relisted не в SQL, is_active не читается;
  • test_ttl_path_never_claims_closed — TTL-путь не имеет права называть протухание снятием.

Фальсификация патч-методом, честно:

правка красных без неё
фото + метрики подсказок 6
статус в истории («снято»/«протухло») 6
события (три типа, LEFT JOIN, NULLIF) 4
вернуть delisted в писатель 1
'closed' на TTL-пути 6
обязательная комнатность 2
now() вместо date_trunc 1

Прогон: 3461 passed, 9 skipped. Единственная красная во всём репозитории — tests/test_search_api.py::test_search_cache_hit (401 вместо 200), pre-existing: ветка не трогает ни app/api/v1/search.py, ни app/core/, ни сам тест (git diff origin/main по этим путям пуст), падает и в изоляции.

В чём не уверен

  • rowcount CTE-статемента задачи 2 равен числу деактивированных по построению, но проверено рассуждением + EXPLAIN, не живой записью. Первый прод-прогон это покажет — сверка с counters выше ровно для этого.
  • Транзакция деактивации теперь держит блокировки на двух таблицах, и порядок чередования по строкам даёт теоретическую возможность взаимной блокировки со скрейпом. Последствие безопасное — прогон падает и повторяется назавтра. Не чиню, но знаю.
  • edited намеренно молчит при изменении цены и при NULL прошлого хеша — это выбор, меняется одной строкой.
  • Ретроспективная чистка 8288 существующих price_change под новую суточную гранулярность не делалась: старые строки останутся с временем прогона. Дубли среди них возможны, но их не больше, чем было.

Про номер миграции

Изначально файл был 212_*; пока ветка была в работе, смержился #2681 с 212_sber_index_pull_weekly.sql. Перенумеровано в 213 (77336d35).

Локальный гейт test_new_files_do_not_reuse_prefix молчал не по своей вине: он сравнивает префиксы файлов в одном дереве, а смерженный 212_sber в этой ветке отсутствует. Проверено симуляцией (копия data/sql + stub 212_sber): с моим 212 тест краснеет («212 уже у нового 212_sber»), с 213 — зелёный. Кросс-ветковым реестром занятых номеров должен служить _manifest_applied.txt, но он отстал на 27 имён, поэтому 212 нигде не числился занятым. Манифест в этом PR не трогаю — см. отдельную задачу.

Refs #2674

Три находки из #2674 одного класса: колонка есть, писатель есть, тест на писателя зелёный — а данные не появляются. Тестами это не ловится по построению, только сверкой с продом. Числа — с `tradein-postgres` на 2026-08-05/06. **Обновлено после ревью** (коммит `ab01f7cc`): два события убраны как невыводимые, статус TTL-пути разведён с 404-путём, починена дедупликация журнала между прогонами и разбор заголовков студий. Подробности в разделах ниже и в конце. **Миграция**: только `213_listings_snapshots_status_vocab.sql` — обновление COMMENT'а словаря статусов. Все колонки и CHECK уже существуют. --- ## 1. Потерянные фотографии подсказок Avito IMV **Сегодня:** `house_suggestions` — 25 055 строк, `image_link` заполнен у **0**. Парсер выбрасывал `imageLink` (`_parse_suggestion` фильтровал его из `raw_payload`, а отдельного поля не было), и колонка не входила в INSERT. Живёт с миграции 064, ~74 дня. **Рядом нашлось ещё:** `area_m2` / `rooms` / `floor` / `total_floors` — тоже **0 из 25 055**. Колонки объявлены в 064, но ни в INSERT, ни в модели их не было. Метрики выводятся из `title` тем же `_parse_title`, которым уже пользуется `placementHistory`. Больше из ответа площадки не теряется ничего: единственное непереложенное поле — `isFavorite`, у него нет колонки и смысла. **Из ревью:** разбор заголовка требовал комнатности, а **1991 заголовок из 25 055 (7.9%)** — «Квартира-студия, 34,2 м², 9/10 эт.». Площадь и этажность там есть, но обязательная группа роняла match целиком и обнуляла **все четыре** поля. Группа стала необязательной — один регексп чинит обоих писателей сразу (у соседней `house_placement_history` тот же потолок: 8.8% строк без площади). Студия → `rooms=0` по конвенции kit'а (`base.py`: «0 = студия»), а не `None`: «студия» и «комнатность неизвестна» — разные факты. **Как проверить на проде:** ```sql SELECT count(*) AS total, count(image_link) AS img, count(area_m2) AS area, count(*) FILTER (WHERE rooms = 0) AS studios, count(*) FILTER (WHERE fetched_at > now() - interval '2 days') AS fresh FROM house_suggestions; ``` Было `total=25055, img=0, area=0, studios=0`. Растут вместе с `fresh` по мере обхода домов. Обратной засыпки нет — `raw_payload` старых строк уже без `imageLink`. --- ## 2. «Снято» и «протухло» в дневной истории объявлений **Сегодня:** `listings_snapshots.status` = `'active'` у всех **394 704** строк при **55 448** неактивных объявлениях. Оба места с литералом `'active'` честны — там объявление действительно видели. Ветку «не видим» не писал никто. **Из ревью — статусов два, а не один.** TTL-задача не знает, что объявление снято; она знает, что МЫ его N суток не видели. Замер: прогон по домклику 02.08 деактивировал **6131 объявление за раз** (TTL 14 суток против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата продажи» одной датой. Поэтому: - `'closed'` — **только** путь 404 (`avito_detail_backfill`): площадка ответила «нет». Жёсткий факт. - `'stale'` — путь TTL (`deactivate_stale_listings`, общий писатель для всех 4 источников): «мы N суток не смотрели». Догадка, помеченная как догадка. Дата фиксируется в обоих случаях, читатель их отличает. CHECK на колонке нет — миграция `213` обновляет только COMMENT (жёсткий словарь на историческую таблицу из 394 704 строк — деструктивный риск ради нуля). Механика не изменилась: снимок пишется в **той же транзакции**, что и снятие флага. Для TTL — один statement, data-modifying CTE (`WITH stale AS (UPDATE … RETURNING id, price_rub) INSERT …`), `rowcount` остаётся равен числу деактивированных (1:1, `ON CONFLICT DO UPDATE` считается в affected rows). Для 404 — тот же SAVEPOINT и существующий `upsert_listing_snapshot`. **Как проверить на проде** — после ночных прогонов (`deactivate_stale_*`, окна 06:47–07:42 UTC): ```sql SELECT status, count(*) FROM listings_snapshots WHERE snapshot_date = CURRENT_DATE GROUP BY 1; ``` Было ровно `active | …`. Ожидается строка `stale` (и `closed`, когда 404-путь что-то найдёт). Число `stale` за дату должно сойтись с суммой `counters->>'deactivated'` прогонов `deactivate_stale_*` за тот же день: ```sql SELECT sum((counters->>'deactivated')::int) FROM scrape_runs WHERE source LIKE 'deactivate_stale%' AND started_at::date = CURRENT_DATE; ``` *(Прежний вариант сверки — «снимок закрыт, а флаг активен → 0» — из описания убран: он бы не дал ноль. Обход выставляет `is_active` обратно, а снимок за тот день остаётся навсегда; плюс при неизменившемся хеше карточки запись снимка пропускается. Спасибо за ловлю.)* --- ## 3. Журнал событий объявлений: три типа из пяти, а не пять **Сегодня:** `listing_source_events` — **8288** строк, все `price_change`. Остальные четыре — ноль за всё время. Изначально в PR были дописаны все четыре. **Ревью показало, что два из них — неправда, и это подтвердилось моими собственными запросами**, местами жёстче заявленного. `is_active` в снимке — derived-признак «`last_seen_at` свежее 7 дней», то есть «мы видели», а не «есть на площадке». Контрольная группа в наших же данных, 14–18.07 — один и тот же ежедневный обход, разница только в покрытии: | источник | покрытие | снятий/сутки | возвратов/сутки | |---|---:|---:|---:| | **domklik** | **99.9–100%** | **1 / 2 / 0 / 2 / 4** | **0 / 0 / 0 / 0 / 0** | | yandex | 34–43% | 343–433 | 0–155 | | cian | 21–27% | 325–858 | 4–292 | | avito | 1.6–3.4% | 0–530 | 0–1 | При полном покрытии возвратов **ровно ноль за все пять суток**, а реальный отток — 1–4 объявления в сутки на 6456 источников. Событие рождается не тем, что объявление вернулось, а тем, что скрейпер **снова до него дошёл**. Подтверждения на всплесках: - avito 13.07, день остановки обхода — **3023 «снятия» за сутки** против контрольной ставки 1–4. Точность события ≈ **4%**. - **4705 «возвратов» из 5493 за 12 дней (85.7%)** — это 2 и 3 августа, два дня после возобновления обхода (2502 + 2203). Сузить окно свежести не помогло бы — стало бы хуже (больше флапаний); окно шире максимального интервала повторного визита обессмысливает само событие. **Убрал обе ветки и оба счётчика.** `is_active` из запроса убран целиком, чтобы ветка не вернулась незаметно. Диф стал короче, чем был. Остаются три события — они утверждают факты о **наших собственных строках**, а не о поведении площадки: `first_seen` («появился новый источник»), `edited` («хеш изменился при той же цене»), `price_change` («цена другая»). Аргумент «price_change так же выводим» был про наличие читателя, а не про истинность — принято, это разные оси. Прочее в этом же запросе: - `JOIN` → `LEFT JOIN LATERAL`: без этого `first_seen` **недостижим по построению**. - `NULLIF(p.price_rub, 0)` на знаменателе: выражения `VALUES` вычисляются **до** фильтра `WHERE e.fires`, поэтому предикат `p.price_rub <> 0` не защищает само деление. - **`change_time` усечён до суток** (`date_trunc('day', now())`, из ревью). С `now()` уникальность `UNIQUE(listing_source_id, change_time, event_type)` работала только **внутри** прогона: второй прогон в те же сутки перезаписывал снимок, предикаты срабатывали заново с другим временем и давали дубли — 2 августа таких прогонов было два. Теперь заявленная идемпотентность действительно работает, а суточная гранулярность честнее для суточного же сравнения. - Счётчики по типам, все три ключа всегда присутствуют. Счётчиков невыводимых типов нет намеренно: вечный 0 читался бы как «событий не было», а не как «мы это сознательно не пишем». **План не деградировал** (регрессия #2607): `EXPLAIN` прогнан на живом проде дважды, до и после правок ревью — `Index Scan using idx_lss_source_date` + `Limit` внутри LATERAL на месте. **Как проверить на проде** — после прогона `listing_source_snapshot` (01:00–02:00 UTC): ```sql SELECT event_type, count(*), max(change_time) FROM listing_source_events GROUP BY 1 ORDER BY 2 DESC; ``` Было одна строка `price_change | 8288`. Ожидаются **три** строки; `delisted`/`relisted` не должны появиться никогда. `max(change_time)` обязан быть полуночью, а не временем прогона. Счётчики: ```sql SELECT started_at, status, counters FROM scrape_runs WHERE source = 'listing_source_snapshot' ORDER BY id DESC LIMIT 1; ``` --- ## Тесты `tradein-mvp/backend/tests/test_2674_writers_honor_schema.py` — 22 теста. Гейты сверяют писателя **со схемой**, а не с копией его же списка: - `test_house_suggestions_insert_covers_every_declared_column` — колонки INSERT против `CREATE TABLE` в 064; - `test_event_writer_covers_every_derivable_schema_event_type` — типы против CHECK в 079, минус явный список невыводимых. И отдельный класс — гейты на то, что писатель **НЕ** пишет (иначе следующий проход «дополнит для полноты»): - `test_not_derivable_events_are_never_written` — `delisted`/`relisted` не в SQL, `is_active` не читается; - `test_ttl_path_never_claims_closed` — TTL-путь не имеет права называть протухание снятием. **Фальсификация патч-методом**, честно: | правка | красных без неё | |---|---| | фото + метрики подсказок | 6 | | статус в истории («снято»/«протухло») | 6 | | события (три типа, LEFT JOIN, NULLIF) | 4 | | вернуть `delisted` в писатель | 1 | | `'closed'` на TTL-пути | 6 | | обязательная комнатность | 2 | | `now()` вместо `date_trunc` | 1 | Прогон: `3461 passed, 9 skipped`. Единственная красная во всём репозитории — `tests/test_search_api.py::test_search_cache_hit` (401 вместо 200), **pre-existing**: ветка не трогает ни `app/api/v1/search.py`, ни `app/core/`, ни сам тест (`git diff origin/main` по этим путям пуст), падает и в изоляции. ## В чём не уверен - `rowcount` CTE-статемента задачи 2 равен числу деактивированных по построению, но проверено рассуждением + `EXPLAIN`, не живой записью. Первый прод-прогон это покажет — сверка с `counters` выше ровно для этого. - Транзакция деактивации теперь держит блокировки на двух таблицах, и порядок чередования по строкам даёт теоретическую возможность взаимной блокировки со скрейпом. Последствие безопасное — прогон падает и повторяется назавтра. Не чиню, но знаю. - `edited` намеренно молчит при изменении цены и при NULL прошлого хеша — это выбор, меняется одной строкой. - Ретроспективная чистка 8288 существующих `price_change` под новую суточную гранулярность не делалась: старые строки останутся с временем прогона. Дубли среди них возможны, но их не больше, чем было. ### Про номер миграции Изначально файл был `212_*`; пока ветка была в работе, смержился #2681 с `212_sber_index_pull_weekly.sql`. Перенумеровано в **213** (`77336d35`). Локальный гейт `test_new_files_do_not_reuse_prefix` молчал не по своей вине: он сравнивает префиксы файлов **в одном дереве**, а смерженный `212_sber` в этой ветке отсутствует. Проверено симуляцией (копия `data/sql` + stub `212_sber`): с моим `212` тест краснеет («212 уже у нового `212_sber`»), с `213` — зелёный. Кросс-ветковым реестром занятых номеров должен служить `_manifest_applied.txt`, но он отстал на 27 имён, поэтому 212 нигде не числился занятым. Манифест в этом PR не трогаю — см. отдельную задачу. Refs #2674
bot-backend added 1 commit 2026-08-05 21:32:43 +00:00
fix(tradein): писатели наконец пишут то, что обещает схема — фото подсказок, статус «снято», события объявлений (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 2m56s
43aaf91b97
Три находки одного класса из эпика: колонка есть, писатель есть, тест на писателя
зелёный — а данные не появляются. Тестами это не ловится по построению, только
сверкой с продом.

1. house_suggestions: парсер выбрасывал imageLink, а INSERT не перечислял
   image_link + area_m2/rooms/floor/total_floors. 25 055 строк с NULL во всех
   пяти колонках, ~74 дня с миграции 064. Метрики парсятся из title тем же
   _parse_title, что и у placementHistory.

2. listings_snapshots.status: 'active' у всех 394 299 строк при 55 448 реально
   неактивных объявлений. Оба места вызова с литералом 'active' честны — там
   объявление действительно видели; не писал никто ветку «снято». Теперь оба
   места деактивации пишут снимок 'closed' в ТОЙ ЖЕ транзакции: TTL-задача
   (data-modifying CTE, все 4 источника через один deactivate_stale_listings)
   и 404 из avito_detail_backfill. Дата снятия перестаёт быть догадкой.

3. listing_source_events: схема знает 5 типов, писался 1 (price_change, 8288
   строк). Дописаны ветки delisted/relisted/edited/first_seen в тот же
   set-based statement — данные для них уже лежат в снимке. JOIN → LEFT JOIN
   LATERAL, иначе first_seen недостижим по построению; план #2607 (per-row
   index point-lookup по idx_lss_source_date) сохранён, проверено EXPLAIN на
   проде. Счётчики прогона теперь по типам, все пять всегда присутствуют —
   ровно они показали бы четыре нуля из пяти.

Миграция не нужна: все колонки и CHECK уже существуют.

Тесты: tests/test_2674_writers_honor_schema.py. Гейты сверяют писателя со
СХЕМОЙ (колонки INSERT против CREATE TABLE 064, типы событий против CHECK 079),
поэтому ловят и следующую забытую колонку. Фальсификация патч-методом: без
фикса 1 — 6 красных, без фикса 2 — 6, без фикса 3 — 4.
Light1YT added 1 commit 2026-08-05 22:01:22 +00:00
fix(tradein): убрать невыводимые события, развести «снято» и «протухло» (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
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 3m1s
ab01f7cc48
Ревью PR #2682 нашло контрольную группу в наших же данных. Перепроверено
собственными запросами к проду — сходится, местами хуже заявленного.

1. delisted/relisted УБРАНЫ из писателя событий.
   Покрытие обхода за 14-18.07: domklik 99.9-100%, yandex 34-43%, cian 21-27%,
   avito 1.6-3.4%. Переходы за те же дни: domklik — снятий 1/2/0/2/4 в сутки и
   возвратов РОВНО 0 все пять суток; yandex — снятий 343-433 в сутки. Тот же
   обход, тот же день, разница только в покрытии: событие рождается тем, что
   скрейпер снова дошёл, а не тем, что объявление вернулось. Подтверждения:
   avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против
   контрольной ставки 1-4 (точность ≈4%); 4705 возвратов из 5493 за 12 дней
   (85.7%) — это 2-3.08, два дня после возобновления обхода.
   Сужение окна свежести сделало бы хуже (больше флапаний). Журнал из догадок
   хуже пустого журнала — не пишем. is_active убран из запроса целиком.
   Гейт-тест ослаблен до трёх типов + новый гейт «невыводимые НЕ пишутся».

2. TTL-путь пишет 'stale', а не 'closed'.
   Прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток
   против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая
   «дата продажи» одной датой. 'closed' остаётся только за 404: там ответила
   площадка. CHECK на колонке нет, миграция 212 обновляет только COMMENT.

3. change_time усечён до суток (date_trunc). С now() UNIQUE(source, change_time,
   type) работал только внутри прогона: второй прогон в те же сутки (2 августа
   их было два) давал дубли. Теперь заявленная идемпотентность действительно
   работает.

4. Комнатность в разборе заголовка стала необязательной: 1991 заголовок из
   25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.», обязательная группа
   роняла match и обнуляла все четыре поля. Чинит обоих писателей сразу
   (house_suggestions + house_placement_history, там 8.8% без площади).
   Студия → rooms=0 по конвенции kit'а, а не None.

Фальсификация: вернуть delisted — 1 красный; 'closed' на TTL-пути — 6;
обязательная комнатность — 2; now() вместо date_trunc — 1.
Light1YT added 1 commit 2026-08-05 22:07:21 +00:00
chore(tradein): перенумеровать миграцию 212 -> 213 (коллизия с #2681)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m55s
77336d351c
PR #2681 смержен, пока ветка была в работе, и принёс
212_sber_index_pull_weekly.sql. Номер 212 занят — беру 213 (свободен,
проверено git ls-tree по origin/main после fetch).

Почему локальный гейт молчал: test_new_files_do_not_reuse_prefix сравнивает
префиксы файлов В ОДНОМ ДЕРЕВЕ, а смерженный 212_sber в этой ветке
отсутствует. Проверено симуляцией (копия data/sql + stub 212_sber):
с моим 212 тест КРАСНЫЙ («212 уже у нового 212_sber»), с 213 — зелёный.

Кросс-ветковым реестром занятых номеров служит _manifest_applied.txt, но он
отстал на 27 имён (171, 187-188, 189-211, 213), поэтому префикс 212 нигде не
числился занятым. Про долг — отдельно, в этом PR манифест не трогаю.

Apply after в шапке обновлён на 212_sber_index_pull_weekly.sql.
bot-backend merged commit 673c02e5d6 into main 2026-08-05 22:12:30 +00:00
Sign in to join this conversation.
No reviewers
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#2682
No description provided.