Раздел 10 · Databases

Своя СУБД на Rust · урок 3: куча и записи

senior~120 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Своя СУБД на Rust · урок 3: куча и записи

Кому это. В уроке 1 ты сделал Page поверх Box<[u8; 8192]> и DiskManager поверх std::fs::File. В уроке 2 поставил между ними буферный пул, где pin это владение: fetch_page отдаёт PageGuard, а Drop снимает закрепление. Но движок пока хранит только безымянные байты. Страница не знает, что в ней лежит строка с колонками id, name, active. Этот урок вводит таблицу и запись.

Зачем именно Rust. Здесь сходятся три инструмента, которые ты разбирал в разделе Rust. Значение колонки это enum Value, и сопоставление с образцом по паре «тип колонки, значение» проверяет схему за один match. Разбор байтов с диска идёт через срезы с проверкой границ, поэтому битый файл даёт Err, а не панику посреди запроса. Sequential scan это свой Iterator, и форма этого итератора целиком продиктована правилами заимствования: почему он не держит страницу между вызовами next, разберём отдельно. Всё это без единого внешнего крейта.

Важно про код. Как и раньше, ты пишешь свою реализацию в крейте ourdb (только std, edition 2021, rust-version = "1.84"). На этом уроке в нём появляются два модуля: tuple.rs (схема, значения, кортеж) и heap.rs (куча). Эталон в examples/our-db/rust это полный движок всех одиннадцати уроков, поэтому в листингах ниже убраны куски уроков 7, 8 и 10 (журнал, MVCC-удаление, заморозка), и каждый такой пропуск назван в тексте. Если ты уже прошёл этот урок на голом TypeScript, байты будут те же до бита: меняется только то, как язык держит тебя за руку.

Где мы в движке

┌────────────────────────────────────────────────┐
│  Бинарный протокол + TCP-сервер                  │  урок 11
├────────────────────────────────────────────────┤
│  Исполнитель (Volcano) + EXPLAIN                 │  урок 6
│  Планировщик, binder, парсер, лексер             │  уроки 5, 6
├────────────────────────────────────────────────┤
│  Vacuum                                          │  урок 10
│  MVCC (видимость, снимки)                        │  уроки 8, 9
│  Блокировки (2PL), менеджер транзакций           │  уроки 8, 9
│  WAL (журнал, redo, recovery)                    │  урок 7
├────────────────────────────────────────────────┤
│  B+Tree индекс                                   │  урок 4
│  Куча (slotted pages, seq scan)                  │  урок 3  ← мы здесь
├────────────────────────────────────────────────┤
│  Буферный пул (кэш страниц, вытеснение)           │  урок 2
│  DiskManager (страницы поверх файла)              │  урок 1
└────────────────────────────────────────────────┘

Куча ложится ровно поверх буферного пула. Когда ей нужна страница, она берёт PageGuard через pool.fetch_page(id), работает с байтами под коротким latch-ем (guard.read() или guard.write()) и просто выходит из области видимости. На диск куча напрямую не ходит вообще.

Три новые сущности

Куча держится на трёх типах, и порядок тут важен.

  • Schema описывает форму строки: имена и типы колонок по порядку. Чистая структура, без ввода-вывода.
  • Tuple это одна строка в памяти (Vec<Value>) плюс заголовок версии, плюс умение сериализовать себя в байты по схеме и разобраться обратно. Чистая арифметика над срезом байтов.
  • HeapFile это таблица: набор страниц поверх пула. Вставка ищет место, scan обходит всё, удаление метит слот. Здесь есть владение пулом, ввод-вывод и закрепления.

В Effect-треке граница между чистым и эффектным кодом проходила по типу: у Schema и Tuple не было ни одного Effect. В Rust такой границы в типе нет, зато есть другая, не менее строгая: кто трогает PageGuard. Schema и Tuple знают только &[u8] и Vec<u8>. О пуле, кадрах и закреплениях знает только HeapFile. Идём снизу вверх.

Schema: форма строки

Минимальный набор типов из контракта: int (8 байт, знаковый), text (UTF-8 переменной длины), bool (1 байт). Этого хватает на весь воркшоп. Тип колонки это перечисление без данных.

// src/tuple.rs
use std::fmt;

use crate::bytes::{get_i64, put_u16, put_u32, read_u16, read_u32, slice};
use crate::error::{invalid, Result};

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ColumnType {
    Int,
    Text,
    Bool,
}

impl ColumnType {
    pub fn name(self) -> &'static str {
        match self {
            ColumnType::Int => "int",
            ColumnType::Text => "text",
            ColumnType::Bool => "bool",
        }
    }
}

impl fmt::Display for ColumnType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.name())
    }
}

ColumnType выводит Copy: это один байт тега, и таскать его по значению дешевле и проще, чем по ссылке. Поэтому name берёт self, а не &self. Display нужен ради сообщений об ошибках: дальше ты увидишь format!("{ty} column expects {ty}, ..."), и тип колонки сам напечатает себя как int. В TS-треке тип колонки был строковым литералом 'int' | 'text' | 'bool'; здесь это настоящий тип, и match по нему компилятор проверяет на полноту.

Колонка и схема:

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Column {
    pub name: String,
    pub ty: ColumnType,
}

impl Column {
    pub fn new(name: impl Into<String>, ty: ColumnType) -> Column {
        Column {
            name: name.into(),
            ty,
        }
    }
}

/// Имена и типы колонок таблицы по порядку.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Schema {
    columns: Vec<Column>,
}

impl Schema {
    pub fn new(columns: Vec<Column>) -> Result<Schema> {
        if columns.is_empty() {
            return Err(invalid("schema must have at least one column"));
        }
        Ok(Schema { columns })
    }

    pub fn columns(&self) -> &[Column] {
        &self.columns
    }

    /// Число колонок (nattrs в заголовке кортежа).
    pub fn len(&self) -> usize {
        self.columns.len()
    }

    pub fn is_empty(&self) -> bool {
        self.columns.is_empty()
    }

    pub fn column_index(&self, name: &str) -> Result<usize> {
        self.columns
            .iter()
            .position(|column| column.name == name)
            .ok_or_else(|| invalid(format!("unknown column \"{name}\"")))
    }
}

Четыре детали, на которых стоит задержаться.

impl Into<String> в Column::new. Конструктор принимает и &str, и String: Column::new("id", ColumnType::Int) читается в тестах без .to_string() на каждой колонке. Владение строкой переходит в колонку один раз, внутри into().

Поле columns приватное. Наружу торчит только срез &[Column]. Это и есть инвариант «схема не пустая»: собрать Schema в обход new нельзя, а new пустой вектор не пропустит. Тот же приём, что в уроке про структуры и методы: закрытое поле плюс конструктор, который проверяет.

Schema::new возвращает Result. В TS-треке конструктор бросал RangeError, в Effect-треке тоже: пустая схема считалась ошибкой программиста. В Rust исключений нет, и конструктор с проверкой честно говорит об этом в сигнатуре. Вызывающий решает сам: в тесте это .unwrap(), в каталоге урока 6 это ?, и ошибка доедет до клиента. invalid(...) это помощник из error.rs урока 1, он собирает Error::Invalid(String).

is_empty рядом с len. Схема по построению не пустая, так что is_empty всегда вернёт false. Метод нужен ради clippy::len_without_is_empty: тип с публичным len без is_empty линтер считает недоделанным. Мелочь, но эталон проходит cargo clippy --all-targets без предупреждений, и твой код тоже должен.

Порядок колонок это часть контракта, а не деталь. Сериализация пишет значения в порядке колонок, разбор читает в том же порядке. Поменяешь местами две колонки в схеме, и старые байты прочитаются неверно. column_index понадобится в уроке 6, когда binder будет переводить имя колонки из SQL в её позицию.

Value: значение колонки как enum

Значение одной клетки таблицы бывает четырёх видов: число, строка, булево или NULL. В Rust это ровно одно перечисление.

