tradein/db: миграция 229 завела дубль индекса по expires_at — точная копия индекса из 001 #2752

Closed
opened 2026-08-06 18:39:53 +00:00 by lekss361 · 3 comments
Owner

Найдено глубоким ревью PR #2746 (уборка дублей индексов). Долг создан сегодня же, поэтому в инвентаризацию техдолга не попал — его тогда не существовало.

Что произошло

229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09, пришла с PR #2547) создала trade_in_estimates_expires_at_idx. Это точный дубль trade_in_estimates_expires_idx из миграции 001.

Сравнение по pg_index совпадает по всем полям: метод доступа, набор и порядок колонок, направление сортировки, классы операторов, предикат. Ни один из двух не привязан к ограничению, то есть удалить можно любой.

trade_in_estimates_expires_idx      | 001 | 48 kB | 234 скана
trade_in_estimates_expires_at_idx   | 229 | 40 kB |   0 сканов

Почему это не поймали

PR #2746 нашёл ровно пять дублей и отказался подгонять под ожидаемые аудитом шесть — это было правильно. Шестой появился в базе позже, чем автор снимал замеры: его миграция применилась в 17:09, а разбор шёл раньше.

То есть это не пропуск ревью, а гонка: два PR одного дня, один чистит дубли, другой заводит новый.

Что сделать

Дропнуть trade_in_estimates_expires_at_idx отдельной миграцией — либо, если в 229 индекс заводился осознанно под конкретный запрос, оставить именно его и дропнуть старый, но тогда записать в комментарии, почему выбран этот.

Перед сносом проверить планы: у старого 234 скана, у нового 0 — но новый только что создан, статистика не показательна.

Побочно (не блокирует)

hph_source_idx (source) остаётся префикс-избыточным относительно house_placement_history_source_ext_item_id_key — 94 скана. Вне объёма #2746, зафиксировано здесь, чтобы не потерялось.

Отдельная рекомендация к будущим миграциям с CONCURRENTLY

Ревью #2746 отметило: CREATE INDEX CONCURRENTLY ждёт завершения чужих транзакций, а деплой-скрипт целиком выполняется под таймаутом. При обрыве получится невалидный индекс, который следующий деплой молча пропустит по IF NOT EXISTS и запишет как применённый.

Для таких миграций стоит дописывать после создания проверку indisvalid с явным исключением при неудаче.

Найдено глубоким ревью PR #2746 (уборка дублей индексов). Долг создан сегодня же, поэтому в инвентаризацию техдолга не попал — его тогда не существовало. ## Что произошло `229_trade_in_estimates_consent_proof.sql` (применена 2026-08-06 17:09, пришла с PR #2547) создала `trade_in_estimates_expires_at_idx`. Это **точный дубль** `trade_in_estimates_expires_idx` из миграции `001`. Сравнение по `pg_index` совпадает по всем полям: метод доступа, набор и порядок колонок, направление сортировки, классы операторов, предикат. Ни один из двух не привязан к ограничению, то есть удалить можно любой. ``` trade_in_estimates_expires_idx | 001 | 48 kB | 234 скана trade_in_estimates_expires_at_idx | 229 | 40 kB | 0 сканов ``` ## Почему это не поймали PR #2746 нашёл ровно пять дублей и отказался подгонять под ожидаемые аудитом шесть — это было правильно. Шестой появился в базе **позже**, чем автор снимал замеры: его миграция применилась в 17:09, а разбор шёл раньше. То есть это не пропуск ревью, а гонка: два PR одного дня, один чистит дубли, другой заводит новый. ## Что сделать Дропнуть `trade_in_estimates_expires_at_idx` отдельной миграцией — либо, если в 229 индекс заводился осознанно под конкретный запрос, оставить именно его и дропнуть старый, но тогда записать в комментарии, почему выбран этот. Перед сносом проверить планы: у старого 234 скана, у нового 0 — но новый только что создан, статистика не показательна. ## Побочно (не блокирует) `hph_source_idx (source)` остаётся префикс-избыточным относительно `house_placement_history_source_ext_item_id_key` — 94 скана. Вне объёма #2746, зафиксировано здесь, чтобы не потерялось. ## Отдельная рекомендация к будущим миграциям с `CONCURRENTLY` Ревью #2746 отметило: `CREATE INDEX CONCURRENTLY` ждёт завершения чужих транзакций, а деплой-скрипт целиком выполняется под таймаутом. При обрыве получится невалидный индекс, который следующий деплой **молча пропустит** по `IF NOT EXISTS` и запишет как применённый. Для таких миграций стоит дописывать после создания проверку `indisvalid` с явным исключением при неудаче.
Collaborator

