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

87 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: 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 — пользователь: страница «Бэкапы» быстро открывается при смене фильтров, «Обновить список» подтягивает изменения бакета.