Задача формулировала «импорт каждый день рапортует done с total_seen=0».
Проверка на проде показала другое: импорт исправен — ежедневно вычитывает
все 96 974 строки FDW-источника и честно их пропускает (rows_fetched =
rows_skipped = 96974), а `total_seen` — поле админ-витрины, не счётчик
импорта. Источник gendesign.rosreestr_deals стоял на Q1 2026 (загружен
30.04), хотя Q2 2026 опубликован Росреестром 10.07 и poll заметил его
14.08 (available=1). Оба сторожа — poll и deals_freshness_monitor —
сработали и семь событий ушли в GlitchTip, где 0 правил / 0 адресатов /
0 отправок.
Корень, которого в задаче не было: rosreestr_deals партиционирована по
period_start_date, партиции созданы списком в 01_schema «2024 Q3 — 2026
Q1», и ничто новые не создаёт. Загрузка Q2 21.08 упала:
ERROR: no partition of relation "rosreestr_deals" found for row
DETAIL: (period_start_date) = (2026-04-01)
То есть даже оператор, запустив загрузчик по подсказке poll, получил бы
отказ. Это и объясняет, почему poll сделан «только сообщить».
Что сделано:
• миграция 193 — партиции Q2, Q3, Q4 2026 с запасом, идемпотентно, с
lock_timeout; индексы наследуются от родителя (проверено: 4 на 2026q2);
• JOBS загрузчика — 2026Q2–Q4 (квартал без CSV честно SKIP);
• ловушка set -e в загрузчике: resolve_csv сигналит «файла нет» кодом 1,
и первый же квартал без CSV молча ронял ВЕСЬ прогон до строки SKIP — на
проде с одним Q2-файлом скрипт завершался rc=0, не напечатав ни строки.
`|| true` на вызове; после правки боевой прогон на VPS: 12 кварталов,
2026Q2 «уже загружен (741874 строк)», остальные SKIP, rc=0;
• тест-сторож горизонта: партиция обязана существовать на последний
публикуемый квартал (+20 дней лага после конца квартала; Q2 2026 вышел
10.07) и на следующий — чтобы предупреждение приходило за квартал до
отказа, а не в день публикации. Читает pg_inherits живого Postgres.
Красная сторона воспроизводима на проде, где миграция уже применена:
DETACH партиции в откатываемой транзакции → головная краснеет по
значению («нет партиции на квартал 2026-04-01»), откат возвращает
партицию (проверено: 12 партиций после теста). Без БД — skip с
причиной, в allowlist; календарный тест идёт везде.
Сам Q2 загружен на прод по штатному пути: 741 874 строки в
rosreestr_deals (ЕКБ-фильтр 13 654), import-rosreestr.sh → tradein.deals
+11 649 сделок, max(deal_date) 2026-01-01 → 2026-04-01.
deals_freshness_monitor на следующем тике: alert 0, latest_quarter 2.
pytest backend/tests/sql — 55 passed (через туннель к проду).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`UNIQUE (doc_group, doc_num)` вводился, чтобы склеивать ОДИН документ,
пришедший из двух схем портала. Замер 20.08.2026 показал, что задача,
ради которой ключ введён, почти отсутствует, а побочный эффект огромен:
docNum у ГИСОГД НЕ уникален — разрешение и изменения к нему носят один
номер.
группа документов различных key различных docNum схлопывается
DocRS 6098 6096 4305 1793
DocRV 5419 5415 4969 450
DocIZ 548 547 393 155
общих docNum между схемами (DocRS): 2 ← ради этого ключ и вводился
общих key между схемами (DocRS): 2 ← те же два
На проде 9182 строки против 12 065 документов на портале — нет 23.9 %
реестра. Пример 66-06-06-2026: портал отдаёт два документа (key …719586 —
само разрешение, key …752293 — изменения к нему), а UPSERT с
предпочтением позднего date_reg оставлял только изменение. Так вытеснено
598 из 4320 строк РНС (13.8 %) — в §6 на месте разрешения показывается
изменение к нему, без признака подмены.
Ключ стал `UNIQUE (source_key)`: разделяет разрешение и изменения (разные
key) и по-прежнему склеивает настоящие межсхемные дубли (у них key
ОБЩИЙ — ровно 7 записей по всем группам). Дедуп перед сменой не нужен:
source_key на проде уже уникален (9182 из 9182, NOT NULL).
Заодно группа DocIZ добавлена в GROUP_CODE — её не было вовсе, 548
документов не грузились. CHECK расширен значением 'IZ'.
§6 сужена до РНС/РВЭ ЯВНО: агрегат обещает total_count = rs_count +
rv_count, а строки 'IZ' попадали бы в total и ни в один счётчик.
Показывать ли изменения отдельной строкой — вопрос продуктовый (#2986);
до его решения сужение стоит в запросе, а не держится на том, что таких
строк «пока нет».
Проверки:
- два гейта на лоадер (GROUP_CODE и цель ON CONFLICT) — БЕЗ базы,
двусторонние: на origin/main дают конкретные неверные значения
({'DocRS','DocRV'} и старый ON CONFLICT в тексте запроса);
- гейт на §6 и контроль инварианта total = rs + rv на данных — красные
на origin/main;
- герметичная репетиция миграции на временной копии: со старым ключом
разрешение и изменение схлопываются в одну строку (и остаётся именно
изменение — как на проде), после миграции живут раздельно; межсхемный
дубль по-прежнему склеивается; CHECK принимает 'IZ' и отвергает мусор.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
заканчивается `ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому
пятничный прогон (`0 7 * * fri`) существующие строки не перезапишет.
Без этой миграции 11 строк остались бы с датой 2004 года навсегда —
правка выглядела бы сделанной, а данные на проде остались бы кривыми.
Замер прода 20.08.2026:
89adb28a… развязка Базовый/Комсомольская/Сибирский тракт 9 строк
9b9d9a99… улица Энергостроителей 2 строки
обе группы: act_date = 2004-07-06
Верные даты не угаданы: оба PDF загружены с екатеринбург.рф и
распознаны тем же трактом, что использует загрузчик (ocr_pdf_text), и в
обоих настоящее основание — постановление Администрации города:
№ 1413 от 27.05.2022 и № 259 от 12.02.2020 соответственно.
Сужение по doc_url обязательно: без него UPDATE задел бы любую строку с
06.07.2004, включая те, где эта дата настоящая. Миграция идемпотентна —
условие `act_date = '2004-07-06'` при повторе не выполнится.
Тест герметичный, прогоняет ТЕЛО миграции целиком на временной копии в
прод-форме (9+2 целевых + 2 контрольных посторонних). Контроль-двойник
`test_without_the_migration_rows_stay_wrong` обязателен: без него тест
неотличим от «оно и так было правильно». Мутационно проверено сужение —
снятие условия по doc_url роняет
test_other_documents_with_same_date_are_untouched.
pytest backend/tests/sql/test_2464_act_date_backfill.py — 6 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину
`'<25'`, что и настоящие студии. Замер прода 20.08.2026 (последний
снапшот на физлот, premise_kind='квартира', не проданные):
в продаже 181 353
без area_pd 11 557 (6.4 %)
реально < 25 м² 7 013
Корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких
лотов в блоке «По предложению (без темпа продаж)».
Зеркала у такого отображения не было: `layout_signature.area_bin`
принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади
не группирует — то есть `NULL → '<25'` было выдумкой, а не переносом
чужого правила.
Исключать такие лоты нельзя: они реально в продаже, и без них
предложение занизилось бы на 6.4 %. Поэтому отдельная корзина «н/д».
Медиана площади у неё выйдет NULL (PERCENTILE_CONT игнорирует NULL) —
честно. Схема не меняется: area_bin остаётся str, OpenAPI прежний.
Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица
objective_lots затеняет боевую в пределах сессии, запрос берётся из
модуля дословно, прод-данные не читаются.
Двусторонне: против origin/main корзины распределяются как
{'<25': 2, '25-40': 1, '40-60': 1} — конкретное неверное значение, ни
одного TypeError/ImportError. Контроли (сумма лотов сохраняется,
обычные корзины не меняются) зелёные с обеих сторон.
pytest backend/tests/sql/ — 38 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_profile/update_profile делают «снять is_default у всех → поставить новому»
двумя отдельными операторами. Между ними инвариант нарушен, и при одновременных
запросах у пользователя может оказаться ДВА профиля с is_default=TRUE. А читающий
_SELECT_DEFAULT брал LIMIT 1 БЕЗ ORDER BY — выбор молча перескакивал между ними от
запроса к запросу.
Два рубежа, а не один:
миграция 190 — частичный уникальный индекс (user_id) WHERE is_default: два
дефолта становятся невозможными на уровне БД;
ORDER BY id — детерминированный выбор, если индекс когда-нибудь снимут.
Соседние запросы этого файла тай-брейк по id уже имеют.
Индекс не мешает штатной переустановке дефолта: порядок операторов в коде уже
правильный (сначала снять у всех, потом поставить), поэтому в момент проверки
дефолтов ноль. Это отдельно проверено тестом.
Безопасность миграции: на проде нарушений нет — у admin один дефолт, у __system__
ноль, ни одного пользователя с двумя. Таблица в 4 строки, индексируется мгновенно.
lock_timeout проставлен по #2752.
Тест проверяет ПОВЕДЕНИЕ на живом Postgres: вторая установка дефолта отвергается
базой. Плюс фальсификация — без индекса два дефолта вставляются молча; без неё
зелёный тест неотличим от «оно и так не вставлялось». Плюс два контроля:
переустановка дефолта работает, разные пользователи сохраняют свои.
Тест про ORDER BY вынесен в tests/services/site_finder, а НЕ внесён в
skip_allowlist: живой БД он не требует, и пропускаться вместе с DB-тестами ему
незачем. Против origin/main он краснеет, показывая запрос без тай-брейка.
Прогоны: без БД — 648 passed rc=0; с БД — 5 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>