Files
ros_control/docs/changes/022-async-db-remainder/plan.md
T
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

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