# План: 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:`) должен сохраниться — подтверждается существующими тестами журнала. ## Документация 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 — пользователь: «Обновить статус», групповые действия, меню «⋯» устройства, добавление устройства, страница «Бэкапы».