From 6ccb99194d76a926764e59c38acaccbcc6bf819d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:56:28 +0400 Subject: [PATCH] =?UTF-8?q?feat(model):=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=8B=20osv=5Fentry=20=D0=B8=20stored=5Ffile=20=D0=B2=20?= =?UTF-8?q?v5.15=20(I-1,=20OQ-117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 3 -- api/alembic/versions/0003_dbml_v5_15.py | 13 +++++- api/src/models/files.py | 6 ++- api/src/models/osv.py | 3 ++ api/tests/test_migrations.py | 41 +++++++++++++++++++ .../changes/slice-2c-osv-ingest/design.md | 5 ++- .../specs/data-model/spec.md | 7 ++-- 7 files changed, 69 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8adfb19..f3f15e1 100644 --- a/README.md +++ b/README.md @@ -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», «ГТД» diff --git a/api/alembic/versions/0003_dbml_v5_15.py b/api/alembic/versions/0003_dbml_v5_15.py index 6b51c48..ef7b3c0 100644 --- a/api/alembic/versions/0003_dbml_v5_15.py +++ b/api/alembic/versions/0003_dbml_v5_15.py @@ -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( diff --git a/api/src/models/files.py b/api/src/models/files.py index 3241f80..b856173 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -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 diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 0f007b4..fbebe82 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -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( diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 505eeb0..a8e74d0 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -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 не затрагиваются.""" diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index a7d1f64..13ae891 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -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, первая: »; «маппинг не удался: ошибка БД/S3 (), попыток 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 | diff --git a/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md index d62d583..b0c3da5 100644 --- a/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md +++ b/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md @@ -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` на той же БД