Плагины Picodata на Rust: pike, миграции и blue-green

Когда SQL не хватает, Picodata расширяют плагином на Rust: сервис внутри кластера со своим жизненным циклом, миграциями схемы и обновлением версий без остановки. Разбор на рабочем плагине — от скелета через pike до blue-green, включая места, где всё это ведёт себя не так, как ожидаешь

У Picodata есть граница SQL, и предыдущая статья серии измерила её точно: 59 проб, 17 отказов. Когда задача попадает за эту границу — или когда данные нужно обработать, не вывозя их наружу, — кластер расширяют собственным кодом. Не хранимой процедурой на Lua, как в Tarantool, а плагином на Rust: отдельным проектом, который компилируется в библиотеку, раскладывается по узлам и живёт в кластере со своей версией, своими миграциями схемы и штатным обновлением.

Разбор построен на рабочем плагине из стенда databases/picodata. Он решает ту же задачу, что Lua-хранимка в статье про Tarantool и Picodata: возвращает топ товаров категории, считая его внутри кластера. Одна задача на двух системах — удобный способ увидеть разницу моделей.

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

В статье

Из чего состоит плагин

Плагин — это набор из трёх вещей.

Сервис — единица логики, реализующая трейт Service. У него есть жизненный цикл: on_start, on_stop, on_config_change, on_leader_change, on_health_check. Сервисов в плагине может быть несколько, у каждого своя конфигурация.

Манифест — файл, связывающий всё вместе: имя плагина, версия, список сервисов с конфигурацией по умолчанию, перечень файлов миграций.

Миграции — SQL-файлы с секциями применения и отката, которые кластер исполняет при установке версии.

Собирается это в динамическую библиотеку, которая должна лежать в каталоге share_dir на каждом узле кластера. Сервис привязывается не к узлу и не к репликасету, а к тиру — логической группе инстансов.

Скелет: pike

Проект не пишут с нуля — его генерирует cargo pike, официальная утилита для разработки плагинов:

cargo pike plugin new near_data

Получается обычный cargo-проект с crate-type = ["cdylib"], готовым сервисом-заглушкой, HTTP- и RPC-обработчиками для примера, каталогом миграций, манифестом-шаблоном и — приятная деталь — интеграционными тестами на picotest, который умеет поднимать кластер прямо из теста.

Первое, что стоит проверить в сгенерированном Cargo.toml, — версию библиотеки:

[dependencies]
picodata-plugin = "=26.1.3"

Требование жёсткое: версия picodata-plugin должна совпадать с версией Picodata, иначе кластер откажется включать плагин. Проверку можно отключить переменной окружения, но документация этого не рекомендует, и правильнее свести версии. На момент написания шаблон pike 5.4.0 закреплял 26.1.3, тогда как на crates.io уже лежала 26.1.5 — стенд поэтому поднят на образе 26.1.3. Мелочь, которая экономит полчаса, если знать о ней заранее.

Сборка: почему в контейнере

Плагин — динамическая библиотека, которую загружает процесс Picodata. Значит собирать её нужно против того же системного окружения, в котором этот процесс работает. Образ Picodata собран на AlmaLinux 8.10 с glibc 2.28; библиотека, собранная на системе поновее, потребует более свежий glibc и просто не загрузится.

Поэтому в стенде отдельный сборочный образ на той же базе, а результат проверяется внутри образа кластера:

ldd /share/near_data/0.1.0/libnear_data.so
# неразрешённых зависимостей: 0
# максимальная требуемая версия: GLIBC_2.28

Вторая деталь того же рода обнаружилась не сразу: версия каталога в share_dir должна совпадать с версией пакета. Реестр сервисов внутри библиотеки регистрирует их под своей версией из Cargo.toml, и сборка 0.2.0, положенная в каталог 0.1.0, устанавливается наполовину — манифест кластер находит, а сервисы нет:

$ bash ops/install-plugin.sh 0.1.0
Failed to install plugin `near_data:0.1.0`: Plugin partial load
(some of services not found: ["near_data_service"])

В стенде сборочный скрипт теперь сверяет запрошенную версию с Cargo.toml и падает при расхождении.

И третье, из разряда «не делайте так»: пересобрать библиотеку поверх файла, который уже загружен работающим процессом, и перезапустить узел — значит уронить инстанс. Штатный путь обновления один, и он ниже.

Логика: RPC и HTTP поверх одной функции

Внутри плагина доступен SQL-интерфейс: запрос строится, параметры биндятся, результат десериализуется в типы:

pub fn top_products(request: &TopRequest) -> anyhow::Result<TopResponse> {
    // LIMIT подставляется в текст: параметр в WHERE биндится, а `LIMIT ?`
    // распределённый планировщик не принимает. Безопасно, потому что u32.
    let sql = format!(
        "SELECT id, title, views FROM products
         WHERE category = ?
         ORDER BY views DESC, id DESC
         LIMIT {}",
        request.limit
    );

    let rows: Vec<ProductRow> = query(&sql)
        .bind(request.category.as_str())
        .fetch::<ProductRow>()?;

    Ok(TopResponse { items: rows, /* ... */ })
}

Эту функцию вызывают две точки входа: RPC-путь, зарегистрированный через RouteBuilder, и HTTP-маршрут поверх встроенного веб-сервера. Логика при этом одна — иначе два транспорта со временем разъедутся.

Здесь стоит сразу назвать вещи своими именами, чтобы не подменять понятия. У Tarantool хранимка идёт по вторичному индексу локально, в одном процессе с кортежами. Плагин Picodata выполняет распределённый запрос: план строится, части исполняются на всех хранилищах, результат собирается. Общее у двух моделей одно, но существенное — клиент не тянет категорию целиком: при limit=5 наружу уходит пять строк вместо 33 276.

