Разработка UDF на C++

Введение

На данный момент в YQL поддерживаются пользовательские функции (UDF) на C++, Python. Ниже речь пойдет только о C++, про Python есть отдельная статья.

На сервисе YQL уже установлен набор базовых функций — рекомендуем ознакомиться со списком.

Терминология

  • Вызываемое значение callable — сущность с фиксированной, строго типизированной сигнатурой, которая при вызове способна вернуть результат по своим аргументам.
  • Пользовательская функция (function, user defined function, UDF) — фабрика по созданию вызываемых значений.
  • Модуль (module) — набор тесно связанных функций с общим префиксом; все функции модуля распространяются вместе в одной разделяемой библиотеке (.so). Если модуль специфичен для какого-либо проекта, то крайне желательно отразить это в его имени в виде префикса.
  • Регистратор (registrator) — интерфейс для внесения модулей и функций в общий реестр.
  • Группа модулей (group) — группа модулей, принадлежащих структурному подразделению или крупному проекту; служит лишь для логического разделения исходного кода (см. следующий раздел), из YQL/SQL не видна.
  • Конфигурационные параметры — опциональный механизм для настройки пользовательской функции (фабрики вызываемых значений). Позволяют изменить поведение вызываемых значений без перекомпиляции:
    • Type Config, User Type — по ним функция должна определить и объявить сигнатуру вызываемого значения; используется при построении графа вычислений. Должны быть известны в момент компиляции, поэтому Type Config обязательно должен быть атомом, а User Type — некоторым типом, например полученным с помощью операции TypeOf.
    • Run Config — по нему функция должна вернуть вызываемое значение с объявленной ранее сигнатурой. Тип значения Run Config указывается в коде UDF при объявлении сигнатуры вызываемого значения. Отличается от обычного аргумента тем, что он передается при инициализации, а не при каждом вызове.

Примеры

Примеры готовых модулей можно посмотреть здесь: https://github.com/ytsaurus/ytsaurus/tree/main/yql/essentials/udfs.

Совсем простые примеры см. в поддиректории examples, приближенные к реальности — в остальных, например common.

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

Интерфейсы (С++)

TUnboxedValue (udf_value.h)

Класс-обертка вокруг типизированных данных. Предоставляет набор конструкторов для создания сложных типов данных из простых типов данных и набор хелперов для работы со сложными типами данных.

Числа, интервалы, даты, включая даты с временной зоной и короткие строки до 14 байт хранятся по значению, метод IsEmbedded() для них вернет true. Строки длиннее 14 байт хранятся по указателю.

TUnboxedValuePod(udf_value.h)

Является базовым классом для TUnboxedValue. Не
производит автоматический подсчет ссылок на свое значение, в отличие от своего наследника, который осуществляет это в конструкторе и деструкторе - ключевым моментом здесь является отсутсвие конструкторов и деструкторов у класса TUnboxedValuePod. Реализует всю логику работы с
типизированными данными.

IValueBuilder (udf_value_builder.h)

Метод Run должен возвращать TUnboxedValue. Для числовых типов можно воспользоваться конструктором типа, например return TUnboxedValue<ui32>(123);. Для создания TUnboxedValue с более сложным содержимым в метод Run передается IValueBuilder. На нем можно вызвать один из методов New*** для получения интерфейса I***ValueBuilder, где *** — одно из String/Struct/Dict/пр. С его помощью можно добавить специфичных для каждого типа элементов и в конце вызвать Build для получения заполненного TUnboxedValue.
Есть вариант создать Struct/Tuple с преаллокацией для хранения содержимого, для этого надо вызвать соответствующий метод New***(const TType* type, TUnboxedValue*& items) и заполнить элементы items на месте.

См. yql/essentials/udfs/examples/structs для примера со структурами.

TBoxedValue (udf_value.h)

Класс типичного UDF наследуется от TBoxedValue и помимо конструктора переопределяет метод Run, в котором должна размещаться основная логика по обработке одного конкретного вызова. В аргументы Run передается интерфейс IValueBuilder, о котором ниже, а также сами переданные аргументы в виде массива TUnboxedValue.

В конструкторе можно сохранить какое-то состояние в приватные переменные класса, таким образом конкретный объект нашего класса-наследника TBoxedValue превращается в вызываемое значение с потенициально уникальным поведением.

Для простых UDF (без использования YQL-структур, C++ шаблонов, наследования и т. п.), можно воспользоваться макросом SIMPLE_UDF(udfName, signature) (находится в udf_helpers.h). signature задается в виде returnType(arg1, arg2, ..., argN).

Пример использования:

SIMPLE_UDF(TAbs, ui64(i64)) {
    i64 input = args[0].Get<i64>();
    ui64 result = static_cast<ui64>(input >= 0 ? input : -input);
    return TUnboxedValue(result);
}

Функция будет зарегистрирована под именем Abs, аргумент типа i64, результат — ui64.

