Тестирование в YTsaurus Flow (Python)

Примечание

Данная страница описывает юнит-тестирование компонентов Python-пайплайна, а также интеграционное тестирование полного пайплайна через FlowTestPythonBase.

Общая архитектура тестирования

В продакшене C++ воркер отправляет gRPC-запросы компаньону, передавая сообщения, таймеры, стейты и watermark-и. Компаньон вызывает Computation.do_process(), который делегирует обработку в ProcessFunction.

В юнит-тестах компоненты пайплайна тестируются прямым вызовом do_process() на объекте Computation с подготовленным RequestContext.

Все тесты используют стандартный pytest.

Зависимости

Для юнит-тестирования компаньон-библиотеки необходимо добавить зависимости в ya.make:

PEERDIR(
    yt/yt/flow/library/python/companion
)

При использовании Protobuf-стейтов также добавьте зависимость на proto-библиотеку.

Тестирование ProcessFunction

Исходный код тестов

Для тестирования RowFunction / BatchFunction создаётся объект Computation и вызывается do_process() с подготовленным RequestContext.

Создание RequestContext

RequestContext — это dataclass, содержащий входные данные для обработки:

from yt.yt.flow.library.python.companion.context import RequestContext
from yt.yt.flow.library.python.companion.job import Job
from yt.yt.flow.library.python.companion.stream import (
    StreamIdsMapping,
    StreamSpecs,
    RawStream,
)
from yt.yt.flow.library.python.companion.row import ExtendedMessage


def make_request_ctx(messages=None, timers=None):
    """Создание минимального RequestContext для тестов."""
    mapping = StreamIdsMapping({"input": 0, "output": 1})
    specs = StreamSpecs(mapping, [RawStream("input"), RawStream("output")])

    job = Job(
        job_id="test-job",
        computation_id="test-comp",
        stream_specs=specs,
        static_spec={},
    )

    return RequestContext(
        job_id="test-job",
        request_id="test-req",
        computation_id="test-comp",
        messages=messages or [],
        timers=timers or [],
        stream_specs=specs,
        job=job,
    )

Тестирование RowFunction

from yt.yt.flow.library.python.companion.computation import (
    Computation,
    RowFunction,
)
from yt.yt.flow.library.python.companion.row import (
    ExtendedMessage,
    Message,
)


class PassthroughFunction(RowFunction):
    def on_message(self, message, output, ctx):
        output.add_message(
            Message(message_id=message.message_id, stream_id="output")
        )


def test_passthrough():
    comp = Computation(
        computation_id="test",
        process_function=PassthroughFunction(),
    )
    messages = [
        ExtendedMessage(message_id="m1", stream_id="input"),
        ExtendedMessage(message_id="m2", stream_id="input"),
    ]
    ctx = make_request_ctx(messages=messages)
    response = comp.do_process(ctx)

    assert len(response.transform_results) == 2
    assert response.transform_results[0].messages[0].message_id == "m1"
    assert response.transform_results[1].messages[0].message_id == "m2"

Тестирование таймеров

from yt.yt.flow.library.python.companion.row import Timer


class TimerFunction(RowFunction):
    def on_message(self, message, output, ctx):
        output.add_timer(trigger_timestamp=1000, event_timestamp=500)

    def on_timer(self, timer, output, ctx):
        output.add_message(
            Message(message_id="from-timer", stream_id="output")
        )


def test_timer_roundtrip():
    comp = Computation(
        computation_id="test",
        process_function=TimerFunction(),
    )
    messages = [ExtendedMessage(message_id="m1")]
    timers = [Timer(message_id="t1")]
    ctx = make_request_ctx(messages=messages, timers=timers)
    response = comp.do_process(ctx)

    results = response.transform_results
    # Сообщение создаёт таймер, таймер создаёт сообщение
    assert any(r.timers for r in results)
    assert any(r.messages for r in results)

Тестирование стейтов

Исходный код тестов

YSON State

import yt.type_info as ti

from yt.yt.flow.library.python.companion.context import DefaultRuntimeContext
from yt.yt.flow.library.python.companion.row import (
    ColumnSchema,
    ExtendedMessage,
    Payload,
    TableSchema,
)
from yt.yt.flow.library.python.companion.stream import (
    StreamIdsMapping,
    StreamSpecs,
    RawStream,
)
from yt.yt.flow.library.python.companion.wire_protocol import (
    ColumnValueType,
    UnversionedRow,
    UnversionedValue,
)


KEY_SCHEMA = TableSchema([ColumnSchema("id", ti.String)])


def make_key_payload():
    row = UnversionedRow(values=[
        UnversionedValue(column_id=0, type=ColumnValueType.STRING, value=b"test-key"),
    ])
    return Payload(row, KEY_SCHEMA)