/// Значение колонки: int это i64, text это String, bool это bool.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Value {
    Null,
    Int(i64),
    Text(String),
    Bool(bool),
}

impl From<i64> for Value {
    fn from(value: i64) -> Self {
        Value::Int(value)
    }
}

impl From<&str> for Value {
    fn from(value: &str) -> Self {
        Value::Text(value.to_owned())
    }
}

impl From<bool> for Value {
    fn from(value: bool) -> Self {
        Value::Bool(value)
    }
}

Сравни с TS-треком. Там значение было объединением bigint | string | boolean | null, и int пришлось держать в bigint, потому что обычный number теряет точность за пределами 2^53. В Rust этой проблемы нет: i64 это родной знаковый 64-битный тип, и i64::to_le_bytes кладёт его на диск ровно восемью байтами. Никаких суффиксов n и никакой отдельной ветки для «больших» чисел.

Почему NULL это вариант Value::Null, а не Option<Value>? Потому что в SQL NULL это полноценное значение выражения: NULL + 1 даёт NULL, NULL = NULL тоже NULL. Вычислитель урока 6 будет возвращать Value из каждого узла выражения, и NULL обязан ходить по нему тем же типом, что и числа. Обёртка Option над каждым значением дала бы два способа сказать «нет значения» и вечный вопрос, который из них правильный.

Value выводит Eq и Hash. Это возможно потому, что среди вариантов нет f64: у чисел с плавающей точкой нет полного равенства (NaN не равен сам себе), и Eq для них не выводится. Целые, строки и булевы сравниваются честно, поэтому Value можно класть ключом в HashMap или HashSet. Это пригодится, когда значения станут ключами группировки или проверки уникальности.

Три From это удобство для тестов и примеров: Value::from("батя") короче, чем Value::Text("батя".to_owned()). Подробнее про пары From/Into в уроке о преобразованиях.

Контракт байтов кортежа

Прежде чем писать Tuple, посмотри на формат, в который он сериализуется. Это контракт из examples/our-db/SPEC.md, общий для всех реализаций воркшопа, поэтому байты Rust-трека обязаны совпадать с TS-, Effect- и Go-треком до бита:

смещение  размер  поле
0         4       xmin       u32   xid транзакции, создавшей версию
4         4       xmax       u32   xid транзакции, удалившей версию (0 если живая)
8         2       infomask   u16   биты-флаги (пока один: HAS_NULL)
10        2       nattrs     u16   число колонок
12        ...     null_bitmap      ceil(nattrs / 8) байт, по биту на колонку
...       ...     data             значения ненулевых колонок по порядку

Заголовок это 12 байт. Два поля в нём, xmin и xmax, несут метаданные MVCC. Поле xmin это номер транзакции, вставившей строку, xmax это номер удалившей (0, пока строка жива). До транзакций они значат мало: мы заполняем xmin тем xid, что передан вставке, а xmax нулём. Но место под них резервируем уже здесь, чтобы в уроке 8 не ломать формат.

Константы заголовка лежат в tuple.rs рядом с типами:

/// Идентификатор транзакции. 0 это сентинел (DDL и "нет удалившего").
pub type Xid = u32;

pub const TUPLE_HEADER_SIZE: usize = 12;
/// Бит infomask: в кортеже есть NULL, null bitmap значим.
pub const INFOMASK_HAS_NULL: u16 = 0x0001;

Xid это псевдоним u32, а не отдельный newtype. Псевдоним не защищает от путаницы xid с номером страницы (оба u32), зато читается в сигнатурах и не требует обёрток на каждом шаге. Если хочешь строже, newtype из урока про стандартные трейты закроет эту дыру ценой пары .0. Эталон выбрал псевдоним ради краткости.

В эталоне рядом лежит ещё INFOMASK_XMIN_FROZEN = 0x0002: этот бит ставит vacuum в уроке 10, а у Tuple есть соответствующее поле frozen. На этом уроке ни бит, ни поле не нужны, поэтому в листингах их нет. Когда дойдёшь до vacuum, добавишь оба.

NULL это бит, а не значение

