feat(model): индексы osv_entry и stored_file в v5.15 (I-1, OQ-117)

- ORM: ix_osv_entry_parent_entry_id, ix_osv_entry_recognition_job_id (models/osv.py),
  ix_stored_file_current_recognition_job_id (models/files.py) — по DBML v5.15 п. 4.
- Миграция 0003_dbml_v5_15: create_index в upgrade, drop_index в downgrade.
- tests/test_migrations.py: индексы есть после upgrade head (btree, не unique), исчезают
  после downgrade на 0002 и возвращаются при повторном upgrade.
- openspec change: design.md решение №53 (I-1 закрыт в v5.15), Migration Plan; дельта
  data-model — 0003 создаёт индексы, откат на шаг их удаляет.
- README «Ограничения Среза 2c»: убран пункт об отсутствии индексов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 00:56:28 +04:00
parent b054ca6261
commit 6ccb99194d
7 changed files with 69 additions and 9 deletions

View File

@ -531,9 +531,6 @@ S3 (storage); без S3 — предупреждение в логе, цикл
- Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон.
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
- У `osv_entry(parent_entry_id)`, `osv_entry(recognition_job_id)` и `stored_file(current_recognition_job_id)` нет
индексов: `DELETE /files/{id}` на большой таблице строк ОСВ медленный (seq scan на проверке self-FK) — OQ-117
(нужны DBML v5.16 и миграция 0004).
- Маппер Среза 2c разбирает только листы ОСВ (`osvDocument`); ББ/ОФР и результаты `scan-gateway` — следующие срезы,
прочие документы результата (BALANCE/INCOME/OTHER) игнорируются без предупреждения.
- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД»

View File

