Привет! Меня зовут Дмитрий Кольцов, я работаю в Picodata и пользуюсь одноимённой СУБД с тех самых пор, когда её ещё не существовало. Одна из наших важных фичей — возможность написать свой плагин на Rust, и я хочу предоставить для этого небольшой мануал.
Что такое плагин в Picodata?
Плагины в Picodata — это механизм расширения функциональности СУБД, позволяющий реализовать произвольное приложение внутри. Я обычно предлагаю думать о плагинах как об отдельных Rust-приложениях, которые работают в необычном рантайме и из коробки включают в себя СУБД как библиотеку. При помощи нашего механизма плагинов можно добавить собственный протокол для взаимодействия с базой данных (чтобы ходить в Picodata как в Redis) или реализовать HTTP-витрины прямо внутри процесса СУБД. В будущем мы планируем добавить и такие возможности, как написать собственный тип индекса или реализовать новые SQL-функции.
Здесь можно задать закономерный вопрос: «А зачем мне приносить логику из своего приложения в Picodata? Разве не лучше будет реализовать её на привычном мне языке программирования в отдельном сервисе?». Что же, реализовать логику в отдельном сервисе действительно проще, но я попробую привести пару аргументов, которые могут мотивировать вас написать плагин:
Производительность. Плагин исполняется в адресном пространстве СУБД, поэтому сетевые задержки при обмене данными с базой просто отсутствуют. Может возникнуть справедливое замечание о величине этих задержек — в пределах одного дата-центра они измеряются единицами миллисекунд, и на первый взгляд кажется, что для одного запроса это несущественно. Но давайте сравним: локальный SSD отдаёт данные с задержкой в единицы-десятки микросекунд и полосой около гигабайта в секунду на диск, тогда как внутри дата-центра сеть — это уже миллисекунды, да ещё и общая для всех узлов полоса в десятки гигабит. А если под базой данных не локальный, а сетевой диск, задержки удваиваются: к сетевому обмену с самой СУБД добавляется еще и сетевой доступ к хранилищу. Поэтому чем больше запросов нужно выполнить в рамках одной операции — а порой это десятки запросов, — тем весомее становится экономия на каждом таком походе по сети. А чтобы минимизировать даже задержки на чтение с диска, мы можем использовать in-memory движок Picodata. Также при написании плагина можно использовать низкоуровневое API, недоступное через SQL. Я могу напрямую обратиться к индексам и таблицам, доставая данные оптимальным способом. Это не так удобно, как написать запрос и переложить эту проблему на планировщик, но ведь любой планировщик не идеален (да, да, и даже наш) и иногда может ошибиться — например, в выборе индекса, что может привести к просадкам производительности.
Централизация логики. Этот аргумент отсылает нас к хранимым процедурам. Я вынужден прибегать к этому сравнению, хоть и не очень его люблю — любое их упоминание сбивает флёр хайповой технологии и несёт затхлые запахи тысяч строк PL/SQL. Мы можем реализовать какое-то правило работы с данными централизованно и, независимо от системы потребителя, оно всегда будет одинаковым. Это снижает вероятность ошибки и дублирование логики в системе. Более того, такая схема также повышает безопасность, позволяя не выдавать права чтения или записи непосредственно на таблицы, но предоставляя API вашего плагина. Предвижу ваши возражения: можно ведь поступить точно так же, реализовав это правило в отдельном сервисе. Но тогда у вас будет ещё одна сущность и ещё один сервис, за которым нужно следить.
Из чего состоит плагин
Плагин — это динамическая библиотека, функции которой реализуют определённый набор callback’ов. Но, к сожалению, в скомпилированном файле не очень удобно хранить метаданные, которые расскажут нам, что же этот бинарный файл делает, поэтому структура пакета плагина содержит больше, чем один файл.
Давайте посмотрим на примере собранного демо-плагина croner. Именно в таком виде он после установки будет лежать в share-dir
croner └── 0.1.0 ├── libcroner.so ├── manifest.yaml └── migrations └── 0001_init.sql
Директория верхнего уровня всегда называется так же, как и сам плагин — это условие того, что Picodata сможет его найти. Уровнем ниже находится директория с версией плагина — ведь мы хотим обновлять наш плагин в ходе его жизни, а в корневой папке может лежать множество версий.
Наконец, мы добрались до самого плагина. Начать разговор нам нужно с файла manifest.yaml
name: croner description: A plugin for picodata version: 0.1.0 services: - name: croner_service description: service that runs SQL queries by cron-like schedule default_configuration: value: example jobs: [] migration: - migrations/0001_init.sql
Этот файл и предоставляет для Picodata основную информацию о плагине. При установке плагина Picodata в первую очередь ищет манифест и потом использует его для инициализации и установки: сверяет имя, версию, сохраняет описание, чтобы администратор через год мог вспомнить, какие плагины установлены в кластере. Следующие поля уже интереснее. Первое, о чём нам нужно поговорить, — это сервисы. Как мы уже обсуждали, наши плагины — это приложения, которые запускаются в распределённом кластере, и поэтому с нашей точки зрения вполне естественно сделать не монолит, а модульную систему — то есть набор сервисов. Данное поле в манифесте как раз описывает, какие внутри приложения есть сервисы, а также их конфигурацию по умолчанию. Завершается манифест списком миграций, которые необходимо выполнить плагину для работы. Нам бы не хотелось, чтобы после включения плагина он падал с ошибкой из-за того, что мы забыли создать нужную таблицу или изменить существующую. Поэтому при установке плагина Picodata самостоятельно следит за схемой данных и применяет или откатывает перечисленные в манифесте миграции.
Механизм миграций устроен так же, как и в большинстве популярных фреймворков (liquibase, goose, alembic). Создаём текстовый файл с набором SQL-команд и специальными директивами помечаем, какие выполняются при накате (-- pico.UP), а какие — при откате (-- pico.DOWN). Мы рассмотрим пример из другого тестового плагина, потому что croner не требует собственной схемы.
-- pico.UP CREATE TABLE "weather" ( latitude DOUBLE NOT NULL, longitude DOUBLE NOT NULL, temperature DOUBLE NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (latitude, longitude) ) USING memtx DISTRIBUTED BY (latitude, longitude); -- pico.DOWN DROP TABLE "weather";
И главный компонент плагина — сама динамическая библиотека libcroner.so. Если мы посмотрим на экспортируемые ею символы, то увидим сотни функций, но для нас интерес представляет только pico_service_registrar
> nm libcroner.so | grep pico_service_registrar 000000000000ec40 T pico_service_registrar
Эта «волшебная» функция и есть точка входа Picodata. Для загрузки плагина мы применяем вполне стандартный подход:
Подгружаем библиотеку через
dlopenИщем нашу точку входа по имени
При вызове
pico_service_registrarрегистрируем конструкторы наших сервисов в Picodata, обернув их вABI-stableобертки
Такая схема позволяет подгружать плагины динамически, запускать и останавливать их в произвольное время и не переживать о сборке разными версиями rustc.

Давайте посмотрим, как это будет выглядеть в коде. Ради компактности я намеренно не буду приводить полный пример из croner. Желающим предлагаю посмотреть полный код или нашу документацию.
use picodata_plugin::{plugin::interface::ServiceRegistry, system::tarantool::say_warn}; use picodata_plugin::plugin::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct CronerServiceCfg { pub value: Option<String>, } #[derive(Debug, Default)] pub struct CronerService {} impl Service for CronerService { type Config = CronerServiceCfg; fn on_config_change( &mut self, ctx: &PicoContext, new_config: Self::Config, old_config: Self::Config, ) -> CallbackResult<()> { _ = ctx; _ = new_config; _ = old_config; Ok(()) } fn on_start(&mut self, context: &PicoContext, config: Self::Config) -> CallbackResult<()> { _ = context; _ = config; say_warn!("Hello"); Ok(()) } fn on_stop(&mut self, context: &PicoContext) -> CallbackResult<()> { _ = context; say_warn!("Bye"); Ok(()) } } #[service_registrar] pub fn service_registrar(reg: &mut ServiceRegistry) { reg.add("croner_service", "0.1.0", CronerService::default); }
Приведённый lib.rs представляет собой минималистичную версию плагина, состоящего из одного сервиса. Сервис определяется трейтом Service (да, именование переменных — это сложно). Вот что о нём полезно знать:
ConfigЕсли наш плагин — это полноценное приложение, то и настраивать нам его хотелось бы без пересборки и редеплоя. В Picodata есть механизм управления собственными настройками плагина — они описываются типом, заданным в реализации трейта — и хранятся в специальной служебной таблице _pico_plugin_config. Для управления конфигурацией мы используем специальный SQL-синтаксис. Исходная конфигурация описывается в манифесте плагина.on_startЭто callback, который будет вызван при включении сервиса на каждом узле и получит текущую конфигурацию. В нём мы запускаем нужные нам процессы или поднимаем HTTP- или TCP-сервер. Еслиon_startупадёт хотя бы на одном узле — Picodata отменит запуск плагина по всему кластеру и вызоветon_stopна каждом узле в качестве отката.on_stopЭто callback, который вызывается при штатной остановке инстанса или отключении плагина. Здесь мы должны почистить всё то, что запустили ранее на старте. Если этот callback упадёт, то это никак не повлияет на отключение плагина — Picodata всё равно будет считать его выключенным.on_config_changeЭто место, которое отвечает за применение новой конфигурации плагина, чтобы не перезапускать его каждый раз. Получает как старую версию конфигурации, так и новую — для определения изменившихся полей.
Как написать и запустить свой плагин для Picodata?
Для примера разберём уже существующий плагин для Picodata — это croner, который выполняет SQL-запросы по расписанию cron. Внутри плагин поднимает один фоновый файбер, который просыпается раз в секунду и сверяет текущее время с расписаниями всех настроенных заданий. Как только время задания подходит, оно выполняется в своём собственном файбере — так долгие запросы не блокируют ни планировщик, ни друг друга.
Структура репозитория
Клонируем репозиторий плагина:
git clone --branch 1.0.0 https://git.picodata.io/vifley/croner.git cd croner
Репозиторий с плагином выглядит следующим образом:
├── build.rs ├── Cargo.lock ├── Cargo.toml ├── manifest.yaml.template ├── migrations │ └── 0001_init.sql ├── plugin_config.yaml ├── README.md ├── rust-toolchain.toml ├── src │ ├── config.rs │ ├── lib.rs │ └── service.rs └── topology.toml
Пройдёмся по содержимому
build.rs
Это файл, который помогает нам собрать наш плагин единой командой cargo build, чтобы не перекладывать манифест, миграции и библиотеку вручную
manifest.yaml.template
Это шаблон манифеста, в который автоматически будет подставлена версия плагина из Cargo.toml, чтобы, опять же, упростить сборку.
topology.toml
Это файл для утилиты Pike, которая упрощает локальную разработку, отладку и запуск плагина. Она позволяет одной командой запустить кластер, поменять конфигурацию плагина или собрать архив с готовым плагином. Я обязательно напишу про Pike отдельную статью, а пока я просто оставлю ссылку на GitHub — Picodata Pike.
Особенности плагинов
Теперь, познакомившись с инфраструктурной обвязкой, посмотрим на то, что отличает разработку плагина для Picodata от обычного Rust-приложения — взглянем на наш SDK. Наш SDK является типичным Rust-крейтом, а его документация лежит на docs.rs. Здесь мы не будем обозревать его целиком, но обратим внимание на несколько самых важных деталей.
Кооперативная многозадачность
Picodata использует кооперативную многозадачность на основе корутин (они же — файберы). Это принципиально отличается от модели, к которой многие привыкли. Основную работу в Picodata выполняет один поток, и он исполняет как логику БД, так и код ваших плагинов. Это значит, что для нормального функционирования инстанса Picodata нужно уметь вовремя передавать выполнение от одних файберов другим. В максимально упрощённом виде это можно сформулировать как «не пишите бесконечные циклы без явной передачи управления». Эта модель многозадачности очень напоминает Golang до версии 1.14, где нет preemptive scheduling.
Точки переключения — это либо явные вызовы fiber::sleep, либо большинство API внутри SDK, которым необходим ввод-вывод. Очень важно не забывать, что стандартные методы Rust’а не знают о наших файберах и могут заблокировать поток ОС. В частности, нельзя использовать std::thread::sleep или привычные HTTP-клиенты. Необходимо использовать библиотеки, специально написанные для экосистемы Picodata.
Что нельзя:
std::thread::sleep(std::time::Duration::from_secs(5)); // ureq, reqwest::blocking и другие синхронные HTTP-клиенты // блокируют системный поток — весь инстанс ждёт ответа от сервера let body = ureq::get("https://api.open-meteo.com/v1/forecast?latitude=55&longitude=37") .call()? .into_string()?;
Что можно:
use picodata_plugin::system::tarantool::fiber; fiber::sleep(std::time::Duration::from_secs(5)); let http_client = fibreq::ClientBuilder::new().build(); let mut resp = http_client .get("https://api.open-meteo.com/v1/forecast?latitude=55&longitude=37")? .request_timeout(std::time::Duration::from_secs(3)) .send()?; let body = resp.text()?;
Для того, чтобы выполнить какой-то код, которому необходимы блокирующие операции в SDK, но нет аналогичной fiber-friendly-библиотеки, предусмотрен модуль picodata_plugin::interplay::tros. Он позволит выполнить код в отдельном tokio runtime асинхронно.
// Выполнить HTTP-запрос через reqwest, не блокируя TX-поток let text = tros::TokioExecutor::new(tros::transport::PicodataTransport::default()) .exec(async { reqwest::get("http://api.open-meteo.com/...").await?.text().await }) .unwrap()?;
Работа с SQL
Для начала посмотрим, как же реализовать запрос к БД внутри плагина:
const SELECT_QUERY: &str = r#" SELECT * FROM weather WHERE latitude = ? AND longitude = ?; "#; let cached: Vec<Weather> = picodata_plugin::sql::query(&SELECT_QUERY) .bind(latitude) .bind(longitude) .fetch::<Weather>() .map_err(|err| format!("failed to retrieve data: {err}"))?;
Это мало чем отличается от выполнения запроса драйвером к любой другой базе данных — мы пишем SQL-запрос, подставляем в него необходимые параметры через bind, чтобы пользоваться преимуществами prepared statement, и потом исполняем. В качестве маркеров параметров в запросе используется символ «?». Список поддерживаемого SQL-синтаксиса можно найти в нашей документации.
Сборка
Для сборки плагина достаточно выполнить cargo build --release. После этого появится директория target/release/croner, в которой будет находиться наш готовый плагин.
Как он запускается
Теперь, когда мы разобрались, как выглядит наш плагин, попробуем его запустить. Запускать мы будем croner на Picodata 26.1.3.
Для запуска нам понадобится Picodata, а инструкцию по установке можно найти здесь.
-
Создадим рабочую директорию и запустим Picodata:
mkdir -p pico/plugin cd pico picodata run --share-dir plugin -
Положим готовые файлы плагина в
--share-dirнашего узла Picodata. Для этого надо переместить папкуtarget/release/croner, которую мы получили по итогам сборки, вpico/plugin.Должна получиться вот такая структура директорий:
├── pico │ └── plugin │ └── croner │ └── 0.1.0 │ ├── libcroner.so │ ├── manifest.yaml │ └── migrations │ └── 0001_init.sql -
Установка плагина доступна только служебному пользователю
admin, поэтому перед установкой плагина давайте подключимся к консоли администратора и установим пароль, чтобы подключиться черезpsql:picodata admin admin.sock Connected to admin console by socket path "admin.sock" type '\help' for interactive help (admin) sql> ALTER USER admin WITH PASSWORD 'T0psecret'; -
Теперь мы можем подключиться к Picodata при помощи утилиты
psql, и все дальнейшие действия будем проводить с её помощью:psql -U admin -h localhost -p 4327 Password for user admin: psql (17.5, server 15.0) Type "help" for help. admin=> -
Теперь можно инициализировать установку плагина с помощью SQL-команды:
admin=> CREATE PLUGIN croner 0.1.0; CREATE PLUGINЭта команда проверит наличие плагина на всех узлах кластера и выполнит предварительную загрузку (
dry-run) динамической библиотеки, а также сверит манифест и загрузит информацию в служебный каталог. Убедиться в успехе можно выборкой из служебной таблицы:admin=> SELECT * FROM _pico_plugin; name | enabled | services | version | description | migration_list --------+---------+--------------------+---------+-----------------------+------------------------------ croner | f | ["croner_service"] | 0.1.0 | A plugin for picodata | ["migrations/0001_init.sql"] (1 row) -
Плагин
cronerне хранит данные в собственных таблицах, но миграция всё равно объявлена в манифесте — применим её:admin=> ALTER PLUGIN croner MIGRATE TO 0.1.0; ALTER PLUGIN MIGRATE TO -
Теперь необходимо указать Picodata, где запускать плагин. Кластеры Picodata зачастую гетерогенны, а значит, в них есть разные типы инстансов/узлов, и иногда мы хотим запускать плагин только на некоторых из них.