НЕ ЗАКРЫТО — дубль на месте, замер на проде 2026-08-07 09:0x UTC

Ни одной миграции после 234 нет; индекс никто не дропал.

trade_in_estimates_expires_idx      | 234 скана | 48 kB
trade_in_estimates_expires_at_idx   |  11 сканов| 40 kB

Ноль имеет причину: никто не брался. Ни один PR из смерженных за двое суток на эту задачу не ссылается — я проверил и по API, и по истории коммитов. Это не «заблокировано выше» и не «неприменимо».

Одна цифра в задаче устарела, и в неудобную сторону

В теле было «у нового 0 сканов, статистика не показательна». Прошли сутки — у нового 11 сканов. То есть планировщик его действительно выбирает, и оговорка «перед сносом проверить планы» из необязательной стала обязательной: снос любого из двух теперь меняет поведение живых запросов, а не только занимает место.

Что осталось

  1. Решить, какой из двух остаётся; при выборе нового — записать в комментарии, почему (в 229 он заводился под конкретный запрос или по недосмотру — из миграции это не читается).
  2. Миграция с DROP INDEX на второй.
  3. Побочный пункт из тела задачи не тронут: hph_source_idx (source) остаётся префикс-избыточным относительно house_placement_history_source_ext_item_id_key, 94 скана.
  4. Рекомендация про CREATE INDEX CONCURRENTLY + проверку indisvalid не реализована — при обрыве по таймауту деплой молча запишет невалидный индекс как применённый.
## НЕ ЗАКРЫТО — дубль на месте, замер на проде 2026-08-07 09:0x UTC Ни одной миграции после 234 нет; индекс никто не дропал. ``` trade_in_estimates_expires_idx | 234 скана | 48 kB trade_in_estimates_expires_at_idx | 11 сканов| 40 kB ``` Ноль имеет причину: **никто не брался**. Ни один PR из смерженных за двое суток на эту задачу не ссылается — я проверил и по API, и по истории коммитов. Это не «заблокировано выше» и не «неприменимо». ### Одна цифра в задаче устарела, и в неудобную сторону В теле было «у нового 0 сканов, статистика не показательна». Прошли сутки — у нового **11 сканов**. То есть планировщик его действительно выбирает, и оговорка «перед сносом проверить планы» из необязательной стала обязательной: снос любого из двух теперь меняет поведение живых запросов, а не только занимает место. ### Что осталось 1. Решить, какой из двух остаётся; при выборе нового — записать в комментарии, почему (в 229 он заводился под конкретный запрос или по недосмотру — из миграции это не читается). 2. Миграция с `DROP INDEX` на второй. 3. Побочный пункт из тела задачи не тронут: `hph_source_idx (source)` остаётся префикс-избыточным относительно `house_placement_history_source_ext_item_id_key`, 94 скана. 4. Рекомендация про `CREATE INDEX CONCURRENTLY` + проверку `indisvalid` не реализована — при обрыве по таймауту деплой молча запишет невалидный индекс как применённый.
Collaborator

Разбор + PR #2784 смержен, но на проде НЕ применилось — застряло на блокировке

Предпосылка задачи подтверждена: это побайтовый дубль

Сравнение через pg_index, а не по имени (прод 2026-08-07):

