fix(tradein): писатели наконец пишут то, что обещает схема — фото подсказок, статус «снято», события объявлений (#2674) #2682
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2682
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2674-writers-honor-schema"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Три находки из #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: «студия» и «комнатность неизвестна» — разные факты.Как проверить на проде:
Было
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):Было ровно
active | …. Ожидается строкаstale(иclosed, когда 404-путь что-то найдёт). Числоstaleза дату должно сойтись с суммойcounters->>'deactivated'прогоновdeactivate_stale_*за тот же день:(Прежний вариант сверки — «снимок закрыт, а флаг активен → 0» — из описания убран: он бы не дал ноль. Обход выставляет
is_activeобратно, а снимок за тот день остаётся навсегда; плюс при неизменившемся хеше карточки запись снимка пропускается. Спасибо за ловлю.)3. Журнал событий объявлений: три типа из пяти, а не пять
Сегодня:
listing_source_events— 8288 строк, всеprice_change. Остальные четыре — ноль за всё время.Изначально в PR были дописаны все четыре. Ревью показало, что два из них — неправда, и это подтвердилось моими собственными запросами, местами жёстче заявленного.
is_activeв снимке — derived-признак «last_seen_atсвежее 7 дней», то есть «мы видели», а не «есть на площадке». Контрольная группа в наших же данных, 14–18.07 — один и тот же ежедневный обход, разница только в покрытии:При полном покрытии возвратов ровно ноль за все пять суток, а реальный отток — 1–4 объявления в сутки на 6456 источников. Событие рождается не тем, что объявление вернулось, а тем, что скрейпер снова до него дошёл.
Подтверждения на всплесках:
Сузить окно свежести не помогло бы — стало бы хуже (больше флапаний); окно шире максимального интервала повторного визита обессмысливает само событие. Убрал обе ветки и оба счётчика.
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 августа таких прогонов было два. Теперь заявленная идемпотентность действительно работает, а суточная гранулярность честнее для суточного же сравнения.План не деградировал (регрессия #2607):
EXPLAINпрогнан на живом проде дважды, до и после правок ревью —Index Scan using idx_lss_source_date+Limitвнутри LATERAL на месте.Как проверить на проде — после прогона
listing_source_snapshot(01:00–02:00 UTC):Было одна строка
price_change | 8288. Ожидаются три строки;delisted/relistedне должны появиться никогда.max(change_time)обязан быть полуночью, а не временем прогона. Счётчики:Тесты
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-путь не имеет права называть протухание снятием.Фальсификация патч-методом, честно:
delistedв писатель'closed'на TTL-путиnow()вместоdate_truncПрогон:
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по этим путям пуст), падает и в изоляции.В чём не уверен
rowcountCTE-статемента задачи 2 равен числу деактивированных по построению, но проверено рассуждением +EXPLAIN, не живой записью. Первый прод-прогон это покажет — сверка сcountersвыше ровно для этого.editedнамеренно молчит при изменении цены и при NULL прошлого хеша — это выбор, меняется одной строкой.price_changeпод новую суточную гранулярность не делалась: старые строки останутся с временем прогона. Дубли среди них возможны, но их не больше, чем было.Про номер миграции
Изначально файл был
212_*; пока ветка была в работе, смержился #2681 с212_sber_index_pull_weekly.sql. Перенумеровано в 213 (77336d35).Локальный гейт
test_new_files_do_not_reuse_prefixмолчал не по своей вине: он сравнивает префиксы файлов в одном дереве, а смерженный212_sberв этой ветке отсутствует. Проверено симуляцией (копияdata/sql+ stub212_sber): с моим212тест краснеет («212 уже у нового212_sber»), с213— зелёный. Кросс-ветковым реестром занятых номеров должен служить_manifest_applied.txt, но он отстал на 27 имён, поэтому 212 нигде не числился занятым. Манифест в этом PR не трогаю — см. отдельную задачу.Refs #2674