listing_source_snapshot пишет полную копию всех источников каждые сутки — 97,6% строк дубли по построению #2993

Open
opened 2026-08-20 17:48:35 +00:00 by lekss361 · 2 comments
Owner

Эпик: #2989

Находка

backend/app/tasks/listing_source_snapshot.py:82-104 делает INSERT … SELECT id, CURRENT_DATE, … FROM listing_sources без единого WHERE. Каждые сутки копируется состояние всех источников независимо от того, обходили их или нет.

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

  • За 20 августа записано 101 795 строк при 101 999 строках в listing_sources — ровно полная копия
  • За последние 7 суток: 789 113 строк, из них 670 602 побайтово совпадают со вчерашней по price_rub, payload_hash, is_active. Это 85 % всех строк и ~97,6 % тех, у которых есть предыдущая
  • Таблица уже 4 568 773 строки / 947 МБ, run-rate ~2,7 млн строк в месяц

На московском объёме это ~673 тыс. строк в сутки = 20,2 млн в месяц (выше исходной оценки 13–18 млн, потому что объём привязан не к обходу, а к числу источников), 4,2 ГБ в месяц и около 6 ГБ WAL в сутки с одной этой таблицы.

Что делать — с оговоркой

Соблазнительный вариант «строка на изменение вместо строки на день» убирает 70× объёма, но ломает защиту от массовой ложной деактивации: реконсилятор живости опирается на факт ежедневного подтверждения. Менять модель можно только вместе с задачей про реконсилятор.

Безопасный первый шаг — партиционирование по месяцу с retention через DROP PARTITION вместо DELETE.

Подводные камни, которые надо учесть заранее:

  • pg_partman / pg_cron / timescaledb в образе отсутствуют (pg_available_extensions вернул ноль строк) — механику либо тащить в образ, либо делать на существующем планировщике scrape_schedules, что для одного человека дешевле
  • Без предсозданных будущих партиций первая же вставка падает с «no partition of relation found»
  • idx_lss_source_date (171 МБ) — почти точный дубликат PK (listing_source_id, snapshot_date); при партиционировании он тиражируется на каждую партицию
  • Запрос «последний снимок до сегодня» (listing_source_snapshot.py:200-206) не имеет нижней границы по дате — без неё pruning не сработает

Acceptance

  • Партиционирование по RANGE(snapshot_date) помесячно, партиции на 3 месяца вперёд
  • Retention через DROP PARTITION, срок согласован с окнами чтения в коде
  • Дубликат idx_lss_source_date не переносится
  • В запросы добавлена нижняя граница по дате, pruning подтверждён EXPLAIN

Scope: backend/app/tasks/listing_source_snapshot.py, backend/data/sql/.

Эпик: #2989 ## Находка `backend/app/tasks/listing_source_snapshot.py:82-104` делает `INSERT … SELECT id, CURRENT_DATE, … FROM listing_sources` **без единого `WHERE`**. Каждые сутки копируется состояние всех источников независимо от того, обходили их или нет. Замеры с прода 2026-08-20: - За 20 августа записано **101 795 строк** при 101 999 строках в `listing_sources` — ровно полная копия - За последние 7 суток: 789 113 строк, из них **670 602 побайтово совпадают со вчерашней** по `price_rub`, `payload_hash`, `is_active`. Это 85 % всех строк и ~97,6 % тех, у которых есть предыдущая - Таблица уже 4 568 773 строки / 947 МБ, run-rate ~2,7 млн строк в месяц **На московском объёме** это ~673 тыс. строк в сутки = **20,2 млн в месяц** (выше исходной оценки 13–18 млн, потому что объём привязан не к обходу, а к числу источников), 4,2 ГБ в месяц и около 6 ГБ WAL в сутки с одной этой таблицы. ## Что делать — с оговоркой Соблазнительный вариант «строка на изменение вместо строки на день» убирает 70× объёма, но **ломает защиту от массовой ложной деактивации**: реконсилятор живости опирается на факт ежедневного подтверждения. Менять модель можно только вместе с задачей про реконсилятор. Безопасный первый шаг — партиционирование по месяцу с retention через `DROP PARTITION` вместо `DELETE`. Подводные камни, которые надо учесть заранее: - `pg_partman` / `pg_cron` / `timescaledb` в образе **отсутствуют** (`pg_available_extensions` вернул ноль строк) — механику либо тащить в образ, либо делать на существующем планировщике `scrape_schedules`, что для одного человека дешевле - Без предсозданных будущих партиций первая же вставка падает с «no partition of relation found» - `idx_lss_source_date` (171 МБ) — почти точный дубликат PK `(listing_source_id, snapshot_date)`; при партиционировании он тиражируется на каждую партицию - Запрос «последний снимок до сегодня» (`listing_source_snapshot.py:200-206`) не имеет нижней границы по дате — без неё pruning не сработает ## Acceptance - [ ] Партиционирование по `RANGE(snapshot_date)` помесячно, партиции на 3 месяца вперёд - [ ] Retention через `DROP PARTITION`, срок согласован с окнами чтения в коде - [ ] Дубликат `idx_lss_source_date` не переносится - [ ] В запросы добавлена нижняя граница по дате, pruning подтверждён `EXPLAIN` Scope: `backend/app/tasks/listing_source_snapshot.py`, `backend/data/sql/`.
lekss361 added the
bug
data
performance
priority/p2
scope/backend
scope/db
tradein
labels 2026-08-20 17:50:54 +00:00
Collaborator