индекс indkey indclass indoption indcollation indpred am
trade_in_estimates_expires_idx (001) 22 3127 0 0 btree
trade_in_estimates_expires_at_idx (229) 22 3127 0 0 btree

Совпадает всё: колонка, класс операторов, направление сортировки, NULLS-порядок (indoption=0), коллация, отсутствие частичного предиката, метод доступа. pg_depend — 0 ссылок, pg_constraint.conindid — 0 строк: снос ничего не роняет по цепочке и не требует CASCADE.

Почему у копии появились сканы

Не «планировщик передумал» и не новый запрос. Замеры подряд:

09:14  expires_idx 234 | expires_at_idx 11
09:16  expires_idx 234 | expires_at_idx 15
09:18  expires_idx 234 | expires_at_idx 15
09:27  expires_idx 234 | expires_at_idx 18

Старый заморожен на 234, весь живой трафик у нового. Причина физическая: индексы идентичны, но новый собран вчера с нуля и плотнее — relpages 5 против 6. genericcostestimate() считает спуск по дереву от числа страниц, 5 < 6 → новый дешевле и выигрывает. Нового запроса не появилось: idx_tup_read/idx_scan у обоих одного порядка (1.88 против 0.93) — тот же класс точечных lookup'ов, просто переехавший на более свежий индекс. Со временем новый забронзовел бы так же и они поменялись бы обратно.

ОПРОВЕРГНУТО: обоснование индекса в самой 229

229 завела индекс осознанно — «обслуживает purge_expired_trade_in_data, без него batched-DELETE делал бы full scan». На проде это не так. EXPLAIN боевого запроса:

Limit -> Sort (Sort Key: expires_at)
  -> Bitmap Heap Scan  Recheck Cond: (created_by IS NULL)  Filter: (expires_at < now())
       -> Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at

Задача purge сужена по created_by IS NULL (129 строк из 1061), планировщик берёт более селективный индекс, expires_at остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не участвует. Развилки «оставить индекс из 229, он заведён под конкретный запрос» не существует — запрос его не использует. Оставлен индекс из 001.

pg_stat_statements в этой БД не установлен — потребители искались по коду.

Планы ДО/ПОСЛЕ

Сняты на чистом PostgreSQL 16.4 с воспроизведённым перекосом плотности. Форма плана, Index Cond и Filter идентичны, меняется только имя индекса и cost на одну страницу спуска:

ДО:     Index Scan using trade_in_estimates_expires_at_idx  (cost=0.28..58.85)
ПОСЛЕ:  Index Scan using trade_in_estimates_expires_idx     (cost=0.28..70.85)

⚠️ Не применилось: DROP INDEX висит на ACCESS EXCLUSIVE

PR #2784 смержен (a9096f12), но миграция на проде не применена. Состояние на 10:19 UTC:

pid   waited     query                                                    blocked_by
93378 00:26:31   DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx;  {83256}
96294 00:13:11   DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx;  {83256,93378}

