Files
ros_control/docs/changes/022-async-db-remainder/plan.md
T

81 lines
7.7 KiB
Markdown
Raw Normal View History

# План: 022 — остаток п. 9: синхронная БД в async-коде
## Context
Ревью `docs/reviews/2026-09-28-1243-codebase-review.md`, п. 9 (частично закрыт в 019). В 019 обработчики без `await` стали `def`,
фоновые записи ушли в `asyncio.to_thread`, но в **async-функциях** остались прямые синхронные вызовы сервисов, которые ходят в SQLite
(`session_scope`) и блокируют event loop. В ревью названы два места; разведка (AST: async-функции, вызывающие функции с `session_scope`
напрямую или через одну ступень) нашла их больше:
| Где | Синхронные вызовы БД |
|---|---|
| `app/services/backups.py::search` | `groups.list_groups`, `devices.list_devices` |
| `app/services/ops.py::run_backup`, `set_channel`, `run_ros_update`, `run_fw_update` | `devices.get_conn` (чтение + расшифровка пароля) |
| `app/services/ops.py::refresh_many` | `devices.list_devices` |
| `app/api/v1.py::refresh_device`, `put_channel`, `list_backups` | `devices.get_device` |
| `app/api/v1.py::refresh_all` | `devices.list_devices` |
| `app/api/v1.py::create_backup`, `install_update`, `upgrade_firmware`, `batch`, `batch_channel` | `jobs.start_jobs`, `BatchIn.resolve` |
| `app/ui/routes.py::refresh`, `device_action` | `_visible`, `_devices_ctx`, `jobs.list_jobs`, `jobs.start_jobs` |
| `app/ui/routes.py::batch` | `jobs.start_jobs`, `jobs.list_jobs` |
| `app/ui/routes.py::device_create` | `devices.create_device`, `_device_form` |
| `app/ui/routes.py::backups_page` | `devices.list_devices`, `groups.list_groups` |
Ложные срабатывания: `events.record` внутри `ops.run_backup` — во вложенных `_create_row/_finish_row`, уже через `to_thread`.
`app/main.py::lifespan` (`init_db`, `fail_stale_jobs`) — выполняется до приёма запросов; **не меняем**.
Решений пользователя не требуется: объём — «оставшиеся задачи п. 9», подход тот же, что в 019.
## Изменения
### Общий подход
- Синхронный вызов в async-функции → `await asyncio.to_thread(fn, *args)` (контекст копируется — актор и `job_id` в ContextVar сохраняются, как в 019).
Сигнатуры синхронных сервисов не меняются.
- Где несколько подряд идущих синхронных вызовов готовят данные для одного ответа (`_devices_ctx` + `list_jobs`, `list_devices` + `list_groups`) —
одна вложенная синхронная функция и один `to_thread`, а не серия переключений.
### `app/services/jobs.py` — `start_jobs`
- Сейчас синхронная: создаёт строки задач в БД и затем `asyncio.create_task` (нужен работающий event loop — поэтому вызывающие обработчики остались `async`).
- Разделить: `_create_jobs(job_type, device_ids, params) -> list[tuple[job_id, device_id]]` — синхронная, только БД и события (с проверками
типа задачи и ID, как сейчас); `async def start_jobs(job_type, device_ids, params=None) -> list[str]` — `await asyncio.to_thread(_create_jobs, …)`,
затем `create_task` в event loop. Все вызовы `start_jobs` — с `await` (API, UI, тесты).
### `app/services/ops.py`
- Хелпер `async def _conn(device_id) -> devices.Conn` = `await asyncio.to_thread(devices.get_conn, device_id)`; использовать в
`refresh_status`, `poll_device` (уже через `to_thread` — заменить на хелпер для единообразия), `run_backup`, `set_channel`, `run_ros_update`, `run_fw_update`.
- `refresh_many`: `devices.list_devices` → `to_thread`.
### `app/services/backups.py::search`
- `groups.list_groups()` и `devices.list_devices()` — одним `to_thread` (вложенная функция, возвращающая `group_names`, `device_group`).
### `app/api/v1.py`
- `refresh_device`, `put_channel`, `list_backups`: `devices.get_device` → `to_thread`.
- `refresh_all`: итоговый `devices.list_devices` → `to_thread`.
- `batch`, `batch_channel`: `body.resolve()` → `to_thread`; `await jobs.start_jobs(...)`. `create_backup`, `install_update`, `upgrade_firmware` — `await jobs.start_jobs(...)`.
### `app/ui/routes.py`
- `refresh`, `batch`, `device_action`: подготовка контекста (`_visible`, `_devices_ctx`, `jobs.list_jobs`) — через `to_thread`; `await jobs.start_jobs(...)`.
`await request.form()` остаётся в event loop (до `to_thread`).
- `device_create`: `devices.create_device` и повторный рендер формы с ошибкой (`_device_form` читает группы) — через `to_thread`;
`await ops.refresh_status` без изменений.
- `backups_page`: `devices.list_devices`, `groups.list_groups` — через `to_thread`.
## Тесты (минимально)
- **Регрессионный тест-линтер** (`tests/test_app.py`): AST-обход `app/` — в `async def` нет прямых вызовов функций, использующих
`session_scope` (напрямую или через одну ступень вызовов), кроме разрешённого списка (`lifespan`); вызовы, переданные ссылкой в
`asyncio.to_thread(fn, …)`, и вложенные `def` не считаются. Логика — как у скрипта разведки из Context. Это закрепляет п. 9 от регресса.
- Существующие тесты, вызывающие `jobs.start_jobs`, — перевести на `await`. Актор в событиях задач (`api`, `ui:<user>`) должен сохраниться —
подтверждается существующими тестами журнала.
## Документация
README: строка 022 в «История изменений», число тестов. `summary.md` — оркестратор.
## Исполнение
Исполнитель (Sonnet): код, тест, README, пересборка стенда. Тесты не запускает, не коммитит, `.env` не читает.
## Проверка
- `pytest` — все зелёные; тест-линтер падает, если временно вернуть прямой вызов в async-функцию (мутационная проверка — оркестратор).
- Повторный запуск скрипта разведки — пусто (кроме `lifespan`).
- Стенд (override 8001, `--force-recreate`): `/login` 200, новый код в контейнере; сценарий: временное устройство `192.0.2.1` →
`POST /api/v1/devices/{id}/backups` → 202, задача `backup` завершается `failed` (недоступно) с актором `api` в событиях; `PUT /batch/channel` → 202;
параллельный `GET /api/v1/devices` во время `refresh` отвечает сразу; устройство удаляется. Боевые данные — сверка по ID.
- Ручная проверка UI — пользователь: «Обновить статус», групповые действия, меню «⋯» устройства, добавление устройства, страница «Бэкапы».