Skip to content

Latest commit

 

History

History
149 lines (107 loc) · 16.7 KB

File metadata and controls

149 lines (107 loc) · 16.7 KB

ARCHITECTURE — целевая архитектура бэкенда

Документ описывает, к чему приводится бэкенд и почему. Реализуется инкрементально; бизнес-логика и публичный контракт API сохраняются (см. раздел «Что сохранено 1:1»). Ссылки вида A1, P1 — на пункты AUDIT.md.

Принципы

  1. Тонкие контроллеры, толстые сервисы, изолированный доступ к данным. HTTP-слой только валидирует и оркеструет; бизнес-логика — в сервисах; SQL — в репозиториях.
  2. Один источник правды для инфраструктуры. Настройки, engine и сессия создаются в одном месте и внедряются через DI.
  3. Ничего лишнего. Структура соответствует размеру проекта; новые зависимости — только с обоснованием.
  4. Совместимость превыше красоты. Любое отклонение от исходного поведения — явное и задокументированное.

Целевая структура

backend/src/
├── core/
│   ├── config.py        # Settings (pydantic-settings) — единый типизированный конфиг
│   └── database.py      # engine, session factory, get_session (DI), session_scope (воркер)
├── storage.py           # FileStorage — абстракция файлового хранилища, неблокирующий I/O
├── exceptions.py        # доменные исключения (FileNotFound, EmptyFile, StoredFileNotFound)
├── models.py            # ORM-модели (StoredFile, Alert)
├── schemas.py           # Pydantic-DTO (FileItem, AlertItem, FileUpdate)
├── repositories/
│   ├── file.py          # FileRepository — доступ к данным files
│   └── alert.py         # AlertRepository — доступ к данным alerts
├── services/
│   ├── file.py          # FileService — загрузка, чтение, обновление, удаление
│   └── processing.py    # ProcessingService — конвейер scan → metadata → alert
├── api/
│   ├── deps.py          # DI-зависимости (сессия → репозитории → сервисы)
│   ├── files.py         # роутер /files
│   └── alerts.py        # роутер /alerts
├── tasks.py             # Celery-приложение + одна задача process_file
└── app.py               # create_app() — сборка приложения

Разбиение по пакетам repositories/, services/, api/ выбрано намеренно: слои видны из дерева каталогов. storage, exceptions, config, database — одиночные модули: сущностей мало, отдельные пакеты были бы раздуванием.

Слои и ответственность

core/config.py — конфигурация (решает C1, C2)

Класс Settings на pydantic-settings читает окружение с типизацией и валидацией, собирает database_url и celery_broker_url как свойства. Голый os.environ, разбросанный по service.py/tasks.py, убирается. Рассинхрон брокера (CELERY_BROKER_URL в .env.devREDIS_URL в коде) устраняется: имя переменной становится единым.

core/database.py — доступ к БД (решает A1, A2)

Единственный create_async_engine и async_sessionmaker. Две точки входа:

  • get_session() — FastAPI-зависимость (генератор), даёт сессию на запрос и гарантированно закрывает её;
  • session_scope() — асинхронный контекст-менеджер для Celery-задач.

Дублирование engine (было в service.py и tasks.py) исчезает.

storage.py — файловое хранилище (решает I1, I2, A4)

Класс FileStorage инкапсулирует каталог хранения и операции над файлами. Запись — потоковая и неблокирующая: UploadFile читается чанками и пишется через anyio.open_file, event loop не блокируется, весь файл не держится в памяти. Чтение/удаление/проверка существования — через anyio.to_thread. Роутер и сервисы больше не трогают pathlib/диск напрямую.

repositories/ — доступ к данным (решает A2, A3, D3)

FileRepository и AlertRepository получают сессию в конструкторе и инкапсулируют все запросы (get, list_ordered, add, delete). Сервисы не пишут SQL. Повторные session.get(...) заменяются переиспользованием уже загруженного объекта.

services/ — бизнес-логика (решает A3, A5, M1)

  • FileService: загрузка (через FileStorage + FileRepository), получение, список, обновление, удаление. Дублирующая логика download из роутера и мёртвый get_file_path заменяются одним методом.
  • ProcessingService: конвейер scan → metadata → alert (перенесён из tasks.py). Работает с уже загруженной записью; порядок вычислений и все значения (scan_status, scan_details, requires_attention, metadata_json, уровни алертов) — без изменений.

Сервисы не знают про HTTP: вместо HTTPException бросают доменные исключения из exceptions.py.

api/ — контроллеры (решает A4, A6)

Роутеры files.py и alerts.py содержат только объявления эндпоинтов и вызовы сервисов через DI (deps.py). Доменные исключения транслируются в HTTP через обработчики исключений, зарегистрированные в create_app(). Так коды ответов (404/400/201/204) сохраняются, но HTTP-детали уходят из сервисов.

tasks.py — фоновые задачи (решает P1, P2, D3)

Celery-приложение и одна задача process_file, выполняющая весь конвейер (см. «Неочевидная оптимизация»). Event loop и engine воркера инициализируются корректно (см. «Надёжность воркера»).

app.py — сборка (решает A6)

create_app() собирает FastAPI: middleware, роутеры, обработчики исключений. Модульных сайд-эффектов на импорте нет.

Неочевидная оптимизация (Этап 3): конвейер из трёх задач → одна

Что было

Загрузка запускала цепочку из трёх Celery-задач, каждая из которых передавала эстафету следующей через .delay():

scan_file_for_threats → extract_file_metadata → send_file_alert