Сверка на стенде: результат плагина совпал с результатом того же запроса к PostgreSQL построчно при limit 5, 10 и 25, включая порядок строк с одинаковым числом просмотров.

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

Установка: четыре шага, и порядок важен

Плагин ставится четырьмя командами, и очевидная последовательность из трёх не работает:

CREATE PLUGIN near_data 0.1.0;
ALTER PLUGIN near_data 0.1.0 ADD SERVICE near_data_service TO TIER default;
ALTER PLUGIN near_data MIGRATE TO 0.1.0;
ALTER PLUGIN near_data 0.1.0 ENABLE;

Без третьей команды включение отвергается прямым текстом:

$ docker exec picodata-origin psql \
    "postgres://admin:Picodata1@picodata-1:5432/picodata?sslmode=disable" \
    -c "ALTER PLUGIN near_data 0.1.0 ENABLE"
cannot enable plugin `near_data:0.1.0`: need to apply migrations first (applied 0/1)

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

Миграции: схема едет вместе с сервисом

У плагина собственные миграции — и это не дублирование внешнего мигратора, а другое по смыслу. Схема, от которой зависит сервис, получает версию и устанавливается вместе с ним, а не отдельным процессом сбоку. Учитывая, что привычные мигранты PostgreSQL с Picodata не работают, это не роскошь, а основной механизм.

Файл миграции размечается аннотациями на секции применения и отката:

-- pico.UP
CREATE TABLE products (
    id           UNSIGNED NOT NULL,
    -- ...
    PRIMARY KEY (id)
)
USING memtx
DISTRIBUTED BY (id);

-- pico.DOWN
DROP TABLE products;

И здесь стенд наткнулся на неочевидное. Имена аннотаций ищутся по всему файлу, а не только там, где они размечают секцию. Я написал в шапке файла пояснение, в котором упомянул имя аннотации, — и установка плагина упала:

$ bash ops/install-plugin.sh 0.1.0
unexpected `pico.UP` annotation, it must be at the start of the line

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

Отдельно стоит помнить про ограничение из предыдущей статьи: ALTER TABLE ... DROP COLUMN в Picodata не поддерживается. Значит секция отката для миграции, добавившей колонку, средствами SQL невыполнима — и планировать откат нужно заранее, а не в момент, когда он понадобился.

Blue-green: переключается не всё

Обновление версии без остановки кластера устроено так: обе версии присутствуют, включена ровно одна. Попытка включить новую, не выключив старую, отвергается:

$ docker exec picodata-origin psql \
    "postgres://admin:Picodata1@picodata-1:5432/picodata?sslmode=disable" \
    -c "ALTER PLUGIN near_data 0.2.0 ENABLE"
plugin `near_data:0.2.0` is already enabled with a different version 0.1.0

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

А дальше — то, ради чего этот раздел написан. На стенде после переключения система сообщала, что включена версия 0.2.0, но запросы продолжала обслуживать 0.1.0. Журнал узла объяснил почему:

$ docker logs pd-1 2>&1 | grep -E "(un)?registered RPC endpoint" | tail -2
# (оставлен только текст сообщений, без префикса журнала)
unregistered RPC endpoint `near_data.near_data_service:v0.1.0/top_products`
registered   RPC endpoint `near_data.near_data_service:v0.2.0/top_products`

RPC-эндпоинты версионированы — старый снимается, новый регистрируется. А HTTP-маршрут из шаблона pike версии не несёт: он регистрируется в общем веб-сервере, и после переключения на пути продолжает сидеть обработчик из старой библиотеки. Новая версия начала отвечать только после перезапуска инстанса.

Про границы этого доказательства стоит сказать прямо. Функционально стенд проверил только HTTP — по нему видно, какая версия реально ответила. Про RPC приведён журнал перерегистрации: он показывает, что путь переключён, но не что он ответил именно новой версией. Настоящий вызов RPC живёт в интеграционном тесте плагина, а вот окно недоступности между DISABLE и ENABLE не измерялось вовсе — команды идут последовательно, и промежуток между ними стенд не засекал. Так что «обновление без остановки кластера» здесь означает «без остановки кластера», а не «без единого отказанного запроса».

Это свойство шаблона, а не самой Picodata, и вывод из него практический: если обновление без рестарта принципиально — точкой входа должен быть RPC, а не HTTP. Обнаружить такое в проде тяжело: система рапортует, что новая версия включена, метрики в порядке, а поведение старое.

Чем платим

  • Плагин — это сборка, а не файл. Lua-хранимку Tarantool правят и перезагружают. Здесь цепочка: собрать под нужную ОС и glibc, разложить по всем узлам, установить, применить миграции, включить.
  • Версии связаны жёстко. Версия picodata-plugin привязана к версии кластера, версия каталога — к версии пакета. Обновление кластера означает пересборку плагинов.
  • Обновление требует внимания к точкам входа. Blue-green работает, но переключает не всё сразу — см. выше.
  • Взамен появляется то, чего у хранимки нет: версионирование, миграции схемы, привязка к тиру, отсутствие отдельного языка внутри процесса базы и обычный инструментарий Rust — типы, тесты, сборка.

Что дальше

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

Сравнение двух моделей переноса вычислений к данным на одной задаче — Lua-хранимка против плагина — в статье про Tarantool и Picodata.

Обсуждение в Telegram

Присоединиться →

Комментарии