gendesign/backend/tests/sql/test_2998_rosreestr_partition_horizon.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

193 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""У 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 добавляет Q2Q4 2026; этот тест следит, чтобы горизонт не
отставал снова: партиция обязана существовать на ПОСЛЕДНИЙ квартал, который по
календарю уже мог быть опубликован (публикация отстаёт от конца квартала ~на 10
дней: Q2 2026 вышел 10.07), плюс на следующий — чтобы предупреждение приходило за
квартал до отказа, а не в день публикации.
Герметично, как соседние DB-тесты: в своей СХЕМЕ-песочнице поднимается
rosreestr_deals из 01_schema (без индексов), применяется миграция 193, горизонт
читается из pg_inherits по этой схеме. Прод не трогается; в CI идёт на
postgres-сервисе. Без БД — skip с причиной.
Красная сторона воспроизводима, а не предполагается: до миграции 193 горизонт
01-схемы кончается на 2026q1, и головная проверка краснеет по значению
(«нет партиции на квартал 2026-04-01»).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import re
import uuid
from datetime import date, timedelta
from pathlib import Path
import pytest
from sqlalchemy import create_engine, text
_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.
_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:
"""Начало последнего квартала, чей дамп по календарю уже мог выйти."""
q = _quarter_start(today)
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 _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(
"""
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
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:
frm = b.split("FROM ('", 1)[1].split("'", 1)[0]
out.add(date.fromisoformat(frm))
return out
@pytest.fixture(scope="module")
def sandbox():
"""Схема-песочница с rosreestr_deals из 01_schema. Удаляется в конце модуля."""
try:
eng = create_engine(_dsn(), future=True)
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 conn, schema
finally:
conn.rollback()
with conn.begin():
conn.execute(text(f"DROP SCHEMA {schema} CASCADE"))
conn.close()
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")))
def test_schema_01_alone_is_red_for_the_publishable_quarter(sandbox) -> None:
"""Красная сторона: ДО миграции 193 партиции на публикуемый квартал нет.
Это ровно состояние origin/main на 21.08.2026, когда загрузка Q2 упала.
Проверка по значению: горизонт 01-схемы кончается на 2026q1.
"""
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:
"""Миграция применяется дважды без ошибки и добавляет Q2Q4 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} — загрузка опубликованного дампа упадёт с "
f"«no partition of relation rosreestr_deals found for row»; есть: {sorted(have)[-3:]}"
)
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} — запаса нет, следующая публикация "
f"снова упрётся в отсутствие партиции; есть: {sorted(have)[-3:]}"
)
def test_calendar_helper_matches_known_publication() -> None:
"""Контроль калибровки (без БД): 21.08.2026 → Q2 2026; 05.07 → ещё Q1; 25.07 → Q2."""
assert _latest_publishable_quarter(date(2026, 8, 21)) == date(2026, 4, 1)
assert _latest_publishable_quarter(date(2026, 7, 5)) == date(2026, 1, 1)
assert _latest_publishable_quarter(date(2026, 7, 25)) == date(2026, 4, 1)