На обработку одного файла приходилось:

  • 3 постановки в брокер и 3 доставки (сериализация/десериализация, round-trip Redis);
  • 3 отдельные сессии БД (checkout соединения из пула ×3);
  • 3 выборки одной и той же записи по первичному ключу (session.get(StoredFile, id) в каждой задаче);
  • 3 независимых event loop-прогонки в воркере.

Подтверждено логом воркера: на каждый файл — три пары received/succeeded.

Почему это неочевидно

Разбиение выглядит как «правильное разделение ответственности»: сканирование, метаданные и алерты — разные шаги. Но конвейер строго линейный: нет ветвления, нет параллелизма, каждый шаг всегда следует за предыдущим для той же записи. В таком случае дробление на задачи не даёт ни изоляции сбоев, ни параллелизма — только тройные накладные расходы на брокер, сессии и выборки.

Что стало

Одна задача process_file(file_id): одна постановка в брокер, одна сессия, одна выборка записи, переиспользуемая всеми тремя шагами. Логика шагов (значения scan/metadata/alert и наблюдаемый переход processing → processed/failed) сохраняется.

Ожидаемый эффект (на один файл)

Ресурс Было Стало
Задач в брокере (round-trip) 3 1
Сессий БД (checkout) 3 1
SELECT записи по PK 3 1
Прогонов event loop 3 1

При потоке в N файлов экономия линейна: −2N обращений к брокеру, −2N сессий, −2N выборок. Латентность обработки падает за счёт устранённых очередей между задачами.

Прочие улучшения производительности

Индексы под реальные запросы (решает D1, D2)

Отдельной Alembic-ревизией добавляются индексы:

  • alerts.file_id — FK не индексируется Postgres автоматически; нужен для выборок алертов по файлу и для каскадного удаления;
  • files.created_at и alerts.created_at — обе выборки списков сортируют по created_at DESC.

Надёжность воркера (решает P2)

Engine больше не создаётся на уровне модуля до появления event loop. Sessionmaker воркера инициализируется лениво внутри рабочего цикла, поэтому asyncpg-пул привязан к тому же loop, в котором исполняется, — исключаются ошибки «Future attached to a different loop».

Осознанные изменения поведения

DELETE файла с алертами: 500 → 204 (B1)

Было: DELETE /files/{id} для обработанного файла падал с HTTP 500 (ForeignKeyViolationError), потому что FK alerts.file_id не имел ON DELETE CASCADE, а сервис не удалял алерты. Обработанный файл (у него всегда есть алерт) удалить было невозможно.

Стало: отдельной ревизией FK пересоздаётся с ON DELETE CASCADE; удаление файла каскадно удаляет его алерты и возвращает 204 — как и задумано контрактом DELETE.

Обоснование: 500 не был частью контракта — это необработанная ошибка целостности. Исправление приводит поведение в соответствие с объявленным 204, а не ломает контракт. Помечено как осознанное изменение согласно требованию «любое изменение поведения — только явное и задокументированное».

Доработки по обратной связи (v2)

После ревью закрыты названные замечания:

  • Метаданные читаются потоково, не целиком. read_text/read_bytes заменены на подсчёт по мере чтения: строки/символы — построчной итерацией (universal newlines, результат идентичен len(text.splitlines()) / len(text)), вхождения /Type /Page в PDF — чанками с overlap на границе. Пиковая память больше не растёт с размером файла.
  • Пагинация /files и /alerts. Добавлены query-параметры limit (1–200, по умолчанию 50) и offset (≥0). Ответ остаётся list[...], запрос без параметров совместим — возвращается первая страница.
  • Retry фоновых задач. process_file повторяется при сбоях (autoretry_for, экспоненциальный retry_backoff, max_retries=3). Конвейер идемпотентен: повтор пересчитывает scan/metadata к тем же значениям, алерт фиксируется только при успешном проходе.
  • Согласованность файла и БД.
    • delete: файл удаляется после успешного commit — сбой commit больше не оставляет запись без файла.
    • create: при ошибке commit уже записанный файл удаляется — не остаётся файла-сироты.

Изменение контракта: GET /files и GET /alerts теперь отдают страницу (по умолчанию до 50 записей), а не весь список. Сделано осознанно по прямому замечанию ревьюера.

Зависимости

  • + pydantic-settings — обоснованно. В Pydantic v2 BaseSettings вынесен в отдельный пакет; это стандартный способ типизированной конфигурации в экосистеме FastAPI. Заменяет ручной разбор os.environ с валидацией и единым источником настроек. Альтернатива (свой парсер env) — больше кода и багов при меньшей надёжности.
  • anyio — без добавления в зависимости. Неблокирующий файловый I/O реализуется на anyio, который уже присутствует транзитивно (через Starlette/FastAPI). Специальная библиотека aiofiles не добавляется — она дала бы то же самое ценой лишней прямой зависимости.

Что сохранено 1:1

  • Публичный контракт API: пути, методы, коды (201/204/400/404), формы запроса/ответа, набор и имена полей DTO (в т.ч. скрытый stored_name), сортировки created_at DESC.
  • Бизнес-логика конвейера: правила scan_status/scan_details/requires_attention, извлечение metadata_json (line/char count для text/*, approx page count для PDF), уровни и тексты алертов, переходы статусов uploaded → processing → processed/failed.
  • Команды запуска: docker compose -f docker-compose.dev.yml up и docker exec -it backend alembic upgrade head; фронт на /test, бэк на /docs.
  • Стек: FastAPI + SQLAlchemy (async) + Celery + Next.js; миграции применяются с нуля.

Намеренные отклонения (оба задокументированы выше): DELETE (500 → 204) и пагинация списков GET /files / GET /alerts.