holder 83256  application_name=psql  xact_age 00:57:20  active
  CREATE TEMP TABLE tmp_res AS WITH e AS (SELECT id, created_at, rooms, area_m2::float area, ...

Держатель — ручная psql-сессия, не приложение (application_name=psql, area_m2::float — рукописный каст, в коде такое запрещено правилом psycopg v3). Идёт 57 минут, похоже на бэктест эстиматора. Оба ждущих DROP — мои же: первый деплой и второй, который повторно прогоняет ту же миграцию, потому что она не записалась в _schema_migrations.

Состояние БД не изменено: оба индекса на месте, _schema_migrations без записи 250, миграция идемпотентна и доедет со следующим деплоем.

Чего это стоит прямо сейчас. Ожидающий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, то есть любой новый SELECT по trade_in_estimates будет ждать. За 25 минут наблюдения ни один запрос приложения в очередь не встал (оба ждущих — мои DROP), так что пользовательского эффекта пока нет. Но риск ненулевой и растёт с каждым новым деплоем, который добавит третий DROP в очередь.

Я ничего не отменял — мандат на проде только на чтение, и чужую 57-минутную сессию я тем более не трогаю. Решение за владельцем. Варианты:

  1. Дождаться конца бэктеста — DROP возьмёт лок за миллисекунды (таблица 1061 строка / heap 1856 kB), деплой доедет сам.
  2. Снять мои зависшие операторы, чтобы убрать ACCESS EXCLUSIVE из очереди: SELECT pg_cancel_backend(93378), pg_cancel_backend(96294); — деплой упадёт с exit 1 (штатно и громко), БД не изменится, миграция применится следующим деплоем.

Я бы предпочёл (2), если бэктест ещё долгий: очередь на боевой таблице стоит дороже, чем красный деплой.

Урок для будущих миграций на этой таблице: дело не в размере (1061 строка — снос занял бы миллисекунды), а в том, что по trade_in_estimates ходят долгие ручные аналитические сессии. lock_timeout в начале файла (SET LOCAL lock_timeout = '5s') превратил бы это в быстрый честный отказ вместо получасовой очереди. В data/sql этого паттерна сейчас нет ни в одной миграции.


Три хвоста — разобраны, в PR не смешаны

  1. hph_source_idx — сносить НЕЛЬЗЯ, префикс-избыточность обманчива. Да, (source) — префикс house_placement_history_source_ext_item_id_key (source, ext_item_id). Но размеры 1336 kB против 8696 kB, и профиль не точечный: 94 скана прочитали 4 544 184 тупла (≈48 тыс. на скан), 757 233 fetch. Это bulk-проходы по source, где узкий индекс в 6.5 раза дешевле по I/O; снос переселил бы их на индекс в 6.5 раза толще (таблица 104 MB / 100 724 строки). Предпосылка «префикс-избыточный ⇒ лишний» здесь опровергнута — отдельного PR на снос быть не должно.

  2. indisvalid — повода нет. SELECT ... FROM pg_index WHERE NOT indisvalid OR NOT indisready OR NOT indislive по всей боевой БД — 0 строк. Недостроенных индексов нет нигде, так что «занимает место и мешает пересозданию» к этому случаю не относится.

  3. Молчаливый пропуск невалидного индекса при обрыве CONCURRENTLY — дыра реальная, открыта. В data/sql 13 файлов с CREATE INDEX CONCURRENTLY. Механика: файл падает по таймауту → INSERT INTO _schema_migrations не выполняется → следующий деплой прогоняет файл заново → IF NOT EXISTS видит невалидный индекс, молча не пересоздаёт → файл «успешен» и записывается как применённый. 225_listing_source_snapshots_run_id_idx.sql этот сценарий уже описал словами, проверки нет. Правильное место — не DO-блок в каждый файл, а один запрос в раннере после цикла миграций: ловит все файлы, прошлые и будущие, без бойлерплейта. Правка в .forgejo/workflows/deploy-tradein.yml — это devops-scope, отдельной задачей (плюс она тянет infra-фильтр и полную пересборку).

## Разбор + PR #2784 смержен, но на проде НЕ применилось — застряло на блокировке ### Предпосылка задачи подтверждена: это побайтовый дубль Сравнение через `pg_index`, а не по имени (прод 2026-08-07): | индекс | indkey | indclass | indoption | indcollation | indpred | am | |---|---|---|---|---|---|---| | `trade_in_estimates_expires_idx` (001) | 22 | 3127 | 0 | 0 | — | btree | | `trade_in_estimates_expires_at_idx` (229) | 22 | 3127 | 0 | 0 | — | btree | Совпадает всё: колонка, класс операторов, направление сортировки, NULLS-порядок (`indoption=0`), коллация, отсутствие частичного предиката, метод доступа. `pg_depend` — 0 ссылок, `pg_constraint.conindid` — 0 строк: снос ничего не роняет по цепочке и не требует CASCADE. ### Почему у копии появились сканы Не «планировщик передумал» и не новый запрос. Замеры подряд: ``` 09:14 expires_idx 234 | expires_at_idx 11 09:16 expires_idx 234 | expires_at_idx 15 09:18 expires_idx 234 | expires_at_idx 15 09:27 expires_idx 234 | expires_at_idx 18 ``` Старый **заморожен** на 234, весь живой трафик у нового. Причина физическая: индексы идентичны, но новый собран вчера с нуля и плотнее — `relpages` **5 против 6**. `genericcostestimate()` считает спуск по дереву от числа страниц, 5 < 6 → новый дешевле и выигрывает. Нового запроса не появилось: `idx_tup_read/idx_scan` у обоих одного порядка (1.88 против 0.93) — тот же класс точечных lookup'ов, просто переехавший на более свежий индекс. Со временем новый забронзовел бы так же и они поменялись бы обратно. ### ОПРОВЕРГНУТО: обоснование индекса в самой 229 229 завела индекс осознанно — «обслуживает `purge_expired_trade_in_data`, без него batched-DELETE делал бы full scan». На проде это **не так**. `EXPLAIN` боевого запроса: ``` Limit -> Sort (Sort Key: expires_at) -> Bitmap Heap Scan Recheck Cond: (created_by IS NULL) Filter: (expires_at < now()) -> Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at ``` Задача purge сужена по `created_by IS NULL` (129 строк из 1061), планировщик берёт более селективный индекс, `expires_at` остаётся Filter'ом. **Ни один из двух expires-индексов в этом плане не участвует.** Развилки «оставить индекс из 229, он заведён под конкретный запрос» не существует — запрос его не использует. Оставлен индекс из 001. `pg_stat_statements` в этой БД не установлен — потребители искались по коду. ### Планы ДО/ПОСЛЕ Сняты на чистом PostgreSQL 16.4 с воспроизведённым перекосом плотности. Форма плана, `Index Cond` и `Filter` идентичны, меняется только имя индекса и cost на одну страницу спуска: ``` ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..58.85) ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..70.85) ``` --- ## ⚠️ Не применилось: `DROP INDEX` висит на ACCESS EXCLUSIVE PR #2784 смержен (`a9096f12`), но миграция **на проде не применена**. Состояние на 10:19 UTC: ``` pid waited query blocked_by 93378 00:26:31 DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; {83256} 96294 00:13:11 DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; {83256,93378} holder 83256 application_name=psql xact_age 00:57:20 active CREATE TEMP TABLE tmp_res AS WITH e AS (SELECT id, created_at, rooms, area_m2::float area, ... ``` Держатель — **ручная psql-сессия**, не приложение (`application_name=psql`, `area_m2::float` — рукописный каст, в коде такое запрещено правилом psycopg v3). Идёт 57 минут, похоже на бэктест эстиматора. Оба ждущих `DROP` — мои же: первый деплой и второй, который повторно прогоняет ту же миграцию, потому что она не записалась в `_schema_migrations`. Состояние БД **не изменено**: оба индекса на месте, `_schema_migrations` без записи 250, миграция идемпотентна и доедет со следующим деплоем. **Чего это стоит прямо сейчас.** Ожидающий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, то есть любой новый SELECT по `trade_in_estimates` будет ждать. За 25 минут наблюдения **ни один запрос приложения в очередь не встал** (оба ждущих — мои `DROP`), так что пользовательского эффекта пока нет. Но риск ненулевой и растёт с каждым новым деплоем, который добавит третий `DROP` в очередь. **Я ничего не отменял** — мандат на проде только на чтение, и чужую 57-минутную сессию я тем более не трогаю. Решение за владельцем. Варианты: 1. Дождаться конца бэктеста — `DROP` возьмёт лок за миллисекунды (таблица 1061 строка / heap 1856 kB), деплой доедет сам. 2. Снять мои зависшие операторы, чтобы убрать ACCESS EXCLUSIVE из очереди: `SELECT pg_cancel_backend(93378), pg_cancel_backend(96294);` — деплой упадёт с exit 1 (штатно и громко), БД не изменится, миграция применится следующим деплоем. Я бы предпочёл (2), если бэктест ещё долгий: очередь на боевой таблице стоит дороже, чем красный деплой. **Урок для будущих миграций на этой таблице:** дело не в размере (1061 строка — снос занял бы миллисекунды), а в том, что по `trade_in_estimates` ходят долгие ручные аналитические сессии. `lock_timeout` в начале файла (`SET LOCAL lock_timeout = '5s'`) превратил бы это в быстрый честный отказ вместо получасовой очереди. В `data/sql` этого паттерна сейчас нет ни в одной миграции. --- ## Три хвоста — разобраны, в PR не смешаны 1. **`hph_source_idx` — сносить НЕЛЬЗЯ, префикс-избыточность обманчива.** Да, `(source)` — префикс `house_placement_history_source_ext_item_id_key (source, ext_item_id)`. Но размеры **1336 kB против 8696 kB**, и профиль не точечный: 94 скана прочитали **4 544 184** тупла (≈48 тыс. на скан), 757 233 fetch. Это bulk-проходы по `source`, где узкий индекс в 6.5 раза дешевле по I/O; снос переселил бы их на индекс в 6.5 раза толще (таблица 104 MB / 100 724 строки). Предпосылка «префикс-избыточный ⇒ лишний» здесь **опровергнута** — отдельного PR на снос быть не должно. 2. **`indisvalid` — повода нет.** `SELECT ... FROM pg_index WHERE NOT indisvalid OR NOT indisready OR NOT indislive` по всей боевой БД — **0 строк**. Недостроенных индексов нет нигде, так что «занимает место и мешает пересозданию» к этому случаю не относится. 3. **Молчаливый пропуск невалидного индекса при обрыве `CONCURRENTLY` — дыра реальная, открыта.** В `data/sql` 13 файлов с `CREATE INDEX CONCURRENTLY`. Механика: файл падает по таймауту → `INSERT INTO _schema_migrations` не выполняется → следующий деплой прогоняет файл заново → `IF NOT EXISTS` видит невалидный индекс, молча не пересоздаёт → файл «успешен» и записывается как применённый. `225_listing_source_snapshots_run_id_idx.sql` этот сценарий уже описал словами, проверки нет. Правильное место — **не DO-блок в каждый файл, а один запрос в раннере после цикла миграций**: ловит все файлы, прошлые и будущие, без бойлерплейта. Правка в `.forgejo/workflows/deploy-tradein.yml` — это devops-scope, отдельной задачей (плюс она тянет `infra`-фильтр и полную пересборку).
Collaborator

Прод 12.08: дубль снят, все четыре пункта разрешены — закрываю

Блокировка, из-за которой 07.08 миграция висела в очереди за часовым ручным бэктестом, разрешилась сама: 250_drop_duplicate_expires_at_index.sql в _schema_migrations, applied_at 2026-08-09 17:15:10.

Дубля нет. pg_stat_user_indexes по trade_in_estimates:

idx_trade_in_estimates_created_by_created_at |  323 скана | 72 kB
trade_in_estimates_created_idx               |  284       | 64 kB
trade_in_estimates_expires_idx      (из 001) |  258       | 48 kB
trade_in_estimates_geom_idx                  |   71       | 64 kB
trade_in_estimates_pkey                      | 6497       | 56 kB
trade_in_estimates_purge_idx        (из 240) |    0       | 16 kB

trade_in_estimates_expires_at_idx (229) отсутствует. Оставшийся из 001 набрал 258 сканов — живой трафик вернулся на него, как и предсказывал разбор про relpages.

Ноль сканов у trade_in_estimates_purge_idx дублем не является и проверен по определению, а не по имени: это частичный индекс (expires_at) WHERE created_by IS NULL AND retain_until IS NULL из миграции 240 — ровно тот, которого не хватало purge-запросу в EXPLAIN от 07.08. Ему трое суток, а purge редкий; повода заводить новую задачу нет.

Пункт про CREATE INDEX CONCURRENTLY — сделан, и тем способом, который разбор назвал правильным. В .forgejo/workflows/deploy-tradein.yml (и симметрично в deploy.yml) после цикла миграций стоит один запрос по pg_index WHERE NOT indisvalid, с явным exit 1, объяснением механики молчаливого пропуска и инструкцией по лечению — не DO-блок в каждом файле. Ссылка на #2752 в комментарии там же.

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

Побочный hph_source_idx закрыт как «неприменимо», а не «не сделано»: предпосылка «префикс-избыточный ⇒ лишний» опровергнута числами (1336 kB против 8696 kB; 94 скана прочитали 4 544 184 тупла, ≈48 тыс. на скан) — снос переселил бы bulk-проходы на индекс в 6.5 раза толще. Отдельного PR быть не должно.

Закрываю: 1) дубль снят, 2) выбор обоснован (индекс из 229 не участвовал в плане purge — развилки не существовало), 3) hph_source_idx — неприменимо, 4) indisvalid — в раннере.

