Производительность и масштабирование, пункты 8–10 ревью (docs/changes/019): - кэш списка бакета и связей файлов с копиями (BACKUPS_CACHE_TTL, 60 с): sync_rows только при реальном чтении бакета; сброс после бэкапа и удаления, счётчик поколений против гонки; refresh=1 и «Обновить список»; - обработчики API/UI без await — обычные функции (пул потоков FastAPI), запуск задач остаётся async; запись статуса, задачи и sync_rows — через asyncio.to_thread; SQLite: WAL, synchronous=NORMAL, busy_timeout; - файловая блокировка <файл БД>.lock: второй процесс на той же БД не стартует; раздел «Ограничения» в README. Тесты: 24 из 24. Стенд (порт 8001): кэш 0,014 с против 0,138 с, второй uvicorn на боевой БД отклонён блокировкой, боевые данные не изменены. Ручная проверка UI пользователем на момент коммита не подтверждена. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
11 KiB
План: 019 — производительность и масштабирование (по ревью 2026-09-27)
Context
Ревью docs/reviews/2026-09-27-codebase-review.md, раздел «Производительность и масштабирование», пункты 8–10:
- п. 8 — каждое открытие страницы «Бэкапы» (и
GET /api/v1/backups) перечитывает весь бакет (s3.list_backups(None)) и запускаетbackups.sync_rowsпо всей таблицеbackups. Нагрузка и стоимость запросов к S3 растут линейно с числом копий. - п. 9 — синхронные запросы к SQLite выполняются прямо в async-обработчиках и фоновых задачах и блокируют event loop.
- п. 10 — состояние хранится в памяти процесса (семафор задач,
_sync_lock, счётчики неудачных паролей, кэш п. 8); запуск нескольких процессов на одной БД (--workers > 1, вторая копия контейнера на том же томе) молча ломает эти гарантии.
Решения пользователя:
- п. 9 — точечно: обработчики без
awaitстановятся обычнымиdef(FastAPI выполняет их в пуле потоков); запись статуса в опросе и в задачах — черезasyncio.to_thread; SQLite — WAL +busy_timeout. Переход на async-драйвер не делаем. - п. 10 — файловая блокировка + README: второй процесс на той же БД не стартует с понятной ошибкой.
- Стенд —
ros_control-ros_control-1с боевыми данными в томе (удалять существующее нельзя; можно создавать новое и удалять только его). Порт 8000 на хосте занят посторонним процессом, стенд запускается на 8001 через override-файл вне репозитория (как в 018).
Изменения
п. 8 — кэш списка бакета (app/services/backups.py, app/services/ops.py, app/ui/routes.py, app/api/v1.py, app/config.py, .env.example)
- В
backups.py:_cache = {"at": monotonic | None, "objects": list}иasyncio.Lock. Функцияasync def bucket_objects(refresh=False) -> list[dict]: при свежем кэше (моложеBACKUPS_CACHE_TTL) возвращает его; иначе под lock (с повторной проверкой после захвата) читаетs3.list_backups(None), выполняетsync_rows(вasyncio.to_thread, см. п. 9) и сохраняет результат.sync_rowsвызывается только при чтении бакета, не при каждом просмотре. Связиkey -> backup_id/device_idтоже кэшируются вместе со списком (результатsync_rows). def invalidate()— сбрасывает кэш. Вызывается после собственных изменений бакета:ops.run_backupпосле загрузки (вfinally, т. к. часть файлов могла успеть загрузиться) иdelete_many(тамsync_rowsуже выполняется — заменить наinvalidate()bucket_objects(refresh=True), чтобы не дублировать логику).
search()иreconcile()используютbucket_objects(); фильтрация — по кэшированному списку, как сейчас.Settings.backups_cache_ttl: int = 60(секунд;0— кэш выключен), в.env.exampleс комментарием.- Принудительное обновление: параметр
refresh=1уGET /backups(UI) иGET /api/v1/backups; кнопка «Обновить список» на странице «Бэкапы» (backups.html) передаётrefresh=1(сохраняя текущие фильтры). - Изменения бакета извне (вручную, другим клиентом) видны не позже чем через TTL или по «Обновить список» — отметить в README.
п. 9 — БД без блокировки event loop (app/api/v1.py, app/ui/routes.py, app/services/*, app/db.py)
- Обработчики без
await→def, кроме тех, что вызываютjobs.start_jobs(внутриasyncio.create_task— нужен работающий event loop, в пуле потоков его нет):create_backup,install_update,upgrade_firmware,batch,batch_channel(API),batch(UI) остаютсяasync. Зависимостьrequire_loginостаётсяasync: ContextVar актора, выставленный в ней, копируется в поток (anyioto_thread.run_syncработает в копии контекста) — это нужно подтвердить тестом. - Фоновые пути (
asyncio.to_thread, контекст копируется — актор иjob_idсохраняются):ops._save_status, чтение устройства вops.poll_device/ops.refresh_status(devices.get_device,devices.get_conn— включая расшифровку пароля);poller.cycle:devices.list_devices();jobs._finish;ops.run_backup: создание строкиBackupи финальная запись статуса;backups.sync_rows(тяжёлая: вся таблицаbackups). Сигнатуры сервисов не меняются — оборачивается вызов, а не функция.
- SQLite (
app/db.py::init_db): для файловой БД на каждом соединенииPRAGMA journal_mode=WAL(один раз достаточно — режим хранится в файле) иPRAGMA busy_timeout=5000черезsqlalchemy.event.listens_for(engine, "connect");synchronous=NORMALдопустим с WAL. Для:memory:— без изменений. Миграции (migrations.run, отдельноеsqlite3-соединение) работают с WAL без изменений; проверить тестом миграции.
п. 10 — один процесс на БД (app/db.py или новый app/process_lock.py, app/main.py, README.md)
- При старте (в
lifespan, доinit_db) —fcntl.flock(LOCK_EX | LOCK_NB)на файл<файл БД>.lockрядом с БД. Не удалось →RuntimeError("БД <путь> уже используется другим процессом ros_control: поддерживается только один процесс (--workers 1)"), приложение не стартует. Блокировка снимается при остановке (закрытие дескриптора вlifespan). - Для
:memory:и не-SQLite URL блокировка не берётся. - README, раздел «Запуск»/«Безопасность» или новый короткий «Ограничения»: только один процесс на БД,
--workers 1; что хранится в памяти процесса; кэш списка бакета (TTL, «Обновить список»).
Тесты (минимально)
- п. 8: два вызова
backups.searchподряд →s3.list_backupsвызван один раз;refresh=Trueилиinvalidate()→ повторный вызов. - п. 9: событие, созданное через синхронный теперь обработчик API (например,
POST /api/v1/devices), имеет актораapi; через UI —ui:<пользователь>(если существующие тесты это уже проверяют — достаточно, что они проходят; иначе добавить assert). - п. 10: вторая попытка взять блокировку на тот же файл БД (в
tmp_path) →RuntimeError; после освобождения — успешно. - Существующие тесты не должны требовать правок, кроме подмен
s3.list_backupsв тестах бэкапов (сброс кэша между тестами —backups.invalidate()в фикстуре или в самих тестах).
Документация
README: ограничения (один процесс, кэш бакета), BACKUPS_CACHE_TTL в «Настройки», refresh=1 у /api/v1/backups, число тестов, строка 019 в истории изменений.
summary.md — оркестратор после проверки.
Исполнение
- Код, тесты, пересборка стенда — исполнитель (Sonnet). Тесты не запускает, не коммитит,
summary.mdне создаёт. - Оркестратор: ревью диффа, полный прогон тестов, проверки на стенде без удаления существующих данных.
- Пользователь: ручная проверка UI.
Проверка
./venv/bin/python -m pytest -q— все зелёные.- Стенд:
docker compose -f docker-compose.yml -f <override 8001> up -d --build --force-recreate;/login→ 200; новый код в контейнере (grep -c bucket_objects /srv/app/services/backups.py≥ 1);PRAGMA journal_mode=wal; в логах нет трейсбеков. - Сверка боевых данных до/после — по согласованной копии БД (
sqlite3.Connection.backupвнутри контейнера; при WAL простойdocker cpфайла БД недостаточен). - Кэш: время ответа
GET /api/v1/backupsпервый и повторный запрос (повторный — без обращения к S3);refresh=1— снова читает бакет. - Event loop: во время медленного запроса к недоступному устройству (
POST /api/v1/devices/{id}/refreshдля временного устройства192.0.2.1) параллельныйGET /api/v1/devicesотвечает без ожидания; временное устройство затем удаляется. - Один процесс:
docker exec ros_control-ros_control-1 python -c "…"-запуск второго экземпляра приложения на той же БД (илиuvicorn --port 8002внутри контейнера) завершается ошибкой блокировки, работающий стенд не затронут. - Ручная проверка UI — пользователь: страница «Бэкапы» быстро открывается при смене фильтров, «Обновить список» подтягивает изменения бакета.