Skip to content

0054. Встроенный vault резервных копий и восстановление в пустую цель (B1) ​

Статус: accepted. Owner: Ian Panaev. Дата: 2026-10-02. Принимает протокол ADR 0037 для этапа B1 и заменяет его выбор движка. Дополняет ADR 0035 и ADR 0043.

Контекст ​

Первый промышленный профиль — Standalone: один сервер, PostgreSQL и диск, с резервными копиями. HA откладывается. ADR 0037 описал согласованную точку T, но оставил движок (restic) до spike. Для первой поставки нужен работающий вертикальный сценарий без внешних бинарников: ручная копия на локальный диск или смонтированный NAS, проверка и восстановление на чистую цель. Внешние pg_dump/pg_restore не подходят: версия клиента на Linux-стендах расходится с сервером, а восстановление дампа исполняет SQL из копии. Online GC, offline repair и retention продолжают работать во время копии и не должны уничтожить blob, нужный точке T.

Решение ​

Состав. Новый composition root apps/backup (allowlist: domain, application, infrastructure; другие apps не импортируются) и операторский CLI arkvory-backup / npm run backup -- …: vault init, capture, list, verify [--deep], restore. Публичного HTTP API, SDK и UI нет (B2). Domain: состояния job и фаз, переходы, решение по повторному ключу, gate версии схемы, модель manifest с runtime-валидацией. Application: CaptureBackup, VerifyPoint, RestoreToEmptyTarget и узкие порты (jobs, pins, barrier, snapshot, content, vault, restore DB/storage, часы и ID внедряются). Infrastructure: PostgreSQL-адаптеры, файловый vault, логический экспорт и загрузка.

Движок — встроенный файловый vault, не restic и не S3. Цель — локальный диск или смонтированный каталог NAS. Узкий порт vault (identity/point/stage/hasBlob/putBlob и чтение) оставляет место для S3/restic-адаптеров позже. Формат 1:

  • vault.json (format, version, vault ID, время создания, encryption: none) обязателен. Без него ни один файл не пишется: так исчезнувший mount NAS не превращается в запись в пустой локальный каталог. Подкаталоги внутри vault создаются только нерекурсивно, vault.json перечитывается перед каждым blob.
  • Vault внутри storage root, storage root внутри vault и цель restore внутри vault отклоняются после разрешения realpath (symlink, junction, 8.3) — unsafe_path.
  • blobs/<2 hex>/<id>: содержимое по неизменяемому ID upload, копируется один раз и разделяется точками. Поток с backpressure, SHA-256 и размер проверяются во время копирования, запись через временный файл + fsync + rename + fsync каталога.
  • points/.staging/<point>.<attempt>.<rnd>/ → points/<point>/: manifest.json, inventory.ndjson, tables/<table>.ndjson и последним — COMMITTED (point ID + SHA-256 manifest). Точка становится видимой одним переименованием каталога. Неудачная попытка удаляет только свой staging и никогда не меняет закоммиченные точки.
  • Manifest версионирован: point/job/vault ID, source instance ID (storage ID), release и commit, схема, PostgreSQL major, T и exported snapshot ID, времена, inventory (число, байты, хеш), таблицы (строки, байты, хеш), исключённые таблицы с причинами, число незавершённых upload на T. Пути файлов не берутся из документа: допускаются только inventory.ndjson и tables/arkvory_*.ndjson, traversal/absolute и неизвестная версия отклоняются (invalid_manifest, unsafe_path).

Без шифрования в B1 (шифрование добавлено ADR 0070: vault версии 2). Vault содержит каталог, хеши паролей и содержимое в открытом виде. Обязанность оператора: размещать vault на зашифрованном томе с ограниченным доступом (учётная запись службы и администратор резервирования). CLI создаёт каталоги 0700 и файлы 0600 на POSIX; на Windows права задаются ACL тома/каталога вручную.

Логический экспорт вместо pg_dump. Реестр в коде перечисляет каждую таблицу схемы 25: 28 экспортируемых в порядке, безопасном для внешних ключей, и 8 исключённых с причиной (история миграций, storage identity, сессии, gateway leases, топология раздачи, состояние backup). Capture отказывает при незарегистрированной таблице arkvory_* (unknown_table), тест сверяет реестр с мигрированной БД, внешние ключи и первичные ключи. Таблица выгружается курсором row_to_json по 500 строк в NDJSON; 64-битные значения не проходят через JS-числа.

Протокол capture и порядок блокировок.

  1. Сессия capture: 18471/4 shared (offline repair невозможен), 18471/17 и 18471/20 shared (маркеры протоколов), 18471/19 exclusive (одна копия на БД). Writer lock не берётся: API и worker работают. Durable job с idempotency key, lease и fencing generation; не более одной незавершённой копии (частичный уникальный индекс).
  2. Барьер допуска unlink: на отдельной сессии pg_advisory_lock(18471,18) с lock_timeout дожидается уже допущенных удалений, фиксирует durable строку closed (job + generation) и отпускает lock. Каждое физическое удаление содержимого — online GC и offline repair — выполняется в порядке upload lock → content lock → try 18471/18 shared → проверка строки барьера и pins → unlink → unlock. Удаляющие только пробуют lock и никогда не ждут, capture не держит объектных locks: цикла ожидания нет. Durable строка продолжает отказывать даже после потери сессии capture.
  3. Snapshot T: REPEATABLE READ READ ONLY, LOCK TABLE … ACCESS SHARE до первого запроса, затем pg_export_snapshot(); проверка версии схемы и реестра внутри T. Набор T — все upload со статусом available: только они имеют байты (каталог, пакеты, assets и история, вложения и копии продвижения ссылаются на них; отменённый ID не публикуется снова).
  4. Inventory читается keyset-порциями; каждая порция сначала закрепляется pins (upload_id, job_id) в отдельной транзакции с FOR SHARE на job, затем пишется в inventory. После последнего pin барьер открывается; дальше GC работает со всем, кроме закреплённого.
  5. Экспорт таблиц в том же T, затем snapshot закрывается — до копирования содержимого. Фаза ограничена временем (ARKVORY_BACKUP_SNAPSHOT_SECONDS, по умолчанию 30 мин), числом записей и объёмом; idle_in_transaction_session_timeout завершает snapshot зависшего процесса. Потеря snapshot — snapshot_lost, новая попытка берёт новый T.
  6. Копирование blob вне SQL-транзакции, manifest, переход committing, публикация точки, completed и освобождение pins в одной транзакции.