Ключевая тонкость формата это null bitmap. NULL не лежит в данных особым значением, это поднятый бит в карте. Если колонка NULL, в области данных она не занимает ничего. Это и компактнее (строка с десятком NULL почти пустая), и честнее: NULL это отсутствие значения, а не нуль и не пустая строка.

Атрибуту с индексом i соответствует бит i % 8 в байте i / 8, младший бит первым. Это та же битовая арифметика, что в уроке о битах и порядке байтов: byte |= 1 << bit зажигает, byte & (1 << bit) != 0 проверяет.

Повозись с раскладкой вживую. Переключай колонки в NULL и обратно, правь имя. Следи за тремя вещами: область данных (NULL-колонка исчезает из неё целиком), бит в null bitmap, флаг HAS_NULL в infomask. Заголовок всегда 12 байт, bitmap всегда 1 байт (трём колонкам хватает одного), меняется только хвост с данными.

Если повозился с виджетом, ты увидел три факта, на которых стоит сериализация. Заголовок и bitmap фиксированной длины, плавает только область данных. NULL-колонка не пишет ни байта данных, её выдаёт только бит. text растёт ровно на длину в UTF-8, и кириллица это 2 байта на символ, поэтому name из русских букв вдвое длиннее, чем кажется по числу символов. В Rust с этим проще, чем в JS: String всегда хранит UTF-8, и text.len() возвращает именно байты, а не символы. Отдельный Buffer.byteLength не нужен.

Доверенные и недоверенные байты

Прежде чем писать сериализацию, разберёмся, чем читать числа. В крейте есть приватный модуль bytes.rs с двумя сортами помощников, и разница между ними это главный инженерный урок этого слоя.

// src/bytes.rs
//! Little-endian чтение и запись чисел по смещению (числа на диске LE, см. SPEC.md).
//! `get_*`/`put_*` работают по фиксированным смещениям внутри страницы и паникуют на
//! выходе за границы (это баг движка); `slice` проверяет границы для недоверенных байтов
//! (кортеж с диска, payload WAL) и отдаёт `Error::Invalid`.

use crate::error::{invalid, Result};

pub(crate) fn get_u16(buf: &[u8], at: usize) -> u16 {
    u16::from_le_bytes([buf[at], buf[at + 1]])
}

pub(crate) fn get_i64(buf: &[u8], at: usize) -> i64 {
    i64::from_le_bytes(buf[at..at + 8].try_into().expect("8 bytes"))
}

pub(crate) fn put_u16(buf: &mut [u8], at: usize, value: u16) {
    buf[at..at + 2].copy_from_slice(&value.to_le_bytes());
}

pub(crate) fn put_u32(buf: &mut [u8], at: usize, value: u32) {
    buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
}

// ... get_u32, get_u64, put_u64, put_i64 устроены так же

/// Срез `buf[at..at + len]` с проверкой границ.
pub(crate) fn slice(buf: &[u8], at: usize, len: usize) -> Result<&[u8]> {
    at.checked_add(len)
        .and_then(|end| buf.get(at..end))
        .ok_or_else(|| {
            invalid(format!(
                "out of bounds: {len} bytes at {at} of {}",
                buf.len()
            ))
        })
}

pub(crate) fn read_u16(buf: &[u8], at: usize) -> Result<u16> {
    slice(buf, at, 2).map(|bytes| get_u16(bytes, 0))
}

pub(crate) fn read_u32(buf: &[u8], at: usize) -> Result<u32> {
    slice(buf, at, 4).map(|bytes| get_u32(bytes, 0))
}

get_*/put_* доверяют смещению. Ими Page из урока 1 читает lower, upper и слоты. Смещения там константы (offset::LOWER = 8), и выход за границы буфера на 8192 байта означает ошибку в коде движка. Индексация buf[at] в таком случае паникует, и это правильно: баг надо увидеть сразу, а не обходить. Заметь try_into().expect("8 bytes"): срез длины 8 превращается в массив [u8; 8], которого ждёт from_le_bytes. Длина гарантирована диапазоном at..at + 8, поэтому expect тут никогда не сработает, он лишь документирует инвариант.

slice и read_* не доверяют. Кортеж приходит с диска, и его байты это граница доверия. Длина строки в нём записана числом u32, и если файл повреждён, это число может указывать за конец страницы. Паника тут недопустима: один битый кортеж уронил бы весь сервер вместе с соседними сессиями. Поэтому slice берёт байты через buf.get(at..end), которое возвращает Option<&[u8]> вместо паники, а checked_add страхует само сложение: at + len на огромной длине не переполнит usize, а даст None. Итог в любом из двух провалов один: Error::Invalid с понятным сообщением.

Это та же мысль, что в уроке про защитный Rust: паника для нарушенного инварианта своего кода, Result для плохих данных извне. В TS-треке buffer.readUInt32LE за границей бросал RangeError, и различать два случая приходилось по смыслу. Здесь различие видно по имени функции и по типу возврата.

Tuple: значения плюс заголовок

Теперь сам кортеж: значения строки плюс заголовок версии.

/// Логическая строка плюс заголовок версии (MVCC).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Tuple {
    pub values: Vec<Value>,
    pub xmin: Xid,
    pub xmax: Xid,
    // в эталоне здесь ещё `frozen: bool` для vacuum (урок 10)
}

impl Tuple {
    pub fn new(values: Vec<Value>) -> Tuple {
        Tuple::with_header(values, 0, 0)
    }

    pub fn with_header(values: Vec<Value>, xmin: Xid, xmax: Xid) -> Tuple {
        Tuple { values, xmin, xmax }
    }

    /// Сериализовать кортеж в байты по схеме.
    pub fn serialize(&self, schema: &Schema) -> Result<Vec<u8>> {
        encode(&self.values, self.xmin, self.xmax, schema)
    }

    // deserialize ниже
}

В TS-треке Tuple был иммутабельным, и чтобы проставить xmin при вставке, звали withHeader, который собирал новый объект. В Rust неизменяемость обеспечивает не дисциплина, а заимствование: пока у тебя &Tuple, поменять его нельзя, а &mut Tuple в один момент бывает только у одного владельца. Поэтому поля спокойно публичные.

Но есть тонкость поважнее. Куча при вставке должна записать кортеж с xmin = xid, а не с тем xmin, что лежит в аргументе. Самый прямой путь, tuple.clone() с подменой поля, скопировал бы весь Vec<Value> вместе со всеми строками только ради того, чтобы поменять четыре байта заголовка. Поэтому сериализация вынесена в свободную функцию encode, которая берёт значения срезом &[Value], а заголовок отдельными аргументами. serialize это тонкая обёртка над ней, а куча позовёт encode напрямую со своим xid. Ни одной копии.

encode: строка в байты

Смысловой центр урока.

/// Собрать байты кортежа из значений и заголовка (без копии самого Tuple).
pub fn encode(values: &[Value], xmin: Xid, xmax: Xid, schema: &Schema) -> Result<Vec<u8>> {
    let nattrs = schema.len();
    if values.len() != nattrs {
        return Err(invalid(format!(
            "tuple has {} values, schema expects {nattrs}",
            values.len()
        )));
    }
    let bitmap_start = TUPLE_HEADER_SIZE;
    let mut out = vec![0u8; bitmap_start + nattrs.div_ceil(8)];
    let mut has_null = false;
    for (index, (column, value)) in schema.columns().iter().zip(values).enumerate() {
        match (column.ty, value) {
            (_, Value::Null) => {
                has_null = true;
                out[bitmap_start + index / 8] |= 1 << (index % 8);
            }
            (ColumnType::Int, Value::Int(int)) => out.extend_from_slice(&int.to_le_bytes()),
            (ColumnType::Bool, Value::Bool(flag)) => out.push(u8::from(*flag)),
            (ColumnType::Text, Value::Text(text)) => {
                out.extend_from_slice(&(text.len() as u32).to_le_bytes());
                out.extend_from_slice(text.as_bytes());
            }
            (ty, other) => {
                return Err(invalid(format!("{ty} column expects {ty}, got {other:?}")));
            }
        }
    }
    let infomask = if has_null { INFOMASK_HAS_NULL } else { 0 };
    put_u32(&mut out, 0, xmin);
    put_u32(&mut out, 4, xmax);
    put_u16(&mut out, 8, infomask);
    put_u16(&mut out, 10, nattrs as u16);
    Ok(out)
}

В эталоне у encode есть ещё параметр frozen: bool, который поднимает бит INFOMASK_XMIN_FROZEN. Он понадобится vacuum-у в уроке 10, сейчас без него.

Разберём по шагам.

  1. Проверка арности. Число значений обязано совпадать с числом колонок. И здесь эта проверка не просто вежливость. Ниже стоит zip, а итератор zip молча останавливается на более коротком из двух. Без проверки кортеж из двух значений под схему из трёх колонок записался бы «успешно», с nattrs = 3 в заголовке и двумя значениями в данных. Разбор такого кортежа потом читал бы чужие байты.
  2. Буфер сразу под заголовок и bitmap. vec![0u8; 12 + nattrs.div_ceil(8)] выделяет нулями заголовок и карту. div_ceil это деление с округлением вверх, ровно ceil(nattrs / 8) из контракта. Значения дальше дописываются в хвост через extend_from_slice и push, поэтому весь кортеж собирается в одном Vec без промежуточных кусков. В TS-треке были отдельные Buffer под заголовок, карту и каждое значение и финальный Buffer.concat.
  3. match по паре. Вот где перечисления окупаются. Мы сопоставляем не значение и не тип по отдельности, а кортеж (column.ty, value). Первая ветка (_, Value::Null) ловит NULL в колонке любого типа и зажигает бит: байт index / 8, бит index % 8. Три следующие ветки это совпадения типа и значения, каждая со своим кодеком. Последняя ветка (ty, other) ловит всё остальное, то есть любое несовпадение схемы и значения, одной строкой. В TS-треке под это было три отдельные проверки typeof в каждой ветке switch.
  4. Кодеки. int это i64::to_le_bytes, восемь байт little-endian. bool это u8::from(*flag): From<bool> for u8 даёт ровно 0 или 1, без if. text это длина в байтах как u32 LE, потом сами байты UTF-8 через as_bytes(), без перекодирования: String уже хранит UTF-8.
  5. Заголовок в конце. infomask зависит от того, был ли хоть один NULL, а это известно только после цикла. Поэтому место под заголовок выделено нулями заранее, а put_u32/put_u16 заполняют его последними. Смещения константные, буфер точно длиннее 12 байт, так что здесь законно работают доверяющие put_*.

Про text.len() as u32. Приведение as молча обрезает значение, если строка длиннее 4 ГиБ. До такого не дойдёт: кортеж больше страницы куча отвергнет сразу после encode. Но если ты возьмёшь encode в другое место (например, в запись WAL), помни, что длина тут не проверена, и u32::try_from(text.len()) сделал бы это честно.

deserialize: байты в строку

Разбор это зеркало. Дано: буфер, смещение начала кортежа в нём и схема. Надо собрать Tuple или вернуть ошибку.

    /// Разобрать кортеж из буфера, начиная со смещения offset, по схеме.
    pub fn deserialize(buf: &[u8], offset: usize, schema: &Schema) -> Result<Tuple> {
        let xmin = read_u32(buf, offset)?;
        let xmax = read_u32(buf, offset + 4)?;
        let infomask = read_u16(buf, offset + 8)?;
        let nattrs = read_u16(buf, offset + 10)? as usize;
        if nattrs != schema.len() {
            return Err(invalid(format!(
                "tuple has {nattrs} attrs, schema expects {}",
                schema.len()
            )));
        }
        let has_null = infomask & INFOMASK_HAS_NULL != 0;
        let bitmap = slice(buf, offset + TUPLE_HEADER_SIZE, nattrs.div_ceil(8))?;
        let mut cursor = offset + TUPLE_HEADER_SIZE + bitmap.len();
        let mut values = Vec::with_capacity(nattrs);
        for (index, column) in schema.columns().iter().enumerate() {
            if has_null && bitmap[index / 8] & (1 << (index % 8)) != 0 {
                values.push(Value::Null);
                continue;
            }
            let (value, next) = decode_value(column.ty, buf, cursor)?;
            values.push(value);
            cursor = next;
        }
        Ok(Tuple { values, xmin, xmax })
    }

