From e6e5bd962ce6b17376b595dd57d7dcb71fd0e90d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 12:48:28 +0500 Subject: [PATCH 1/3] =?UTF-8?q?fix(data):=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20rosreestr=5Fdeals=20=D0=BD=D0=B0=20Q2?= =?UTF-8?q?=E2=80=93Q4=202026=20+=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20(#2998)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задача формулировала «импорт каждый день рапортует 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 --- backend/tests/skip_allowlist.txt | 10 + .../test_2998_rosreestr_partition_horizon.py | 184 ++++++++++++++++++ data/sql/02_load_all_quarters.sh | 15 +- .../193_partitions_rosreestr_2026_q2_q4.sql | 29 +++ 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 backend/tests/sql/test_2998_rosreestr_partition_horizon.py create mode 100644 data/sql/193_partitions_rosreestr_2026_q2_q4.sql diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index d1c28220..31809ac9 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -165,3 +165,13 @@ tests/sql/test_2986_permits_source_key.py::test_old_key_collapses_permit_and_its tests/sql/test_2986_permits_source_key.py::test_after_migration_both_documents_survive tests/sql/test_2986_permits_source_key.py::test_cross_schema_duplicate_still_merges tests/sql/test_2986_permits_source_key.py::test_migration_allows_the_izmeneniya_group + +# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста читают pg_inherits живого +# Postgres (партиция обязана существовать на публикуемый квартал + на следующий, миграция +# 193 идемпотентна, красная сторона воспроизводится DETACH'ем в откатываемой транзакции). +# В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. Календарный +# test_calendar_helper_matches_known_publication базы НЕ требует и в список НЕ входит. +tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter +tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead +tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_is_idempotent +tests/sql/test_2998_rosreestr_partition_horizon.py::test_headline_is_red_without_the_partition diff --git a/backend/tests/sql/test_2998_rosreestr_partition_horizon.py b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py new file mode 100644 index 00000000..ce06661c --- /dev/null +++ b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py @@ -0,0 +1,184 @@ +"""У rosreestr_deals есть партиция под каждый квартал, который Росреестр уже мог +опубликовать (#2998). + +Таблица партиционирована по period_start_date, и партиции создавались списком в +01_schema_rosreestr_deals.sql — «2024 Q3 — 2026 Q1». Дальше этого горизонта таблица +ничего не знала, и никакой механизм новые партиции не создаёт. Q2 2026 вышел 10.07, +poll заметил его 14.08, а загрузка 21.08 упала: + + ERROR: no partition of relation "rosreestr_deals" found for row + DETAIL: Partition key of the failing row contains (period_start_date) = (2026-04-01). + +То есть даже оператор, запустив 02_load_all_quarters.sh по подсказке poll, получил +бы отказ. Миграция 193 добавляет Q2–Q4 2026; этот тест следит, чтобы горизонт не +отставал снова: партиция обязана существовать на ПОСЛЕДНИЙ квартал, который по +календарю уже мог быть опубликован (публикация отстаёт от конца квартала ~на 10 +дней: Q2 2026 вышел 10.07), плюс на следующий — чтобы предупреждение приходило за +квартал до отказа, а не в день публикации. + +Проверяется на живом Postgres по pg_inherits/relpartbound — то есть по тому, что +база ДЕЙСТВИТЕЛЬНО примет, а не по тексту миграции. На машине без БД — skip +(в skip_allowlist). В CI идёт. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import date +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text + +_MIGRATION = ( + Path(__file__).resolve().parents[3] / "data" / "sql" / "193_partitions_rosreestr_2026_q2_q4.sql" +) + +# Публикация квартала отстаёт от его конца; берём запас, чтобы не требовать партицию +# раньше, чем данные вообще могут появиться. Q2 2026 (конец 30.06) опубликован 10.07. +_PUBLICATION_LAG_DAYS = 20 + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return raw.replace("postgresql://", "postgresql+psycopg://", 1) + + +def _quarter_start(d: date) -> date: + return date(d.year, 3 * ((d.month - 1) // 3) + 1, 1) + + +def _next_quarter(q: date) -> date: + return date(q.year + (q.month == 10), 1 if q.month == 10 else q.month + 3, 1) + + +def _latest_publishable_quarter(today: date) -> date: + """Начало последнего квартала, чей дамп по календарю уже мог выйти.""" + # Квартал считается «публикуемым», если с его конца прошло ≥ _PUBLICATION_LAG_DAYS. + q = _quarter_start(today) + # предыдущий квартал закончился в день q-1 + from datetime import timedelta + + prev_q = _quarter_start(q - timedelta(days=1)) + if (today - q).days >= _PUBLICATION_LAG_DAYS: + return prev_q + return _quarter_start(prev_q - timedelta(days=1)) + + +def _partition_starts(conn) -> set[date]: + rows = conn.execute( + text( + """ + SELECT pg_get_expr(c.relpartbound, c.oid) AS bound + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + JOIN pg_class p ON p.oid = i.inhparent + WHERE p.relname = 'rosreestr_deals' + """ + ) + ).scalars() + out: set[date] = set() + for b in rows: + # FOR VALUES FROM ('2026-04-01') TO ('2026-07-01') + frm = b.split("FROM ('", 1)[1].split("'", 1)[0] + out.add(date.fromisoformat(frm)) + return out + + +@pytest.fixture(scope="module") +def conn(): + try: + eng = create_engine(_dsn(), future=True) + c = eng.connect() + c.execute(text("SELECT 1")) + except Exception as e: # pragma: no cover - среда без БД + pytest.skip(f"нет Postgres для проверки партиций: {e}") + try: + yield c + finally: + c.close() + + +def test_partition_exists_for_every_publishable_quarter(conn) -> None: + """Головной: на каждый уже-публикуемый квартал есть партиция. + + Без миграции 193 на origin/main последняя партиция — 2026q1, а по календарю + 21.08.2026 публикуемым является Q2 2026 — тест красный по значению + («нет партиции на 2026-04-01»), не по отсутствию символа. + """ + have = _partition_starts(conn) + assert have, "у rosreestr_deals нет ни одной партиции — это не та база" + need = _latest_publishable_quarter(date.today()) + assert need in have, ( + f"нет партиции на квартал {need} — загрузка опубликованного дампа упадёт с " + f"«no partition of relation rosreestr_deals found for row»; есть: {sorted(have)[-3:]}" + ) + + +def test_partition_exists_one_quarter_ahead(conn) -> None: + """Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть. + + Иначе предупреждение пришло бы в день публикации, когда дамп уже лежит и его + уже нельзя загрузить — ровно то, что случилось с Q2 2026. + """ + have = _partition_starts(conn) + need = _next_quarter(_latest_publishable_quarter(date.today())) + assert need in have, ( + f"нет партиции на следующий квартал {need} — запаса нет, следующая публикация " + f"снова упрётся в отсутствие партиции; есть: {sorted(have)[-3:]}" + ) + + +def test_migration_is_idempotent(conn) -> None: + """Контроль: миграция 193 повторно применяется без ошибки (IF NOT EXISTS).""" + sql = _MIGRATION.read_text(encoding="utf-8") + # Соединение после предыдущих SELECT уже в авто-открытой транзакции — закрываем её, + # иначе begin() падает «already initialized a Transaction». + conn.rollback() + with conn.begin(): + conn.execute(text(sql)) + conn.execute(text(sql)) + have = _partition_starts(conn) + assert {date(2026, 4, 1), date(2026, 7, 1), date(2026, 10, 1)} <= have + + +def test_headline_is_red_without_the_partition(conn) -> None: + """Красная сторона, воспроизводимая на проде, где миграция уже применена. + + В транзакции отцепляем партицию публикуемого квартала и проверяем, что головная + проверка краснеет ПО ЗНАЧЕНИЮ («нет партиции на 2026-04-01»), а не по отсутствию + символа; затем откатываем. Без этого теста зелёный головной на проде неотличим от + тавтологии «партиции есть, потому что есть». + """ + need = _latest_publishable_quarter(date.today()) + name = f"rosreestr_deals_{need.year}q{(need.month - 1) // 3 + 1}" + conn.rollback() + trans = conn.begin() + try: + conn.execute(text("SET LOCAL lock_timeout = '5s'")) + conn.execute(text(f"ALTER TABLE rosreestr_deals DETACH PARTITION {name}")) + have = _partition_starts(conn) + assert need not in have, "партиция не отцепилась — проверка красной стороны не состоялась" + # Это и есть то, что увидел бы тест на origin/main: + with pytest.raises(AssertionError, match="нет партиции на квартал"): + assert need in have, f"нет партиции на квартал {need}" + finally: + trans.rollback() + assert need in _partition_starts(conn), "откат не вернул партицию — тест испортил базу" + + +def test_calendar_helper_matches_known_publication() -> None: + """Контроль калибровки: 21.08.2026 → публикуемый квартал Q2 2026, следующий — Q3. + + Не требует БД. Фиксирует дату, на которой отказ реально произошёл. + """ + assert _latest_publishable_quarter(date(2026, 8, 21)) == date(2026, 4, 1) + # 05.07 — квартал только закончился, дамп ещё не вышел → требуется лишь Q1 + assert _latest_publishable_quarter(date(2026, 7, 5)) == date(2026, 1, 1) + # 25.07 — прошло 25 дней, Q2 уже публикуем + assert _latest_publishable_quarter(date(2026, 7, 25)) == date(2026, 4, 1) diff --git a/data/sql/02_load_all_quarters.sh b/data/sql/02_load_all_quarters.sh index 69326c0c..51f63383 100644 --- a/data/sql/02_load_all_quarters.sh +++ b/data/sql/02_load_all_quarters.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Loads all 7 quarters of dataset_СДЕЛКИ into rosreestr_deals via staging. +# Loads quarters of dataset_СДЕЛКИ into rosreestr_deals via staging (see JOBS below). # Q3 2024 uses ';' separator, all later quarters use '~'. # # Usage: @@ -42,6 +42,12 @@ declare -a JOBS=( "2025Q3:2025-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_3.csv:~" "2025Q4:2025-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_4.csv:~" "2026Q1:2026-01-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv:~" + # #2998: партиции под Q2–Q4 2026 — миграция 193. Загрузчик скипает квартал, для + # которого нет CSV в data/raw/, поэтому строки на ещё не опубликованные кварталы + # безопасны: они ждут файла, а не падают. + "2026Q2:2026-04-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv:~" + "2026Q3:2026-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_3.csv:~" + "2026Q4:2026-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_4.csv:~" ) # Возможные имена zip-ов в data/raw/ (можно класть как есть, скрипт распакует). @@ -82,7 +88,12 @@ resolve_csv() { for job in "${JOBS[@]}"; do IFS=':' read -r SRC_Q PERIOD FILE_CANDIDATES SEP <<< "$job" - FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q") + # `|| true` обязателен (#2998): resolve_csv сигналит «файла нет» кодом 1, а скрипт + # идёт под `set -e` — без этого первый же квартал без CSV в data/raw/ молча ронял + # ВЕСЬ прогон до строки SKIP, и до реально лежащего файла (например, одного Q2 на + # VPS) загрузчик не доходил никогда. Проверено на проде 21.08: с одним Q2-файлом в + # каталоге скрипт завершался с rc=0, не напечатав ни одной строки. + FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q" || true) if [[ -z "$FILE" ]]; then echo "=== $SRC_Q SKIP — нет CSV в data/raw/ (искал: $FILE_CANDIDATES; sdelki_${SRC_Q,,}.csv.zip)" continue diff --git a/data/sql/193_partitions_rosreestr_2026_q2_q4.sql b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql new file mode 100644 index 00000000..d0a26ef8 --- /dev/null +++ b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql @@ -0,0 +1,29 @@ +-- Партиции rosreestr_deals на Q2–Q4 2026 (#2998). +-- +-- Почему это понадобилось. Схема 01_schema_rosreestr_deals.sql создавала партиции +-- списком «2024 Q3 — 2026 Q1» — дальше этого горизонта таблица ничего не знала, и +-- никакой механизм новые партиции не создаёт. Q2 2026 опубликован Росреестром +-- 10.07, poll заметил его 14.08, а загрузка упёрлась бы в +-- ERROR: no partition of relation "rosreestr_deals" found for row +-- DETAIL: Partition key of the failing row contains (period_start_date) = (2026-04-01) +-- — ровно это и случилось при первой попытке загрузить 21.08. То есть даже оператор, +-- запустив 02_load_all_quarters.sh по подсказке poll, получил бы отказ. +-- +-- Создаём с запасом на три квартала вперёд, чтобы каждый новый квартал не начинался +-- с этой же ошибки. Q1 2027 и далее — следующая такая же миграция (или автосоздание, +-- если до него дойдут руки; пока его нет нигде — проверено grep'ом по app/ и data/sql/). +-- +-- Индексы НЕ перечисляем: партиции наследуют четыре индекса родителя автоматически +-- (проверено по rosreestr_deals_2026q1: pkey(id, period_start_date), doc_type partial, +-- realestate_type_code, (region_code, quarter_cad_number)). +-- +-- Range FROM inclusive, TO exclusive — [start_q, start_next_q). Идемпотентно. + +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q2 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q3 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q4 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); From ebb7b7d32d17fad88af860bac365a8a0d0d52cdb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 13:04:35 +0500 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D0=BA=20backend-tests=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=B6=D0=BE=D0=B1=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=20=D1=81=20?= =?UTF-8?q?07:49=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20(#2998)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Остальные семь чеков прошли за минуты, backend-tests стоял 1ч30м без единого обновления статуса. Actions API на этом Forgejo недоступен (/actions/tasks обрывает соединение), перезапуск — только новым коммитом. Код не меняется. From 9b54d64bd8b1ee93657f52d6808b4c673318efb0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 13:33:57 +0500 Subject: [PATCH 3/3] =?UTF-8?q?test(#2998):=20=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20=E2=80=94=20=D0=B3=D0=B5=D1=80=D0=BC=D0=B5=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=B2=20=D1=81=D1=85=D0=B5=D0=BC=D0=B5-=D0=BF?= =?UTF-8?q?=D0=B5=D1=81=D0=BE=D1=87=D0=BD=D0=B8=D1=86=D0=B5,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=BC?= =?UTF-8?q?=D1=83=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (см. описание в PR #3014) Co-Authored-By: Claude Opus 5 --- backend/tests/skip_allowlist.txt | 11 +- .../test_2998_rosreestr_partition_horizon.py | 165 +++++++++--------- 2 files changed, 93 insertions(+), 83 deletions(-) diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 31809ac9..0c08811c 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -166,12 +166,13 @@ tests/sql/test_2986_permits_source_key.py::test_after_migration_both_documents_s tests/sql/test_2986_permits_source_key.py::test_cross_schema_duplicate_still_merges tests/sql/test_2986_permits_source_key.py::test_migration_allows_the_izmeneniya_group -# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста читают pg_inherits живого -# Postgres (партиция обязана существовать на публикуемый квартал + на следующий, миграция -# 193 идемпотентна, красная сторона воспроизводится DETACH'ем в откатываемой транзакции). +# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста поднимают rosreestr_deals из +# 01_schema в СХЕМЕ-ПЕСОЧНИЦЕ (герметично, прод не трогают), применяют миграцию 193 и +# читают горизонт из pg_inherits: до 193 красно по значению (01-схема кончается на 2026q1), +# после — партиция есть на публикуемый квартал + на следующий; 193 идемпотентна. # В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. Календарный # test_calendar_helper_matches_known_publication базы НЕ требует и в список НЕ входит. +tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_for_the_publishable_quarter +tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead -tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_is_idempotent -tests/sql/test_2998_rosreestr_partition_horizon.py::test_headline_is_red_without_the_partition diff --git a/backend/tests/sql/test_2998_rosreestr_partition_horizon.py b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py index ce06661c..1275c420 100644 --- a/backend/tests/sql/test_2998_rosreestr_partition_horizon.py +++ b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py @@ -16,9 +16,14 @@ poll заметил его 14.08, а загрузка 21.08 упала: дней: Q2 2026 вышел 10.07), плюс на следующий — чтобы предупреждение приходило за квартал до отказа, а не в день публикации. -Проверяется на живом Postgres по pg_inherits/relpartbound — то есть по тому, что -база ДЕЙСТВИТЕЛЬНО примет, а не по тексту миграции. На машине без БД — skip -(в skip_allowlist). В CI идёт. +Герметично, как соседние DB-тесты: в своей СХЕМЕ-песочнице поднимается +rosreestr_deals из 01_schema (без индексов), применяется миграция 193, горизонт +читается из pg_inherits по этой схеме. Прод не трогается; в CI идёт на +postgres-сервисе. Без БД — skip с причиной. + +Красная сторона воспроизводима, а не предполагается: до миграции 193 горизонт +01-схемы кончается на 2026q1, и головная проверка краснеет по значению +(«нет партиции на квартал 2026-04-01»). """ from __future__ import annotations @@ -27,15 +32,17 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from datetime import date +import re +import uuid +from datetime import date, timedelta from pathlib import Path import pytest from sqlalchemy import create_engine, text -_MIGRATION = ( - Path(__file__).resolve().parents[3] / "data" / "sql" / "193_partitions_rosreestr_2026_q2_q4.sql" -) +_SQL_DIR = Path(__file__).resolve().parents[3] / "data" / "sql" +_SCHEMA_01 = _SQL_DIR / "01_schema_rosreestr_deals.sql" +_MIGRATION = _SQL_DIR / "193_partitions_rosreestr_2026_q2_q4.sql" # Публикация квартала отстаёт от его конца; берём запас, чтобы не требовать партицию # раньше, чем данные вообще могут появиться. Q2 2026 (конец 30.06) опубликован 10.07. @@ -59,18 +66,21 @@ def _next_quarter(q: date) -> date: def _latest_publishable_quarter(today: date) -> date: """Начало последнего квартала, чей дамп по календарю уже мог выйти.""" - # Квартал считается «публикуемым», если с его конца прошло ≥ _PUBLICATION_LAG_DAYS. q = _quarter_start(today) - # предыдущий квартал закончился в день q-1 - from datetime import timedelta - prev_q = _quarter_start(q - timedelta(days=1)) if (today - q).days >= _PUBLICATION_LAG_DAYS: return prev_q return _quarter_start(prev_q - timedelta(days=1)) -def _partition_starts(conn) -> set[date]: +def _schema_01_without_indexes() -> str: + """01_schema без CREATE INDEX: индексы тесту не нужны, а часть их — по колонкам, + которых в песочнице проверять нечего. Партиции и сама таблица — целиком.""" + sql = _SCHEMA_01.read_text(encoding="utf-8") + return re.sub(r"CREATE INDEX[^;]*;", "", sql, flags=re.S) + + +def _partition_starts(conn, schema: str) -> set[date]: rows = conn.execute( text( """ @@ -78,41 +88,84 @@ def _partition_starts(conn) -> set[date]: FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid JOIN pg_class p ON p.oid = i.inhparent - WHERE p.relname = 'rosreestr_deals' + JOIN pg_namespace n ON n.oid = p.relnamespace + WHERE p.relname = 'rosreestr_deals' AND n.nspname = :schema """ - ) + ), + {"schema": schema}, ).scalars() out: set[date] = set() for b in rows: - # FOR VALUES FROM ('2026-04-01') TO ('2026-07-01') frm = b.split("FROM ('", 1)[1].split("'", 1)[0] out.add(date.fromisoformat(frm)) return out @pytest.fixture(scope="module") -def conn(): +def sandbox(): + """Схема-песочница с rosreestr_deals из 01_schema. Удаляется в конце модуля.""" try: eng = create_engine(_dsn(), future=True) - c = eng.connect() - c.execute(text("SELECT 1")) + conn = eng.connect() + conn.execute(text("SELECT 1")) + conn.rollback() # SELECT 1 авто-открыл транзакцию — закрыть, иначе begin() падает except Exception as e: # pragma: no cover - среда без БД pytest.skip(f"нет Postgres для проверки партиций: {e}") + schema = f"t2998_{uuid.uuid4().hex[:10]}" + with conn.begin(): + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute(text(f"SET LOCAL search_path TO {schema}")) + conn.execute(text(_schema_01_without_indexes())) try: - yield c + yield conn, schema finally: - c.close() + conn.rollback() + with conn.begin(): + conn.execute(text(f"DROP SCHEMA {schema} CASCADE")) + conn.close() -def test_partition_exists_for_every_publishable_quarter(conn) -> None: - """Головной: на каждый уже-публикуемый квартал есть партиция. +def _apply_193(conn, schema: str) -> None: + conn.rollback() # предыдущие SELECT авто-открыли транзакцию + with conn.begin(): + conn.execute(text(f"SET LOCAL search_path TO {schema}")) + conn.execute(text(_MIGRATION.read_text(encoding="utf-8"))) - Без миграции 193 на origin/main последняя партиция — 2026q1, а по календарю - 21.08.2026 публикуемым является Q2 2026 — тест красный по значению - («нет партиции на 2026-04-01»), не по отсутствию символа. + +def test_schema_01_alone_is_red_for_the_publishable_quarter(sandbox) -> None: + """Красная сторона: ДО миграции 193 партиции на публикуемый квартал нет. + + Это ровно состояние origin/main на 21.08.2026, когда загрузка Q2 упала. + Проверка по значению: горизонт 01-схемы кончается на 2026q1. """ - have = _partition_starts(conn) - assert have, "у rosreestr_deals нет ни одной партиции — это не та база" + conn, schema = sandbox + have = _partition_starts(conn, schema) + assert have, "песочница пуста — 01_schema не применилась" + assert max(have) == date( + 2026, 1, 1 + ), f"горизонт 01-схемы ожидался 2026q1, есть {sorted(have)[-2:]}" + need = date(2026, 4, 1) # публикуемый квартал на дату инцидента + assert need not in have, "красная сторона не состоялась: 01-схема уже знает Q2 2026" + + +def test_migration_193_is_idempotent_and_closes_the_gap(sandbox) -> None: + """Миграция применяется дважды без ошибки и добавляет Q2–Q4 2026.""" + conn, schema = sandbox + _apply_193(conn, schema) + _apply_193(conn, schema) + have = _partition_starts(conn, schema) + assert {date(2026, 4, 1), date(2026, 7, 1), date(2026, 10, 1)} <= have, sorted(have)[-4:] + + +def test_partition_exists_for_every_publishable_quarter(sandbox) -> None: + """Головной сторож горизонта: на каждый уже-публикуемый квартал есть партиция. + + Сегодня это Q2 2026 (после 193 — есть). Когда календарь дойдёт до квартала, + которого миграции ещё не создали, тест покраснеет по значению — и это его работа. + """ + conn, schema = sandbox + _apply_193(conn, schema) + have = _partition_starts(conn, schema) need = _latest_publishable_quarter(date.today()) assert need in have, ( f"нет партиции на квартал {need} — загрузка опубликованного дампа упадёт с " @@ -120,13 +173,12 @@ def test_partition_exists_for_every_publishable_quarter(conn) -> None: ) -def test_partition_exists_one_quarter_ahead(conn) -> None: - """Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть. - - Иначе предупреждение пришло бы в день публикации, когда дамп уже лежит и его - уже нельзя загрузить — ровно то, что случилось с Q2 2026. - """ - have = _partition_starts(conn) +def test_partition_exists_one_quarter_ahead(sandbox) -> None: + """Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть — предупреждение + должно приходить за квартал до отказа, а не в день публикации.""" + conn, schema = sandbox + _apply_193(conn, schema) + have = _partition_starts(conn, schema) need = _next_quarter(_latest_publishable_quarter(date.today())) assert need in have, ( f"нет партиции на следующий квартал {need} — запаса нет, следующая публикация " @@ -134,51 +186,8 @@ def test_partition_exists_one_quarter_ahead(conn) -> None: ) -def test_migration_is_idempotent(conn) -> None: - """Контроль: миграция 193 повторно применяется без ошибки (IF NOT EXISTS).""" - sql = _MIGRATION.read_text(encoding="utf-8") - # Соединение после предыдущих SELECT уже в авто-открытой транзакции — закрываем её, - # иначе begin() падает «already initialized a Transaction». - conn.rollback() - with conn.begin(): - conn.execute(text(sql)) - conn.execute(text(sql)) - have = _partition_starts(conn) - assert {date(2026, 4, 1), date(2026, 7, 1), date(2026, 10, 1)} <= have - - -def test_headline_is_red_without_the_partition(conn) -> None: - """Красная сторона, воспроизводимая на проде, где миграция уже применена. - - В транзакции отцепляем партицию публикуемого квартала и проверяем, что головная - проверка краснеет ПО ЗНАЧЕНИЮ («нет партиции на 2026-04-01»), а не по отсутствию - символа; затем откатываем. Без этого теста зелёный головной на проде неотличим от - тавтологии «партиции есть, потому что есть». - """ - need = _latest_publishable_quarter(date.today()) - name = f"rosreestr_deals_{need.year}q{(need.month - 1) // 3 + 1}" - conn.rollback() - trans = conn.begin() - try: - conn.execute(text("SET LOCAL lock_timeout = '5s'")) - conn.execute(text(f"ALTER TABLE rosreestr_deals DETACH PARTITION {name}")) - have = _partition_starts(conn) - assert need not in have, "партиция не отцепилась — проверка красной стороны не состоялась" - # Это и есть то, что увидел бы тест на origin/main: - with pytest.raises(AssertionError, match="нет партиции на квартал"): - assert need in have, f"нет партиции на квартал {need}" - finally: - trans.rollback() - assert need in _partition_starts(conn), "откат не вернул партицию — тест испортил базу" - - def test_calendar_helper_matches_known_publication() -> None: - """Контроль калибровки: 21.08.2026 → публикуемый квартал Q2 2026, следующий — Q3. - - Не требует БД. Фиксирует дату, на которой отказ реально произошёл. - """ + """Контроль калибровки (без БД): 21.08.2026 → Q2 2026; 05.07 → ещё Q1; 25.07 → Q2.""" assert _latest_publishable_quarter(date(2026, 8, 21)) == date(2026, 4, 1) - # 05.07 — квартал только закончился, дамп ещё не вышел → требуется лишь Q1 assert _latest_publishable_quarter(date(2026, 7, 5)) == date(2026, 1, 1) - # 25.07 — прошло 25 дней, Q2 уже публикуем assert _latest_publishable_quarter(date(2026, 7, 25)) == date(2026, 4, 1)