Разведка сделана — числа подтверждают постановку, а «первый безопасный шаг» безопасным не назвать

Замер 21.08: 4 671 239 строк, таблица 553 МБ + индексы 410 МБ; за 7 суток 795 423 строки, из них 676 470 (85 %) побайтово равны вчерашним по price_rub/payload_hash/is_active. По месяцам: май 22 тыс. → июнь 800 тыс. → июль 1.96 млн → август 1.89 млн (к 21-му числу). idx_lss_source_date (173 МБ) — те же колонки, что PK (177 МБ), только DESC: дубликат по смыслу.

Читатели (всё проверено по коду, не по описанию):

  • listing_source_snapshot.pytoday = snapshot_date = CURRENT_DATE (prunable) и LATERAL «последний снимок до сегодня» — без нижней границы → pruning не сработает;
  • deactivate_stale_avito.py:314max(snapshot_date) <= CURRENT_DATE - :health_window_daysбез нижней границы (подзапрос-скаляр по всей таблице);
  • миграции 079/202/219/225/232/250 — DDL/комментарии; 102_grant и backtest_estimator.py упоминают таблицу только в докстринге — не сломаются.

Что делает перевод нетривиальным, и чего в постановке нет:

  • В tradein-БД нет прецедента перевода живой таблицы в партиционированную (ATTACH PARTITION нигде) — это будет первая такая миграция на проде, с переносом 4.67 млн строк.
  • pg_repack / pg_partman / pg_cron в образе отсутствуют (подтверждено pg_available_extensions, PG 16.4) — онлайн-перевод инструментами не сделать.
  • Запись — одно окно в сутки: listing_source_snapshot в 01:00–02:00 UTC, бюджет 900 с. То есть ~23 часа в сутки таблица только читается — перевод «новая партиционированная + INSERT…SELECT батчами + RENAME» укладывается в это окно без гонок с писателем; читатели на время переноса увидят либо старую, либо новую таблицу целиком.
  • Сегодня я сам прошёл ловушку «нет партиции под следующий период» на rosreestr_deals (#2998): предсоздание партиций вперёд и сторож горизонта — обязательная часть, не «потом».

План (отдельный заход, не хвост цикла)

  1. Миграция: listing_source_snapshots_new PARTITION BY RANGE (snapshot_date), помесячные партиции с мая 2026 по +3 месяца вперёд, PK (listing_source_id, snapshot_date); индексы — idx_lss_snapshot_date, idx_lss_run_id; без idx_lss_source_date (дубликат PK). Перенос батчами по месяцу в окне вне 01:00–02:00 UTC, затем RENAME в одной транзакции. Откат — обратный RENAME, старая таблица не удаляется до подтверждения.
  2. Запросы: нижняя граница по дате в обоих читателях (>= CURRENT_DATE - N), N = самое длинное окно health_window_days + запас; pruning подтверждается EXPLAIN на проде.
  3. Retention: DROP PARTITION старше 6 месяцев через scrape_schedules (как все джобы) — сегодня данных 4 месяца, первый DROP случится не раньше ноября; срок — на согласование.
  4. Сторож горизонта партиций (герметичный DB-тест в песочнице, как для rosreestr_deals в #3014).

Развилка для владельца — задача её сама ставит

Партиционирование уменьшает стоимость хранения (DROP вместо DELETE, pruning), но не уменьшает запись: 97.6 % дублей продолжают писаться и дальше. Вариант «строка на изменение» убрал бы 70× объёма, но ломает защиту от ложной деактивации — deactivate_stale опирается на ежедневное подтверждение через снапшот. Его можно делать только вместе с переделкой реконсилятора.

Я бы делал п.1–4 сейчас (безопасно, окупается на хранении), а «строку на изменение» — отдельной задачей с реконсилятором. Но это выбор модели данных, его принимает владелец.

## Разведка сделана — числа подтверждают постановку, а «первый безопасный шаг» безопасным не назвать **Замер 21.08:** 4 671 239 строк, таблица 553 МБ + индексы 410 МБ; за 7 суток 795 423 строки, из них **676 470 (85 %) побайтово равны вчерашним** по `price_rub`/`payload_hash`/`is_active`. По месяцам: май 22 тыс. → июнь 800 тыс. → июль 1.96 млн → август 1.89 млн (к 21-му числу). `idx_lss_source_date` (173 МБ) — те же колонки, что PK (177 МБ), только DESC: дубликат по смыслу. **Читатели** (всё проверено по коду, не по описанию): - `listing_source_snapshot.py` — `today` = `snapshot_date = CURRENT_DATE` (prunable) и LATERAL «последний снимок до сегодня» — **без нижней границы** → pruning не сработает; - `deactivate_stale_avito.py:314` — `max(snapshot_date) <= CURRENT_DATE - :health_window_days` — **без нижней границы** (подзапрос-скаляр по всей таблице); - миграции 079/202/219/225/232/250 — DDL/комментарии; `102_grant` и `backtest_estimator.py` упоминают таблицу только в докстринге — не сломаются. **Что делает перевод нетривиальным, и чего в постановке нет:** - В tradein-БД **нет прецедента** перевода живой таблицы в партиционированную (`ATTACH PARTITION` нигде) — это будет первая такая миграция на проде, с переносом 4.67 млн строк. - `pg_repack` / `pg_partman` / `pg_cron` в образе отсутствуют (подтверждено `pg_available_extensions`, PG 16.4) — онлайн-перевод инструментами не сделать. - Запись — одно окно в сутки: `listing_source_snapshot` в 01:00–02:00 UTC, бюджет 900 с. То есть ~23 часа в сутки таблица только читается — перевод «новая партиционированная + INSERT…SELECT батчами + RENAME» укладывается в это окно без гонок с писателем; читатели на время переноса увидят либо старую, либо новую таблицу целиком. - Сегодня я сам прошёл ловушку «нет партиции под следующий период» на `rosreestr_deals` (#2998): предсоздание партиций вперёд и сторож горизонта — обязательная часть, не «потом». ## План (отдельный заход, не хвост цикла) 1. Миграция: `listing_source_snapshots_new PARTITION BY RANGE (snapshot_date)`, помесячные партиции с мая 2026 по **+3 месяца вперёд**, PK `(listing_source_id, snapshot_date)`; индексы — `idx_lss_snapshot_date`, `idx_lss_run_id`; **без** `idx_lss_source_date` (дубликат PK). Перенос батчами по месяцу в окне вне 01:00–02:00 UTC, затем `RENAME` в одной транзакции. Откат — обратный `RENAME`, старая таблица не удаляется до подтверждения. 2. Запросы: нижняя граница по дате в обоих читателях (`>= CURRENT_DATE - N`), `N` = самое длинное окно `health_window_days` + запас; pruning подтверждается `EXPLAIN` на проде. 3. Retention: `DROP PARTITION` старше **6 месяцев** через `scrape_schedules` (как все джобы) — сегодня данных 4 месяца, первый DROP случится не раньше ноября; срок — на согласование. 4. Сторож горизонта партиций (герметичный DB-тест в песочнице, как для `rosreestr_deals` в #3014). ## Развилка для владельца — задача её сама ставит Партиционирование уменьшает **стоимость хранения** (DROP вместо DELETE, pruning), но **не уменьшает запись**: 97.6 % дублей продолжают писаться и дальше. Вариант «строка на изменение» убрал бы 70× объёма, но ломает защиту от ложной деактивации — `deactivate_stale` опирается на ежедневное подтверждение через снапшот. Его можно делать только вместе с переделкой реконсилятора. Я бы делал п.1–4 сейчас (безопасно, окупается на хранении), а «строку на изменение» — отдельной задачей с реконсилятором. Но это выбор модели данных, его принимает владелец.
Collaborator

Перепроверка 27.08.2026 (после пересборки базы 25-26.08 и переезда продукта на Poincare). Тело тикета писалось 20-24.08 по старой инфраструктуре.

Проблема не рассосалась, а выросла. Перемер:

в теле (20.08) сейчас (27.08)
строк 4 568 773 5 299 011
размер 947 МБ 1012 МБ
за 7 суток 789 113 831 749

Партиционирования нет (Access method: heap). Копия за сутки — 106 034 строки. Тикет живой, числа в теле занижены.

**Перепроверка 27.08.2026** (после пересборки базы 25-26.08 и переезда продукта на Poincare). Тело тикета писалось 20-24.08 по старой инфраструктуре. Проблема не рассосалась, а выросла. Перемер: | | в теле (20.08) | сейчас (27.08) | |---|---|---| | строк | 4 568 773 | **5 299 011** | | размер | 947 МБ | **1012 МБ** | | за 7 суток | 789 113 | **831 749** | Партиционирования нет (`Access method: heap`). Копия за сутки — 106 034 строки. Тикет живой, числа в теле занижены.
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#2993
No description provided.