def make_ctx(internal_state_names=None, **kwargs):
    streams = [RawStream("input"), RawStream("output")]
    mapping = StreamIdsMapping({s.stream_id: i for i, s in enumerate(streams)})
    specs = StreamSpecs(mapping, streams)
    return DefaultRuntimeContext(
        internal_state_names=internal_state_names or set(),
        stream_specs=specs,
        internal_states=kwargs.get("internal_states", {}),
        external_states=kwargs.get("external_states", {}),
        watermarks={},
        min_watermark=0,
        computation_parameters={},
        key_schema=KEY_SCHEMA,
    )


def test_yson_state_roundtrip():
    ctx = make_ctx(internal_state_names={"word-state"})
    message = ExtendedMessage(message_id="m1", key=make_key_payload())

    accessor = ctx.state("word-state", message)
    accessor.set({"word": "hello", "count": 1})

    accessor2 = ctx.state("word-state", message)
    result = accessor2.get()
    assert result["word"] == "hello"
    assert result["count"] == 1

Proto State

from google.protobuf.wrappers_pb2 import Int64Value


def test_proto_state_roundtrip():
    ctx = make_ctx(internal_state_names={"join-state"})
    message = ExtendedMessage(message_id="m1", key=make_key_payload())

    accessor = ctx.proto_state("join-state", message, Int64Value)
    state = Int64Value(value=42)
    accessor.set(state)

    accessor2 = ctx.proto_state("join-state", message, Int64Value)
    assert accessor2.get().value == 42

External State

from yt.yt.flow.library.python.companion.state import StatesHolder
from yt.yt.flow.library.python.companion.row import PayloadBuilder


STATE_SCHEMA = TableSchema([
    ColumnSchema("count", ti.Int64),
    ColumnSchema("name", ti.String),
])


def test_external_state_roundtrip():
    ext_holder = StatesHolder("ext", KEY_SCHEMA, STATE_SCHEMA)
    ctx = make_ctx(external_states={"/shuffle-state": ext_holder})
    message = ExtendedMessage(message_id="m1", key=make_key_payload())

    state = ctx.external_state("/shuffle-state", message)
    builder = state.to_builder()
    builder.set("count", 99)
    state.set(builder.finish())

    state2 = ctx.external_state("/shuffle-state", message)
    assert state2.get("count") == 99

Анализ результатов

Объект ResponseContext, возвращаемый do_process(), содержит:

Поле Тип Описание
transform_results List[TransformResult] Список результатов обработки
internal_states Dict[str, StatesHolder] Внутренние стейты после обработки
external_states Dict[str, StatesHolder] Внешние стейты после обработки

Каждый TransformResult содержит:

Поле Тип Описание
parent_ids List[str] Идентификаторы родительских сообщений
messages List[Message] Выходные сообщения
timers List[NewTimer] Созданные таймеры

Интеграционное тестирование с FlowTestPythonBase

Для полного интеграционного тестирования пайплайна (с реальными C++ воркерами, очередями и стримами) используется базовый класс FlowTestPythonBase.

Зависимости

Помимо PEERDIR на integration_test_base, интеграционному тесту нужны рецепт кластера, DEPENDS на бинарь пайплайна и flow_server, а также DATA со спекой. Полный ya.make теста из WordCount:

PY3TEST()

INCLUDE(${ARCADIA_ROOT}/yt/yt/flow/library/python/integration_test_base/recipe.inc)

TEST_SRCS(
    test_wordcount.py
    yt_sync.py
)

PEERDIR(
    yt/yt/flow/library/python/queue
)

DEPENDS(
    ${MODDIR}/..
    yt/yt/flow/bin/flow_server
)

DATA(arcadia/${MODDIR}/pipeline.yson)

REQUIREMENTS(
    cpu:4
    ram:32
)

TAG(ya:huge_logs)

SIZE(MEDIUM)

END()

Настройка

Тест наследуется от FlowTestPythonBase и задаёт атрибут PYTHON_COMPANION_BINARY:

class Test(FlowTestPythonBase):
    PYTHON_COMPANION_BINARY = yatest.common.binary_path("yt/yt/flow/examples/python/word_count/word_count")
Атрибут Описание
PYTHON_COMPANION_BINARY Путь к бинарю Python-компаньона

Пример E2E-теста WordCount

Важно

Интеграционные тесты требуют развёрнутого кластера YTsaurus и запускаются через ya make -tt. Для быстрой итерации используйте юнит-тесты, описанные выше.

