Апсерт переписывает 41 колонку безусловно — корень 15 ГБ раздутости и 7 ГБ WAL в сутки #2992

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

Эпик: #2989 · Окупается ещё до переезда.

Находка

packages/scraper-kit/src/scraper_kit/base.py:577-683 — в ON CONFLICT … DO UPDATE SET безусловно присваивается 41 колонка, включая description = COALESCE(...). Каждый повторный скрейп неизменившегося объявления заново тостит текст и плодит новые TOAST-чанки.

Замеры с прода 2026-08-20:

  • n_tup_upd = 20 857 049 на 105 276 строк — 198 апдейтов на строку за 91 день
  • HOT-обновлений 0,43 % (89 932) — по двум причинам сразу: fillfactor = 100 и индексы на самих меняющихся колонках
  • TOAST 15 ГБ при реальном содержимом toast-колонок ~2,2 КБ на строку → ~230 МБ на все 105 тыс.
  • WAL 7,02 ГБ/сутки при ~4 пользовательских расчётах в сутки

Косвенное подтверждение: дамп базы в gzip весит 285 МБ.

Ирония в том, что детектор изменений уже есть рядом: card_hash вычисляется в base.py:426 и используется в base.py:826. Гейта в самом апсерте просто нет.

Что делать

Гейт не по card_hash — проверяющий забраковал этот вариант, он ломает дозаполнение через COALESCE. Рабочий вариант — по фактическому результату строки:

ON CONFLICT (src, src_ext_id) DO UPDATE SET 
WHERE (listings.col1, , listings.colN)
   IS DISTINCT FROM (<итоговые post-COALESCE значения>);

Это одновременно race-free и не мешает COALESCE реально что-то дописать: если дописал — строка отличается, апдейт проходит.

Не забыть вторую таблицу

listing_sources — тот же анти-паттерн и вторая по нагрузке таблица базы: 10,4 млн апдейтов на 180 016 строк, 360 МБ, 16 372 автовакуума. backend/app/services/matching/listings.py:273-289 — такой же безусловный SET без гейта. Если починить только listings, половина записи останется как была.

Acceptance

  • Гейт IS DISTINCT FROM в апсерте listings (base.py:577)
  • Тот же гейт в listing_sources (matching/listings.py:273)
  • Замер n_tup_upd и суточного WAL до и после — ожидание кратного падения
  • Тест: повторный скрейп неизменившегося объявления не увеличивает n_tup_upd

Scope: packages/scraper-kit/src/scraper_kit/base.py, backend/app/services/matching/listings.py.

Эпик: #2989 · **Окупается ещё до переезда.** ## Находка `packages/scraper-kit/src/scraper_kit/base.py:577-683` — в `ON CONFLICT … DO UPDATE SET` безусловно присваивается **41 колонка**, включая `description = COALESCE(...)`. Каждый повторный скрейп неизменившегося объявления заново тостит текст и плодит новые TOAST-чанки. Замеры с прода 2026-08-20: - `n_tup_upd` = **20 857 049** на 105 276 строк — 198 апдейтов на строку за 91 день - HOT-обновлений **0,43 %** (89 932) — по двум причинам сразу: `fillfactor` = 100 и индексы на самих меняющихся колонках - TOAST **15 ГБ** при реальном содержимом toast-колонок ~2,2 КБ на строку → ~230 МБ на все 105 тыс. - WAL **7,02 ГБ/сутки** при ~4 пользовательских расчётах в сутки Косвенное подтверждение: дамп базы в gzip весит **285 МБ**. Ирония в том, что детектор изменений уже есть рядом: `card_hash` вычисляется в `base.py:426` и используется в `base.py:826`. Гейта в самом апсерте просто нет. ## Что делать Гейт **не по `card_hash`** — проверяющий забраковал этот вариант, он ломает дозаполнение через `COALESCE`. Рабочий вариант — по фактическому результату строки: ```sql ON CONFLICT (src, src_ext_id) DO UPDATE SET … WHERE (listings.col1, …, listings.colN) IS DISTINCT FROM (<итоговые post-COALESCE значения>); ``` Это одновременно race-free и не мешает `COALESCE` реально что-то дописать: если дописал — строка отличается, апдейт проходит. ## Не забыть вторую таблицу `listing_sources` — тот же анти-паттерн и вторая по нагрузке таблица базы: **10,4 млн апдейтов** на 180 016 строк, 360 МБ, 16 372 автовакуума. `backend/app/services/matching/listings.py:273-289` — такой же безусловный `SET` без гейта. Если починить только `listings`, половина записи останется как была. ## Acceptance - [ ] Гейт `IS DISTINCT FROM` в апсерте `listings` (`base.py:577`) - [ ] Тот же гейт в `listing_sources` (`matching/listings.py:273`) - [ ] Замер `n_tup_upd` и суточного WAL до и после — ожидание кратного падения - [ ] Тест: повторный скрейп неизменившегося объявления не увеличивает `n_tup_upd` Scope: `packages/scraper-kit/src/scraper_kit/base.py`, `backend/app/services/matching/listings.py`.
lekss361 added the
bug
performance
priority/p1
scope/backend
scrapers
tradein
labels 2026-08-20 17:50:53 +00:00
Author
Owner