## Прод 12.08: дубль снят, все четыре пункта разрешены — закрываю Блокировка, из-за которой 07.08 миграция висела в очереди за часовым ручным бэктестом, разрешилась сама: `250_drop_duplicate_expires_at_index.sql` в `_schema_migrations`, applied_at **2026-08-09 17:15:10**. **Дубля нет.** `pg_stat_user_indexes` по `trade_in_estimates`: ``` idx_trade_in_estimates_created_by_created_at | 323 скана | 72 kB trade_in_estimates_created_idx | 284 | 64 kB trade_in_estimates_expires_idx (из 001) | 258 | 48 kB trade_in_estimates_geom_idx | 71 | 64 kB trade_in_estimates_pkey | 6497 | 56 kB trade_in_estimates_purge_idx (из 240) | 0 | 16 kB ``` `trade_in_estimates_expires_at_idx` (229) отсутствует. Оставшийся из 001 набрал 258 сканов — живой трафик вернулся на него, как и предсказывал разбор про `relpages`. Ноль сканов у `trade_in_estimates_purge_idx` дублем **не является** и проверен по определению, а не по имени: это частичный индекс `(expires_at) WHERE created_by IS NULL AND retain_until IS NULL` из миграции 240 — ровно тот, которого не хватало purge-запросу в EXPLAIN от 07.08. Ему трое суток, а purge редкий; повода заводить новую задачу нет. **Пункт про `CREATE INDEX CONCURRENTLY` — сделан, и тем способом, который разбор назвал правильным.** В `.forgejo/workflows/deploy-tradein.yml` (и симметрично в `deploy.yml`) после цикла миграций стоит **один** запрос по `pg_index WHERE NOT indisvalid`, с явным `exit 1`, объяснением механики молчаливого пропуска и инструкцией по лечению — не DO-блок в каждом файле. Ссылка на #2752 в комментарии там же. Проверка, что гейт не «зелёный из-за заглушенного канала»: мой независимый запрос по тому же условию на проде даёт **0 строк** — ловить действительно нечего, а не «запрос не отработал». **Побочный `hph_source_idx` закрыт как «неприменимо», а не «не сделано»:** предпосылка «префикс-избыточный ⇒ лишний» опровергнута числами (1336 kB против 8696 kB; 94 скана прочитали 4 544 184 тупла, ≈48 тыс. на скан) — снос переселил бы bulk-проходы на индекс в 6.5 раза толще. Отдельного PR быть не должно. Закрываю: 1) дубль снят, 2) выбор обоснован (индекс из 229 не участвовал в плане purge — развилки не существовало), 3) `hph_source_idx` — неприменимо, 4) `indisvalid` — в раннере.
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#2752
No description provided.