Files

86 lines
11 KiB
Markdown
Raw Permalink Normal View 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 — пользователь: страница «Бэкапы» быстро открывается при смене фильтров, «Обновить список» подтягивает изменения бакета.