Дополнение из волта — два факта, которые упрощают задачу исполнителю.

Приём уже обкатан в этом же репозитории

Аудит audits/Mera_Hard_Audit_0712.md, пункт #2496: «ON CONFLICT DO UPDATE сырых фактов (enrichment lat/lon/geom/cadastr сохранён, WHERE IS DISTINCT FROM). 221 тест, live-verified».

То есть WHERE ... IS DISTINCT FROM в апсерте — не новая техника для проекта, а уже применённый и проверенный на проде паттерн. Задача сводится к тому, чтобы распространить его с ingest сырых фактов на главный путь записи объявлений. Стоит посмотреть, как именно это сделано в #2496, и повторить форму.

Частичная оптимизация уже существует — и понятно, почему её не хватило

code/modules/Module_Tradein_Skip_Seen_Today.md (июнь 2026, PR feat/tradein-skip-seen-today): в save_listings добавлен параметр skip_seen_today, конфиг SCRAPER_SKIP_SEEN_TODAY=true по умолчанию. Повторный проход в тот же день пропускает объявление, если last_seen_at уже сегодняшний по МСК — без апсерта и без снапшота. Для этого там же читается card_hash.

Два ограничения объясняют, почему 20,86 млн апдейтов остались:

  1. Подключено только к трём full_load — cian (~2094), yandex (~2318), avito (~2511). В заметке прямо перечислено, что НЕ затронуты: city_sweep, newbuilding_sweep, detail_backfill, domclick. А городские свипы ходят ежедневно и дают основной поток записи.
  2. Гранулярность — сутки, а не содержимое. Первое касание за день переписывает все 41 колонку целиком, даже если объявление не менялось неделю.

Что из этого следует для реализации

  • Гейт IS DISTINCT FROM работает на уровень ниже skip_seen_today и покрывает все пути разом, включая те четыре, что остались без оптимизации. После него skip_seen_today можно оставить как дешёвый ранний выход, но он перестаёт быть единственной защитой.
  • Путь в коде сместился после миграции на scraper_kit: заметка описывает app/services/scrapers/base.py, актуальный файл — packages/scraper-kit/src/scraper_kit/base.py:577-683. Каталог tradein-mvp/backend/app/services/scrapers сейчас пуст.
  • Проверять эффект надо не по логам джобы, а по n_tup_upd и n_tup_hot_upd в pg_stat_user_tables и по суточному приросту WAL. Замер «до» есть в теле issue.

И про порядок с repack

VACUUM не возвращает место операционной системе — чтобы забрать 15 ГБ, нужен VACUUM FULL или pg_repack. Но при текущем паттерне мусор возвращается примерно за 45 суток (~340 МБ в сутки), поэтому repack до фикса бессмысленен.

С учётом #2989 repack, вероятно, не понадобится вовсе: перезаливка при переезде даёт компактную таблицу побочным эффектом. Условие одно — апсерт должен быть починен до миграции, иначе дефект переедет вместе с данными.

Дополнение из волта — два факта, которые упрощают задачу исполнителю. ## Приём уже обкатан в этом же репозитории Аудит `audits/Mera_Hard_Audit_0712.md`, пункт **#2496**: «`ON CONFLICT DO UPDATE` сырых фактов (enrichment lat/lon/geom/cadastr сохранён, **`WHERE IS DISTINCT FROM`**). 221 тест, live-verified». То есть `WHERE ... IS DISTINCT FROM` в апсерте — не новая техника для проекта, а **уже применённый и проверенный на проде паттерн**. Задача сводится к тому, чтобы распространить его с ingest сырых фактов на главный путь записи объявлений. Стоит посмотреть, как именно это сделано в #2496, и повторить форму. ## Частичная оптимизация уже существует — и понятно, почему её не хватило `code/modules/Module_Tradein_Skip_Seen_Today.md` (июнь 2026, PR `feat/tradein-skip-seen-today`): в `save_listings` добавлен параметр `skip_seen_today`, конфиг `SCRAPER_SKIP_SEEN_TODAY=true` по умолчанию. Повторный проход в тот же день пропускает объявление, если `last_seen_at` уже сегодняшний по МСК — без апсерта и без снапшота. Для этого там же читается `card_hash`. Два ограничения объясняют, почему 20,86 млн апдейтов остались: 1. **Подключено только к трём `full_load`** — cian (~2094), yandex (~2318), avito (~2511). В заметке прямо перечислено, что **НЕ затронуты: `city_sweep`, `newbuilding_sweep`, `detail_backfill`, `domclick`**. А городские свипы ходят ежедневно и дают основной поток записи. 2. **Гранулярность — сутки, а не содержимое.** Первое касание за день переписывает все 41 колонку целиком, даже если объявление не менялось неделю. ## Что из этого следует для реализации - Гейт `IS DISTINCT FROM` работает **на уровень ниже** `skip_seen_today` и покрывает все пути разом, включая те четыре, что остались без оптимизации. После него `skip_seen_today` можно оставить как дешёвый ранний выход, но он перестаёт быть единственной защитой. - Путь в коде сместился после миграции на scraper_kit: заметка описывает `app/services/scrapers/base.py`, актуальный файл — `packages/scraper-kit/src/scraper_kit/base.py:577-683`. Каталог `tradein-mvp/backend/app/services/scrapers` сейчас пуст. - Проверять эффект надо не по логам джобы, а по `n_tup_upd` и `n_tup_hot_upd` в `pg_stat_user_tables` и по суточному приросту WAL. Замер «до» есть в теле issue. ## И про порядок с repack `VACUUM` не возвращает место операционной системе — чтобы забрать 15 ГБ, нужен `VACUUM FULL` или `pg_repack`. Но при текущем паттерне мусор возвращается примерно за 45 суток (~340 МБ в сутки), поэтому repack до фикса бессмысленен. С учётом #2989 repack, вероятно, не понадобится вовсе: перезаливка при переезде даёт компактную таблицу побочным эффектом. Условие одно — апсерт должен быть починен **до** миграции, иначе дефект переедет вместе с данными.
Collaborator

PR #3016 открыт; замер «до» зафиксирован, «после» — с датой

