Производительность и масштабирование, пункты 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>
38 lines
4.8 KiB
Markdown
38 lines
4.8 KiB
Markdown
# Итоги: 019 — производительность и масштабирование (по ревью 2026-09-27)
|
||
|
||
Источник — ревью `docs/reviews/2026-09-27-codebase-review.md`, раздел «Производительность и масштабирование» (пункты 8–10).
|
||
|
||
## Сделано
|
||
- **п. 8 — кэш списка бакета** (`app/services/backups.py::bucket_objects`): список объектов и связи `key → backup_id` кэшируются на
|
||
`BACKUPS_CACHE_TTL` секунд (60; `0` — выключен); `sync_rows` выполняется только при реальном чтении бакета. `asyncio.Lock` с повторной
|
||
проверкой — параллельные запросы не читают бакет дважды. `invalidate()` вызывается после загрузки бэкапа и удаления;
|
||
счётчик поколений не даёт чтению, начатому до `invalidate()`, пометить кэш свежим. Принудительно — `refresh=1`
|
||
(`GET /backups`, `GET /api/v1/backups`, кнопка «Обновить список»).
|
||
- **п. 9 — БД не блокирует event loop**: 44 обработчика API/UI без `await` стали `def` (пул потоков FastAPI); обработчики, вызывающие
|
||
`jobs.start_jobs` (нужен работающий event loop для `create_task`), остались `async`. Через `asyncio.to_thread`: чтение устройства и запись
|
||
статуса в опросе (`ops.poll_device`, `ops.refresh_status`), `poller.cycle`, `jobs._finish`, строки `Backup` в `ops.run_backup`, `sync_rows`.
|
||
SQLite: `journal_mode=WAL`, `synchronous=NORMAL`, `busy_timeout=5000` (только файловая БД).
|
||
- **п. 10 — один процесс на БД** (`app/process_lock.py`): `flock` на `<файл БД>.lock` в `lifespan` до `init_db`, снимается при остановке;
|
||
второй процесс получает `RuntimeError` и не стартует. README — раздел «Ограничения».
|
||
|
||
## Найдено на ревью и исправлено
|
||
- Гонка кэша: `invalidate()` во время чтения бакета терялся — устаревший список считался свежим весь TTL. Исправлено счётчиком поколений;
|
||
мутационная проверка: без исправления новый тест падает (`4 == 5`), с исправлением проходит.
|
||
- Ошибка в тесте гонки (кэш перед сценарием был свежим) — исправлена одной строкой оркестратором.
|
||
|
||
## Проверено
|
||
- `pytest`: 24 из 24 (новые: `test_bucket_list_is_cached_between_reads`, `test_process_lock_blocks_second_process`).
|
||
Актор в событиях из теперь синхронных обработчиков (`api`, `ui:admin`) подтверждён существующими тестами.
|
||
- Стенд (порт 8001): новый код, `journal_mode=wal`, в логах без ошибок.
|
||
- Кэш: 36 файлов бакета; `refresh=1` — 0,138 с, из кэша — 0,014 с.
|
||
- Event loop: пока `POST /devices/{id}/refresh` ждал недоступное устройство (4,0 с), параллельный `GET /devices` ответил за 0,012 с.
|
||
- Второй `uvicorn` на боевой БД внутри контейнера: `RuntimeError` блокировки, `Application startup failed`; основной стенд работает, прерванных задач нет.
|
||
- Боевые данные: группы 4/4, устройства 14/14, бэкапы 45/45, задачи 71/71 — совпадают по ID; добавлены 3 события временного устройства (удалено).
|
||
Сверка — по согласованной копии (`sqlite3 backup` внутри контейнера), т. к. при WAL файл БД без `-wal` неполон.
|
||
|
||
## Оговорки
|
||
- Кэш, семафор задач и счётчики паролей — в памяти процесса; горизонтальное масштабирование по-прежнему не поддерживается (теперь явно).
|
||
- Изменения бакета извне видны не позже TTL или по «Обновить список»; `refresh=1` остаётся в адресе страницы после обновления.
|
||
- Для копирования БД с хоста теперь нужен и файл `-wal` (или `sqlite3 .backup`).
|
||
- Ручная проверка UI пользователем на момент коммита не подтверждена.
|