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:
parent
b054ca6261
commit
6ccb99194d
@ -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», «ГТД»
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 не затрагиваются."""
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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` на той же БД
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user