0056. Резервные копии без участия оператора: агент, расписание, retention и API (B2)
Статус: accepted. Owner: Ian Panaev. Дата: 2026-10-02. Продолжает ADR 0054 (vault, capture, verify, restore B1) и протокол ADR 0037. Интерфейс консоли и регистрация службы установщиками — следующие инкременты на этом контракте.
Контекст
После B1 копию делает оператор командой capture. Для Standalone-профиля нужна работа без участия человека: ежедневная копия в явно выбранном часовом поясе, хранение по правилам, регулярная проверка и понятные предупреждения, которые видят консоль, CLI и мониторинг. Исполнение не должно попасть в API или transfer worker: API не читает vault и не принимает путей, а долгое копирование 4 ТБ не должно делить ресурсы и жизненный цикл с HTTP. Хранение старых точек требует удаления данных vault, поэтому удаление не может конкурировать с копией, которая только что решила переиспользовать blob.
Решение
Агент. arkvory-backup agent (npm run backup -- agent) — долгоживущий процесс того же composition root apps/backup, которым управляет служба ОС. Каталог vault задаёт только оператор переменной ARKVORY_BACKUP_VAULT (инициализированный vault init каталог); без неё агент работает, сообщает vault_not_configured и отклоняет задания с vault_missing. Дополнительно: ARKVORY_BACKUP_BYTES_PER_SECOND (ограничение полосы копирования, не меньше 64 KiB/s; без значения — без ограничения), ARKVORY_BACKUP_POLL_SECONDS (15). Остальная конфигурация — как у B1 (ARKVORY_DATABASE_URL, ARKVORY_DATA_DIR, ARKVORY_BACKUP_LEASE_SECONDS и т. д.).
Один активный агент: lease и fencing. Строка arkvory_backup_agent хранит владельца, generation, срок lease и heartbeat. Lease берётся только при отсутствии владельца или после истечения срока и всякий раз увеличивает generation. Продление каждую треть срока (при 60 с — 20 с, не реже 30 с) одновременно является heartbeat: версия, vault ID, свободное и общее место тома vault (statfs), последний код ошибки. «Vault доступен» означает: vault.json читается, statfs отвечает, а проба записи (пустой файл в points/.staging, создаётся и удаляется) при первом heartbeat и затем раз в 10 минут удалась; так перемонтирование тома только на чтение видно до следующей копии. Локально lease доверяется на две трети срока от начала продления, неудачное продление окончательно. Каждая запись агента — заявки, план, каталог — выполняется с условием owner + generation + lease_until > now() и FOR SHARE строки агента, поэтому агент, потерявший lease, ничего не меняет, а новый владелец не пересекается с его записью. Второй агент ждёт в standby; после остановки первого (lease освобождается) или его гибели (после истечения lease) он становится владельцем. Потеря lease останавливает работу и возвращает агента в standby. SIGTERM/SIGINT прерывают текущую фазу по правилам B1 (interrupted, pins и барьер освобождаются), заявка возвращается в очередь, lease освобождается, выход 0. Гарантии B1 сохраняются: точка видима только с COMMITTED, pins снимаются только завершением, отказом попытки или fencing.
Цикл. Один шаг — одна единица работы: (a) самая старая заявка из очереди (или выполнявшаяся при прежней generation), (b) иначе наступивший слот расписания, (c) иначе глубокая проверка новейшей точки, если глубокой проверки не было 7 дней. Успешная копия ставит за собой две отдельные задачи: структурную проверку новой точки и retention. Захват копии выполняется в собственной сессии источника B1 (claimBackupSource), поэтому offline repair возможен между копиями. Заявка повторяется после shutdown, конкуренции (busy, barrier_timeout) и потери соединения или lease не более 5 раз, остальные отказы окончательны (failed с машинным кодом).
Расписание (domain, чистые функции). Ежедневно в hour:minute явного IANA-пояса (проверка через Intl, без новых зависимостей). Время передаётся параметром. Несуществующее местное время (перевод вперёд) выполняется в первый допустимый момент после него — момент перехода; неоднозначное (перевод назад) — один раз, при первом наступлении. nextRunAt отсчитывается от последнего израсходованного слота, а не от окончания задания. После простоя выполняется ровно один догоняющий запуск — для последнего пропущенного слота; израсходованный слот записывается в той же транзакции, что и заявка, с проверкой ревизии плана, поэтому слот выполняется не более одного раза. Изменение полей расписания (enabled, hour, minute, timezone) сдвигает schedule_from: слоты до изменения не догоняются, а включение плана не запускает копию немедленно.
Retention (domain, чистая функция). Сохраняются новейшие точки N последних местных дней, N недель ISO 8601 и N месяцев в поясе плана (по умолчанию 7/4/6; пределы 366/260/120), группы объединяются. Всегда сохраняются закреплённые оператором (pinned) и новейшая точка, поэтому при наличии точек остаётся минимум одна. Точки с ошибкой проверки не участвуют: не удаляются и не вытесняют исправную. Точки другого источника (другой storage ID в том же vault) retention не трогает, их содержимое prune сохраняет. Preview возвращает keep с причинами (daily|weekly|monthly|pinned|newest) и delete.
Apply и prune. Только под эксклюзивной блокировкой vault (ниже): синхронизация каталога с vault; завершение удалений, начатых прерванным apply; для каждой точки вне политики сначала отметка forgotten_at в каталоге (закрепление, сделанное раньше, побеждает), затем удаление COMMITTED (точка исчезает) и каталога точки; затем prune. Prune сначала проверяет SHA-256, число строк и порядок id всех inventory оставшихся точек и только потом удаляет: остатки попыток (points/.staging, временные файлы) и blob, которых нет ни в одном inventory, — однопроходным слиянием отсортированных inventory (не больше 64 открытых файлов, остальные сводятся в промежуточные отсортированные файлы), память не зависит от числа blob. Каталог без COMMITTED, не отмеченный как забытый, или повреждённый manifest останавливают prune с invalid_manifest: содержимое повреждённой точки неизвестно, vault оставляется до решения оператора. Каждый шаг повторяем, поэтому crash в любом месте исправляет следующий apply. Неудачная копия и заполненный vault не запускают retention, а новейшая точка не удаляется никогда.
Блокировка vault. Advisory lock 18471/21 в БД источника на отдельной сессии: копии (CLI и агент) и проверки агента держат его shared, retention — exclusive. Все стороны только пробуют блокировку и получают busy, никто не ждёт, цикл ожидания невозможен; блокировка сессии исчезает вместе с соединением и никогда не снимается по возрасту. Ограничение: блокировка действует в пределах одной БД; один vault одновременно пишет один источник. Проверка B1 CLI без подключения к БД блокировку не берёт и при параллельном apply может сообщить об ошибке у удаляемой точки.
Каталог точек — кэш. Таблица arkvory_backup_points перестраивается из vault при старте агента и перед каждым retention: новые точки (например, сделанные CLI) добавляются, исчезнувшие отмечаются forgotten_at, повреждённые получают verify_error=manifest_invalid. Vault остаётся источником истины; в каталоге живут только закрепления и результаты проверок. newBytes записывается точно при копии агентом, а при перестроении оценивается как объём, не общий с предыдущей точкой этого источника.
Права. Системные права backup.read и backup.manage (manage включает read) — не действия репозитория. Их имеют администраторы учётных записей (сессии; PAT никогда не несёт флага администратора) и файловый ключ владельца/bootstrap (administrator или serviceAdministrator в ARKVORY_KEYS_FILE). Управляемые сервисные ключи и ключи, ограниченные репозиториями, их не получают. Политика операции — новый вид system с действием; /api/v1/operations показывает операции с requiredActions: ["backup.read"|"backup.manage"] только обладателям прав (visibility administrator, surface administration).
API (/api/v1/backup, JSON, байты — десятичные строки, время — ISO UTC, ошибки — ADR 0051). GET status, GET|PUT plan (CAS по revision, 409 revision_mismatch, 400 с details), POST runs, POST points/{id}/verify (глубокая проверка), POST retention/apply — 202 с {id, kind, state}, обязательный Idempotency-Key (повтор того же ключа тем же вызывающим для той же операции возвращает то же задание, ключ verify для другой точки — 409 idempotency_mismatch, не больше 100 открытых заявок — 507 queue_full); GET jobs и GET points — курсорные страницы новыми первыми (limit 1–100, 50); PUT points/{id}/pin; GET retention/preview. Права проверяются до разбора входа. API не принимает и не возвращает путей файловой системы, не читает vault и не исполняет копию: состояние vault он получает только из heartbeat.
Предупреждения (domain, чистая функция статуса). vault_not_configured, agent_offline (нет heartbeat 2 минуты или агент освободил lease; critical), schedule_disabled, no_backup_yet, backup_stale (T новейшей точки старше 26 ч при включённом плане; critical), last_run_failed (последняя завершённая копия неуспешна), vault_unavailable (critical), vault_low_space (< 10% свободно или меньше удвоенного newBytes новейшей точки), verify_failed (critical), never_deep_verified (нет глубокой проверки 8 дней). Пока агент не отвечает, сведения о vault неизвестны и предупреждения о нём не выдаются. Ни пути, ни секреты в предупреждения не попадают.
Метрики и оповещения. API (кэш 5 с, single-flight, как completion jobs): arkvory_backup_last_success_timestamp_seconds (T новейшей точки), arkvory_backup_agent_last_seen_timestamp_seconds, arkvory_backup_warnings{code} (1 — активно, 0 — нет; метка из закрытого набора). Правила ArkvoryBackupStale, ArkvoryBackupAgentOffline, ArkvoryBackupWarning (vault_unavailable, verify_failed) в deploy/monitoring/arkvory-alerts.yml.
Схема 26 (expand-only). arkvory_backup_plan (одна строка, выключенный шаблон 02:00 UTC, 7/4/6), arkvory_backup_agent, arkvory_backup_requests, arkvory_backup_points, счётчики прогресса bytes_copied/bytes_total/blobs_copied/blobs_total на arkvory_backup_jobs с постоянным default. Новые таблицы исключены из экспорта копии с причиной: восстановленный экземпляр начинает с выключенным планом и пустым каталогом.
Альтернативы
- Исполнение в API или worker: долгие копии и удаление данных vault внутри HTTP-процесса, путь vault в конфигурации API; отклонено.
- Cron ОС вокруг
capture: нет единой очереди, статуса, предупреждений и защиты prune от параллельной копии. - Блокировка файлом в vault: устаревший файл после аварии пришлось бы снимать по времени без подтверждения отсутствия владельца.
- Prune по множеству id в памяти: память растёт с числом blob (до 10 млн); заменён слиянием отсортированных inventory.
- Догонять все пропущенные дни: очередь копий после простоя без пользы для восстановления.
Последствия
SCHEMA_VERSION = 26; перед запуском агента все процессы обновлены до схемы 26.MINIMUM_RESTORE_SCHEMAостаётся 25.- Установщики регистрируют агента службой systemd, WinSW и Compose, vault подключает
arkvory configure --backup-vault(ADR 0057); установка из исходников запускает агента своим supervisor, см. CORE_RUNBOOK. - Консоль (раздел «Резервные копии») и установщики используют этот контракт без его изменения; SDK —
client.backup, CLI —arkvoryctl backup. - Ограничения: шифрование vault есть (ADR 0070), S3 нет (B3); restore drill и cutover — B4; один vault на агента; prune требует отсутствия повреждённых точек; ограничение полосы локально для процесса агента.
Проверка
Unit: tests/backup-agent-probe.test.mjs (проба записи не оставляет файлов и не пишет в пустую точку монтирования; доступность включает запись, проверку раз в интервал и повтор после отказа), tests/backup-schedule.test.mjs (Europe/Moscow, переход и перекрытие America/New_York, догоняющий запуск, schedule_from, недели ISO), tests/backup-policy.test.mjs (группы retention, закрепление, новейшая, минимум, причины preview, валидация плана с details, права, предупреждения, wire-наборы), tests/backup-vault-maintenance.test.mjs (prune, слияние больше 64 inventory, отказ при подменённом inventory, забывание, ограничение полосы), tests/backup-agent.test.mjs (цикл агента на портах в памяти, повтор, исчерпание попыток, crash в apply). Integration на PostgreSQL: tests/integration/backup-agent.test.mjs (два агента, standby, fencing, захват после истечения lease, слот один раз и один догоняющий запуск, run-now, прогресс, retention и prune, повтор после crash, перестроение каталога), tests/integration/backup-api.test.mjs (401/403, operations, CAS, details, идемпотентность, queue_full, пагинация, pin, preview, метрики, SDK и arkvoryctl), migration-upgrade.test.mjs (миграция 26 expand-only), monitoring-rules.test.mjs.