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