Дескрипторы и связи блоков
Часть читающих методов хранилища блоков — данные, доказательство и диф очереди — принимает не идентификатор блока, а его дескриптор. У остальных адресация другая. Ожидание блока по-прежнему адресует его обычным идентификатором. Поиск по мастер-блоку находит блок по одному лишь seqno, сканируя диапазон ключей: идентификатор для этого не нужен. Постраничный список вовсе не адресует конкретный блок, а отдаёт страницу идентификаторов. Дескриптор не входит в само хранилище блоков: он живёт в соседней подсистеме, хранилище дескрипторов блоков, и несёт метаинформацию о блоке (какие его компоненты уже сохранены и каков статус блока).
Вторая подсистема, хранилище связей блоков, стоит рядом: она хранит ссылки от блока к соседям по цепочке и умеет ждать появления связи со следующим блоком, отдавая его идентификатор.
Дескриптор блока и его метаинформация
Хранилище дескрипторов блоков состоит из двух частей: таблиц RocksDB и кэша дескрипторов в памяти. Другого состояния у него нет. Экземпляр создаётся один раз при открытии хранилища узла и дальше раздаётся всем потребителям.
Дескриптор (BlockHandle) — обёртка над разделяемой ссылкой на неизменяемый идентификатор блока, изменяемую метаинформацию и три независимые блокировки на компоненты блока: данные, доказательство и диф очереди. У него есть и слабая форма: она не удерживает дескриптор в памяти сама по себе, а лишь пытается восстановить сильную ссылку на него. Это удаётся, только пока дескриптор жив хотя бы у одного другого держателя. Если все держатели уже отпустили дескриптор, объект уничтожен и восстановить сильную ссылку не получится.
Метаинформация блока (BlockMeta) упакована в одно атомарное 64-битное слово: старшие 32 бита несут флаги блока (см. ниже), младшие 32 бита — seqno мастер-блока, которым блок «привязан» к мастерчейну. Время создания блока хранится отдельным неизменяемым полем. В RocksDB эта метаинформация записывается ровно 12 байтами.
Флаги блока
BlockFlags — набор флагов, упакованных в старшие 32 бита слова метаинформации:
| Флаг | Что означает |
|---|---|
HAS_DATA | сохранены данные блока |
HAS_PROOF | сохранено доказательство блока |
HAS_QUEUE_DIFF | сохранён диф очереди |
HAS_ALL_BLOCK_PARTS | составной флаг: все три компонента сохранены |
HAS_STATE | сохранено состояние шарда |
HAS_PERSISTENT_SHARD_STATE | сохранено устойчивое состояние шарда |
HAS_PERSISTENT_QUEUE_STATE | сохранено устойчивое состояние очереди |
HAS_VIRTUAL_STATE | сохранено виртуальное состояние |
HAS_NEXT_1 / HAS_NEXT_2 | известна связь со следующим блоком (одним, или обоими при разделении шарда) |
HAS_PREV_1 / HAS_PREV_2 | известна связь с предыдущим блоком (одним, или обоими при слиянии шардов) |
IS_COMMITTED | блок зафиксирован |
IS_KEY_BLOCK | блок ключевой |
IS_PERSISTENT | по блоку сохранено устойчивое состояние |
IS_REMOVED | блок вычищен сборкой мусора |
IS_ZEROSTATE | блок — нулевое состояние |
SKIP_STATES_GC / SKIP_STATES_GC_FINISHED | защита состояния от сборки мусора начата / завершена |
SKIP_BLOCKS_GC / SKIP_BLOCKS_GC_FINISHED | защита данных блока от сборки мусора начата / завершена |
Флаги — единственный «журнал состояния» блока в узле: по ним читающее API решает, отдавать ли компонент, а сборка мусора решает, можно ли блок удалить.
Обновление флагов в RocksDB
Метаинформация блока обновляется в RocksDB не обычной записью, а ассоциативным оператором слияния колонки дескрипторов: новое 12-байтовое значение объединяется со старым побитовым ИЛИ, без какой-либо другой логики. Значит, флаг можно только добавить, а не снять. Обычное сохранение дескриптора снятый флаг не сохранит: при следующем объединении старый бит вернётся. Снятый в памяти флаг всё же попадает на диск только в одном месте — при стартовой сверке во время открытия хранилища, которая пишет пересчитанную метаинформацию напрямую, минуя оператор слияния.
Такое устройство даёт конкурентность без блокировок: несколько потоков, ставящих разные флаги одному и тому же блоку, не затирают чужое обновление, а порядок применения объединений не важен. Цена — асимметрия между постановкой и снятием флага: в базе сохраняется только «флаг поставлен», а не «флаг снят».
Весь набор сеттеров хранилища дескрипторов стоит на этом принципе: методы, каждый из которых ставит один конкретный флаг — защиту от сборки мусора, статус зафиксированного блока, признаки сохранённых состояний и нулевого состояния. Каждый сеттер атомарно добавляет свой флаг и возвращает признак, что флаг действительно изменился. Только тогда дескриптор дозаписывается в RocksDB.
Повторный вызов того же сеттера — дешёвая операция без обращения к диску: он ничего не меняет. Публичного метода, который снимал бы флаг, у хранилища дескрипторов нет: снятие не переживает запись через оператор слияния.
Особые случаи нулевого блока
По одному лишь нулевому seqno дескриптор истинен сразу по нескольким предикатам без собственного флага: он всегда сообщает, что относится к нулевому состоянию, что по нему сохранено устойчивое состояние, а для мастерчейна — что он ещё и ключевой блок. Перечисленные предикаты дескриптора (зафиксирован ли блок, есть ли у него состояние, виртуальное состояние, устойчивые состояния и связь со следующим блоком) смотрят только на свой флаг.
Практический смысл — нулевое состояние сети не нуждается в специальной разметке при создании: даже без единого выставленного флага его дескриптор уже ведёт себя как устойчивый, нулевой и, для мастерчейна, ключевой блок.
Защита от сборки мусора
Защита блока от сборки мусора устроена так же — парой флагов «начато» и «завершено», а не одним, отдельно для данных блока и отдельно для состояния. Раз снятый флаг не сохраняется, отменить защиту можно только выставив рядом второй флаг.
Сборка мусора блоков проверяет то же условие («начата и не завершена») прямо по сырым байтам метаинформации, не разбирая дескриптор целиком, и точно так же безусловно пропускает блоки с флагом ключевого, устойчивого или нулевого блока. Кто и когда выставляет эти флаги и что именно защищает от удаления — тема будущей отдельной статьи о сборке мусора, а не этой.
Кэш дескрипторов
Кэш хранилища дескрипторов держит только слабые ссылки — сам он никого не удерживает. Пока хотя бы у одного потребителя дескриптор блока на руках, запись в кэше поднимается до сильной ссылки на тот же объект: два обращения за дескриптором одного и того же блока получают один и тот же экземпляр, а не две копии.
Запись сама освобождается из кэша, когда последняя сильная ссылка на дескриптор отпущена, — но только если слот к этому моменту не занял новый дескриптор того же блока.
Есть и принудительный путь: чистка кэша по высоте блока, часть сборки мусора хранилища, снимает запись независимо от того, держит ли дескриптор ещё кто-то. Если это произошло с ещё живым дескриптором, следующее обращение за тем же блоком построит второй, отдельный экземпляр со своими флагами и своими блокировками, не связанный с первым.
Единственность экземпляра на идентификатор блока необходима для корректности: и флаги метаинформации, и блокировки на компоненты блока имеют смысл, только если все потребители работают с одним и тем же объектом. Иначе один поток мог бы выставить флаг, который другой не увидит до перечитывания из RocksDB.
У кэша нет ни лимита записей, ни времени жизни: его размер определяется тем, сколько дескрипторов реально удерживают потребители узла, и чисткой по высоте блока.
Создание и сохранение дескриптора
Получить дескриптор блока можно, создав его при необходимости и заодно узнав, был ли он только что создан или уже существовал. Метод проверяет три источника по очереди:
- дескриптор уже в кэше и жив — возвращается сразу, без обращения к RocksDB;
- дескриптора в кэше нет, но метаинформация уже есть в таблице дескрипторов — дескриптор поднимается из неё и кладётся в кэш;
- блок неизвестен вовсе — создаётся новый дескриптор с переданной метаинформацией.
Есть и упрощённый вариант того же поиска, без создания: только первые два пути, а для неизвестного блока он возвращает пустой результат.
На уже известном блоке переданная метаинформация игнорируется целиком: ни время создания, ни привязка к мастер-блоку, ни признак ключевого блока не перезаписываются повторно. Метаинформация блока задаётся один раз, при первом появлении блока в узле, и дальше меняются только флаги.
Сохранение дескриптора в RocksDB учитывает два независимых условия: создаётся ли дескриптор впервые и является ли блок ключевым. Если хотя бы одно из них истинно, запись идёт одним атомарным пакетом. Метаинформация в основной таблице дескрипторов пишется при каждом сохранении, независимо от этих условий. Впервые созданный дескриптор дополнительно попадает в отдельную вспомогательную таблицу, а ключевой блок — в индекс ключевых блоков, который разобран в следующем разделе. Когда ни одно из условий не выполняется, то есть при обычном обновлении флагов у давно известного блока, пакет не собирается вовсе: идёт одиночная запись метаинформации.
Индекс ключевых блоков
Индекс ключевых блоков — таблица «seqno самого ключевого блока → идентификатор блока», отдельная от основной таблицы дескрипторов. Запись в неё добавляется при каждом сохранении дескриптора ключевого блока, а порядок seqno в ключе совпадает с числовым, так что по индексу можно ходить обычным итератором RocksDB.
Через индекс хранилище даёт четыре способа найти ключевой блок:
| Метод | Что находит |
|---|---|
load_key_block_handle(seqno) | дескриптор ключевого блока с точным seqno |
find_last_key_block() | последний известный ключевой блок |
find_prev_key_block(seqno) | ближайший ключевой блок строго до seqno |
key_blocks_iterator(direction) | обход всего индекса вперёд от seqno или назад от последней записи |
Все они возвращают дескриптор (или идентификатор блока — для обхода итератором), а не данные блока: за данными нужно отдельно обращаться к хранилищу блоков.
find_prev_key_block ищет ключевой блок строго меньше переданного seqno: сам блок с этим seqno в результат не попадает, а для нулевого seqno метод сразу возвращает пустой результат. Вызывающему, которому нужен ключевой блок «не позже данного», приходится самому передавать seqno + 1. Пустой результат означает и то, что индекс пуст, и то, что все ключевые блоки лежат правее запрошенной границы. Различить эти два случая по ответу метода нельзя.
Отдельно есть find_prev_persistent_key_block — поиск ближайшего устойчивого ключевого блока. Он идёт по индексу назад от заданного seqno и для каждой пары соседних ключевых блоков проверяет, признаётся ли более поздний из них устойчивым по меткам времени создания обоих. Как только пара такую проверку проходит, возвращается более поздний блок пары. Если индекс исчерпан, а условие ни разу не выполнилось, результат пустой. Устойчивость здесь определяется по меткам времени, а не по флагу дескриптора. Флаг устойчивости ставится по факту сохранения устойчивого состояния. Этот метод его не вычисляет.
Связи между блоками
Хранилище связей блоков — подсистема, отдельная от хранилища дескрипторов и от хранилища блоков.
Связь блоков — сохранённая ссылка от блока к соседу по цепочке в одном из четырёх направлений: два предка при слиянии шардов (Prev1, Prev2) и два потомка при разделении (Next1, Next2). Когда шард не сливается и не делится, используются только Prev1 и Next1.
У хранилища связей всего три метода: записать связь, прочитать связь и дождаться связи Next1. Асимметрия между чтением и записью умышленная — пишущий метод принимает дескриптор блока, потому что попутно правит его флаги, а читающий получает обычный идентификатор: дескриптор ему не нужен. Ждать можно только Next1, подписок на прочие направления нет.
Связи хранятся обычной колонкой RocksDB: на каждую связь одна запись, а обратная связь (от соседа к этому блоку) пишется отдельным вызовом.
Как связи появляются
Признак «связь уже записана» живёт не в самой таблице связей, а во флаге дескриптора: свой флаг на каждое направление. Первым делом запись связи проверяет этот флаг. Если он уже стоит, вызов сразу завершается, ничего не читая и не записывая. Иначе связь сначала пишется в таблицу, и только затем дескриптору ставится флаг — если флаг действительно изменился, обновлённая метаинформация сохраняется в основной таблице дескрипторов. Такой «кэш признака» в дескрипторе экономит чтение RocksDB на горячем пути: при повторной обработке уже известного блока обращения к таблице связей не происходит вовсе.
Модуль продвижения по блокам расставляет связи при сохранении блока.
Для блока с одним предком:
- новому блоку пишется связь назад,
Prev1; - предку пишется связь вперёд —
Next2, если новый блок оказался правым потомком предка при разделении шарда, иначеNext1.
Если у блока сразу два предка (слияние шардов), обоим предкам пишется Next1, а новому блоку — Prev1 и Prev2.
Связь вперёд пишется только тогда, когда дескриптор предка уже найден в хранилище. Связь назад пишется всегда: предка в узле может не оказаться при старте с середины цепочки, тогда как дескриптор самого блока только что создан.
Семантика направлений:
Next1/Next2— левый и правый потомок при разделении;Prev1/Prev2— два предка при слиянии.
Ожидание Next1
В статье о хранилище блоков было показано, что ожидание следующего блока цепочки сначала дожидается, пока у предыдущего блока не появится связь со следующим, и только потом ждёт появления самих данных. Именно эту связь, Next1, и умеет ждать хранилище связей.
Ожидание защищено блокировкой с перевёрнутыми ролями: ждущий берёт эксклюзивную часть, а пишущие — разделяемую, хотя обычно бывает наоборот.
Под своей частью блокировки ждущий сначала проверяет, не появилась ли связь уже: если появилась, она возвращается сразу. Если нет, он оформляет ожидание и только после этого отпускает свою часть блокировки и засыпает.
Пишущий, наоборот, берёт свою часть блокировки только при записи связи Next1, а для остальных направлений блокировка не используется вовсе. Уведомление ожидающих происходит до того, как эта часть блокировки будет отпущена.
Такая полярность закрывает окно между проверкой «связи ещё нет» и оформлением ожидания: в этот момент никакая параллельная запись связи Next1 невозможна, поэтому пробуждение не может проскочить мимо ждущего. У ожидания нет таймаута: вызывающий ждёт следующий блок сколько угодно долго и может лишь сам отменить ожидание.
Cassadilia
Зачем блокам и архивам Tycho отдельный движок хранения вместо RocksDB — блобы, адресуемые по содержимому, транзакция записи через staging-файл, инвариант «сначала блоб, потом индекс» и публичный интерфейс стора Cas.
Хранилище ячеек
Как узел хранит состояния шардов в виде дедуплицированных ячеек — отдельная база RocksDB вместо Cassadilia, ленивая материализация ячейки в памяти, счётчик ссылок в оперативной структуре со снапшотом на диск и зона молодых ячеек с собственным журналом и чекпоинтами для короткоживущих ячеек.