Files
ayurishchevandClaude Opus 5.5 ba8ac7be71 Кэш списка бакета, БД без блокировки event loop, один процесс на БД
Производительность и масштабирование, пункты 8–10 ревью (docs/changes/019):
- кэш списка бакета и связей файлов с копиями (BACKUPS_CACHE_TTL, 60 с):
  sync_rows только при реальном чтении бакета; сброс после бэкапа и
  удаления, счётчик поколений против гонки; refresh=1 и «Обновить список»;
- обработчики API/UI без await — обычные функции (пул потоков FastAPI),
  запуск задач остаётся async; запись статуса, задачи и sync_rows —
  через asyncio.to_thread; SQLite: WAL, synchronous=NORMAL, busy_timeout;
- файловая блокировка <файл БД>.lock: второй процесс на той же БД не
  стартует; раздел «Ограничения» в README.

Тесты: 24 из 24. Стенд (порт 8001): кэш 0,014 с против 0,138 с, второй
uvicorn на боевой БД отклонён блокировкой, боевые данные не изменены.
Ручная проверка UI пользователем на момент коммита не подтверждена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:18:32 +03:00

11 KiB
Raw Permalink Blame History

План: 019 — производительность и масштабирование (по ревью 2026-09-27)

Context

Ревью docs/reviews/2026-09-27-codebase-review.md, раздел «Производительность и масштабирование», пункты 8–10:

  • п. 8 — каждое открытие страницы «Бэкапы» (и GET /api/v1/backups) перечитывает весь бакет (s3.list_backups(None)) и запускает backups.sync_rows по всей таблице backups. Нагрузка и стоимость запросов к S3 растут линейно с числом копий.
  • п. 9 — синхронные запросы к SQLite выполняются прямо в async-обработчиках и фоновых задачах и блокируют event loop.
  • п. 10 — состояние хранится в памяти процесса (семафор задач, _sync_lock, счётчики неудачных паролей, кэш п. 8); запуск нескольких процессов на одной БД (--workers > 1, вторая копия контейнера на том же томе) молча ломает эти гарантии.

Решения пользователя:

  • п. 9 — точечно: обработчики без await становятся обычными def (FastAPI выполняет их в пуле потоков); запись статуса в опросе и в задачах — через asyncio.to_thread; SQLite — WAL + busy_timeout. Переход на async-драйвер не делаем.
  • п. 10 — файловая блокировка + README: второй процесс на той же БД не стартует с понятной ошибкой.
  • Стенд — ros_control-ros_control-1 с боевыми данными в томе (удалять существующее нельзя; можно создавать новое и удалять только его). Порт 8000 на хосте занят посторонним процессом, стенд запускается на 8001 через override-файл вне репозитория (как в 018).

Изменения

п. 8 — кэш списка бакета (app/services/backups.py, app/services/ops.py, app/ui/routes.py, app/api/v1.py, app/config.py, .env.example)

  • В backups.py: _cache = {"at": monotonic | None, "objects": list} и asyncio.Lock. Функция async def bucket_objects(refresh=False) -> list[dict]: при свежем кэше (моложе BACKUPS_CACHE_TTL) возвращает его; иначе под lock (с повторной проверкой после захвата) читает s3.list_backups(None), выполняет sync_rows (в asyncio.to_thread, см. п. 9) и сохраняет результат. sync_rows вызывается только при чтении бакета, не при каждом просмотре. Связи key -> backup_id/device_id тоже кэшируются вместе со списком (результат sync_rows).
  • def invalidate() — сбрасывает кэш. Вызывается после собственных изменений бакета: ops.run_backup после загрузки (в finally, т. к. часть файлов могла успеть загрузиться) и delete_many (там sync_rows уже выполняется — заменить на invalidate()
    • bucket_objects(refresh=True), чтобы не дублировать логику).
  • search() и reconcile() используют bucket_objects(); фильтрация — по кэшированному списку, как сейчас.
  • Settings.backups_cache_ttl: int = 60 (секунд; 0 — кэш выключен), в .env.example с комментарием.
  • Принудительное обновление: параметр refresh=1 у GET /backups (UI) и GET /api/v1/backups; кнопка «Обновить список» на странице «Бэкапы» (backups.html) передаёт refresh=1 (сохраняя текущие фильтры).
  • Изменения бакета извне (вручную, другим клиентом) видны не позже чем через TTL или по «Обновить список» — отметить в README.

