fix(tradein): апсерт не переписывает неизменившуюся строку — гейт IS DISTINCT FROM + МСК-день (#2992) #3016

Merged
bot-backend merged 1 commit from fix/2992-upsert-distinct-gate into main 2026-08-21 09:08:06 +00:00
Collaborator

Что было

ON CONFLICT … DO UPDATE SET в апсерте listings безусловно присваивал 41 колонку при каждом повторном скрейпе, включая description = COALESCE(...) — заново тостил текст и плодил TOAST-чанки. listing_sources — тот же анти-паттерн.

Замер прода 20.08 (тело задачи) и мой контроль 21.08 08:37 UTC совпали:

listings:         n_tup_upd 20 866 205 · HOT 0.44 % · table 17 GB · TOAST 15 GB · idx 1.5 GB
listing_sources:  n_tup_upd 10 265 108 · HOT 0.10 % · 268 MB
WAL LSN на момент замера: A2/EB45510

Гейт — два условия, при любом апдейт идёт

WHERE (<41 текущая колонка>) IS DISTINCT FROM (<те же 41 post-COALESCE>)
   OR (listings.last_seen_at AT TIME ZONE 'Europe/Moscow')::date
      IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date
  1. Контент изменился — сравнение по кортежу итоговых (post-COALESCE) значений, NULL-safe. Именно поэтому COALESCE-дозаполнение (адрес, город, сегмент…) по-прежнему проходит: если дописал — строка отличается. Гейт по card_hash был забракован за то, что ломает дозаполнение.
  2. last_seen_at ещё не сегодняшний по МСК — метка живости обязана сдвигаться хотя бы раз в сутки: деактиватор (TTL), снапшоты (is_active = last_seen_at > now()-7d), эстиматор (scraped_at > NOW()-14d, #2206), монитор. Это семантика уже существующего skip_seen_today, но в SQL, race-free и для всех 18 путей save_listings, а не трёх full_load, к которым skip_seen_today подключён (в этом и была причина 20 млн).

При пропуске RETURNING пуст → listing_id берётся из pre-read (в SELECT добавлен id), downstream — снапшот, матчинг, listing_sources — идёт как прежде. listing_sources получает симметричный гейт с тем же МСК-критерием — иначе разъехались бы listings.last_seen_at и listing_sources.last_seen_at, равные сегодня у 100 % пар.

Как проверено — на живом Postgres, двусторонне

test_2992_upsert_unchanged_gate.py — через реальный save_listings / upsert_listing_source к tradein-postgres (туннель; в CI Trade-In — postgres-сервис; без БД skip с причиной в allowlist). Свои строки t2992-* удаляет в finally.

Измеритель — ctid строки. Первая редакция мерила pg_stat_xact_user_tables.n_tup_upd и давала «0 == 0» тавтологией: save_listings коммитит, транзакционные счётчики сбрасываются. Поймано на контроле «цена изменилась», где счётчик тоже показал 0. UPDATE всегда создаёт новую версию → ctid меняется; пропуск → тот же.

головной:  повторный скрейп в тот же день → ctid тот же, (0,0), last_seen_at не сдвинут
           против origin/main — КРАСНЫЙ по значению: «переписал строку: ctid (16710,4)→(16710,6)»
контроли (зелёные с обеих сторон):
  цена изменилась                        → переписана
  COALESCE-дозаполнение города           → переписана   (город — параметр save_listings, не поле лота)
  last_seen_at вчерашний, контент тот же → переписана   (живость)
  при пропуске матчинг всё равно вызван  → listing_id не потерян (лоту дан адрес — иначе матчинг не зовётся вовсе)
  listing_sources: повтор → ctid тот же; смена цены → переписана

Два контроля в первой редакции были сконструированы не через тот путь (город полем лота; лот без адреса — матчинг не зовётся и в первый раз) и краснели по своей вине, не по вине гейта — оба исправлены с проверкой «первый вызов проходит».

pytest tradein-mvp/backend4655 passed, 29 skipped, rc=0 (шесть live-тестов в allowlist).

Что НЕ измерено и будет измерено — с датой

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

Известное ограничение: реконcile-ветка (drift dedup_hash, прямой UPDATE … WHERE source, source_id) гейта не имеет — она редкая и по построению меняет строку.

Часть #2992.

## Что было `ON CONFLICT … DO UPDATE SET` в апсерте `listings` безусловно присваивал **41 колонку** при каждом повторном скрейпе, включая `description = COALESCE(...)` — заново тостил текст и плодил TOAST-чанки. `listing_sources` — тот же анти-паттерн. Замер прода 20.08 (тело задачи) и мой контроль 21.08 08:37 UTC совпали: ``` listings: n_tup_upd 20 866 205 · HOT 0.44 % · table 17 GB · TOAST 15 GB · idx 1.5 GB listing_sources: n_tup_upd 10 265 108 · HOT 0.10 % · 268 MB WAL LSN на момент замера: A2/EB45510 ``` ## Гейт — два условия, при любом апдейт идёт ```sql WHERE (<41 текущая колонка>) IS DISTINCT FROM (<те же 41 post-COALESCE>) OR (listings.last_seen_at AT TIME ZONE 'Europe/Moscow')::date IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date ``` 1. **Контент изменился** — сравнение по кортежу **итоговых** (post-COALESCE) значений, NULL-safe. Именно поэтому COALESCE-дозаполнение (адрес, город, сегмент…) по-прежнему проходит: если дописал — строка отличается. Гейт по `card_hash` был забракован за то, что ломает дозаполнение. 2. **`last_seen_at` ещё не сегодняшний по МСК** — метка живости обязана сдвигаться хотя бы раз в сутки: деактиватор (TTL), снапшоты (`is_active = last_seen_at > now()-7d`), эстиматор (`scraped_at > NOW()-14d`, #2206), монитор. Это семантика уже существующего `skip_seen_today`, но в SQL, race-free и для **всех 18** путей `save_listings`, а не трёх `full_load`, к которым `skip_seen_today` подключён (в этом и была причина 20 млн). При пропуске RETURNING пуст → `listing_id` берётся из pre-read (в SELECT добавлен `id`), downstream — снапшот, матчинг, `listing_sources` — идёт как прежде. `listing_sources` получает **симметричный** гейт с тем же МСК-критерием — иначе разъехались бы `listings.last_seen_at` и `listing_sources.last_seen_at`, равные сегодня у 100 % пар. ## Как проверено — на живом Postgres, двусторонне `test_2992_upsert_unchanged_gate.py` — через **реальный** `save_listings` / `upsert_listing_source` к tradein-postgres (туннель; в CI Trade-In — postgres-сервис; без БД skip с причиной в allowlist). Свои строки `t2992-*` удаляет в `finally`. **Измеритель — `ctid` строки.** Первая редакция мерила `pg_stat_xact_user_tables.n_tup_upd` и давала «0 == 0» **тавтологией**: `save_listings` коммитит, транзакционные счётчики сбрасываются. Поймано на контроле «цена изменилась», где счётчик тоже показал 0. UPDATE всегда создаёт новую версию → `ctid` меняется; пропуск → тот же. ``` головной: повторный скрейп в тот же день → ctid тот же, (0,0), last_seen_at не сдвинут против origin/main — КРАСНЫЙ по значению: «переписал строку: ctid (16710,4)→(16710,6)» контроли (зелёные с обеих сторон): цена изменилась → переписана COALESCE-дозаполнение города → переписана (город — параметр save_listings, не поле лота) last_seen_at вчерашний, контент тот же → переписана (живость) при пропуске матчинг всё равно вызван → listing_id не потерян (лоту дан адрес — иначе матчинг не зовётся вовсе) listing_sources: повтор → ctid тот же; смена цены → переписана ``` Два контроля в первой редакции были сконструированы не через тот путь (город полем лота; лот без адреса — матчинг не зовётся и в первый раз) и краснели по своей вине, не по вине гейта — оба исправлены с проверкой «первый вызов проходит». `pytest tradein-mvp/backend` — **4655 passed**, 29 skipped, rc=0 (шесть live-тестов в allowlist). ## Что НЕ измерено и будет измерено — с датой Эффект в числах: `n_tup_upd`/сутки и WAL/сутки **после** деплоя против зафиксированного «до». Замер — **22.08 ~09:00 UTC** (сутки после). Ожидание из модели: потолок экономии — повторные заходы в один МСК-день; за 10 минут утром 150 строк трогалось при `n_tup_upd` без движения, так что суточная картина — единственный честный срез. Известное ограничение: реконcile-ветка (drift `dedup_hash`, прямой `UPDATE … WHERE source, source_id`) гейта не имеет — она редкая и по построению меняет строку. Часть #2992.
bot-backend added 1 commit 2026-08-21 08:58:07 +00:00
fix(tradein): апсерт не переписывает неизменившуюся строку — гейт IS DISTINCT FROM + МСК-день (#2992)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
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 4m15s
6cdf56820d
(полное описание — в PR)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit 95d348f3c3 into main 2026-08-21 09:08:06 +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#3016
No description provided.