Чтобы в деталях разобраться, как устроено разделение узлов в кластере на группы, я рекомендую статью Константина Осипова, где он рассказывает об особенностях топологии Picodata, а также о том, что такое тиры. Наша задача сейчас — определить, на каких тирах Picodata запустит наши сервисы. Сейчас укажем, что единственный сервис нашего плагина нужно запускать на единственном узле в нашем тире
default:admin=> ALTER PLUGIN croner 0.1.0 ADD SERVICE croner_service TO TIER "default"; ALTER PLUGIN ADD SERVICE TO TIERЗаодно сразу зададим сервису одно задание — параметры конфигурации плагина меняются через
ALTER PLUGIN ... SET <service>.<key>='<value>':admin=> ALTER PLUGIN croner 0.1.0 SET croner_service.jobs='[{"query":"SELECT 1","schedule":"0 * * * * *"}]'; ALTER PLUGIN SET -
Теперь, когда все подготовительные работы завершены, мы можем включить наш плагин:
admin=> ALTER PLUGIN croner 0.1.0 ENABLE; ALTER PLUGIN ENABLE -
Убедимся, что наш плагин работает — подождём минуту и заглянем в лог инстанса Picodata:
INFO ... executing job "SELECT 1" INFO ... job "SELECT 1" completed: ok
Поздравляю, мы собрали и запустили плагин для Picodata!
Заключение
Механизм плагинов в Picodata — это не просто способ добавить пару функций в СУБД. Это возможность реализовывать произвольно сложную логику прямо внутри базы данных, без сетевых прослоек и микросервисного оверхеда. Так мы сделали реализацию Redis’а и плагин, имитирующий Cassandra.
Чтобы разработка таких плагинов не превращалась в квест, у нас есть замечательные инструменты: Pike и picotest. Подробнее мы познакомим вас с ними в будущих статьях. Если вас заинтересовала статья или работа у нас — приходите в нашу группу в Telegram.