Устаревшие процессы, не знающие pins, обнаруживаются по writer lock 18471/3 без маркера 18471/20 — upgrade_required. Полнота копии дополнительно гарантирована проверкой SHA-256 каждого blob: отсутствующий файл — отказ, не точка с предупреждением.

Повтор и fencing. Ключ повторно возвращает завершённую точку; живой lease — busy; истёкший — fencing: новая generation, interrupted, pins и барьер освобождаются, затем новая попытка (не более 5). Каждая запись прогресса требует совпадения generation, владельца и неистёкшего lease. Ошибка до committing записывается как failed с машинным кодом; после committing исход неизвестен и решается reconciliation: если точка с этим ID уже есть в vault, job завершается без второй точки. Pins освобождаются только завершением, отказом самой попытки или fencing — никогда только по TTL.

Restore только в пустую цель. Отказ, если в целевой схеме есть таблицы arkvory_* или каталог хранения не пуст (target_not_empty). Без --yes выполняется только read-only preflight. Порядок: проверка точки (manifest, хеши файлов) → копирование и проверка blob в новый storage → migrate(pool, { upTo }) до схемы копии → одна транзакция: таблицы в порядке FK через INSERT … SELECT FROM json_populate_recordset (ключи каждой строки обязаны совпасть со столбцами; SQL из копии не исполняется), самоссылки вторым проходом, сброс identity/serial последовательностей, нормализация, привязка нового storage ID → оставшиеся миграции. Копии новее релиза отклоняются, старше схемы 25 — тоже (нет проверенной нормализации). Сейчас схема копии равна текущей; путь upTo проверен отдельным тестом миграций.

Нормализация 1 (по умолчанию). Незавершённые upload отменяются; у всех строк temp_cleaned, reclaimed для не опубликованных, части не опубликованных удаляются; очередные/выполняющиеся completion jobs → failed/conflict; незавершённые продвижения удаляются; персональные токены отзываются; сервисные ключи → revoked; политики очистки и retention выключаются; сессии, leases, топология раздачи и состояние backup не переносятся; в журнал безопасности пишется backup.restored. Пользователи, группы, права и хеши паролей сохраняются: пароли, изменённые после T, на восстановленном экземпляре снова действуют — оператор сбрасывает их по своей политике. Bootstrap-ключи берутся из файла ключей цели.

Альтернативы ​

  • restic за адаптером: внешний бинарник, лицензия/SBOM и spike на Windows/NAS/S3; перенесён в последующие этапы вместе с шифрованием.
  • pg_dump/pg_restore: несовпадение версий клиента и сервера и исполнение SQL копии при восстановлении.
  • Только grace GC без pins: зависит от длительности копии и не защищает от offline repair.
  • Удерживать snapshot на всё копирование: MVCC pressure часами; snapshot закрывается до копирования.
  • Advisory lock без durable строки: потеря сессии capture снова открыла бы удаление до завершения pins.

Последствия ​

  • Миграция 25 expand-only: arkvory_backup_jobs, arkvory_backup_pins, arkvory_backup_barrier; SCHEMA_VERSION = 25. Перед первой копией обновляются все writer/worker/reader/maintenance процессы.
  • Пока vault не очищается: удаление точек и prune — B2 (реализованы в ADR 0056). Blob неудачных попыток остаются в vault и переиспользуются до prune.
  • Не PITR и не HA backup: точка описывает опубликованное состояние на T; незавершённые upload не восстанавливаются; один vault на запуск; копирование последовательное, без ограничения полосы.
  • Во время экспорта таблиц DDL ждёт снятия ACCESS SHARE: миграции и копию не запускают одновременно.
  • Удалённый источник не нужен для restore: vault самодостаточен.

Проверка ​

Unit: tests/backup.test.mjs (переходы, повтор ключа, gate схемы, manifest/inventory, аргументы и коды выхода, файловый vault и вложенные пути), tests/backup-protocol.test.mjs (порядок протокола на портах), tests/offline-cleanup.test.mjs. Integration на PostgreSQL: tests/integration/backup-capture.test.mjs (гонки с публикациями, удалениями и online GC, ожидание барьера и его timeout, отказы на каждой границе фаз, crash и fencing, отказ старого writer и потеря snapshot, offline repair с pins), backup-restore.test.mjs (CLI, restore во временную БД и запуск настоящего API, ACL, сессии, PAT, сервисные ключи, безопасность vault и подменённый manifest), backup-schema.test.mjs (полнота реестра, порядок FK, ограниченная миграция), migration-upgrade.test.mjs.

Arkvory · ProAnimaStudio