Обходим подводные камни работы с UDA в коде на Lua для ScyllaDB: дружим Java-драйвер и пустые значения

от автора

Привет, Хабр! Мое имя Александр Коваль, я разработчик IoT-сервисов в МТС Web Services. Сейчас ScyllaDB поддерживает ограниченное количество функций, в том числе агрегационных. В стандартном наборе: min, max, count, avg. Но ее функциональность расширяется двумя типами пользовательских функций: скалярными (scalar functions) и агрегационными (aggregate functions). Первые работают со значениями одной строки, а вторые — нескольких. Реализовать такие функции можно на Lua или Rust.

В процессе работы с агрегационными функциями можно столкнуться с тем, что ScyllaDB и Java-драйвер по-разному обрабатывают пустые значения. В этом посте я расскажу, как это можно решить относительно просто и без сложных дополнительных телодвижений. Для примера возьму код на Lua и покажу, как он реализуется в виде функции ScyllaDB.

Дисклеймер: этот материал написан на основе личного опыта — все решения получены методом проб и ошибок. Конструктивные предложения и советы по их улучшению приветствуется. Код с примерами и ссылки на ресурсы можно найти у меня в репозитории GitHub.

Исходная задача

Итак, нужно среди заданных элементов найти самый часто повторяющейся. В терминах ScyllaDB эта задача звучит так:

«Среди элементов одной колонки, при заданных ограничениях найти тот, который встречается чаще других».

Допустим, нам надо хранить информацию обо всех изменениях одного свойства. У него есть: группа, имя, время изменения и значение. Для этого будем использовать таблицу с именем property и такими колонками: group, name, date и value_string. У всех них тип «текст», кроме date, у нее «время и дата». Group и name определяют основной ключ (primary key). Date — кластерный (cluster key).

CQL-скрипт для создания таблицы выглядит так:

CREATE TABLE IF NOT EXISTS property ( group text, name text, date timestamp, value_string text, PRIMARY KEY((group,name),date)) WITH CLUSTERING ORDER BY (date DESC);

Отмечу, что для простоты изложения  в скриптах и примерах отсутствует информация о keyspace.

Пусть в таблице будут заданы следующие строки и значения:

group | name | date                            | value_string ------+------+---------------------------------+--------------     g |    a | 2025-03-11 00:15:17.000000+0000 |       data_5     g |    a | 2025-03-11 00:15:16.000000+0000 |       data_3     g |    a | 2025-03-11 00:15:15.000000+0000 |       data_2     g |    a | 2025-03-11 00:15:14.000000+0000 |       data_2     g |    a | 2025-03-11 00:15:13.000000+0000 |       data_1

В этом примере видим, что самое часто повторяющееся значение — data_2.

Пишем код на Lua

Предположим, что есть функция most_common_text(text), которая ищет самое частое значение. Она принимает и выдает значение типа текст.

Тогда CQL-запрос будет выглядеть так:

SELECT most_common_text(value_string) FROM property WHERE date >= '2025-03-11 00:00:00' AND date < '2025-03-11 23:00:00'

Такая задача, к примеру, решается с использованием словаря. Давайте напишем ее код решения на Lua.

Функция для обработки одного значения:

function accumulate(storage, val)    if storage == nil then        storage = {}    end    if val == nil then        return storage    end    if storage[val] == nil then        storage[val] = 1    else        storage[val] = storage[val] + 1    end    return storage end

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

function calculate(storage)    if storage == nil    then        return nil    end    local value = nil    local count = 0    for v, c in pairs(storage) do        if c > count then            value = v            count = c        end    end    return value end

Функция запуска процесса накопления и вычисления результата:

function most_common(data)    if data == nil    then        return nil    end    local storage = {}    for k, v in pairs(data) do        storage = accumulate(storage, v)    end    return calculate(storage) end

Пример вызова:

function most_common_test()    local samples = {'data_1', 'data_2', 'data_2', 'data_3', 'data_5'}    local result = most_common(samples)    print(result) end

Такая функция в итоге выведет нам искомое значение — data_2.

Функция для ScyllaDB

ScyllaDB позволяет использовать для функций различные языки. Ограничения вводятся на уровне самой БД. А указание языка, на котором производится реализация функции, указывается при создании самой функции через ключевое слово LANGUAGE.  Они могут выглядеть следующим образом.

