0058. Зеркала репозиториев: свой каталог, забор по публичному API
Статус: accepted. Owner: Ian Panaev. Дата: 2026-10-02. Заменяет mirror-профиль ADR 0044 (синхронный HA-профиль ADR 0044 остаётся отдельной темой).
Контекст
Потребители (CI/CD, агенты развёртывания) работают в разных сетях и на разных площадках. Им нужна копия выбранных репозиториев рядом: скачивание без дальнего канала и чтение, когда главный сервер недоступен. Проект ADR 0044 держал каталог и права только на главном: каждое чтение с зеркала авторизовалось у главного (гранты по 5 с), узлы регистрировались по mTLS, нужен был отдельный протокол и журнал. Такое зеркало не раздаёт без главного, а объём работы несоразмерен задаче. Владелец выбрал (2026-10-02): у зеркала свой каталог и свои ключи, копирование забирает само зеркало.
Факты текущего кода, на которые опирается решение:
- изменения каталога одного репозитория выполняются под транзакционной блокировкой
18472/hashtext(repository)и пишутarkvory_audit; публикация файла блокировку не брала и в журнал не писалась; - изменения стадий выполняются под той же блокировкой и пишутся в
arkvory_promotion_events; - UUID артефакта выдаёт сервер; загрузка частями (
uploads+parts+complete) уже переживает обрывы; - отдельной таблицы репозиториев нет: репозиторий существует через права.
Решение
Зеркало — обычная установка Arkvory. В ней отдельные репозитории объявлены зеркалами репозитория другого (главного) сервера. Остальные репозитории той же установки работают как обычно. Зеркало раздаёт свой каталог своими ключами и учётными записями; пользователи, ключи и права с главного не переносятся. Отзыв ключа на главном на зеркало не действует.
Что копируется. Опубликованные артефакты с их байтами и теми же ID, аннотации (метки, метаданные, коллекции), регистрация UPack, стадии, текущие пути файлов (assets), образы контейнеров (ADR 0063, см. «Дополнение») и удаления. Номера ревизий аннотаций и путей на зеркале свои: история зеркала начинается с первой синхронизации. Не копируются незавершённые загрузки, ссылки удержания (references), вложения (attachments), история путей, которой зеркало не видело, журналы аудита и политики хранения. Скачивание по ID, по пакету (версия, диапазон, стадия) и по пути файла на зеркале отвечает так же, как на главном на момент последней синхронизации.
Лента изменений на главном. Журнал arkvory_audit становится полной упорядоченной лентой репозитория:
- публикация (
artifact.publish) берёт блокировку репозитория в той же короткой транзакции, что меняет статус, и пишет событие; порядок блокировок тот же, что у остальных изменений каталога (доступ сервиса → репозиторий); - изменения стадий пишут
stage.add/stage.remove, изменения путей —asset.replace/asset.restoreс путём; - новая колонка
detail(путь или стадия), миграция 27, аддитивно.
Под блокировкой номер события выдаётся в порядке commit внутри репозитория, поэтому курсор по sequence не теряет событий. Новый маршрут GET /api/v1/repositories/{r}/changes?after=&limit= с правом artifact.list (ключ только для чтения) отдаёт {items: [{sequence, action, artifactId, detail}], head, next} без автора события. Событие — подсказка «у этого артефакта изменилось это»: зеркало перечитывает текущее состояние через существующие маршруты чтения, поэтому повтор и пропущенные промежуточные состояния безопасны.
Синхронизация на зеркале — цикл в существующем worker, новой службы нет. Для каждого зеркального репозитория:
- Первое заполнение: запомнить
headленты, затем пройтиGET R/artifactsиGET R/assets/pageпостранично и привести каталог к ним; потом следовать ленте от запомненногоhead. Порядок «сначала head, потом список» не теряет удалений, случившихся во время заполнения. - Следование ленте: события применяются по порядку, курсор сохраняется только после применения события. Удаление на главном удаляет артефакт на зеркале обычным путём удаления (tombstone, затем GC).
- Байты копируются загрузкой частями в локальное хранилище под тем же ID (
StorageServiceс источником ID, равным ID главного, и служебным субъектом зеркала): часть скачивается с главного по Range, проверяется её SHA-256, записывается; после перезапуска копирование продолжается с первой отсутствующей части. Итоговые размер и SHA-256 сверяются с главным приcomplete. Другой SHA-256 для того же ID — ошибкаmirror_mismatch, существующий файл не перезаписывается. - Пакет UPack регистрируется на зеркале из тех же байтов тем же валидатором, поэтому его идентичность совпадает с главным.
Только чтение. Запись в зеркальный репозиторий отклоняется для всех, кроме синхронизации: загрузки, аннотации, пути, стадии, удаление, продвижение в него и политики очистки получают 409 с причиной mirror_read_only. Продвижение копией из зеркала в обычный репозиторий разрешено.
Настройка — файл ARKVORY_MIRRORS_FILE (JSON, как ARKVORY_KEYS_FILE): {repository, upstream, sourceRepository, tokenFile}. Ключ главного лежит в отдельном файле с правами только для учётной записи службы и в БД, журналы и копии не попадает. TLS проверяется всегда; корпоративный или самоподписанный УЦ главного задаётся --mirror-ca-file и хранится в config/mirrors/ca.pem (ARKVORY_MIRRORS_CA_FILE, см. «Дополнение»). Изменение — arkvory configure --mirror <repository> --mirror-upstream <origin> [--mirror-source <repository>] --mirror-token-file <file> и --mirror-detach <repository>. До изменений команда проверяет главный этим ключом: capabilities.features.mirrorFeed и ответ ленты для исходного репозитория. Затем она пишет ключ и mirrors.json в config/mirrors/ с правами runtime.json; в Compose каталог монтирует только на время зеркал файл-дополнение config/compose.mirrors.yml. После этого API и worker перезапускаются с проверкой готовности, при сбое прежние файлы возвращаются. API читает файл для запрета записи и показа состояния, worker — для синхронизации. Состояние (курсор, фаза, отставание, ошибка, объём) хранится в таблице arkvory_mirror_state и видно в GET /api/v1/repositories/{repository}/mirror, консоли и метриках arkvory_mirror_*.
Отсоединение. Запись репозитория удаляется из ARKVORY_MIRRORS_FILE (в установках — arkvory configure --mirror-detach <repository>), API и worker перезапускаются. Репозиторий становится обычным: синхронизация прекращается, запись разрешается, ID и данные остаются. Это ручной переход на копию при потере главного (RPO равен отставанию зеркала). Обратного присоединения в этом решении нет: состояние зеркала остаётся, и подключение того же репозитория к другому источнику отклоняется с mirror_source_changed (см. «Дополнение»).
Режим импорта по стадии (dev → prod, 2026-10-02). Запись с полем stages (--mirror-stages release[,hotfix]) описывает не зеркало, а обычный репозиторий, который забирает с источника версии с одной из этих стадий. Поведение — как у продвижения между серверами:
- версия переносится один раз, с тем же ID, байтами, аннотациями, регистрацией UPack (если байты — UPack, а такой идентичности здесь ещё нет) и подходящими стадиями;
- дальнейшие изменения, снятие стадии и удаление на источнике её не затрагивают;
- версия, удалённая здесь, повторно не импортируется: локальный tombstone сильнее источника;
- запись в репозиторий разрешена, своя очистка работает как обычно.
Первое заполнение проходит списки артефактов с нужными стадиями (GET R/stages?stage=) после запоминания головы ленты. Потом worker следует ленте и смотрит только stage.add с подходящей стадией. Перенос однократный, поэтому повтор после сбоя только дополняет уже скопированное: аннотации — пока здесь их никто не менял (ревизия 0), стадии — только добавляются. Стадии входят в идентичность источника: при их смене импорт заново проходит первое заполнение. Это безопасно, потому что импорт ничего не удаляет и пропускает то, что уже есть. Зеркало при смене источника, наоборот, останавливается.
Альтернативы
- Каталог и права только на главном (ADR 0044): зеркало не раздаёт без главного, нужны регистрация узлов, mTLS и протокол грантов. Отклонено владельцем.
- Главный отправляет на зеркала: нужен входящий доступ к каждому зеркалу и ключ записи на главном. Отклонено.
- Новые ID на зеркале: ссылки и скачивание по ID с главного не работали бы на зеркале.
- Периодическая полная сверка вместо ленты: тысячи запросов на каждом цикле и отставание на минуты. Полная сверка остаётся только как первое заполнение и повторный засев (смена стадий импорта, восстановленный главный).
- Отдельная служба зеркала: регистрация в установщиках, как у агента копий, без выигрыша — у worker уже есть БД, хранилище и супервизор.
Последствия
- Публикации одного репозитория сериализуются на время короткой транзакции смены статуса; проверка байтов идёт до неё.
- Worker получает исходящий HTTPS к главному и зависимость от SDK (allowlist
apps/worker→packages/sdk): зеркало — клиент публичного API главного. - Зеркало не является резервной копией установки (нет пользователей и ключей) и не становится главным автоматически.
- Главный и зеркало обновляются независимо; зеркало требует на главном версию с лентой изменений (capability
mirrorFeed).
Состояние — GET /api/v1/repositories/{r}/mirror с правом repository.read (404 для обычного репозитория): фаза, caughtUp, время последней проверки и синхронизации, код последней ошибки, число и объём скопированного. Номера ленты глобальные, поэтому их разность не равна числу изменений и в ответ не выводится. Для оповещений API отдаёт arkvory_mirror_last_sync_timestamp_seconds, arkvory_mirror_last_check_timestamp_seconds и arkvory_mirror_failing с метками repository и mode (кэш 5 с, как у копий); правила ArkvoryMirrorStale (нет синхронизации больше часа) и ArkvoryMirrorFailing. Процедура второй площадки на этих зеркалах — SECOND_SITE.
Удаление на зеркале идёт через хранилище удалений, а не через сценарий клиента: клиентам удаление разрешено только управляемым ключом, а синхронизация — системный субъект. Перед удалением зеркало снимает свои стадии, иначе они защищали бы копию.
Проверка
tests/integration/mirror.test.mjs— два экземпляра, каждый со своей БД и каталогом хранения:- первое заполнение и следование ленте: артефакты с теми же ID и байтами, файл из нескольких частей, аннотации, пакет, стадия, путь, удаление, перенос пути;
- запрет записи клиентам (409
mirror_read_only) и состояние зеркала; - обрыв копирования и продолжение только с недостающих частей;
- недоступный главный: зеркало продолжает раздавать, ошибка записана в состоянии.
tests/integration/mirror-feed.test.mjs— лента: состав и порядок событий, страницы, доступ ключом чтения без авторов, ошибки курсора, числовой порядок при десятках событий (ORDER BYтекстовой колонки сортировал «10» раньше «9» и вR/audit; исправлено там же и в журнале сервисных ключей).tests/integration/mirror.test.mjs, сценарий dev → prod: перенос версии со стадиейreleaseс метками и стадией, без промежуточных версий; позднее продвижение по ленте; снятие стадии и удаление на dev не доходят до prod; загрузки на prod работают; удалённая на prod версия не возвращается; состояниеmode: import.tests/mirror-import.test.mjs— первое заполнение по стадиям и страницам, только нужная стадия, удалённое здесь не импортируется, повтор дополняет, стадия снята до импорта, новое заполнение при смене стадий.tests/mirror-sync.test.mjs— порядок первого заполнения, применение событий с перечитыванием, курсор при сбое, отказ продолжать чужой источник.tests/mirror-policy.test.mjs— разбор настройки (только HTTPS, без учётных данных в URL), запрет записи для файловых и управляемых ключей при сохранении чтения.tests/mirror-setup.test.mjs— команда установщика: проверка главного до изменений, файлы и переменная для systemd и Compose, замена ключа, откат при сбое перезапуска, отсоединение, проверка главного без ключа в тексте ошибки.tests/browser/mirror.mjs— значок по состоянию синхронизации, источник в подсказке, нет действий загрузки, RU/EN. Интеграционный тест проверяет, что обнаружение операций скрывает изменения зеркала и оставляет чтение и продвижение из него.
Дополнение (2026-10-03): смена источника, восстановленный главный, долгий обрыв, образы
Проверка на нескольких установках нашла остановки, которые статус не показывал. Решения:
Смена источника. Отказ
mirror_source_changedзаписывается в состояние (значок,arkvory_mirror_failing), курсор и источник прежней копии не меняются. Тот же главный и исходный репозиторий с другим списком стадий импорта или в другом режиме (зеркало ↔ импорт) начинают новый засев: засев перечитывает главный и ничего не удаляет, счётчики копирования сохраняются. Импорт по-прежнему может сменить и главный — он ничего не удаляет.Главный восстановлен из копии или переустановлен. Голова его ленты оказывается ниже курсора зеркала, и новые события получили бы уже пройденные номера. Зеркало засевает заново и до догоняния показывает
mirror_source_behind. Артефакты, которых на восстановленном главном нет, на зеркале остаются: засев ничего не удаляет. Обратный случай: копию, которую зеркало удалило вслед за главным, восстановленный главный снова показывает. Удаление на зеркале остаётся в силе, артефакт пропускается. Раньше синхронизация повторяла этот шаг бесконечно сmirror_upload_cancelled.Долгий обрыв. Незавершённую копию через неделю отменяет онлайн-очистка. Синхронизация снова открывает такую загрузку (под замком загрузки, которым пользуется и очистка) и копирует части заново. Опубликованная, а затем удалённая копия не открывается.
Политики хранения зеркального репозитория отклоняются, как и любое изменение в нём (
conflictв последнем результате политики): удаляет в зеркале только синхронизация.Чтение с правом записи (журнал каталога, свои загрузки и задачи файловых ключей) в зеркале разрешено: запрещена запись, а не чтение.
Ограниченное время запросов. Каждый запрос к главному ограничен сроком, у потока содержимого — ещё и срок до заголовков, поэтому зависший главный не держит worker.
Голова ленты читается после элементов страницы, поэтому она не меньше последнего элемента.
Образы контейнеров. Изменения индекса реестра (
oci.blob,oci.manifest,oci.tag,oci.manifest.delete,oci.tag.delete) пишутся в ленту в транзакции изменения, под замком каталога репозитория. Зеркало применяет их к своему индексу через тот же реестр, поэтому зеркало зеркала тоже получает образы. У реестра нет списка для засева, поэтому после засева зеркало проходит ленту с начала и до головы засева применяет только записи реестра. Режим импорта образы не переносит.Сертификат главного от своего УЦ. Раньше документация требовала
NODE_EXTRA_CA_CERTSу worker, но установщик не мог его задать: вruntime.jsonдопускаются толькоARKVORY_*, и их применяют уже после запуска Node. Теперьarkvory configure --mirror … --mirror-ca-file <pem>проверяет сертификаты (читаются, не просрочены) и кладёт их вconfig/mirrors/ca.pem(ARKVORY_MIRRORS_CA_FILE). Worker при старте добавляет их к стандартным (tls.setDefaultCACertificates), и проверка источника в установщике использует тот же набор. Проверка TLS не ослабляется. Найдено стендом двух серверов.
Проверки дополнения:
tests/integration/mirror-processes.test.mjs: две установки (свои БД и хранилища), главный на встроенном HTTPS с самоподписанным сертификатом, которому worker доверяет черезARKVORY_MIRRORS_CA_FILE. Синхронизирует продуктовый процесс worker поARKVORY_MIRRORS_FILE. Сценарий: засев, обрыв главного (зеркало продолжает раздавать, статус показывает отказ), отзыв ключа (unauthorized), новый ключ в файле без перезапуска, перезапуск worker с продолжением от курсора без повторного копирования.tests/integration/mirror-images.test.mjs: образы до и после засева скачиваются с зеркала через/v2, удаления тега и манифеста доходят, push в зеркало отклоняется, лента зеркала несёт свои записи реестра.tests/integration/mirror.test.mjs: копия, отменённая очисткой, продолжается, журнал читается ключом с правом записи.tests/integration/storage-policy.test.mjs: политика в зеркальном репозитории ничего не удаляет.tests/mirror-sync.test.mjs: смена источника видна в статусе, другие стадии засевают заново, отставший главный, проигрыш записей реестра.
Дополнение: проверка перед 0.4.0
--mirror-ca-fileдобавляет сертификаты к общему набору зеркал установки, одинаковые хранятся один раз. Раньше CA второго зеркала заменял CA первого, и первое зеркало после перезапуска теряло TLS.- Ограничение: набор доверяется процессу worker целиком (
setDefaultCACertificates), а не соединениям конкретного зеркала. УЦ задаёт администратор установки, и действует он только в worker. Изоляция по зеркалу требует собственного HTTP-агента в SDK и запланирована отдельно. - Вебхуки доверяют только УЦ, которые были у процесса до добавления УЦ зеркал (и своему
ARKVORY_WEBHOOKS_CA_FILE): УЦ источника зеркала не участвует в доставке получателям.
Дополнение: встречный импорт (2026-10-12)
Две установки могут импортировать друг у друга: прод берёт release со стейджинга, стейджинг берёт hotfix с прода. Версия на обеих сторонах под одним ID. Импорт определял «уже есть здесь» только по загрузкам, которые сделал сам. Версия, сделанная на этой установке человеком или CI, вернувшись с другой стороны, считалась отсутствующей: импорт пытался создать её снова, падал на первичном ключе и повторял это на каждом шаге, а значит, переставал импортировать что-либо ещё. Теперь чужая запись с этим ID решает сама: опубликованная здесь версия — присутствует (импорт только добавляет стадии), всё остальное (удалённая здесь, чужая незавершённая загрузка, ID артефакта другого репозитория) не забирается и не создаётся второй раз. Удаление не передаётся ни в одну сторону. Для двух направлений нужны разные стадии: при одной и той же снятие стадии на одной стороне может вернуть импорт с другой. Проверка: tests/integration/mirror.test.mjs, встречный импорт двух установок.
Дополнение: проверка репликации (2026-10-12, схема 37)
Три независимых ревью (зеркало, импорт, сбои и восстановление) и интеграционные тесты двух установок нашли и закрыли:
- Продвижение в зеркалируемый репозиторий источника. Продвижение копирует регистрацию пакета в своей транзакции и пишет в ленту только
artifact.receive, безpackage.register. Зеркало регистрировало пакет только поpackage.register, поэтому после первого заполнения продвинутая версия на зеркале не разрешалась по версии и стадии (404 при «зелёном» статусе). Теперьartifact.receiveтоже регистрирует пакет. - Восстановленный источник. Номера ленты общие для всех репозиториев, и восстановление из бэкапа выдаёт пройденные зеркалом номера заново. Признак «голова меньше позиции» пропускал случай, когда новые записи успели обогнать позицию до следующего опроса: записи между ними терялись молча. Теперь каждая страница читается с самой записи на позиции, и зеркало сверяет её с сохранённой меткой (
anchor: действие, артефакт, деталь). Не та запись — новое заполнение с кодомmirror_source_behind. Отдельного запроса и изменения API нет. - Отключённое зеркало. Отключение удаляло только настройку, и повторное подключение продолжало со старой позиции, смешивая изменения, сделанные после отключения, с лентой источника. Воркер при запуске помечает состояние неподключённых репозиториев (
detached_at), зеркало по такому состоянию не продолжается:mirror_detached. Импорт, который только добавляет, начинает заново. - Импорт возвращал снятые здесь стадии и менял версии, сделанные здесь. Повторное заполнение и любое следующее
stage.addдобавляли все подходящие стадии источника, а аннотации версии с ревизией 0 перезаписывались. Теперь импорт записывает принятые версии (arkvory_mirror_adoptions, при миграции заполняется копиями, опубликованными синхронизацией). Принятая или сделанная здесь версия «решена»: заполнение её пропускает, а продвижение на источнике добавляет только стадию из этого события. Копию, которую сбой оставил недоведённой, импорт доводит, как раньше. - Конфликт версии пакета. Если здесь уже есть та же группа, имя и версия с другими байтами, регистрация молча пропускалась, и версия со стадией не разрешалась. Теперь это остановка с кодом
mirror_package_conflict(с 2026-10-15 — отложенная запись, см. ниже): клиенты продолжают получать здешнюю версию, а снятие стадии или версии на источнике снимает остановку. - Тот же ID в другом репозитории. Зеркало падало на первичном ключе без понятной причины, импорт молча пропускал версии. Конфигурация теперь запрещает брать один репозиторий источника в два репозитория установки, а оставшиеся случаи (цепочки) останавливаются с кодом
mirror_id_taken. - Долгие копии. Повторное открытие истёкшей незавершённой копии удаляло записанные части, поэтому копия дольше времени жизни загрузки (7 дней) никогда не завершалась. Части теперь сбрасываются только у отменённой копии и при несовпадении SHA-256 собранного файла.
- Наблюдаемость. Новое заполнение сохраняет время последней синхронизации, чтобы
ArkvoryMirrorStaleсрабатывал и во время долгого заполнения. Успешный шаг заполнения снимает прежнюю временную ошибку, аmirror_source_behindостаётся до догоняния. Остаткиmirror-stagingпосле падения воркер удаляет при запуске, до начала копирования.
Оставлено и описано в wiki: остановка на «ядовитом» изменении (повреждённые байты источника, mirror_delete_blocked) без пропуска (конфликты версии пакета и занятого ID с 2026-10-15 откладываются, см. ниже) (это видно в статусе и тревоге, лечится на источнике); восстановление установки-приёмника из бэкапа может вернуть версии, удалённые после бэкапа; комментарий стадии на зеркале не обновляется при повторном добавлении той же стадии.
Проверки: tests/mirror-sync.test.mjs (запись на позиции после восстановления), tests/mirror-import.test.mjs (решённые версии, доведение после сбоя), tests/integration/mirror-recovery.test.mjs (продвижение в источник, отключённое зеркало, конфликт версии, долгая копия, занятый ID), tests/integration/mirror.test.mjs (встречный импорт). Ключевые тесты проверены мутацией: без исправления они падают.
Дополнение: состояние зеркал для людей (2026-10-12)
GET /api/v1/mirrors(listMirrors, аддитивно) — все зеркала и импорты установки, чей репозиторий вызывающий может читать (политикаrepository-discovery, невидимые пропускаются, не 403). Ответ — те же объекты, чтоR/mirror, не больше 64 (предел числа зеркал).- Сетевые отказы источника больше не сливаются в
mirror_failed: недоступный источник (отказ соединения, таймаут, имя не разрешается) —mirror_unreachable, недоверенный, просроченный или чужой сертификат —mirror_certificate. Код берётся из цепочкиcause; SDK для этого сохраняет причину вArkvoryNetworkError(необязательный аргумент конструктора, аддитивно). - Консоль: страница «Зеркала» (обновляется каждые 20 с, пока открыта) и значок над каталогом (обновляется сам) показывают причину остановки словами, следующий шаг и команду, если она есть (новый ключ, УЦ источника), со ссылкой на таблицу кодов в wiki. Причина видна сразу, а не в подсказке.
Проверки: tests/mirror-sync.test.mjs (классификация кодов), tests/integration/mirror-processes.test.mjs (обрыв источника даёт mirror_unreachable), tests/integration/mirror.test.mjs (список и скрытие недоступных репозиториев), tests/browser/mirror.mjs (причина и команда, страница, обновление без перезагрузки, 320 px).
Дополнение: стабилизация (2026-10-15)
- Повторное подключение после отсоединения. Отметку
detached_atснимает только тот же источник (upstream|sourceRepository): курсор и метка записи другого источника ничего не значат, такое подключение остаётсяmirror_detached. Признак «здесь писали» берётся по порядку журнала, а не по времени: worker ставит отметку при запуске, когда API без зеркала уже мог принять записи, а время записи журнала — начало транзакции. Подключение возобновляется, только если после первой записи синхронизатора в репозиторий никто другой не писал (нет записей синхронизатора — не писал никто). - Запуск зеркал в worker. Отметка отсоединённых и очистка
mirror-stagingбольше не завершают процесс: недоступная БД повторяется с паузой зеркал (mirror.step_failed,mirror_startup_failed) до остановки, задания и вебхуки идут без ожидания; неудачная очистка (mirror_staging_failed) не мешает запуску. Каталогmirror-stagingстроится от проверенногоARKVORY_DATA_DIR, пустой путь отклоняется. - Консоль. Значок зеркала перечитывает состояние и после неудачного первого чтения; на странице «Зеркала» отображается только самый новый ответ.
Проверки: tests/mirror-sync.test.mjs, tests/mirror-startup.test.mjs, tests/integration/mirror-recovery.test.mjs (запись до отметки), tests/browser/mirror.mjs.
Дополнение: конфликтующие записи откладываются (2026-10-15, схема 41)
Проблема. Решения 2026-10-12 сделали конфликт версии пакета (mirror_package_conflict) и занятый ID (mirror_id_taken) остановкой. Остановка оказалась слишком сильной: курсор (или страница засева) не проходил запись, шаг повторялся каждые 5 минут, и все последующие изменения источника, не связанные с конфликтом, переставали приходить. Одна «ядовитая» версия на стейджинге блокировала импорт в прод навсегда. До 0.4 тот же случай пропускался молча — это тоже неприемлемо. Владелец выбрал (2026-10-15): пропускать и записывать, никогда не молча.
Решение.
- Конфликт одной записи — только эти два кода, у зеркала и у импорта (включая
artifact.receiveиpackage.register, засев и следование ленте) — откладывается: запись (артефакт источника, действие, деталь — путь или стадия) сохраняется вarkvory_mirror_conflictsс кодом, временем первой и последней попытки и числом попыток, а курсор идёт дальше. Единицы засева называются как события, которые дают то же изменение (artifact.publish,package.register,asset.replaceс путём;stage.addбез стадии у импорта), поэтому повтор — это обычное применение записи, перечитывающее текущее состояние источника. - Любая другая ошибка (сеть, TLS, ключ, SHA-256,
mirror_mismatch,mirror_delete_blocked, место) по-прежнему останавливает шаг и повторяется с паузой: такие ошибки не пропускаются никогда. - Записи реестров (образы, Git LFS, npm) не откладываются: их порядок важен (тег переезжает, манифест удаляется), и запись, применённая позже, могла бы откатить более новое изменение. Конфликт в них останавливает ленту, как раньше. Пакеты UPack регистрируются только по записям каталога, так что
mirror_package_conflictв реестрах не возникает; остаётся редкийmirror_id_takenцепочек. - Повтор: когда зеркало догнало источник, шаг пробует не больше 8 отложенных записей, последняя попытка которых была не меньше 10 минут назад. Применилась — запись удаляется; источника больше нет (версия удалена, у импорта снята стадия) — применение ничего не делает, запись тоже удаляется; снова конфликт — растёт число попыток; другая ошибка — шаг падает, запись остаётся.
- Видимость:
conflicts {count, oldestAt, sample}(до 10 самых старых) вGET R/mirrorиGET /api/v1/mirrors— аддитивное поле; SDK читает ответ прежнего сервера без поля как «нет отложенных» (прежний сервер ничего не откладывает, он останавливается и показываетerrorCode). Консоль показывает число на странице «Зеркала» и под значком; состояние зеркала не меняется, потому что остальное синхронизируется. Метрикаarkvory_mirror_conflicts{repository,mode}и правилоArkvoryMirrorConflicts(warning, больше нуля 30 минут). Журнал worker:mirror.conflict_set_aside(warning, при каждой неудачной попытке) иmirror.conflict_resolved. Вarkvory_auditзапись не пишется: этот журнал — лента изменений каталога, которую читают зеркала зеркал и вебхуки. - Схема 41, только expand:
arkvory_mirror_conflictsбез внешнего ключа (ID — источника, здесь его может держать другой репозиторий); ключ записи — MD5 действия и детали (путь до 1024 символов не помещается в элемент индекса). Таблица входит в резервную копию вместе сarkvory_mirror_state: без неё восстановленный курсор оказался бы за отложенными записями, и они были бы пропущены навсегда.
Альтернативы. Останавливаться, как с 2026-10-12: одна версия блокирует все остальные. Пропускать молча, как до 0.4: версия не приходит, и никто об этом не знает. Откладывать любые ошибки: сетевые и проверочные ошибки временные или требуют разбора, их пропуск терял бы изменения. Отдельная служба или очередь: хватает таблицы и шага существующего worker.
Последствия. Зеркало может отличаться от источника на отложенные записи, пока их не разрешат; это видно в статусе, консоли, метрике и тревоге. Путь файла, указывающий на отложенный артефакт, появляется после повтора записи asset.replace.
Проверки: tests/mirror-conflicts.test.mjs (только два кода откладываются, лента идёт дальше, другие ошибки останавливают, записи реестров останавливают, повтор через 10 минут и не больше 8 за шаг, удаление после применения и после удаления на источнике, импорт со снятой стадией), tests/integration/mirror-recovery.test.mjs (конфликт версии пакета при импорте и занятый ID в зеркале: следующие изменения приходят, статус и метрика показывают отложенное, после разрешения повтор очищает), tests/integration/backup-schema.test.mjs (таблица в реестре копии), tests/monitoring-rules.test.mjs (правило на существующей метрике), tests/browser/mirror.mjs (число на странице и под значком, исчезает после очистки).