Общие принципы написания интеграционных тестов на пайплайны:

  • Тестируем конечные сценарии. То есть:
    • Записываем входные/исходные данные в локальный YT.
    • В спеке пайплайна отмечаем источники как finite=%true.
    • Для Key-visitor-стрима, который должен работать во время теста, задаём finite=%false.
    • Запускаем пайплайн.
    • Когда визитор должен завершиться, вызываем метод базового класса интеграционного теста self.ask_key_visitor_to_complete("<computation_id>", "<stream_id>"): он переключает динамический параметр finite этого стрима на %true.
    • Ждём, пока пайплайн завершится.
    • Проверяем выходные данные.
  • Готовим окружение тем же кодом, что и в проде:
    • Тестируем тот же бинарь пайплайна что будет работать в проде.
    • Генерируем спеки пайплайна тем же кодом, что будет генерировать их для прода.
    • Если входные/выходные данные нетривиально сериализуются/парсятся, то делаем эту работу общим с продом кодом.
  • Используем общий тестовый фреймворк (есть README.md).
  • Делаем failover тест.
    • Многие ошибки вскрываются на выпадении воркеров и переподхвате их работы другими воркерами. Поэтому делаем тест с problems=True и с более чем 1 воркером.
  • Пишем стабильные тесты.
    • Помним, что в CI любая часть теста может работать неожиданно долго.
      • Идеальное время работы одного теста — 20 секунд при любом типе сборки.
      • Для санитайзерных сборок уменьшаем количество входных данных.
      • Все локальные таймауты выставляем с кратным запасом.
    • Логика выполнения теста не должна значимо зависеть от текущего времени. Например, тест не должен падать, если запустился до полуночи, а завершился — после.

Отладка тестов

Логи

После завершения работы теста его логи можно найти в test-results/py3test/testing_out_stuff в директории теста. Основные важные:

  • run.log — логи python-теста.
  • <test_class_name>/<test_name>/Controller_<number>... — логи контроллеров (.err — это stderr процесса, .log — обычные логи, которые пишутся через YT_LOG_...).
  • <test_class_name>/<test_name>/Worker_<number>... — логи воркеров.
  • <test_class_name>/<test_name>/Runner... — логи раннера.

Если что-то не работает, то имеет смысл поискать ошибки во всех этих логах. Принцип изучения логов такой же, как в рабочем пайплайне; см. логи Vanilla-операции.

Логи можно посмотреть ещё до завершения теста, для этого нужно найти временную директорию, в которой тест работает. Самый простой способ — запускать тест с флагом --keep-temps: ya make --keep-temps -ttt <target>. В этом случае ya make не удалит временную директорию по окончании теста и напечатает ссылку на неё в выводе.

Также можно воспользоваться такими bash-алиасами:

alias curtestdir="ps -f -u $USER | python3 -c \"import sys, re; drs = set(e for e in re.findall(r'[\s=](/\S*testing' + r'_out_stuff)\b', sys.stdin.read())); print('' if len(drs) == 1 else 'Select first from ' + repr(drs), file=sys.stderr); print(list(drs)[0])\""
alias cdcurtestdir='cd $(curtestdir)'

# Достать из логов ссылку на UI локального YTsaurus.
alias curlocalyt='cat $(curtestdir)/stderr 2>/dev/null | grep YT'

Тестовый фреймворк

Как настроить окружение для лучшей работы тестового фреймворка и как можно влиять на параметры тестирования можно прочитать в README.md фреймворка.

Поведение интеграционных тестов настраивается через --test-param NAME=VAL:

Параметр

По умолчанию

Значения

Действие

RUNNER_LOG_LEVEL

Error, Info, Debug, …

Уровень логирования процесса, запускающего пайплайн (runner).

PAUSE_BEFORE_FLOW_PROCESS_FEDERATION_TEARDOWN

0

0, 1

Зависнуть в тесте перед остановкой процессов Flow. В комбинации с --test-disable-timeout позволяет надолго оставить работающий локальный YTsaurus и федерацию процессов Flow для неспешного изучения через UI.

EXTERNAL_YT_CONFIG

(не задан)

yson — см. ниже

Запускать пайплайн на реальных внешних кластерах YTsaurus вместо локального рецепта.

Примеры:

ya make -A --test-param RUNNER_LOG_LEVEL=Debug
ya make -A --test-disable-timeout --test-param PAUSE_BEFORE_FLOW_PROCESS_FEDERATION_TEARDOWN=1

EXTERNAL_YT_CONFIG

Локальный YTsaurus всё ещё стартует рецептом, но тест его игнорирует.

Обязательные поля, общие для всех кластеров:

  • path — базовая директория.
  • tablet_cell_bundle — bundle создаваемых динамических таблиц.
  • proxy_role — RPC proxy role.

Опционально: primary_medium (по умолчанию "default").

Список clusters — первый элемент primary. Поля записи: cluster_name (обязательно), proxy_url (по умолчанию равен cluster_name).

Авторизация: при внешнем YTsaurus YT_TOKEN/YT_USER из локального рецепта чистятся, yt-wrapper подхватывает токен из ~/.yt/token.

Изоляция: work_yt_path = path/<local-username>/<test_name>; директория path/<username> удаляется и пересоздаётся один раз на класс в setup_class.

Пример:

ya make -A --test-param 'EXTERNAL_YT_CONFIG={path="//tmp/yt_flow";tablet_cell_bundle=default;proxy_role=default;clusters=[{cluster_name=<cluster-name>};];}'

Ускорение итерации: --ext-py

Флаг --ext-py избавляет от линковки бинаря на каждый запуск ya make, если изменения с предыдущего запуска касались только *.py файлов:

ya make -A --ext-py

См. также

Предыдущая
Следующая