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 и порядок блокировок.
- Сессия capture:
18471/4shared (offline repair невозможен),18471/17и18471/20shared (маркеры протоколов),18471/19exclusive (одна копия на БД). Writer lock не берётся: API и worker работают. Durable job с idempotency key, lease и fencing generation; не более одной незавершённой копии (частичный уникальный индекс). - Барьер допуска 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. - Snapshot T:
REPEATABLE READ READ ONLY,LOCK TABLE … ACCESS SHAREдо первого запроса, затемpg_export_snapshot(); проверка версии схемы и реестра внутри T. Набор T — все upload со статусомavailable: только они имеют байты (каталог, пакеты, assets и история, вложения и копии продвижения ссылаются на них; отменённый ID не публикуется снова). - Inventory читается keyset-порциями; каждая порция сначала закрепляется pins
(upload_id, job_id)в отдельной транзакции сFOR SHAREна job, затем пишется в inventory. После последнего pin барьер открывается; дальше GC работает со всем, кроме закреплённого. - Экспорт таблиц в том же T, затем snapshot закрывается — до копирования содержимого. Фаза ограничена временем (
ARKVORY_BACKUP_SNAPSHOT_SECONDS, по умолчанию 30 мин), числом записей и объёмом;idle_in_transaction_session_timeoutзавершает snapshot зависшего процесса. Потеря snapshot —snapshot_lost, новая попытка берёт новый T. - Копирование 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.