п. 9 — БД без блокировки event loop (app/api/v1.py, app/ui/routes.py, app/services/*, app/db.py)

  • Обработчики без await → def, кроме тех, что вызывают jobs.start_jobs (внутри asyncio.create_task — нужен работающий event loop, в пуле потоков его нет): create_backup, install_update, upgrade_firmware, batch, batch_channel (API), batch (UI) остаются async. Зависимость require_login остаётся async: ContextVar актора, выставленный в ней, копируется в поток (anyio to_thread.run_sync работает в копии контекста) — это нужно подтвердить тестом.
  • Фоновые пути (asyncio.to_thread, контекст копируется — актор и job_id сохраняются):
    • ops._save_status, чтение устройства в ops.poll_device / ops.refresh_status (devices.get_device, devices.get_conn — включая расшифровку пароля);
    • poller.cycle: devices.list_devices();
    • jobs._finish;
    • ops.run_backup: создание строки Backup и финальная запись статуса;
    • backups.sync_rows (тяжёлая: вся таблица backups). Сигнатуры сервисов не меняются — оборачивается вызов, а не функция.
  • SQLite (app/db.py::init_db): для файловой БД на каждом соединении PRAGMA journal_mode=WAL (один раз достаточно — режим хранится в файле) и PRAGMA busy_timeout=5000 через sqlalchemy.event.listens_for(engine, "connect"); synchronous=NORMAL допустим с WAL. Для :memory: — без изменений. Миграции (migrations.run, отдельное sqlite3-соединение) работают с WAL без изменений; проверить тестом миграции.

п. 10 — один процесс на БД (app/db.py или новый app/process_lock.py, app/main.py, README.md)

  • При старте (в lifespan, до init_db) — fcntl.flock(LOCK_EX | LOCK_NB) на файл <файл БД>.lock рядом с БД. Не удалось → RuntimeError("БД <путь> уже используется другим процессом ros_control: поддерживается только один процесс (--workers 1)"), приложение не стартует. Блокировка снимается при остановке (закрытие дескриптора в lifespan).
  • Для :memory: и не-SQLite URL блокировка не берётся.
  • README, раздел «Запуск»/«Безопасность» или новый короткий «Ограничения»: только один процесс на БД, --workers 1; что хранится в памяти процесса; кэш списка бакета (TTL, «Обновить список»).

Тесты (минимально)

  • п. 8: два вызова backups.search подряд → s3.list_backups вызван один раз; refresh=True или invalidate() → повторный вызов.
  • п. 9: событие, созданное через синхронный теперь обработчик API (например, POST /api/v1/devices), имеет актора api; через UI — ui:<пользователь> (если существующие тесты это уже проверяют — достаточно, что они проходят; иначе добавить assert).
  • п. 10: вторая попытка взять блокировку на тот же файл БД (в tmp_path) → RuntimeError; после освобождения — успешно.
  • Существующие тесты не должны требовать правок, кроме подмен s3.list_backups в тестах бэкапов (сброс кэша между тестами — backups.invalidate() в фикстуре или в самих тестах).

Документация

README: ограничения (один процесс, кэш бакета), BACKUPS_CACHE_TTL в «Настройки», refresh=1 у /api/v1/backups, число тестов, строка 019 в истории изменений. summary.md — оркестратор после проверки.

Исполнение

  • Код, тесты, пересборка стенда — исполнитель (Sonnet). Тесты не запускает, не коммитит, summary.md не создаёт.
  • Оркестратор: ревью диффа, полный прогон тестов, проверки на стенде без удаления существующих данных.
  • Пользователь: ручная проверка UI.

Проверка

  • ./venv/bin/python -m pytest -q — все зелёные.
  • Стенд: docker compose -f docker-compose.yml -f <override 8001> up -d --build --force-recreate; /login → 200; новый код в контейнере (grep -c bucket_objects /srv/app/services/backups.py ≥ 1); PRAGMA journal_mode = wal; в логах нет трейсбеков.
  • Сверка боевых данных до/после — по согласованной копии БД (sqlite3.Connection.backup внутри контейнера; при WAL простой docker cp файла БД недостаточен).
  • Кэш: время ответа GET /api/v1/backups первый и повторный запрос (повторный — без обращения к S3); refresh=1 — снова читает бакет.
  • Event loop: во время медленного запроса к недоступному устройству (POST /api/v1/devices/{id}/refresh для временного устройства 192.0.2.1) параллельный GET /api/v1/devices отвечает без ожидания; временное устройство затем удаляется.
  • Один процесс: docker exec ros_control-ros_control-1 python -c "…"-запуск второго экземпляра приложения на той же БД (или uvicorn --port 8002 внутри контейнера) завершается ошибкой блокировки, работающий стенд не затронут.
  • Ручная проверка UI — пользователь: страница «Бэкапы» быстро открывается при смене фильтров, «Обновить список» подтягивает изменения бакета.