Пример, когда успех не гарантируется:

SIMPLE_UDF(TParseDuration, TOptional<ui64>(char*)) {
    TDuration result;
    auto input = args[0].AsStringRef();
    bool success = TDuration::TryParse(input, result);
    return success ? TUnboxedValue::Optional(result.Seconds()) : TUnboxedValue();
}

Под Optional (type) (тип, допускающий значение NULL) подразумевается возможность отсутствия значения указанного типа, например в случае какой-то ошибки. Из SQL такого типа наличие в результате можно проверить с помощью конструкции IS [NOT] NULL.

IUdfModule (udf_registrator.h)

Для типовых модулей можно воспользоваться макросом SIMPLE_MODULE(moduleName, udfs...) (находится в udf_helpers.h):

  • Первым аргументом — имя класса-модуля, при регистрации префикс T и суффикс Module будут отброшены.
  • Далее — список из N входящих в него UDF, при регистрации которых будет отброшен только префикс T.

Таким образом, в примере SIMPLE_MODULE(TFooModule, TBar, TBaz) после регистрации (о которой ниже) из YQL будет доступен модуль Foo с двумя UDF — Bar и Baz.

Реализация модуля вручную

Класс типичного модуля наследуется от IUdfModule и должен реализовать два метода:

  • GetAllFunctionNames — в переданном IFunctionNamesSink нужно вызвать метод Add(name) для каждой из UDF, входящей в модуль.
  • BuildFunctionTypeInfo — по переданному имени UDF и Type Config нужно на переданном IFunctionTypeInfoBuilder вызвать:
    • Args()->Add<ui32>()[->Add(foo)->Add...].Done():
      • для указания аргументов и их типов, для чисел и строк тип аргумента можно указать шаблоном;
      • сложный тип вроде Struct/Dict/etc можно описать с помощью одноименного метода IFunctionTypeInfoBuilder, затем передав его в аргумент Add, пример:
ui32 a = 0;
ui32 b = 0;
auto foo = builder.Struct()->AddField<char*>("A", &a).AddField<ui32>("B", &b).Build();
builder.Args()->Add(foo);
  • При описании типов наподобие builder.List()->... следует максимально внимательно копировать чужие примеры, так как часто вызов цепочки методов билдера в неправильной последовательности или с неправильными аргументами нормально компилируется, но в рантайме падает в корку с нетривиальным бектрейсом. Пример такой цепочки:
// udf returns list of lists of char*
auto retType = buider.List()->Item(builder.List()->Item<char*>().Build()).Build(); // Correct
auto retType = buider.List()->Item<char*>().Build(); // Wrong type, coredump with backtrace in casting to string deep in validating
auto retType = buider.List()->Build(); // No type at all, coredump with attempt to dereference nullptr deep in type machinery
builder.Returns(retType);
  • Также в аргументах передается битовая маска ui32 flags, в которой на момент написания может присутствовать только (cpp)TFlags::TypesOnly, при наличии которого не обязательно вызвать builder.Implementation(new TMyUdf); для UDF, соответствующей переданному имени.

IRegistrator (udf_registrator.h)

Для упрощенной регистрации модуля в системе рекомендуется использовать макрос:

REGISTER_MODULES(
    TFooModule,
    TBarModule<true>,
    TBarModule<false>
)

Имя модуля в этом случае берется из метода Name(), который должен вернуть TStringRef по значению, макрос SIMPLE_MODULE создает его автоматически.

Альтернативный вариант для продвинутых пользователей

Нужно объявить функцию Register следующего вида и в переданном интерфейсе зарегистрировать модуль(-и), а также экспортировать версию ABI:

extern "C" YQL_UDF_API void Register(NKikimr::NUdf::IRegistrator& registrator, ui32 flags)
{
     registrator->AddModule("Foo", new NDetail::TFooModule(flags));
}

extern "C" YQL_UDF_API ui32 AbiVersion()
{
    return CurrentAbiVersion();
}

Список флагов перечислен в IUdfModule::TFlags. На момент написания реализован только TypesOnly, который используется как указание на то, что реальных вычислений пока производиться не будет, так как ещё идет построение графа вычислений, и на данном этапе нужны только сигнатуры функций.

Обработка исключений/ошибок

Чтобы возвращать пользователю фатальные исключения, нужно использовать следующую конструкцию:

try {
   ...
} catch (const std::exception& e) {
    UdfTerminate(e.what());
}

Вместо e.what() можно написать человекочитаемое описание ошибки, если это возможно.

Внимание

Завершать выполнение программы целиком с помощью abort, exit, Y_ABORT_UNLESS, Y_ABORT и подобных механизмов из кода UDF крайне не рекомендуется, так как это ограничит область её применения только теми окружениями, где под каждый расчет создается отдельный процесс. Реализация UdfTerminate знает о текущем окружении и вызовет ожидаемый им механизм обработки ошибок.