@ -1,4 +1,5 @@
"""DBML v5.15: osv_data_type += NU, app_user.email в нижнем регистре (design D-13, решение №13).
"""DBML v5.15: osv_data_type += NU, app_user.email в нижнем регистре (design D-13, решение №13), индексы для проверок FK
при удалении файла и обхода дерева строк ОСВ (v5.15 п.4, I-1, OQ-117).
`ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип osv_data_type со значениями v5.14 (строк `osv_entry`
с `data_type = 'NU'` быть не должно; иначе downgrade падает на USING — это осознанно, как в 0002).
@ -24,15 +25,25 @@ depends_on: str | Sequence[str] | None = None
SCHEMA = "dkb"
OSV_DATA_TYPE_V5_14 = ("BU", "CURRENCY", "QUANTITY", "SUM")
# v5.15 п.4: (имя, таблица, колонка) — обычные btree-индексы, имена как в ORM
FK_LOOKUP_INDEXES = (
("ix_osv_entry_parent_entry_id", "osv_entry", "parent_entry_id"),
("ix_osv_entry_recognition_job_id", "osv_entry", "recognition_job_id"),
("ix_stored_file_current_recognition_job_id", "stored_file", "current_recognition_job_id"),
)
def upgrade() -> None:
# порядок значений — как в DBML v5.15 (сверка enum'ов сравнивает упорядоченные списки)
op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type ADD VALUE IF NOT EXISTS 'NU' AFTER 'BU'")
op.execute(f"UPDATE {SCHEMA}.app_user SET email = lower(email) WHERE email <> lower(email)")
for name, table, column in FK_LOOKUP_INDEXES:
op.create_index(name, table, [column], schema=SCHEMA)
def downgrade() -> None:
for name, table, _column in FK_LOOKUP_INDEXES:
op.drop_index(name, table_name=table, schema=SCHEMA)
op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type RENAME TO osv_data_type_old")
postgresql.ENUM(*OSV_DATA_TYPE_V5_14, name="osv_data_type", schema=SCHEMA).create(op.get_bind(), checkfirst=False)
op.execute(

View File

@ -51,7 +51,11 @@ class StoredFile(UUIDPrimaryMixin, Base):
"""Физический файл одной загрузки; что внутри — `document` (1:N). Первичка не редактируется (ТЗ 8.2)."""
__tablename__ = "stored_file"
__table_args__ = (Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"),)
__table_args__ = (
Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"),
# v5.15 п.4 (OQ-117): проверка FK при удалении recognition_job / файла
Index("ix_stored_file_current_recognition_job_id", "current_recognition_job_id"),
)
legal_entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False

View File

@ -24,6 +24,9 @@ class OsvEntry(UUIDPrimaryMixin, Base):
__table_args__ = (
Index("uq_osv_entry_document_id_row_number", "document_id", "row_number", unique=True),
Index("ix_osv_entry_owner_account_counterparty", "owner_legal_entity_id", "account_id", "counterparty_id"),
# v5.15 п.4 (OQ-117): проверки FK при удалении документа/файла (в том числе self-FK) и обход дерева строк
Index("ix_osv_entry_parent_entry_id", "parent_entry_id"),
Index("ix_osv_entry_recognition_job_id", "recognition_job_id"),
)
recognition_job_id: Mapped[uuid.UUID] = mapped_column(

View File

@ -40,6 +40,12 @@ V5_14 = "0002_dbml_v5_14"
PREVIOUS = "0001_dbml_v5_13"
VERSION_TABLE = "alembic_version"
FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job"
# v5.15 п.4 (I-1, OQ-117): индексы для проверок FK при удалении файла/документа и обхода дерева строк ОСВ
V5_15_INDEXES = {
"ix_osv_entry_parent_entry_id": ("osv_entry", "parent_entry_id"),
"ix_osv_entry_recognition_job_id": ("osv_entry", "recognition_job_id"),
"ix_stored_file_current_recognition_job_id": ("stored_file", "current_recognition_job_id"),
}
async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]:
@ -270,6 +276,41 @@ async def test_downgrade_one_step_fails_while_nu_rows_exist(scratch_db_url: str)
await engine.dispose()
async def _v5_15_indexes(conn: AsyncConnection) -> dict[str, str]:
rows = (
await conn.execute(
text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND indexname = ANY(:names)"),
{"names": list(V5_15_INDEXES)},
)
).all()
return {row[0]: row[1] for row in rows}
@pytest.mark.integration
async def test_upgrade_0003_creates_fk_lookup_indexes(scratch_db_url: str) -> None:
"""0003 создаёт обычные (не unique) btree-индексы v5.15 п.4; откат на 0002 удаляет, повторный upgrade возвращает."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
indexes = await _v5_15_indexes(conn)
assert set(indexes) == set(V5_15_INDEXES)
for name, (table, column) in V5_15_INDEXES.items():
assert indexes[name] == f"CREATE INDEX {name} ON dkb.{table} USING btree ({column})"
await engine.dispose()
await alembic(scratch_db_url, "downgrade", V5_14)
async with engine.connect() as conn:
assert await _v5_15_indexes(conn) == {}
await engine.dispose()
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
assert set(await _v5_15_indexes(conn)) == set(V5_15_INDEXES)
finally:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_0003_lowercases_existing_emails(scratch_db_url: str) -> None:
"""0003 приводит `app_user.email` к нижнему регистру; уже строчные и NULL не затрагиваются."""

View File

@ -147,7 +147,9 @@ rows без TOTAL, у каждого не-корня есть родитель,
`0003_dbml_v5_15`: `ALTER TYPE osv_data_type ADD VALUE 'NU' AFTER 'BU'` (downgrade — пересоздание типа, как в 0002);
`UPDATE app_user SET email = lower(email)` (при дублях по регистру миграция падает — осознанно; downgrade данные не
возвращает). `HEAD` в тестах миграций → `0003_dbml_v5_15`.
возвращает); индексы v5.15 п.4 `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`,
`ix_stored_file_current_recognition_job_id` (downgrade — `drop_index`; I-1, OQ-117, решение №53). `HEAD` в тестах
миграций → `0003_dbml_v5_15`.
## Open Questions
@ -210,3 +212,4 @@ rows без TOTAL, у каждого не-корня есть родитель,
| 50 | 2026-10-06 | Task 12: статус документа `ERROR` описан в README как зарезервированный (ядро Среза 2c его не создаёт: файл без ОСВ → файл `ERROR`); «нарастающая по данным Q1 → `NEEDS_CHECK`» (R-17, №30) вынесена в OQ как вопрос владельцу | в Срезе 2c нет сценария, где документ получает `ERROR`; решение R-17 меняет поведение для всех нарастающих Q2–Q4 | текст README/OQ |
| 51 | 2026-10-06 | R-24 (final review I-1): индексы `osv_entry(parent_entry_id)`, `osv_entry(recognition_job_id)`, `stored_file(current_recognition_job_id)` в этой ветке НЕ добавляются — нужны DBML v5.16 и миграция 0004 (источник истины — DBML); заведено как OQ-117 и follow-up issue с блокером «до прода / до подсреза 2d (UI удаления)». РЕШЕНИЕ ЧЕЛОВЕКА | поля и индексы вне DBML запрещены; правка модели — отдельный change с версией DBML | `DELETE /files/{id}` деградирует на большой таблице: замер 8,6 с на документ в 1000 строк при 200 тыс. строк (seq scan на проверке self-FK), при ~3 млн строк ≈ 2 мин → HTTP-таймаут и долгие блокировки |
| 52 | 2026-10-06 | R-25 (final review fix wave: M-1, M-3, T7.2): `metrics.mapping_error` — короткая категория вместо текста исключения («результат не соответствует схеме recognition-result: ошибок N, первая: <loc> <type>»; «маппинг не удался: ошибка БД/S3 (<ExcType>), попыток N»), полный текст — только в логе; `DELETE` ставит `expires_at` только объектам папок своего файла (`uploads`+`uploads/{file_id}/`, `results`+`results/{file_id}/`), чужой объект по ссылке задания не трогается и ошибкой не считается (уточняет №36, №48 «безусловно»); черновики `osv_entry` перед вставкой пачками устойчиво сортируются по `level` (родитель раньше потомка на границе пачки); фабрика `make_stored_file` кладёт загрузку в `uploads/{file_id}/…` | `mapping_error` виден пользователю с `DOCUMENT_VIEW` — тексты pydantic/SQLAlchemy раскрывают входные данные и SQL; inbox переиспользует `s3_object` при конфликте `(bucket, key)`; контракт не гарантирует порядок строк | диагностика по карточке беднее (детали — в логе); объект с нестандартным ключом не получит `expires_at` (растёт бакет, OQ-111) |
| 53 | 2026-10-06 | I-1 закрыт сессией в v5.15: три индекса `osv_entry(parent_entry_id)`, `osv_entry(recognition_job_id)`, `stored_file(current_recognition_job_id)` добавлены в DBML v5.15 (п. 4 changelog, docs) и в ORM + миграцию `0003_dbml_v5_15` (`ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`, `ix_stored_file_current_recognition_job_id`); v5.16 и миграция 0004 не заводятся — v5.15 ещё не слит. Отменяет №51 в части «индексы в этой ветке не добавляются»; OQ-117 закрыт | поручение сессии (контроллера) после final review; DBML остаётся источником истины — индексы сначала в DBML, сверка `make dbml-check` пуста | минимальная: обычные btree-индексы, замедляют только вставку строк ОСВ; откат — `drop_index` в downgrade 0003 |

View File

@ -40,8 +40,9 @@ ORM-метаданные SHALL содержать ровно 43 таблицы
### Requirement: Миграции применяются с нуля и откатываются
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` SHALL создавать схему `dkb` (если нет), все
enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`)
на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type` и приводить
`app_user.email` к нижнему регистру; `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через
на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type`, приводить
`app_user.email` к нижнему регистру и создавать индексы `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`,
`ix_stored_file_current_recognition_job_id` (DBML v5.15 п. 4); `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через
пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок.
#### Scenario: Полный цикл
@ -51,7 +52,7 @@ enum'ы, таблицы, ограничения, индексы и послед
#### Scenario: Откат на один шаг
- **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14`
- **THEN** `osv_data_type` не содержит `NU`; регистр `app_user.email` не возвращается (данные)
- **THEN** `osv_data_type` не содержит `NU`, индексов v5.15 п. 4 нет; регистр `app_user.email` не возвращается (данные)
#### Scenario: Автогенерация ничего не находит
- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД