fix(data): партиции rosreestr_deals на Q2–Q4 2026 + загрузчик доходит до файла (#2998)
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 38s
Deploy / build-worker (push) Successful in 39s
Deploy / deploy (push) Successful in 1m15s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 38s
Deploy / build-worker (push) Successful in 39s
Deploy / deploy (push) Successful in 1m15s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
Закрывает корень #2998 (партиции rosreestr_deals). Данные Q2 уже на проде. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
ce466c6d03
4 changed files with 246 additions and 2 deletions
|
|
@ -165,3 +165,14 @@ 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-теста поднимают 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
|
||||
|
|
|
|||
193
backend/tests/sql/test_2998_rosreestr_partition_horizon.py
Normal file
193
backend/tests/sql/test_2998_rosreestr_partition_horizon.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""У 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), плюс на следующий — чтобы предупреждение приходило за
|
||||
квартал до отказа, а не в день публикации.
|
||||
|
||||
Герметично, как соседние 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:
|
||||
"""Миграция применяется дважды без ошибки и добавляет 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} — загрузка опубликованного дампа упадёт с "
|
||||
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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
29
data/sql/193_partitions_rosreestr_2026_q2_q4.sql
Normal file
29
data/sql/193_partitions_rosreestr_2026_q2_q4.sql
Normal file
|
|
@ -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');
|
||||
Loading…
Add table
Reference in a new issue