Files
ayurishchevandClaude Sonnet 5 26dd1f8be4 Уникальные ID сущностей, журнал событий и его UI с ротацией и очисткой
Глобально уникальные 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>
2026-09-19 17:01:52 +03:00

12 KiB
Raw Permalink Blame 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, а не общим счётчиком; монотонность порядка гарантируется в пределах процесса.
  • Журнал растёт: пишутся только смены состояния (не каждый опрос); срок хранения/очистка — отдельная доработка при необходимости.