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

Cassadilia

Зачем блокам и архивам Tycho отдельный движок хранения вместо RocksDB — блобы, адресуемые по содержимому, транзакция записи через staging-файл, инвариант «сначала блоб, потом индекс» и публичный интерфейс стора Cas.

Тела блоков и готовые архивы физически хранит не RocksDB, а Cassadilia — отдельный движок класса content-addressable storage (CAS, хранилище, адресуемое по содержимому) для больших неизменяемых объектов.

В протоколе у движка одна главная точка входа — обвязка над ним в составе хранилища блоков и архивов. RocksDB в этом тракте остаётся только под метаданные: дескрипторы блоков, журнал событий архивов и временный список блоков собираемого архива.

Причины выбора отдельного движка

Cassadilia стоит выбирать, когда блобы весят от ~10 МБ, доступ к ним преимущественно на чтение и нужно уметь прочитать произвольный диапазон байт без открытия блоба целиком. Ещё один сигнал в её пользу: RocksDB на такой нагрузке уже пробовали, и он убил диск. Отдельное условие — критичность нулевого усиления записи: ровно одна физическая запись на блоб, без компакций. Обратный случай: множество мелких файлов, для которых лучше подходит LSM-дерево, и не-unix-подобная ОС.

За этим стоит конкретная проблема хранения: тела блоков и архивы — большие неизменяемые объекты, а LSM-дерево вроде RocksDB переписывает данные при каждой компакции, то есть тратит запись диска кратно их объёму. В CAS блоб пишется один раз и дальше только читается. Это и даёт нулевое усиление записи.

Блоб и транзакция записи

Единица хранения Cassadilia — блоб: неизменяемый объект, адресуемый хешем собственного содержимого (blake3). Запись оформлена как транзакция на один ключ: вызов put создаёт объект Transaction и вместе с ней staging-файл, куда идут данные, пока транзакция не завершена. Метод записи можно вызывать сколько угодно раз подряд: он дописывает данные в буфер, увеличивает счётчик размера и по ходу обновляет хешер blake3. На этом построена потоковая упаковка архива в Tycho: данные zstd-потока уходят в транзакцию по мере сжатия, и архив не нужно держать в памяти целиком.

Метод finish() выполняет коммит фиксированной последовательностью шагов: сбросить буфер записи в файл, синхронизировать файл с диском, взять итоговый хеш блоба из хешера и зарегистрировать интент «этот ключ вот-вот получит такой-то блоб». Дальше staging-файл переносится в дерево хранилища атомарным переименованием, операция дописывается в журнал, обновляется индекс CAS — и только после этого удаляются блобы, на которые не осталось ссылок.

Transaction не завершается сама по себе: библиотека настойчиво напоминает о необходимости явно вызвать finish(). Если транзакцию всё же уронить, staging-файл удаляется сам, а индекс не меняется вовсе — операция считается несостоявшейся.

Порядок фиксации блоба и индекса

Базовое правило Cassadilia: сначала данные попадают в CAS, и только потом ключ появляется в индексе. Отсюда следует, что индекс не может сослаться на физически отсутствующий блоб: такая рассинхронизация невозможна по построению. Единственно возможный вид рассинхронизации — обратный перекос: данные без ссылки в индексе. Такие файлы называются осиротевшими блобами, и их список можно получить при старте хранилища.

Tycho пользуется тем же принципом уровнем выше: архив сначала целиком фиксируется в CAS-сторе архивов, и только потом в RocksDB появляется отметка, что он закоммичен. Подробнее об этом — в статье об архивах.

Интерфейс стора

Стор Cas<K> параметризован типом ключа: K должен реализовывать KeyBytes (кодирование в байты и обратно) плюс обычный набор трейтов ключа.

В Tycho один стор хранит компоненты блоков под ключом PackageEntryKey, частичным идентификатором блока плюс типом компонента: этот ключ разобран в статье о хранилище блоков. Второй стор хранит готовые архивы под ключом u32, номером архива.

МетодЧто делает
openоткрыть стор
open_with_recoverто же самое, но дополнительно вернуть результат стартового скана
putначать транзакцию записи блоба под ключ
getпрочитать блоб целиком
get_sizeузнать размер блоба по индексу, не читая файл
get_readerполучить поток на файл блоба
get_rangeпрочитать участок блоба
removeудалить ключ
remove_rangeудалить все ключи из диапазона
checkpointпринудительно сохранить индекс
read_index_stateполучить доступ к индексу на чтение
statsсчётчики блобов и размера индекса
root_pathкорневой каталог стора
as_arcвнутренний Arc стора для сбора метрик

Записать сразу несколько ключей одной транзакцией нельзя: Transaction привязана ровно к одному ключу. Удаление, наоборот, умеет работать целым диапазоном ключей.

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

Частичное чтение

get_range(key, start, end) не поднимает блоб целиком: индекс даёт его размер, а сами данные читаются позиционным чтением ровно на запрошенном участке. Отсутствующий ключ даёт пустой ответ без обращения к диску. Верхняя граница диапазона обрезается по фактическому размеру блоба, а начало на конце блоба или за ним тоже даёт пустой результат. Обратный диапазон (начало больше конца) — ошибка.

На ней построена раздача больших объектов по сети чанками одного размера — около 1 МБ (1 МиБ): такими кусками узел отдаёт и архив, и тело блока. Весь объект при этом в память не поднимается. Как это устроено на стороне потребителя, рассказывают статьи о хранилище блоков и об архивах.

Место Cassadilia в хранилище узла

Сам движок не знает ни о блоках, ни об архивах, ни о сборке мусора: он оперирует только ключами и блобами. Кто читает, пишет и удаляет, с каким ключом и когда, решает уровень выше — обвязка над Cassadilia, которая уже описана как часть хранилища блоков и архивов. Модуль продвижения по блокам, коллатор, blockchain RPC, RPC-сервис узла и control-сервер обращаются не к Cassadilia напрямую, а к фасаду хранилища блоков, о котором рассказывает одноимённая статья. Сам фасад тоже не обращается к движку напрямую: между ними стоит та же обвязка над Cassadilia, что упомянута в начале статьи. Это и есть главная точка, где движок подключён к остальному коду узла. Получается трёхуровневая цепочка: потребитель обращается к фасаду хранилища блоков, тот опирается на обвязку над Cassadilia, а она уже работает с самим движком.

Сборка мусора хранилища тоже обращается к Cassadilia. Что именно и когда она удаляет — тема статьи о сборке мусора.