Files
ros_control/docs/changes/016-unique-ids-and-event-log/plan.md
T

61 lines
12 KiB
Markdown
Raw Normal View History

# План: 016 — глобально уникальные ID для всех сущностей и журнал событий
## Context
Сейчас у сущностей числовые ID «по таблицам» (`INTEGER PRIMARY KEY`): у устройства №1, задачи №1 и бэкапа №1 одинаковый ID, а без `AUTOINCREMENT` SQLite выдаёт `max(id)+1`, то есть **ID удалённой последней записи повторно используется**. Журнала событий нет (есть только задачи), а метаданные бэкапа (`backups`) со списком файлов в бакете не связаны — страница берёт файлы прямо из S3. Данные сейчас: 6 устройств, 4 группы, 32 задачи, 23 бэкапа.
Цель: у каждой сущности — группа, устройство, задача, бэкап, запись журнала — свой ID, который не пересекается с ID других сущностей и никогда не повторяется.
Решения (согласованы): формат **префикс типа + UUIDv7**; **журнал событий в БД**; числовые ID **заменяются везде** (с миграцией данных).
## ID: как гарантируется уникальность
`app/ids.py` (новый), формат `<префикс>_<uuid7>`: `dev_`, `grp_`, `job_`, `bkp_`, `evt_`; например `dev_0192f3a1-7c4e-7b1d-9a3f-5e2c1d0b8a47`.
- **Между типами** — префикс: ID разных типов не пересекаются по построению, а не «по вероятности».
- **Внутри типа** — UUIDv7 (RFC 9562): время в мс + 12-битный счётчик + 62 случайных бита (в исходном плане было ошибочно указано 74 случайных бита); собственный генератор (в Python 3.12 `uuid.uuid7` нет), монотонный внутри процесса (счётчик в пределах миллисекунды, при откате часов время не уменьшается). Сортировка по ID = сортировка по времени создания.
- **Не переиспользуются**: ID случайный/временной, удаление записи ничего не «освобождает».
- **БД**: `PRIMARY KEY` в каждой таблице — дубликат не сохранится, а вызовет явную ошибку, а не тихую перезапись.
- **Проверка входа**: `parse_id(value, prefix)` — ID чужого типа в пути API/UI (например `job_…` в `/devices/…`) отклоняется (404 с пояснением).
## Схема и связи (`app/models.py`)
Все PK — `String(40)`, генерируются на стороне приложения (`default=lambda: new_id("dev")`), все ссылки — строки с тем же форматом.
- `device_groups.id` = `grp_…`; `devices.id` = `dev_…`, `devices.group_id` → `grp_…`.
- `jobs.id` = `job_…`, `jobs.device_id` → `dev_…` (без FK, как сейчас: история переживает удаление устройства; имя хранится в `device_name`).
- `backups.id` = `bkp_…` — **один бэкап = пара файлов** (`.backup` + `.rsc`); новые поля: `job_id` (задача, создавшая бэкап), `device_name` (снимок имени для истории), `deleted_at`. Удаление файлов из бакета помечает бэкап удалённым, строка и ID остаются.
- **Связь с бакетом**: при загрузке файлы получают S3 user-metadata `backup-id`, `device-id` (`s3.upload_file(..., metadata=…)`), ключи остаются прежними (`backups/<имя>/<метка>.<расширение>`). `services/backups.search` сопоставляет объекты с `backups` по ключу и отдаёт `backup_id` в API.
- **`events` — новая таблица** (журнал): `id evt_…`, `ts` (UTC, индекс), `type`, `entity_type`, `entity_id` (индекс), `device_id`, `job_id`, `actor` (`ui:<пользователь>` / `api` / `poller` / `system`), `message`, `data` (JSON).
## Журнал событий (`app/services/events.py`, новый)
`events.record(type, entity_type, entity_id, message, *, device_id=None, job_id=None, data=None)`; актор берётся из `ContextVar`, который выставляют зависимости `require_login` (UI), `require_api_token` (API) и опрос/задачи (`poller`, `system`; задачи наследуют контекст).
Типы: `device.created|updated|deleted`, `device.online|offline` (**только смены состояния**, не каждый опрос), `group.created|renamed|deleted`, `devices.moved`, `job.created|started|done|failed`, `backup.created|done|failed|deleted`, `auth.login|failed`, `system.migrated`.
API (только чтение): `GET /api/v1/events` (`entity_id`, `type`, `device_id`, `job_id`, `limit`, `before` — курсор по ID, т.к. ID сортируется по времени), `GET /api/v1/events/{id}`. UI-страницы журнала в этом изменении нет (требует макета — отдельная задача).
## Миграция существующих данных (`app/migrations.py`, новый; вызывается из `db.init_db`)
Версионируется через `PRAGMA user_version` (0 → 1), идемпотентна.
1. Перед изменениями — копия файла БД (`sqlite3` online backup) → `ros_control.db.bak-<метка>` рядом с БД (в томе).
2. Для каждой таблицы строится соответствие «старый числовой ID → новый»; временная часть UUIDv7 берётся из `created_at`/`requested_at`, поэтому порядок записей сохраняется; таблицы пересоздаются (`*_new` → копирование с заменой ссылок `group_id`, `device_id` → `drop` → `rename`) одной транзакцией.
3. Запись события `system.migrated` (сколько строк по таблицам).
4. `backups.reconcile()` при старте (best-effort, без S3 не падает): файлы бакета без строки в `backups` (старые/загруженные вручную) получают строку `bkp_…` по паре `<имя>/<метка>`. У уже лежащих в бакете объектов S3-metadata не проставляется (переписывать объекты не будем) — связь через БД.
## Затрагиваемый код (паттерн повторяется)
`int`-ID заменяется на `str` с проверкой префикса: `app/api/v1.py` (пути, `device_ids: list[str]`, `group_id`), `app/ui/routes.py` (`_opt_int` → `_opt_id(prefix)`, `isdigit` в фильтре `f_group` → `is_id("grp", …)`), `app/services/{devices,groups,jobs,ops,backups,poller}.py`, шаблоны (`d.id`, `g.id`, `j.id` подставляются в URL как есть). В таблице «Задачи» колонка «#» становится «ID» с коротким видом (последние 8 символов, моноширинным шрифтом; полный ID в подсказке) — это единственное видимое отличие от утверждённого макета; UUIDv7 в таблицах целиком не помещается.
Запись событий добавляется в места изменений: `services/devices.py`, `groups.py`, `jobs.py` (`_finish`), `ops.py` (`run_backup`, `poll_device`/`_save_status` — смена online↔offline), `ui/routes.py` (логин), `backups.delete_many`.
## Проверка
- **Тесты** (`pytest`, сейчас 12): существующие обновляются под строковые ID; новые (минимум): (1) `ids`: 100 тыс. ID по всем префиксам — без дублей, строго возрастают внутри типа, префиксы не пересекаются, `parse_id` отклоняет чужой тип; (2) миграция: временная БД в **старой схеме** → после миграции ID с префиксами, ссылки (`group_id`, `device_id`) сохранены, число строк совпадает, повторный запуск ничего не меняет; (3) события: создание устройства, жизненный цикл задачи, смена online→offline, вход/отказ записываются со ссылками на ID и актором.
- **Репетиция на копии реальной БД**: копия файла из тома → миграция во временном каталоге → сверка числа строк и связей (устройство ↔ группа ↔ задачи ↔ бэкапы) до обращения к боевой БД.
- **Боевая миграция**: пересборка контейнера; проверка `GET /api/v1/devices|groups|jobs|events` (все ID с префиксами, число записей прежнее, есть `.bak`); UI в браузере (Playwright): вкладки групп, окна устройства/групп, выбор строк, меню, фильтры, групповое удаление бэкапов (на собственных тестовых файлах), «Задачи» с коротким ID; опрос продолжает работать.
- **S3-metadata**: загрузка тестового файла через `s3.upload_file` с метаданными в `backups/qa-id/…`, `head_object` подтверждает `backup-id`, файл удаляется. Полный бэкап на реальном устройстве (создаёт файлы, `.rsc` с секретами) — **только после вашего подтверждения**.
- Документы: `docs/changes/016-unique-ids-and-event-log/{plan,summary}.md`, README (раздел «Идентификаторы», API событий, примеры с `dev_…`), история изменений.
## Риски и оговорки
- **Ломающее изменение API/URL**: старые curl-скрипты с числовыми ID перестанут работать. Откат — из `.bak` файла БД и предыдущего коммита.
- Пересоздание таблиц SQLite при работающем приложении: миграция выполняется на старте до запуска опроса; в БД одновременно пишет один процесс.
- Уникальность между несколькими процессами обеспечена случайной частью (62 бита) и PK, а не общим счётчиком; монотонность порядка гарантируется в пределах процесса.
- Журнал растёт: пишутся только смены состояния (не каждый опрос); срок хранения/очистка — отдельная доработка при необходимости.