Гейт: WHERE (<41 колонка>) IS DISTINCT FROM (<post-COALESCE>) OR last_seen_at не сегодняшний по МСК — в обеих таблицах, симметрично. Второе условие обязательно: без него замерла бы метка живости, на которую завязаны деактиватор, снапшоты (is_active = last_seen_at > now()-7d), эстиматор (#2206) и монитор. Это семантика skip_seen_today, но в SQL и для всех 18 путей записи, а не трёх full_load — в этом и была причина 20 млн.

Замер «до» (21.08 08:37:32 UTC, прод):

listings:         n_tup_upd 20 866 205 · n_tup_hot_upd 91 307 (0.44 %) · TOAST 15 GB · idx 1.5 GB
listing_sources:  n_tup_upd 10 265 108 · HOT 0.10 % · 268 MB
WAL LSN:          A2/EB45510

«После» — 22.08 ~09:00 UTC (сутки после деплоя): Δn_tup_upd за сутки по обеим таблицам и Δ WAL по LSN. Ожидание — кратное падение; точное число не обещаю: потолок экономии — повторные заходы в один МСК-день, а их долю я оценить заранее не смог (утром за 10 минут трогалось 150 строк при стоящем n_tup_upd; суточный срез — единственный честный).

Проверено двусторонне на живом Postgres через реальный save_listings/upsert_listing_source, измеритель — ctid (первая редакция мерила pg_stat_xact_* и была тавтологией: save_listings коммитит). Против origin/main головной тест красный по значению («переписал строку: ctid (16710,4)→(16710,6)»); контроли — изменение цены, COALESCE-дозаполнение, суточная живость, сохранение listing_id для матчинга, listing_sources — зелёные с обеих сторон.

По acceptance: гейт listings ✔ · гейт listing_sources ✔ · тест «повторный скрейп не увеличивает апдейты» ✔ (по ctid, см. почему не по n_tup_upd) · замер до/после — «до» есть, «после» 22.08.

## PR #3016 открыт; замер «до» зафиксирован, «после» — с датой Гейт: `WHERE (<41 колонка>) IS DISTINCT FROM (<post-COALESCE>) OR last_seen_at не сегодняшний по МСК` — в обеих таблицах, симметрично. Второе условие обязательно: без него замерла бы метка живости, на которую завязаны деактиватор, снапшоты (`is_active = last_seen_at > now()-7d`), эстиматор (#2206) и монитор. Это семантика `skip_seen_today`, но в SQL и для всех 18 путей записи, а не трёх `full_load` — в этом и была причина 20 млн. **Замер «до» (21.08 08:37:32 UTC, прод):** ``` listings: n_tup_upd 20 866 205 · n_tup_hot_upd 91 307 (0.44 %) · TOAST 15 GB · idx 1.5 GB listing_sources: n_tup_upd 10 265 108 · HOT 0.10 % · 268 MB WAL LSN: A2/EB45510 ``` **«После» — 22.08 ~09:00 UTC** (сутки после деплоя): Δ`n_tup_upd` за сутки по обеим таблицам и Δ WAL по LSN. Ожидание — кратное падение; точное число не обещаю: потолок экономии — повторные заходы в один МСК-день, а их долю я оценить заранее не смог (утром за 10 минут трогалось 150 строк при стоящем `n_tup_upd`; суточный срез — единственный честный). **Проверено двусторонне на живом Postgres** через реальный `save_listings`/`upsert_listing_source`, измеритель — `ctid` (первая редакция мерила `pg_stat_xact_*` и была тавтологией: `save_listings` коммитит). Против `origin/main` головной тест красный по значению («переписал строку: ctid (16710,4)→(16710,6)»); контроли — изменение цены, COALESCE-дозаполнение, суточная живость, сохранение `listing_id` для матчинга, `listing_sources` — зелёные с обеих сторон. По acceptance: гейт `listings` ✔ · гейт `listing_sources` ✔ · тест «повторный скрейп не увеличивает апдейты» ✔ (по `ctid`, см. почему не по `n_tup_upd`) · замер до/после — «до» есть, «после» 22.08.
Author
Owner

Закрыто — гейт уже в main

Проверено против forgejo/main (не по тексту PR, а по коду):

1. tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py (~578-760) — ON CONFLICT (dedup_hash) DO UPDATE SET получил WHERE (...) IS DISTINCT FROM (...). Ключевое: сравнение идёт по итоговым, post-COALESCE значениям, а не по EXCLUDED и не по card_hash — то есть дозаполнение (адрес, город, сегмент, кухня, потолки и т.д.) по-прежнему проходит. Это ровно то, чего требовал адверсариальный разбор.

2. tradein-mvp/backend/app/services/matching/listings.py (267-310) — симметричный гейт для listing_sources. Без него listings.last_seen_at и listing_sources.last_seen_at разъехались бы, а сегодня они равны у 100% пар.

Пульс решён точнее, чем в постановке. Issue требовал, чтобы last_seen_at/scraped_at/is_active обновлялись всегда. В реализации присваивание в SET безусловное, а гейт дополнен OR (last_seen_at AT TIME ZONE 'Europe/Moscow')::date IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date — метка живости гарантированно сдвигается минимум раз в МСК-сутки, но не на каждом скрейпе. На неё завязаны деактиватор (TTL), снапшоты, эстиматор (scraped_at > NOW()-14d, #2206) и монитор свежести — все они работают на суточной гранулярности, так что семантика сохранена, а churn убран. is_active при этом входит в список сравнения, поэтому реактивация false → true триггерит апдейт немедленно и не ждёт смены суток.

Тесты: tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py — неизменённый рескрейп в тот же день не трогает строку; изменившаяся цена проходит; дозаполнение через COALESCE проходит; на следующие сутки пульс сдвигается даже без изменений; пропущенная строка всё равно отдаёт listing_id для downstream; симметричный кейс для listing_sources.

Влито merge-коммитом 95d348f3 — «апсерт не переписывает неизменившуюся строку — гейт IS DISTINCT FROM + МСК-день (#2992)», подтверждённый ancestor main.

Что осталось замерить после деплоя (не блокирует закрытие): n_tup_upd, доля HOT и суточный WAL до/после. Ожидание из аудита — WAL с 7,02 ГБ/сутки к целевым 1,5-3 ГБ, HOT заметно выше нынешних 0,43%.

Refs #2989

## Закрыто — гейт уже в `main` Проверено против `forgejo/main` (не по тексту PR, а по коду): **1. `tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py`** (~578-760) — `ON CONFLICT (dedup_hash) DO UPDATE SET` получил `WHERE (...) IS DISTINCT FROM (...)`. Ключевое: сравнение идёт по **итоговым, post-`COALESCE` значениям**, а не по `EXCLUDED` и не по `card_hash` — то есть дозаполнение (адрес, город, сегмент, кухня, потолки и т.д.) по-прежнему проходит. Это ровно то, чего требовал адверсариальный разбор. **2. `tradein-mvp/backend/app/services/matching/listings.py`** (267-310) — симметричный гейт для `listing_sources`. Без него `listings.last_seen_at` и `listing_sources.last_seen_at` разъехались бы, а сегодня они равны у 100% пар. **Пульс решён точнее, чем в постановке.** Issue требовал, чтобы `last_seen_at`/`scraped_at`/`is_active` обновлялись всегда. В реализации присваивание в `SET` безусловное, а гейт дополнен `OR (last_seen_at AT TIME ZONE 'Europe/Moscow')::date IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date` — метка живости гарантированно сдвигается **минимум раз в МСК-сутки**, но не на каждом скрейпе. На неё завязаны деактиватор (TTL), снапшоты, эстиматор (`scraped_at > NOW()-14d`, #2206) и монитор свежести — все они работают на суточной гранулярности, так что семантика сохранена, а churn убран. `is_active` при этом входит в список сравнения, поэтому реактивация `false → true` триггерит апдейт немедленно и не ждёт смены суток. **Тесты:** `tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py` — неизменённый рескрейп в тот же день не трогает строку; изменившаяся цена проходит; дозаполнение через `COALESCE` проходит; на следующие сутки пульс сдвигается даже без изменений; пропущенная строка всё равно отдаёт `listing_id` для downstream; симметричный кейс для `listing_sources`. Влито merge-коммитом `95d348f3` — «апсерт не переписывает неизменившуюся строку — гейт IS DISTINCT FROM + МСК-день (#2992)», подтверждённый ancestor `main`. **Что осталось замерить после деплоя** (не блокирует закрытие): `n_tup_upd`, доля HOT и суточный WAL до/после. Ожидание из аудита — WAL с 7,02 ГБ/сутки к целевым 1,5-3 ГБ, HOT заметно выше нынешних 0,43%. Refs #2989
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#2992
No description provided.