Тестирование в 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-компаньона |
Важно
Интеграционные тесты требуют развёрнутого кластера 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 секунд при любом типе сборки.
- Для санитайзерных сборок уменьшаем количество входных данных.
- Все локальные таймауты выставляем с кратным запасом.
- Логика выполнения теста не должна значимо зависеть от текущего времени. Например, тест не должен падать, если запустился до полуночи, а завершился — после.
- Помним, что в CI любая часть теста может работать неожиданно долго.
Отладка тестов
Логи
После завершения работы теста его логи можно найти в 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). |
|
|
|
|
|
Зависнуть в тесте перед остановкой процессов Flow. В комбинации с |
|
|
(не задан) |
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
См. также
- Computation (Python)
- Работа со стейтами (Python)
- Флаг distribute (Python)
- Тестирование (Java)
- Если дорабатываете сам Flow — Фреймворк для тестирования пайплайнов.