Здесь каждое чтение идёт через проверяющие помощники, и каждое заканчивается ?. Знак вопроса пробрасывает Err наверх и обрывает разбор на первом же битом поле. Подробно про этот оператор в уроке об обработке ошибок. Шаги те же, что при записи, в обратную сторону.

  1. Заголовок. Четыре поля по смещениям от offset. Сверяем nattrs со схемой: если кортеж записан под другую схему, лучше вернуть ошибку, чем выдать мусор.
  2. Bitmap срезом. slice отдаёт &[u8] длины ceil(nattrs / 8) прямо внутри исходного буфера, без копии. Раз срез получен, его длина проверена, и bitmap[index / 8] внутри цикла уже не выйдет за границу: index < nattrs, а байт выделен под каждую колонку.
  3. Курсор по данным. cursor начинается сразу за картой и ползёт вперёд по мере чтения колонок. NULL-колонка курсор не двигает: в данных её нет.
  4. Vec::with_capacity(nattrs). Число значений известно заранее, поэтому вектор выделяется один раз, без перевыделений по ходу push.

Проверка NULL идёт первой не случайно. Если бы мы попробовали прочитать значение NULL-колонки из данных, мы бы прочитали байты следующей колонки и сбили курсор на всё остальное. Bitmap говорит, какие колонки в данных вообще присутствуют. А условие has_null && ... это короткое замыкание: если в infomask флага нет, в карту даже не смотрим.

/// Декодировать одно значение, вернуть его и смещение сразу за ним.
fn decode_value(ty: ColumnType, buf: &[u8], at: usize) -> Result<(Value, usize)> {
    Ok(match ty {
        ColumnType::Int => (Value::Int(get_i64(slice(buf, at, 8)?, 0)), at + 8),
        ColumnType::Bool => (Value::Bool(slice(buf, at, 1)?[0] != 0), at + 1),
        ColumnType::Text => {
            let len = read_u32(buf, at)? as usize;
            let bytes = slice(buf, at + 4, len)?;
            let text = String::from_utf8_lossy(bytes).into_owned();
            (Value::Text(text), at + 4 + len)
        }
    })
}

Каждый кодек возвращает пару: значение и смещение сразу за ним. Возврат смещения из декодера и двигает курсор в deserialize: декодер сам знает, сколько байт он съел.

Посмотри на ветку Int. slice(buf, at, 8)? проверяет, что восемь байт есть, и отдаёт их срезом. Дальше get_i64(..., 0) читает из этого среза без проверок: границы уже доказаны. Так проверка делается ровно один раз, на входе, а не на каждом байте.

Ветка Text самая показательная. Длина len прочитана из файла, ей нельзя верить, поэтому slice(buf, at + 4, len)? проверяет, что столько байт действительно есть. А String::from_utf8_lossy не падает на невалидном UTF-8: битые последовательности заменяются символом U+FFFD. Это сознательное решение эталона, и оно совпадает с поведением Buffer.toString('utf8') в TS-треке. Альтернатива String::from_utf8(bytes.to_vec()) вернула бы ошибку, и тогда одна испорченная буква делала бы нечитаемой всю строку. into_owned() нужен, потому что from_utf8_lossy отдаёт Cow<str>: заимствование, если байты валидны, или новую строку, если что-то заменили. Кортеж должен владеть своими данными, поэтому берём владеющую String в любом случае.

match ty исчерпывающий: три варианта ColumnType покрыты, ветки _ нет. Добавишь четвёртый тип колонки, и компилятор покажет пальцем на каждый match, где его забыли.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.