Files
ayurishchevandClaude Opus 5.5 0bda0038d0 Остаток п. 9 ревью: синхронная БД вне event loop, повторное ревью
Повторное ревью кодовой базы: 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>
2026-09-28 17:12:44 +03:00

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