Повторное ревью кодовой базы: docs/reviews/2026-09-28-1243-codebase-review.md (статус 12 замечаний, новые замечания 13–16). Остаток п. 9 (docs/changes/022): - 14 async-функций API, UI и сервисов больше не обращаются к SQLite напрямую — через asyncio.to_thread; jobs.start_jobs стал async (БД в потоке, create_task в event loop); ops._conn для подключения к устройству; - тест-линтер по AST: в async def нет прямых вызовов функций с session_scope — защита от регресса. Тесты: 29 из 29. Стенд: задачи и актор событий в порядке, параллельные запросы не ждут медленного устройства, боевые данные не изменены. Ручная проверка UI пользователем на момент коммита не подтверждена. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
7.7 KiB
Markdown
82 lines
7.7 KiB
Markdown
# План: 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 — пользователь: «Обновить статус», групповые действия, меню «⋯» устройства, добавление устройства, страница «Бэкапы».
|