test(#2998): сторож партиций — герметично в схеме-песочнице, а не по живому проду
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 17m6s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 17m6s
(см. описание в PR #3014) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ebb7b7d32d
commit
9b54d64bd8
2 changed files with 93 additions and 83 deletions
|
|
@ -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_cross_schema_duplicate_still_merges
|
||||||
tests/sql/test_2986_permits_source_key.py::test_migration_allows_the_izmeneniya_group
|
tests/sql/test_2986_permits_source_key.py::test_migration_allows_the_izmeneniya_group
|
||||||
|
|
||||||
# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста читают pg_inherits живого
|
# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста поднимают rosreestr_deals из
|
||||||
# Postgres (партиция обязана существовать на публикуемый квартал + на следующий, миграция
|
# 01_schema в СХЕМЕ-ПЕСОЧНИЦЕ (герметично, прод не трогают), применяют миграцию 193 и
|
||||||
# 193 идемпотентна, красная сторона воспроизводится DETACH'ем в откатываемой транзакции).
|
# читают горизонт из pg_inherits: до 193 красно по значению (01-схема кончается на 2026q1),
|
||||||
|
# после — партиция есть на публикуемый квартал + на следующий; 193 идемпотентна.
|
||||||
# В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. Календарный
|
# В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. Календарный
|
||||||
# test_calendar_helper_matches_known_publication базы НЕ требует и в список НЕ входит.
|
# 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_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_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
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,14 @@ poll заметил его 14.08, а загрузка 21.08 упала:
|
||||||
дней: Q2 2026 вышел 10.07), плюс на следующий — чтобы предупреждение приходило за
|
дней: Q2 2026 вышел 10.07), плюс на следующий — чтобы предупреждение приходило за
|
||||||
квартал до отказа, а не в день публикации.
|
квартал до отказа, а не в день публикации.
|
||||||
|
|
||||||
Проверяется на живом Postgres по pg_inherits/relpartbound — то есть по тому, что
|
Герметично, как соседние DB-тесты: в своей СХЕМЕ-песочнице поднимается
|
||||||
база ДЕЙСТВИТЕЛЬНО примет, а не по тексту миграции. На машине без БД — skip
|
rosreestr_deals из 01_schema (без индексов), применяется миграция 193, горизонт
|
||||||
(в skip_allowlist). В CI идёт.
|
читается из pg_inherits по этой схеме. Прод не трогается; в CI идёт на
|
||||||
|
postgres-сервисе. Без БД — skip с причиной.
|
||||||
|
|
||||||
|
Красная сторона воспроизводима, а не предполагается: до миграции 193 горизонт
|
||||||
|
01-схемы кончается на 2026q1, и головная проверка краснеет по значению
|
||||||
|
(«нет партиции на квартал 2026-04-01»).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -27,15 +32,17 @@ import os
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
_MIGRATION = (
|
_SQL_DIR = Path(__file__).resolve().parents[3] / "data" / "sql"
|
||||||
Path(__file__).resolve().parents[3] / "data" / "sql" / "193_partitions_rosreestr_2026_q2_q4.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.
|
# раньше, чем данные вообще могут появиться. Q2 2026 (конец 30.06) опубликован 10.07.
|
||||||
|
|
@ -59,18 +66,21 @@ def _next_quarter(q: date) -> date:
|
||||||
|
|
||||||
def _latest_publishable_quarter(today: date) -> date:
|
def _latest_publishable_quarter(today: date) -> date:
|
||||||
"""Начало последнего квартала, чей дамп по календарю уже мог выйти."""
|
"""Начало последнего квартала, чей дамп по календарю уже мог выйти."""
|
||||||
# Квартал считается «публикуемым», если с его конца прошло ≥ _PUBLICATION_LAG_DAYS.
|
|
||||||
q = _quarter_start(today)
|
q = _quarter_start(today)
|
||||||
# предыдущий квартал закончился в день q-1
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
prev_q = _quarter_start(q - timedelta(days=1))
|
prev_q = _quarter_start(q - timedelta(days=1))
|
||||||
if (today - q).days >= _PUBLICATION_LAG_DAYS:
|
if (today - q).days >= _PUBLICATION_LAG_DAYS:
|
||||||
return prev_q
|
return prev_q
|
||||||
return _quarter_start(prev_q - timedelta(days=1))
|
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(
|
rows = conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -78,41 +88,84 @@ def _partition_starts(conn) -> set[date]:
|
||||||
FROM pg_inherits i
|
FROM pg_inherits i
|
||||||
JOIN pg_class c ON c.oid = i.inhrelid
|
JOIN pg_class c ON c.oid = i.inhrelid
|
||||||
JOIN pg_class p ON p.oid = i.inhparent
|
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()
|
).scalars()
|
||||||
out: set[date] = set()
|
out: set[date] = set()
|
||||||
for b in rows:
|
for b in rows:
|
||||||
# FOR VALUES FROM ('2026-04-01') TO ('2026-07-01')
|
|
||||||
frm = b.split("FROM ('", 1)[1].split("'", 1)[0]
|
frm = b.split("FROM ('", 1)[1].split("'", 1)[0]
|
||||||
out.add(date.fromisoformat(frm))
|
out.add(date.fromisoformat(frm))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def conn():
|
def sandbox():
|
||||||
|
"""Схема-песочница с rosreestr_deals из 01_schema. Удаляется в конце модуля."""
|
||||||
try:
|
try:
|
||||||
eng = create_engine(_dsn(), future=True)
|
eng = create_engine(_dsn(), future=True)
|
||||||
c = eng.connect()
|
conn = eng.connect()
|
||||||
c.execute(text("SELECT 1"))
|
conn.execute(text("SELECT 1"))
|
||||||
|
conn.rollback() # SELECT 1 авто-открыл транзакцию — закрыть, иначе begin() падает
|
||||||
except Exception as e: # pragma: no cover - среда без БД
|
except Exception as e: # pragma: no cover - среда без БД
|
||||||
pytest.skip(f"нет Postgres для проверки партиций: {e}")
|
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:
|
try:
|
||||||
yield c
|
yield conn, schema
|
||||||
finally:
|
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 — тест красный по значению
|
def test_schema_01_alone_is_red_for_the_publishable_quarter(sandbox) -> None:
|
||||||
(«нет партиции на 2026-04-01»), не по отсутствию символа.
|
"""Красная сторона: ДО миграции 193 партиции на публикуемый квартал нет.
|
||||||
|
|
||||||
|
Это ровно состояние origin/main на 21.08.2026, когда загрузка Q2 упала.
|
||||||
|
Проверка по значению: горизонт 01-схемы кончается на 2026q1.
|
||||||
"""
|
"""
|
||||||
have = _partition_starts(conn)
|
conn, schema = sandbox
|
||||||
assert have, "у rosreestr_deals нет ни одной партиции — это не та база"
|
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())
|
need = _latest_publishable_quarter(date.today())
|
||||||
assert need in have, (
|
assert need in have, (
|
||||||
f"нет партиции на квартал {need} — загрузка опубликованного дампа упадёт с "
|
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:
|
def test_partition_exists_one_quarter_ahead(sandbox) -> None:
|
||||||
"""Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть.
|
"""Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть — предупреждение
|
||||||
|
должно приходить за квартал до отказа, а не в день публикации."""
|
||||||
Иначе предупреждение пришло бы в день публикации, когда дамп уже лежит и его
|
conn, schema = sandbox
|
||||||
уже нельзя загрузить — ровно то, что случилось с Q2 2026.
|
_apply_193(conn, schema)
|
||||||
"""
|
have = _partition_starts(conn, schema)
|
||||||
have = _partition_starts(conn)
|
|
||||||
need = _next_quarter(_latest_publishable_quarter(date.today()))
|
need = _next_quarter(_latest_publishable_quarter(date.today()))
|
||||||
assert need in have, (
|
assert need in have, (
|
||||||
f"нет партиции на следующий квартал {need} — запаса нет, следующая публикация "
|
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:
|
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)
|
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)
|
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)
|
assert _latest_publishable_quarter(date(2026, 7, 25)) == date(2026, 4, 1)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue