Хранилище блоков
Узел должен надёжно сохранить свежесобранный или скачанный блок и потом отдать его обратно. Модуль продвижения по блокам — подсистема, которая ведёт узел по цепочке блоков, — записывает его. Коллатор после сборки блока отдельно сохраняет своё доказательство и диф очереди. Blockchain RPC раздаёт уже сохранённые блоки другим узлам.
Хранилище блоков отвечает за весь этот цикл. Это фасад, через который узел принимает и выдаёт три отдельно хранимых компонента блока: данные, доказательство блока и диф очереди.
Сам фасад байты не хранит: он передаёт их ниже, в blob-хранилище поверх Cassadilia — устройство этого нижнего слоя отдельная тема. Здесь фиксируется только то, что делает сам фасад: следит, какие компоненты блока сохранены, пишет и читает их по дескриптору, держит кэш уже разобранных блоков и умеет ждать появления блока. Нижний слой делает раскладку байт на диске.
Компоненты блока и их ключ
Три части одного блока хранятся как три независимые записи: данные блока, доказательство блока и диф очереди можно писать и читать по отдельности. Но полностью изолированными их не назвать — у записей общий префикс ключа (рабочая цепочка, префикс шарда, seqno и root hash), и в индексе они лежат рядом.
Каждая запись адресуется собственным ключом. Ключ складывается из частичного идентификатора блока (рабочей цепочки, префикса шарда, seqno и root hash) и типа компонента, а итоговый размер составляет 49 байт. В ключ не входит file hash: в частичном идентификаторе его нет.
Доказательство можно сохранить независимо от того, есть ли уже данные блока. Общий дескриптор блока, в котором для каждого компонента заведён свой флаг наличия, также связывает три компонента одного блока в хранилище.
Устройство фасада
Хранилище блоков владеет кэшем блоков, очередью подписчиков на появление блока и внутренней блокировкой, которая координирует запись и подписку. Дескрипторы блоков и связи между блоками фасаду не принадлежат: он только пользуется ими через соседние хранилища.
Не сам фасад хранит байты компонентов, а blob-хранилище: туда он делегирует запись, чтение и сжатие каждого из них. У фасада нет своих таблиц для тела блока — всё, что связано именно с байтами, лежит уровнем ниже, в Cassadilia.
Запись компонента
Записать компонент блока — данные, доказательство или диф очереди — нужно ровно один раз, даже если параллельно это пытаются сделать несколько вызовов.
Метод, сохраняющий данные блока, принимает сам блок вместе с его сериализованным представлением и метаданными для дескриптора. Методы, сохраняющие доказательство и диф очереди, принимают либо уже готовый дескриптор блока, либо метаданные, из которых дескриптор при необходимости будет создан или подхвачен. Идентификатор блока для них берётся из самого доказательства или дифа — отдельным параметром он не передаётся, поэтому в варианте с метаданными дескриптор создаётся именно по этому идентификатору.
Запись идемпотентна. Перед тем как писать байты, метод проверяет флаг наличия соответствующего компонента у дескриптора, и если флаг уже стоит, запись пропускается. Проверка происходит дважды: один раз до блокировки конкретного компонента дескриптора и ещё раз под ней. Поэтому параллельные попытки сохранить один и тот же компонент одного блока дают одну реальную запись. Только тот вызов, что застал компонент действительно отсутствующим под блокировкой, пишет байты в blob-хранилище и выставляет флаг, а остальные видят уже готовый результат. Запись данных блока, доказательства и дифа очереди устроена одинаково.
Сохранив данные блока, хранилище дополнительно уведомляет тех, кто ждёт появления этого блока, независимо от того, была запись новой или нет, и кладёт разобранный блок в кэш блоков. Для доказательства и дифа очереди ни уведомления, ни кэш не задействуются.
Диф очереди — третий обязательный компонент наравне с данными и доказательством. Только когда сохранены все три, дескриптор блока считается полным. Это важно тем потребителям, которым нужен блок целиком, а не отдельная его часть.
Компонент сжимается алгоритмом zstd с уровнем сжатия 3 уже внутри blob-хранилища — это часть единственного пути записи компонента. Распаковка происходит симметрично в методах, отдающих содержимое компонента. Ниже, в разделе о раздаче блока чанками, показано, что не любое чтение распаковывает. Раз сжатие идёт по каждому компоненту отдельно, компоненты читаются независимо друг от друга: общего словаря сжатия или групповой упаковки на этом уровне нет.
Чтение компонента по дескриптору
Чтение — операция, обратная записи: по дескриптору блока получить один из трёх компонентов, либо уже разобранным, либо распакованными байтами для тех, кому разбор не нужен.
Читающие методы для конкретного компонента блока — данных, доказательства и дифа очереди — принимают на вход дескриптор блока, а не идентификатор. Дескриптор здесь нужен: в нём есть полный идентификатор блока для ключа записи, флаг наличия нужного компонента и блокировка, которой чтение синхронизируется с записью. Практически это значит, что перед чтением компонента дескриптор нужно сначала загрузить отдельно.
Так устроено не всё чтение хранилища блоков. Ожидание появления блока адресует блок обычным идентификатором и загружает дескриптор само — оно рассмотрено дальше отдельно.
Прежде чем обратиться к blob-хранилищу, каждый из методов чтения компонента — данных блока, доказательства и дифа очереди, разобранных ниже, — проверяет соответствующий флаг дескриптора. Без данных, доказательства или дифа очереди чтение сразу отвечает отдельной ошибкой для каждого случая, не трогая нижний слой. Флаг учитывает и удаление: блок, вычищенный сборкой мусора, читается как отсутствующий, даже если сам дескриптор ещё жив, потому что флаг удаления гасит флаг наличия.
Данные блока. Сначала хранилище проверяет кэш блоков: при попадании возвращается уже разобранный блок. При промахе байты читаются из blob-хранилища, распаковываются и разбираются в структуру блока. Блоки крупнее 1 МБ разбираются в отдельном пуле потоков, а не в потоке текущей асинхронной задачи: так разбор не задерживает остальные задачи узла.
Доказательство блока. Доступно и распакованными байтами напрямую, и уже разобранной структурой — второе устроено как тонкая надстройка над первым. Кэш блоков доказательство не хранит, и порога вынесения разбора в отдельный пул потоков для него нет.
Диф очереди. Устроен так же, как доказательство: распакованные байты либо разобранная структура. Разбор дифов крупнее 100 КиБ, в отличие от доказательства, выносится в отдельный пул потоков: порог на порядок меньше, чем у данных блока, а сами дифы очереди в среднем компактнее.
Методы, которые отдают доказательство и диф очереди байтами без разбора, возвращают не то, что физически лежит в blob-хранилище, а уже распакованные из zstd байты — просто ещё не превращённые в типизированную структуру. Отдельный способ получить именно сжатое, ещё не распакованное представление данных блока описан ниже, в разделе о раздаче блока чанками.
Раздача блока чанками
У чтения есть и такая задача: отдать блок по сети чанками фиксированного размера, не разбирая и не распаковывая его на стороне хранилища.
Хранилище блоков умеет отдавать данные блока как есть, сжатым представлением, без распаковки: диапазон байт по смещению и длине, а также полный размер сжатой записи целиком. Оба способа читают ровно то, что лежит в blob-хранилище, без обращения к кэшу блоков и без разбора в структуру.
Раздача блока чанками устроена так: blockchain RPC нарезает тело блока по 1 МБ и, если блок не помещается в один чанк, сообщает клиенту полный размер сжатой записи. Уже сам клиент на другой стороне собирает чанки и распаковывает блок целиком.
Кэш блоков
Кэш блоков играет соседнюю роль: ускоряет повторное чтение только что сохранённого блока, не обращаясь к blob-хранилищу заново.
Кэш блоков реализован как синхронный кэш в памяти, в котором ключом служит идентификатор блока, а значением — уже разобранный блок. Вытеснение работает по правилу LRU: учитываются время жизни записи и ёмкость в байтах, а не число блоков. Вес записи считается как размер идентификатора блока плюс фиксированные накладные расходы плюс размер данных самого блока, и именно сумма весов сравнивается с ёмкостью.
Кэш наполняется только при сохранении данных блока — блок попадает в кэш независимо от того, была ли запись байт в blob-хранилище новой: даже когда данные уже существующего блока повторно не пишутся, сам блок в кэш всё равно кладётся. Чтение данных блока не изменяет кэш ни при попадании, ни при промахе: промах не прогревает запись на будущее. Явной инвалидации записей тоже нет: запись уходит из кэша по истечении времени жизни или её вытесняет LRU при нехватке ёмкости.
Кэш поэтому ускоряет повторное чтение блока сразу после того, как тот был сохранён, — именно тогда он нужен другому потребителю почти сразу же после записи. При чтении истории, которую заново никто не записывал, кэш не помогает.
Ожидание появления блока
Иначе устроено ожидание появления блока: получить его по идентификатору, когда неизвестно, сохранён ли он уже, не опрашивая хранилище в цикле.
Если данные блока уже сохранены, вызов сразу возвращает блок обычным чтением. Если нет, вызывающий оформляет подписку на блок по его идентификатору и ждёт, пока запись данных не разбудит его. Это способ прочитать блок из хранилища блоков, которому дескриптор заранее не нужен: он адресует блок обычным идентификатором и при необходимости сам загружает дескриптор внутри.
Есть и вариант, который ждёт именно следующий блок цепочки. Он сначала дожидается, пока у предыдущего блока не появится связь со следующим, и только потом ждёт появления самого следующего блока тем же способом. Так модуль продвижения по блокам получает от хранилища блоков очередной блок цепочки, не опрашивая его сам.
Потребители хранилища блоков
Вокруг хранилища блоков сложилось наблюдаемое распределение ролей.
- Модуль продвижения по блокам на стороне записи сохраняет блок при его обработке, а при проверке скачанного блока — доказательство и следом диф очереди с тем же дескриптором. Тот же порядок используется при холодном старте узла. На стороне чтения он получает очередной блок цепочки ожиданием, как описано выше.
- Коллатор после сборки блока сохраняет доказательство и диф очереди тем же способом, что и модуль продвижения по блокам. Данные самого блока в хранилище кладёт модуль продвижения по блокам.
- Blockchain RPC раздаёт блок другим узлам чанками, как описано выше.
- RPC-сервис узла читает доказательства блока.
Хранилище блоков — общая точка входа к данным блоков: все перечисленные подсистемы работают с ними только через его методы.
Конфигурация
Кэш блоков настраивается секцией blocks_cache внутри конфигурации хранилища узла. Секция целиком опциональна: при её отсутствии или отсутствии отдельных полей внутри неё берутся значения по умолчанию.
Значения по умолчанию секции blocks_cache:
| Параметр | По умолчанию | Что задаёт |
|---|---|---|
ttl | "300s" | Время жизни записи в кэше блоков |
size | "500 MB" | Ёмкость кэша блоков в байтах |
Мегабайты в поле size узел считает в десятичных приставках, а не в мебибайтах (MiB).
Хранилище
Карта раздела Storage — из каких частей состоит хранилище узла Tycho и как они опираются друг на друга.
Архивы блоков
Как узел упаковывает диапазон блоков в архив для ускоренной синхронизации — распределение блоков по архивам и разрез по ключевым блокам, сборка и коммит архива, восстановление состояния при рестарте, поиск архива по номеру и выдача его потребителям.