Функция для обработки одного значения:

CREATE OR REPLACE FUNCTION most_common_text_accumulate(storage _type_, val text) RETURNS NULL ON NULL INPUT RETURNS _type_ LANGUAGE lua AS $$ ... $$;

Функция для нахождения результата:

CREATE OR REPLACE FUNCTION most_common_text_calculate(storage _type_) RETURNS NULL ON NULL INPUT RETURNS text LANGUAGE lua AS $$ ... $$;

Функция запуска процесса накопления и вычисления результата:

CREATE OR REPLACE AGGREGATE most_common_text(text)   SFUNC most_common_text_accumulate   STYPE _type_   FINALFUNC most_common_text_calculate   INITCOND _default_;

Тут стоит остановиться на двух моментах: типе параметра, который используется в качестве словаря, и начальном значении для него.

Предположим, что для типа параметра используется встроенный типа map<text, bigint>. Тогда для начального значения можно задать null. Если так сделать, то при создании функции ScyllaDB выдает следующую ошибку.

CREATE OR REPLACE AGGREGATE most_common_text_map(text)               ...    SFUNC most_common_text_accumulate_map               ...    STYPE map<text, bigint>               ...    FINALFUNC most_common_text_calculate_map               ...    INITCOND null; ServerError: <Error from server: code=0000 [Server error] message="marshaling error: read_simple - not enough bytes (expected 4, got 0) Backtrace: 0x415a30e ...libreloc/libc.so.6+0x100352   --------   seastar::lambda_task<seastar::execution_stage::flush()::$_5>">

Результаты других попыток создания можно посмотреть у меня в GitHub.

К примеру, если не задавать INITCOND:

CREATE OR REPLACE AGGREGATE most_common_text_map(text)               ...    SFUNC most_common_text_accumulate_map               ...    STYPE map<text, bigint>               ...    FINALFUNC most_common_text_calculate_map; ServerError: <Error from server: code=0000 [Server error] message="conjunctions are not yet reachable via term_raw_expr::prepare() Backtrace: 0x415a30e .../libreloc/libc.so.6+0x100352   --------   seastar::lambda_task<seastar::execution_stage::flush()::$_5>">

ScyllDB использует «@» для пустых значений, но Java-драйвер его не может стандартно обработать. Кажется, что эту проблему невозможно решить без кода.

Решаем проблему с пустыми значениями в ScyllaDB

Методом проб и ошибок я пришел к следующему решению. Я использовал для словаря пользовательский тип, который содержит поле со встроенным типом. И в качестве начального значения применил в нем пустое значение для словарей «{}»:

Тип:

CREATE TYPE IF NOT EXISTS most_common_text_data_map  ( text_data map<bigint, bigint> )

Начальное значение этой функции выглядит так:

{text_data: {}}

Но она все еще не работает. При попытке выполнить select выдается ошибка:

InvalidRequest: Error from server: code=2200 [Invalid query] message="value is not a number"

Нужно внести поправку в новый тип: добавить «frozen» на поле. Более подробно этот момент объяснен в документации. Например, тут и тут.

Итак, обновляем тип:

CREATE TYPE IF NOT EXISTS most_common_text_data  ( text_data frozen<map<bigint, bigint>> );

Работает!

SELECT most_common_text(value_string) FROM property WHERE date >= '2025-03-11 00:00:00' AND date < '2025-03-11 23:00:00';   most_common_text(value_string) --------------------------------------------                                     data_2   (1 rows)

Вместо заключения

Пользовательские функции расширяют функциональность ScyllaDB: их можно быстро реализовать и встроить в запросы. Но какие тут могут быть ограничения и на что нужно обратить внимание?

В первую очередь — на производительность. Навскидку можно предположить, что использование языка Lua  — не самый производительный вариант, хотя и простой и понятный в реализации. Недаром разработчики предлагают ему альтернативу в виде Rust. Есть много статей, где описывается ускорение запросов в десятки раз с помощью Rust. Но использование своих функций необходимо проверять на собственных данных и в своем окружении.

Также возможно, что решение конкретной задачи можно упростить и воспользоваться встроенными функциями, которые в свою очередь работают без посредников. На вопрос производительности, в этом случае, в большей части будет отвечать сама ScyllaDB.


ссылка на оригинал статьи https://habr.com/ru/articles/902544/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *