Глобально уникальные ID (docs/changes/016): - у устройств, групп, задач, резервных копий и записей журнала ID вида <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются, внутри типа ID не повторяются и сортируются по времени; - миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД перед миграцией, сверка числа строк, одна транзакция); - резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают метаданные backup-id/device-id; синхронизация метаданных с бакетом; - журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы, смена online/offline, вход в UI; API чтения GET /api/v1/events. Журнал в UI, ротация и очистка (docs/changes/017): - страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи; - настройки ротации (срок и максимум записей) хранятся в БД (схема v2), ротация при старте, раз в час и после сохранения настроек; - очистка журнала только через окно с паролем пользователя, блокировка после 5 неверных попыток, остаётся запись о факте очистки; через API очистки нет. Тесты: 20 из 20. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 KiB
План: 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), идемпотентна.
- Перед изменениями — копия файла БД (
sqlite3online backup) →ros_control.db.bak-<метка>рядом с БД (в томе). - Для каждой таблицы строится соответствие «старый числовой ID → новый»; временная часть UUIDv7 берётся из
created_at/requested_at, поэтому порядок записей сохраняется; таблицы пересоздаются (*_new→ копирование с заменой ссылокgroup_id,device_id→drop→rename) одной транзакцией. - Запись события
system.migrated(сколько строк по таблицам). 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, а не общим счётчиком; монотонность порядка гарантируется в пределах процесса.
- Журнал растёт: пишутся только смены состояния (не каждый опрос); срок хранения/очистка — отдельная доработка при необходимости.