Хранение данных

Архивы блоков

Как узел упаковывает диапазон блоков в архив для ускоренной синхронизации — распределение блоков по архивам и разрез по ключевым блокам, сборка и коммит архива, восстановление состояния при рестарте, поиск архива по номеру и выдача его потребителям.

Хранилище блоков хранит и отдаёт данные блока, доказательство блока и диф очереди по одному блоку за раз. При синхронизации этого недостаточно: узлу, который догоняет сеть, нужно скачать сразу большой диапазон истории, а не запрашивать блоки по одному. Для этого подряд идущие блоки заранее упаковываются в архив блоков — один сжатый объект, и синхронизирующийся узел получает историю уже архивами.

Архив живёт в том же blob-хранилище, что и отдельные компоненты блоков, но в собственном CAS-сторе archives (CAS: content-addressable storage, хранилище, адресуемое по содержимому). Устройство этого нижнего слоя — отдельная тема. Здесь описано только поведение самой архивной подсистемы, построенной поверх него.

Идентификатор архива и распределение блоков

Каждый архив адресуется идентификатором архива — номером seqno мастер-блока, с которого архив начат. Blob-хранилище держит в памяти множество идентификаторов уже открытых архивов и для каждого следующего блока решает, к какому архиву его отнести: берётся ближайший идентификатор, не превышающий seqno мастер-блока, к которому привязан блок. Блок приписывается к архиву по мастер-блоку, которым он отреферен (ref_by_mc_seqno его дескриптора). Блоки рабочей цепочки и отреферивший их мастер-блок поэтому всегда лежат в одном архиве.

Новый архив открывается, если архивов ещё нет вообще, либо от начала текущего архива накопилось 100 мастер-блоков, либо сработал форсированный разрез по ключевому блоку (см. ниже). Во всех остальных случаях блок относится к уже открытому архиву. Отсюда следует, что архив с идентификатором N не обязательно содержит ровно 100 мастер-блоков начиная с N: ключевой блок может разрезать архив раньше.

Разрез архива на ключевом блоке

Ключевой блок принудительно разрезает архив: следующий за ним мастер-блок начинает новый архив, даже если в текущем накопилось меньше 100 мастер-блоков. Разрез срабатывает и когда ключевым является сам обрабатываемый блок, и когда ключевым является мастер-блок, по которому этот блок обрабатывается.

Разрез не мгновенный. При обнаружении ключевого блока blob-хранилище только запоминает точку будущего разреза: seqno следующего мастер-блока. Пока обрабатываемые блоки не дошли до этой точки, они идут в текущий архив как обычно. Дойдя до неё, хранилище открывает новый архив и отправляет предыдущий на коммит. Чтобы точка разреза пережила рестарт узла, она фиксируется отдельным событием архива: событие переписывается на каждом блоке, пока разрез ожидается, но переписывается идемпотентно — ключ и значение при этом одни и те же.

Короткий архив — ожидаемое следствие разреза, а не отклонение, но у него два разных случая. Основной: если ключевой блок оказался последним блоком уже открытого архива, этот архив закрывается на нём и содержит не больше 100 мастер-блоков. Дело в самом форсированном разрезе, а не в его отложенности: отложенность лишь переносит границу с ключевого блока на следующий за ним. Ровно 100 получается в пограничном случае, когда ключевой блок пришёлся на последний блок и без того полного архива: тут разрез ничего не меняет. Угловой: если ключевой блок сам открывает новый архив, тот может состоять всего из одного мастер-блока. Разрез выравнивает границы архивов по ключевым блокам: архив, скачанный с сети, должен начинаться с блока, от которого можно продолжить проверку доказательств.

Сборка и коммит архива

Единственный путь блока в архив — метод, который модуль продвижения по блокам ставит отложенной задачей после того, как сохранил блок и решил, что архивирование включено (см. ниже, «Отключение упаковки в архивы»). Он одной атомарной пакетной записью дописывает идентификатор блока в список блоков текущего архива и фиксирует произошедшее событиями архива: архив начат, обнаружена точка разреза, предыдущий архив пора коммитить. Байты компонентов блока при этом никуда не копируются: они уже лежат в blob-хранилище, а в архив попадут только на коммите.

Коммит архива — фоновая задача, которая запускается, когда открывается следующий архив и предыдущий закрывается для дозаписи. Задача читает список блоков закрытого архива и проходит его: сначала по данным блока, затем по доказательствам, затем по дифам очереди. На каждом проходе задача читает соответствующий компонент из blob-хранилища, распаковывает его и записывает в архив одним общим потоком сжатия zstd вместе с заголовком (идентификатор блока, тип компонента, длина данных). Такая группировка по типам компонентов даёт лучшее сжатие. Коммит архива невозможен, пока хотя бы у одного блока из списка не хватает данных, доказательства или дифа очереди: незавершённая сборка блока откладывает коммит всего архива.

Готовый архив в CAS-сторе архивов устроен так: 4-байтовый префикс, а за ним подряд записи «заголовок плюс данные компонента». В отличие от blob-хранилища, где каждый компонент сжат по отдельности, здесь сжат весь поток целиком одним потоком zstd: компоненты внутри архива лежат уже распакованными, а сжатие даёт сам архивный поток.

Порядок завершения коммита и устойчивость к сбоям

Финализация коммита идёт в строго фиксированном порядке: сначала архив атомарно фиксируется в CAS-сторе архивов целиком, и только после этого одной пакетной записью в RocksDB архив помечается закоммиченным, а временный список его блоков удаляется. Порядок выбран ради единственного действительно опасного случая — пометки «архив закоммичен» без самого архива в хранилище: раз архив сначала целиком появляется в CAS и лишь потом помечается, такая рассинхронизация невозможна.

Из этого порядка следуют гарантии на случай падения узла. Если узел упал до того, как архив зафиксирован в CAS, временные метаданные (список блоков и событие о постановке в очередь на коммит) остаются на месте, и коммит просто повторяется при следующем запуске. Если узел упал уже после того, как архив зафиксирован в CAS, но до финальной пакетной записи, штатного механизма, который дописал бы её позже, не предусмотрено. Расхождение (архив в CAS уже есть, пометки о коммите нет, а временный список его блоков остаётся неудалённым) так и останется навсегда.

Поиск архива по номеру мастер-блока

Отдать архив потребителю — значит сначала понять, в каком архиве лежит запрошенный мастер-блок. Поиск идёт только по множеству известных архивов в памяти, не заглядывая в CAS, и возвращает один из трёх исходов:

  • архив найден, назван его идентификатор;
  • архив ещё слишком новый: запрошенный seqno относится к архиву, который только строится и ещё не закоммичен;
  • архив не найден: запрошенный диапазон уже вышел за пределы известных архивов.

Найденный идентификатор не гарантирует, что архив уже лежит в CAS: закрытый архив может ещё дожидаться фоновой задачи коммита (например, если сборке не хватает компонента хотя бы одного блока), и потребитель дополнительно проверяет его размер. Разные потребители трактуют отсутствие размера по-разному: blockchain RPC превращает это в ответ «архив ещё строится», а локальный управляющий сервис узла — в ответ «архив не найден».

Восстановление состояния архивов при рестарте

При открытии хранилища узел восстанавливает состояние архивов не из памяти, а проигрывая журнал событий архива с начала. Событие «архив начат» добавляет архив в множество открытых. Событие о точке разреза запоминает её, сбрасывая эту отметку, как только встречен архив с идентификатором больше неё. Событие о постановке в очередь на коммит добавляет архив в список ожидающих, а событие «архив закоммичен» снимает последний элемент этого списка. Открытие хранилища проверяет согласованность этого журнала (например, что событие о постановке в очередь есть только у уже начатого архива, а после события «закоммичен» список ожидающих коммитов пуст) и падает, если согласованность нарушена.

Архивы, которые к концу восстановления остались в списке ожидающих коммит, коммитятся прямо на старте. Архив, который был начат, но ни разу не поставлен в очередь на коммит, просто остаётся открытым — ему незачем и нечем коммититься, он продолжит наполняться обычным образом.

Тот же стартовый проход заодно приводит в соответствие флаги наличия компонентов у всех дескрипторов блоков. Индекс CAS — источник истины о том, какой компонент физически есть, а флаг в дескрипторе подтягивается к нему: снимается, если блоба нет, и восстанавливается, если блоб есть, а флаг почему-то не выставлен. Это напрямую касается архивов: коммит требует, чтобы у каждого блока были выставлены все три флага, поэтому пересчёт флагов выполняется до того, как узел дозапускает незавершённые коммиты.

Выдача архива потребителям

Как только состояние восстановлено (или архив только что закоммичен на живом узле), его можно отдавать потребителям. Готовый архив хранилище отдаёт наружу как есть, без распаковки: можно узнать список известных идентификаторов, получить размер конкретного архива, прочитать его целиком потоком или скачать чанк фиксированного размера по смещению — смещение должно быть выровнено на границу чанка в 1 МиБ. Раздачей архивов пользуются blockchain RPC и локальный управляющий сервис узла. Дальше получатель распаковывает архив.

О готовности архива хранилище дополнительно сообщает через отдельный канал: идентификатор попадает в него только после того, как коммит архива подтверждённо завершился. Основной подписчик — модуль продвижения по блокам, который на каждом обработанном блоке вычитывает канал и реагирует на каждый готовый архив. Это точка расширения: на готовый архив могут быть повешены и внешние потребители за пределами модуля продвижения по блокам, например выгрузка готовых архивов в S3.

Отключение упаковки в архивы

Постановка блока в архив выключается одним флагом конфигурации. Модуль продвижения по блокам, сохранив блок, проверяет поле store_archives конфигурации хранилища узла и только при включённом флаге ставит отложенную задачу архивирования. При выключенном флаге новые архивы не открываются, а список блоков и события текущего архива не пополняются.

Флаг не действует на уже накопленное состояние: при открытии хранилища узел, как описано выше, всё равно восстанавливает множество архивов из журнала событий и дозапускает незавершённые коммиты, независимо от значения флага. Поэтому у узла, которому архивирование выключили после того, как архивы уже строились, поиск архива по-прежнему находит их, а на старте может выполниться отложенный коммит. Удаление старых архивов этим же флагом тоже не управляется — это отдельный механизм.

Флаг фактически переключает роль узла: без архивов узел экономит место на диске и процессорное время на сжатии, но не может раздавать историю сети другим узлам — им придётся синхронизироваться с узла, на котором архивирование включено.

Конфигурация

ПараметрПо умолчаниюЧто задаёт
store_archivestrueУпаковывать ли блоки в архивы