Снимки источников МЕРА пишутся только при изменении — в 26–38 раз меньше строк за ночь (#2993, PR-C) #3577

Merged
bot-backend merged 2 commits from fix/snapshot-change-only into main 2026-09-17 11:23:02 +00:00
Collaborator

PR-C из четырёх по переходу listing_source_snapshots на «строку на изменение» (решение владельца 2026-08-23, развилка в #2993). Читатель уже готов: пол переобхода ищет прошлое наблюдение через LATERAL (PR-A #3056), рельсы объёма снятия стоят (PR-B #3066). Этот PR меняет писателя.

Что было

_SNAPSHOT_SQL делал INSERT … SELECT … FROM listing_sources без WHERE, то есть каждую ночь копировал все источники. Замер чтением на проде 17.09:

дата строк за сутки (сейчас) из них нет прошлого снимка отличаются от прошлого снимка по (price_rub, payload_hash, is_active, last_seen_at)
11.09 155 203 36 301 46 627
12.09 195 638 40 435 51 476
13.09 289 707 94 069 97 868
14.09 290 225 518 7 688
15.09 291 286 1 061 8 347
16.09 291 602 316 10 295
17.09 293 173 1 571 11 402

11–13.09 — всплеск первых появлений (московский объём). В обычные сутки меняются 3–4 % строк, то есть писать нужно в 26–38 раз меньше. Таблица сейчас 8,1 млн строк, 1,87 ГБ.

Что сделано

Строка пишется, только если у источника ещё нет снимка или хоть одно из четырёх значений (price_rub, is_active, last_seen_at, payload_hash) отличается от его последнего снимка:

FROM (SELECT id, price_rub, (last_seen_at > now() - ) AS is_active, last_seen_at,
             md5(raw_payload::text) AS payload_hash FROM listing_sources) cur
LEFT JOIN LATERAL (SELECT  FROM listing_source_snapshots s
                   WHERE s.listing_source_id = cur.id
                   ORDER BY s.snapshot_date DESC LIMIT 1) p ON true
WHERE p.snapshot_date IS NULL
   OR (cur.price_rub, cur.is_active, cur.last_seen_at, cur.payload_hash)
      IS DISTINCT FROM (p.price_rub, p.is_active, p.last_seen_at, p.payload_hash)

Почему именно так:

  • last_seen_at в сравнении обязателен. Пол переобхода (_revisit_floor_from_where_sql) берёт last_seen_at последнего снимка не позже якоря. Состояние на дату D = последняя строка с snapshot_date <= D, и для всех четырёх колонок оно совпадает с тем, что записала бы суточная копия. Если убрать last_seen_at из сравнения, пары «прошлое наблюдение → текущее» считались бы от старой свежести. Сколько это стоит: без last_seen_at писалось бы 1,7–4,7 тыс. строк в сутки, с ним 7,7–11,4 тыс.
  • is_active тоже в сравнении. Он зависит от now(): если last_seen_at не меняется, строка всё равно перестаёт быть активной через 7 суток. Это изменение состояния на дату, одна строка на источник, на проде 0,6–4 тыс. в сутки.
  • p включает сегодняшний снимок. Повторный прогон в те же сутки сравнивает с уже записанной сегодня строкой. Если цена вернулась к вчерашней, сегодняшняя строка перезаписывается, и на сегодня не остаётся цены, которой у источника уже нет.
  • Event-diff не менялся. Источник без сегодняшней строки совпадает с последним снимком по цене и хешу, значит ни first_seen, ни price_change, ни edited у него сработать не могли. Набор событий тот же, что при суточной копии.
  • Старые суточные снимки не трогаются: решение владельца. Компактизации истории в этом PR нет.

Время с условием (EXPLAIN ANALYZE того же отбора на проде 17.09, только SELECT): 293 602 LATERAL-поиска по PK заняли 1,0 с. Бюджет SET LOCAL statement_timeout 900 с. Сейчас весь прогон идёт 7–13 с, event-diff после правки обрабатывает 3–4 % строк вместо 100 %.

Миграция 325: только COMMENT ON TABLE listing_source_snapshots и COMMENT ON VIEW v_listing_source_price_on_date, с lock_timeout 5s. Прежние тексты «Ежедневный снимок…» и «на дату снимка» после перехода стали бы неправдой: WHERE snapshot_date = D теперь отдаёт только изменившиеся в D. Читателей у view нет: по коду grep пустой, у gendesign_reader нет грантов, в pg_stat_statements обращений нет. Схема не меняется.

Проверено, кто ещё читает таблицу: на проде по pg_depend есть только этот view, функций нет. В коде читают два места: deactivate_stale_avito._revisit_floor_from_where_sql (LATERAL, готов с PR-A) и сам писатель. Строчка max(snapshot_date) из разведки 21.08 ушла ещё в PR-A.

Тесты

Новый tests/test_2993_snapshot_change_only.py, три live-теста против настоящего Postgres, проверки по значениям:

  • test_only_changed_or_new_sources_get_a_row_today. Семь источников, по одному на каждую ветку: без изменений; без изменений при снимке 5-дневной давности и более старом, отличающемся (сравнение идёт с последним); цена; хеш; только last_seen_at; только is_active; новый. Проверяется, у кого есть строка за сегодня и с какими значениями.
  • test_events_survive_change_only_writes: события ровно price_change (2,0408 %), edited и first_seen у нужных источников.
  • test_rerun_same_day_compares_with_todays_row: при втором прогоне в те же сутки строка перезаписывается при возврате цены, появляется при изменении после первого прогона и не трогается у неизменившегося.

Прогоны (МЕРА backend, локальный postgis 16-3.4 со всеми миграциями, включая 325, применённую дважды):

  • целевые: test_2993_* test_listing_source_snapshot test_2674_writers_honor_schema test_revisit_floor_lateral_lookup test_migration_numbering test_3463_db_timeouts: 59 passed, rc=0;
  • весь сьют на живой БД: 6416 passed, 1 skipped, 4 failed. На заглушке localhost:5432/test: 6370 passed, 47 skipped, 4 failed. Эти 4 падения (test_3466_corridor_tier_a ×2, test_estimator_radius_floor ×2, 'Settings' object has no attribute 'estimate_corridor_clamp_slack') воспроизводятся на чистом origin/main a130303c в отдельном worktree (4 failed, 6 passed). К этому PR они не относятся;
  • ruff check app tests: passed; ruff format --check: passed.

Фальсификация

Копия исходника в scratchpad, три поломки по очереди, после каждой исходник восстановлен, diff -q чистый.

  1. Убрал WHERE, то есть вернул полную копию:
E             {'same': (5000000, True)} != {'same': None}
E             {'same_gap': (5000000, True)} != {'same_gap': None}
FAILED tests/test_2993_snapshot_change_only.py::test_only_changed_or_new_sources_get_a_row_today
FAILED tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row
2 failed, 1 passed
  1. Убрал last_seen_at из сравнения:
E             {'seen': None} != {'seen': (5000000, True)}
FAILED tests/test_2993_snapshot_change_only.py::test_only_changed_or_new_sources_get_a_row_today
FAILED tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row
2 failed, 1 passed
  1. Сравнение только с прошлыми сутками (s.snapshot_date < CURRENT_DATE):
E           assert (5000000, True) == (4900000, True)
FAILED tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row
1 failed, 2 passed

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

Приёмка на проде

Первый прогон после деплоя: окно 01:00–02:00 UTC, ближайший next_run_at 2026-09-18 01:22 UTC. Если деплой будет позже, даты сдвигаются на столько же.

  1. 18.09: у scrape_runs с source='listing_source_snapshot' статус done, counters.snapshotted порядка 8–15 тыс., а не ~293 тыс. Та же цифра в SELECT count(*) FROM listing_source_snapshots WHERE snapshot_date = '2026-09-18'.
  2. 18.09: события того же порядка, что 14–17.09: first_seen_events 316–1571, price_change_events 174–607, edited_events 7–66.
  3. 21–22.09: якорь CURRENT_DATE - 3 впервые попадает в change-only период. deactivate_stale_{avito,cian,domklik,yandex}.counters.floor_n_pairs не падает больше чем в 5 раз против 14–17.09 (avito 828–2208, cian 719–2935, domklik 3285–6075, yandex 2142–2598), skipped_floor_degraded нет.

Что не входит

  • Дубликат индекса idx_lss_source_date (363 МБ, те же колонки, что у PK). План на проде идёт Index Scan Backward using listing_source_snapshots_pkey. Удаление — отдельный PR с DROP INDEX CONCURRENTLY.
  • Компактизация старых суточных снимков: по решению владельца ничего не удаляется.
  • Acceptance в теле #2993 до сих пор про партиционирование, решение 23.08 в самой issue не записано. Поэтому issue не закрывается.

Refs #2993, #2659

🤖 Generated with Claude Code

PR-C из четырёх по переходу `listing_source_snapshots` на «строку на изменение» (решение владельца 2026-08-23, развилка в #2993). Читатель уже готов: пол переобхода ищет прошлое наблюдение через LATERAL (PR-A #3056), рельсы объёма снятия стоят (PR-B #3066). Этот PR меняет писателя. ## Что было `_SNAPSHOT_SQL` делал `INSERT … SELECT … FROM listing_sources` без `WHERE`, то есть каждую ночь копировал все источники. Замер чтением на проде 17.09: | дата | строк за сутки (сейчас) | из них нет прошлого снимка | отличаются от прошлого снимка по (price_rub, payload_hash, is_active, last_seen_at) | |---|---|---|---| | 11.09 | 155 203 | 36 301 | 46 627 | | 12.09 | 195 638 | 40 435 | 51 476 | | 13.09 | 289 707 | 94 069 | 97 868 | | 14.09 | 290 225 | 518 | **7 688** | | 15.09 | 291 286 | 1 061 | **8 347** | | 16.09 | 291 602 | 316 | **10 295** | | 17.09 | 293 173 | 1 571 | **11 402** | 11–13.09 — всплеск первых появлений (московский объём). В обычные сутки меняются 3–4 % строк, то есть писать нужно в 26–38 раз меньше. Таблица сейчас 8,1 млн строк, 1,87 ГБ. ## Что сделано Строка пишется, только если у источника ещё нет снимка или хоть одно из четырёх значений `(price_rub, is_active, last_seen_at, payload_hash)` отличается от его **последнего** снимка: ```sql FROM (SELECT id, price_rub, (last_seen_at > now() - …) AS is_active, last_seen_at, md5(raw_payload::text) AS payload_hash FROM listing_sources) cur LEFT JOIN LATERAL (SELECT … FROM listing_source_snapshots s WHERE s.listing_source_id = cur.id ORDER BY s.snapshot_date DESC LIMIT 1) p ON true WHERE p.snapshot_date IS NULL OR (cur.price_rub, cur.is_active, cur.last_seen_at, cur.payload_hash) IS DISTINCT FROM (p.price_rub, p.is_active, p.last_seen_at, p.payload_hash) ``` Почему именно так: - **`last_seen_at` в сравнении обязателен.** Пол переобхода (`_revisit_floor_from_where_sql`) берёт `last_seen_at` последнего снимка не позже якоря. Состояние на дату D = последняя строка с `snapshot_date <= D`, и для всех четырёх колонок оно совпадает с тем, что записала бы суточная копия. Если убрать `last_seen_at` из сравнения, пары «прошлое наблюдение → текущее» считались бы от старой свежести. Сколько это стоит: без `last_seen_at` писалось бы 1,7–4,7 тыс. строк в сутки, с ним 7,7–11,4 тыс. - **`is_active` тоже в сравнении.** Он зависит от `now()`: если `last_seen_at` не меняется, строка всё равно перестаёт быть активной через 7 суток. Это изменение состояния на дату, одна строка на источник, на проде 0,6–4 тыс. в сутки. - **`p` включает сегодняшний снимок.** Повторный прогон в те же сутки сравнивает с уже записанной сегодня строкой. Если цена вернулась к вчерашней, сегодняшняя строка перезаписывается, и на сегодня не остаётся цены, которой у источника уже нет. - **Event-diff не менялся.** Источник без сегодняшней строки совпадает с последним снимком по цене и хешу, значит ни `first_seen`, ни `price_change`, ни `edited` у него сработать не могли. Набор событий тот же, что при суточной копии. - **Старые суточные снимки не трогаются**: решение владельца. Компактизации истории в этом PR нет. **Время с условием** (EXPLAIN ANALYZE того же отбора на проде 17.09, только SELECT): 293 602 LATERAL-поиска по PK заняли **1,0 с**. Бюджет `SET LOCAL statement_timeout` 900 с. Сейчас весь прогон идёт 7–13 с, event-diff после правки обрабатывает 3–4 % строк вместо 100 %. **Миграция 325**: только `COMMENT ON TABLE listing_source_snapshots` и `COMMENT ON VIEW v_listing_source_price_on_date`, с `lock_timeout 5s`. Прежние тексты «Ежедневный снимок…» и «на дату снимка» после перехода стали бы неправдой: `WHERE snapshot_date = D` теперь отдаёт только изменившиеся в D. Читателей у view нет: по коду grep пустой, у `gendesign_reader` нет грантов, в `pg_stat_statements` обращений нет. Схема не меняется. Проверено, кто ещё читает таблицу: на проде по `pg_depend` есть только этот view, функций нет. В коде читают два места: `deactivate_stale_avito._revisit_floor_from_where_sql` (LATERAL, готов с PR-A) и сам писатель. Строчка `max(snapshot_date)` из разведки 21.08 ушла ещё в PR-A. ## Тесты Новый `tests/test_2993_snapshot_change_only.py`, три live-теста против настоящего Postgres, проверки по значениям: - `test_only_changed_or_new_sources_get_a_row_today`. Семь источников, по одному на каждую ветку: без изменений; без изменений при снимке 5-дневной давности и более старом, отличающемся (сравнение идёт с последним); цена; хеш; только `last_seen_at`; только `is_active`; новый. Проверяется, у кого есть строка за сегодня и с какими значениями. - `test_events_survive_change_only_writes`: события ровно `price_change` (2,0408 %), `edited` и `first_seen` у нужных источников. - `test_rerun_same_day_compares_with_todays_row`: при втором прогоне в те же сутки строка перезаписывается при возврате цены, появляется при изменении после первого прогона и не трогается у неизменившегося. Прогоны (МЕРА backend, локальный postgis 16-3.4 со всеми миграциями, включая 325, применённую дважды): - целевые: `test_2993_* test_listing_source_snapshot test_2674_writers_honor_schema test_revisit_floor_lateral_lookup test_migration_numbering test_3463_db_timeouts`: **59 passed**, rc=0; - весь сьют на живой БД: **6416 passed, 1 skipped, 4 failed**. На заглушке `localhost:5432/test`: **6370 passed, 47 skipped, 4 failed**. Эти 4 падения (`test_3466_corridor_tier_a` ×2, `test_estimator_radius_floor` ×2, `'Settings' object has no attribute 'estimate_corridor_clamp_slack'`) воспроизводятся на чистом `origin/main` a130303c в отдельном worktree (4 failed, 6 passed). К этому PR они не относятся; - `ruff check app tests`: passed; `ruff format --check`: passed. ## Фальсификация Копия исходника в scratchpad, три поломки по очереди, после каждой исходник восстановлен, `diff -q` чистый. 1. Убрал `WHERE`, то есть вернул полную копию: ``` E {'same': (5000000, True)} != {'same': None} E {'same_gap': (5000000, True)} != {'same_gap': None} FAILED tests/test_2993_snapshot_change_only.py::test_only_changed_or_new_sources_get_a_row_today FAILED tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row 2 failed, 1 passed ``` 2. Убрал `last_seen_at` из сравнения: ``` E {'seen': None} != {'seen': (5000000, True)} FAILED tests/test_2993_snapshot_change_only.py::test_only_changed_or_new_sources_get_a_row_today FAILED tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row 2 failed, 1 passed ``` 3. Сравнение только с прошлыми сутками (`s.snapshot_date < CURRENT_DATE`): ``` E assert (5000000, True) == (4900000, True) FAILED tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row 1 failed, 2 passed ``` Тест событий при поломке 1 остаётся зелёным, и так и должно быть: события не зависят от того, пишутся неизменившиеся строки или нет. ## Приёмка на проде Первый прогон после деплоя: окно 01:00–02:00 UTC, ближайший `next_run_at` 2026-09-18 01:22 UTC. Если деплой будет позже, даты сдвигаются на столько же. 1. **18.09**: у `scrape_runs` с `source='listing_source_snapshot'` статус `done`, `counters.snapshotted` порядка 8–15 тыс., а не ~293 тыс. Та же цифра в `SELECT count(*) FROM listing_source_snapshots WHERE snapshot_date = '2026-09-18'`. 2. **18.09**: события того же порядка, что 14–17.09: `first_seen_events` 316–1571, `price_change_events` 174–607, `edited_events` 7–66. 3. **21–22.09**: якорь `CURRENT_DATE - 3` впервые попадает в change-only период. `deactivate_stale_{avito,cian,domklik,yandex}.counters.floor_n_pairs` не падает больше чем в 5 раз против 14–17.09 (avito 828–2208, cian 719–2935, domklik 3285–6075, yandex 2142–2598), `skipped_floor_degraded` нет. ## Что не входит - Дубликат индекса `idx_lss_source_date` (363 МБ, те же колонки, что у PK). План на проде идёт `Index Scan Backward using listing_source_snapshots_pkey`. Удаление — отдельный PR с `DROP INDEX CONCURRENTLY`. - Компактизация старых суточных снимков: по решению владельца ничего не удаляется. - Acceptance в теле #2993 до сих пор про партиционирование, решение 23.08 в самой issue не записано. Поэтому issue не закрывается. Refs #2993, #2659 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 09:54:24 +00:00
fix(tradein/snapshot): снимок источника пишется только при изменении (#2993, PR-C)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 26s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 31s
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) Failing after 7m37s
e4d44025e5
listing_source_snapshot каждую ночь копировал listing_sources целиком: прод
14-17.09 — 290-293 тыс. строк в сутки, из них отличались от предыдущего
снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23 — строка на
изменение; читатель (пол переобхода, PR-A #3056) и рельсы объёма (PR-B
#3066) уже смержены.

Строка пишется, если снимка ещё нет или (price_rub, is_active, last_seen_at,
payload_hash) отличается от последнего снимка источника, включая сегодняшний
(повторный прогон в те же сутки перезаписывает строку, только если значение
снова сдвинулось). Состояние на дату D = последняя строка с snapshot_date <= D
— для всех четырёх колонок то же, что дала бы суточная копия, поэтому пол
переобхода и события не меняются. Старые суточные снимки не трогаются.

Замер чтением на проде 17.09: отбор с условием — 1.0 с на 293 602 источника
(бюджет 900 с). Миграция 325 — только COMMENT на таблицу и view: прежние
«ежедневный снимок»/«на дату снимка» стали бы неправдой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 10:50:09 +00:00
Merge remote-tracking branch 'origin/main' into fix/snapshot-change-only
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 8m31s
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 / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 31s
c14119a20b
bot-backend merged commit 5b6be30894 into main 2026-09-17 11:23:02 +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#3577
No description provided.