fix(data): партиции rosreestr_deals на Q2–Q4 2026 + загрузчик доходит до файла (#2998)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Failing after 17m20s
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Failing after 17m20s
Задача формулировала «импорт каждый день рапортует 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>
This commit is contained in:
parent
a003fdd37c
commit
e6e5bd962c
4 changed files with 236 additions and 2 deletions
|
|
@ -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_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_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 живого
|
||||||
|
# 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
|
||||||
|
|
|
||||||
184
backend/tests/sql/test_2998_rosreestr_partition_horizon.py
Normal file
184
backend/tests/sql/test_2998_rosreestr_partition_horizon.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env bash
|
#!/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 '~'.
|
# Q3 2024 uses ';' separator, all later quarters use '~'.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
|
|
@ -42,6 +42,12 @@ declare -a JOBS=(
|
||||||
"2025Q3:2025-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_3.csv:~"
|
"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:~"
|
"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:~"
|
"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/ (можно класть как есть, скрипт распакует).
|
# Возможные имена zip-ов в data/raw/ (можно класть как есть, скрипт распакует).
|
||||||
|
|
@ -82,7 +88,12 @@ resolve_csv() {
|
||||||
|
|
||||||
for job in "${JOBS[@]}"; do
|
for job in "${JOBS[@]}"; do
|
||||||
IFS=':' read -r SRC_Q PERIOD FILE_CANDIDATES SEP <<< "$job"
|
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
|
if [[ -z "$FILE" ]]; then
|
||||||
echo "=== $SRC_Q SKIP — нет CSV в data/raw/ (искал: $FILE_CANDIDATES; sdelki_${SRC_Q,,}.csv.zip)"
|
echo "=== $SRC_Q SKIP — нет CSV в data/raw/ (искал: $FILE_CANDIDATES; sdelki_${SRC_Q,,}.csv.zip)"
|
||||||
continue
|
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