
Всем привет!
В прошлой статье была поставлена задача о надёжных мутациях и транзакциях в архитектуре Профи, в этой статье разберём один из вариантов решения — применить workflow-engine Temporal.
Почему именно Temporal
Впервые я наткнулся на Temporal в Technology Radar от ThoughtWorks, где запомнилась эта цитата:
Although we don’t recommend using distributed transactions in microservice architectures, if you do need to implement them or long-running Sagas, you may want to look at Temporal. https://www.thoughtworks.com/en-us/radar/platforms/temporal
Помимо Temporal существуют ещё десятки workflow engines, однако, если пройтись по ним и оставить только решения с приличным количеством звёзд, то список сильно сократится. Далее применяем фильтр по потенциальной подходимости нашим критериям и остаётся 2-3 кандидата, один из которых — Temporal. Фичи, которые сходу показались интересными:
Алгоритмы транзакций и мутации можно описывать кодом на Go, TypeScript, PHP, Python и т.п.
При этом код выполняется децентрализованно — Temporal только координирует.
При ошибках поддерживаются повторы.
Есть GUI, где можно отслеживать процессы и управлять транзакциями.
В целом неплохая документация.
Как работает Temporal
Если коротко и упрощённо то получается:
Есть сценарии (далее workflow).
Сценарии состоят из шагов (далее activity).
Temporal-кластер оркестрирует запуск workflow и activity
Сами workflow и activity непосредственно выполняются распределёнными воркерами.
Ну и есть приложения (в широком смысле), которые соединяются с Temporal-кластером и говорят — запусти такой-то workflow.

Если хотите разобраться в деталях, то рекомендую следующую последовательность:
Посмотрите визуальный гайд на официальном сайте.
Пройдите один из dev-гайдов тут.
Прочтите статьи на хабре от авторов Temporal php-sdk — раз, два.
Просмотрите подробности базовых концепций.
Задача про вынос биллинга
Профи — это маркетплейс, где есть специалисты предоставляющие услуги и клиенты, а основной бизнес-сценарий выглядит так:
Клиент оставляет заказ на услугу.
Специалист, если заказ ему понравился, отправляет на него заявку, которая в общем случае — платная.
После оплаты заявки, специалисту открывается чат с контактами клиента, где можно обсудить детали заказа.
Биллинг при этом входит в общую царь-транзакцию, которая обеспечивает консистентность продуктовых и финансовых данных — деньги уплачены, специалист может общаться с клиентом и т.п.

Какие тут есть проблемы:
Если падает монолит, то падает и биллинг, который используется не только в процессе заявки.
Хранение финансовых и продуктовых данных в одной БД, конечно, не катастрофа. Однако данные, влияющие на финансовую отчётность и пробитие чеков лучше хранить отдельно от продуктовых данных у которых, так сказать, произвольный уровень качества :)
Если вдруг падает БД или моргнула сеть, то мы теряем
корнизаявки. Такое случается крайне редко, так что аргумент скорее теоретический, но круто иметь механизм, который допинает заявку в случае очень неприятных ситуаций.
А вот такую картинку хочется получить в итоге: разделить сценарий на шаги и выполнять их с надёжностью сравнимой с классическими MySQL транзакциями.

Собираем прототип
Очень круто то, что Temporal позволяет собрать прототип используя уже существующий код:
Поднимаем Temporal кластер;
Создаём Temporal-совместимые классы для workflow и activity;
Добавляем вызовы существующей бизнес-логики внутрь созданных классов;
Запускаем воркер, который выполняет workflow и activity.
Внедряем код запуска workflow в текущую бизнес-логику.
Запускаем кластер
Тут всё просто и хорошо описано в официальной инструкции — пара команд и сервер установлен. Для запуска сервера в dev-режиме вбиваем в консоль temporal server start-dev --ip 0.0.0.0 и проверяем, что по адресу ваш_хост:8233 отдаётся GUI:

Стартовый код
Примерно так выглядит изначальный код сценария заявки с царь-транзакцией:
<?php class OrderApplier { public function apply( int $orderId, string $specId, OrderApplyParams $params, ): int { if (!$this->applyValidator->couldBeApplied($orderId, $specId, $params)) { throw new CouldNotApply(); } $apply = null; $chatTask = null; $this->db->beginTransaction(); try { $apply = $this->applyWriter->createApply($orderId, $specId, $params); $this->ordersWriter->applyForOrder($orderId, $apply); // Внутри billApply — Несколько INSERT-ов и UPDATE-ов фин-таблиц $this->billing->billApply($apply); // Cохраняем таску в outbox таблицу $chatTask = $this->tasks->createTask(new CreateApplyChatTask($apply)); $this->db->commit(); } catch (Throwable $e) { $this->db->rollBack(); throw $e; } // В конце выполнения этой таски запускается следующая — пост-процессинг // (сброс кешей, аналитика и т.п.) $this->tasks->queueTask($chatTask); return $apply; } }
Создаём activities
Чтобы создать activity нужно определить Temporal-совместимый интерфейс с методами, которые возвращают сериализуемые значения. Но на самом деле работать будет и без отдельного интерфейса — достаточно просто добавить нужные аннотации в обычные PHP классы.
Предварительная проверка (через имплементацию интерфейса)
<?php #[ActivityInterface] interface OrderApplyValidatorInterface { public function couldBeApplied( int $orderId, string $specId, OrderApplyParams $params ): bool; } class OrderApplyValidator implements OrderApplyValidatorInterface { public function couldBeApplied( int $orderId, string $specId, OrderApplyParams $params ): bool { ... // бизнес-логика проверки ... return true; } }
Запись заявки в базу (без имплементации интерфейса)
<?php class CreateApplyResult { public ?Apply $apply; public int $errorCode = 0; } #[ActivityInterface] class OrderApplyCreator { public function createApply( int $orderId, string $specId, OrderApplyParams $params ): CreateApplyResult { $this->db->beginTransaction(); try { $apply = $this->applyWriter->createApply($orderId, $specId, $params); $this->ordersWriter->applyForOrder($orderId, $apply); $this->db->commit(); return new CreateApplyResult($apply); } catch (Throwable $e) { $this->db->rollBack(); return new CreateApplyResult(null, $e->getCode()); } } }
Биллинг
<?php class BillApplyResult { public int $errorCode = 0; } #[ActivityInterface] interface OrderApplyBillerInterface { public function billApply(Apply $apply): BillApplyResult; } class OrderApplyBiller implements OrderApplyBillerInterface { public function billApply(Apply $apply): BillApplyResult { ... // локальная транзакция биллинга ... return new BillApplyResult(...); } }
Создание чата
<?php #[ActivityInterface] interface OrderApplyChatCreatorInterface { public function createChat(Apply $apply): void; } class OrderApplyChatCreator implements OrderApplyChatCreatorInterface { public function createChat(Apply $apply): void { ... // бизнес-логика создания чата ... } }
Ну в общем понятно — постпроцессинг делается по аналогии.
Важный момент — все аргументы методов и возвращаемые значения должны быть либо сериализуемыми, либо имплементировать интерфейс Data Converter.
Создаём workflow
Как и в случае с activity, для workflow нужно создать Temporal-совместимый интерфейс, опять же — не обязательно, но мы сделаем как в официальных доках:
<?php class ApplyForOrderResult { public ?Apply $apply; public int $errorCode = 0; } #[WorkflowInterface] interface ApplyForOrderWorkflowInterface { #[WorkflowMethod("applyForOrder")] #[ReturnType(ApplyForOrderResult::class)] public function applyForOrder( int $orderId, string $specId, OrderApplyParams $params ); }
Далее создаём класс, который имплементирует этот интерфейс — в нём и реализуется основная логика нашей надёжной транзакции:
<?php class ApplyForOrderWorkflow implements ApplyForOrderWorkflowInterface { public function __construct() { // Создаём activity-стабы (RPC) для вызова activity-методов $this->validator = Workflow::newActivityStub( OrderApplyValidatorInterface::class, ActivityOptions::new()->withStartToCloseTimeout( \DateInterval::createFromDateString('3 seconds') ) ); $this->creator = Workflow::newActivityStub( OrderApplyCreator::class, //... ); // Пример того, как можно создать activity-стаб для activity, // которая реализована в другом сервисе или на другом стеке $this->biller = Workflow::newUntypedActivityStub( ActivityOptions::new()->withStartToCloseTimeout( \DateInterval::createFromDateString('5 seconds') ) ); $this->chat = Workflow::newActivityStub( OrderApplyChatCreatorInterface::class, //... ); } public function applyForOrder( int $orderId, string $specId, OrderApplyParams $params ) { $couldBeApplied = yield $this->validator->couldBeApplied( $orderId, $specId, $params ); if (!$couldBeApplied) { return new ApplyForOrderResult(null, 1); } $createResult = yield $this->creator->createApply( $orderId, $specId, $params ); if ($createResult->errorCode != 0) { return new ApplyForOrderResult(null, $createResult->error_code); } // Пример того, как можно вызвать activity из другой кодовой базы — по имени. $billingResult = yield $this->biller->execute( 'billApply', [$createResult->apply], BillApplyResult::class // Мапим результат на внутренний класс ); if ($billingResult->errorCode != 0) { // // @todo сюда можно добавить логику отката заявки т.к. биллинг не прошёл // return new ApplyForOrderResult(null, $billingResult->error_code); } yield $this->chat->createChat($createResult->apply); return new ApplyForOrderResult($createResult->apply); } }
На что здесь стоит обратить внимание:
При создании activity-стабов можно указать различные настройки — таймауты, рейт-лимиты и т.п.
Activity могут находиться в разных микросервисах и могут быть написаны на разных языках.
Конструкцию
$foo = yield bar()можно воспринимать как аналогиюawaitв TypeScript или Python. Если интересно, то можно почитать про механику этой непривычной для PHP конструкции.
Создаём и запускаем воркер
Temporal работает через RoadRunner — высокопроизводительный инструмент, позволяющий эффективно запускать PHP-приложения в долгоживущем режиме.
Настраиваем RoadRunner
temporal: address: ${хост_порт_temporal_кластера} activities: num_workers: 8 rpc: enable: true listen: tcp://127.0.0.1:6001 server: command: "php worker.php"
Пишем скрипт воркера
<?php ... $container = Container::getInstance(); $factory = WorkerFactory::create(); $worker = $factory->newWorker(); // Регистрируем какие workflow будет выполнять воркер $worker->registerWorkflowTypes( ApplyForOrderWorkflow::class ); // Аналогично регистрируем activity $worker->registerActivity( OrderApplyValidator::class, fn(ReflectionClass $class) => $container->get($class->getName()) ); ... $worker->registerActivity( OrderApplyChatCreator::class, fn(ReflectionClass $class) => $container->get($class->getName()) ); $factory->run();
Запускам воркер через RoadRunner и слушаем очередь

Наблюдаем за воркерами

Запускаем workflow
Осталось последнее — добавить запуск workflow в бизнес логику и протестить, что всё работает. Workflow можно запускать синхронно и асинхронно. В нашем случае мы пойдём по синхронному пути т.к. фронтенд пока не готов к асинхронному созданию заявки, а для прототипа достаточно будет и синхронного варианта. Однако, 99% workflow, конечно же, должны работать асинхронно т.к. повторы, задержки и вот это вот всё.
Код синхронного запуска workflow выглядит так:
<?php ... $result = $this->apply_workflow->applyForOrder( $orderId, $specId, $params ); ...
Тестируем создание заявки
Находим активного специалиста с балансом, заходим под ним в тестовый бекофис и отправляем заявку:

Заявка успешно отправлена, деньги списаны, чат создан:

Идём в GUI Temporal и смотрим на логи нашего workflow:

Обработка падений
Во время работы над прототипом я случайно и намеренно создавал ситуации, при которых workflow не отрабатывал:
Исключения и ошибки в коде activity.
Выключенные воркеры.
Недетерминированный код workflow.
Ошибка MySQL has gone away т.к. код не был приспособлен к долгоиграющим скриптам.
и т.п.
Все эти кейсы Temporal успешно отработал, даже сценарии с недетерминированным кодом, а вот так выглядит ошибки в GUI:

Вроде бы всё рассказал, пора подводить итоги.
Плюсы Temporal
Универсальность
Поддерживает весь наш стек — PHP, TypeScript и Python.
Позволяет решать широкий класс задач: надёжные мутации, транзакции, отложенные задания, рассылки, кроны, флоу бизнес-моделей и т.п. — всё это можно реализовать через Temporal. Например, особенно взрывает мозг workflow подписок, который работает на протяжении всего жизненного цикла пользователя.
Текущую бизнес логику довольно легко засунуть в activity и написать императивный алгоритм в workflow. Правда у кода самого workflow есть ограничения, но в activity можно творить всё что угодно.
Более простой путь по интеграции ML-алгоритмов в наши бизнес-процессы.
В будущем можно запилить инструмент для продактов, которые смогут в GUI собирать бизнес-сценарии из activity и играть между ними в А/Б тесты.
Надёжность
Temporal изначально был разработан в Uber, также его используют такие бигтехи как Snap и Netflix — это внушает доверие.
Если workflow стартовал, то Temporal выполнит его до конца c учётом retry policy. Даже если упали воркеры, отвалились сервисы, что-то пошло не так — workflow будет выполнен.
Для activity можно определять rate limits и retry policy.
Ошибки можно обработать в коде workflow и тут же запустить откат-сценарии.
Разные плюшки
Готовый GUI, где можно отслеживать выполнение workflow.
Настраиваемые мониторинги для workflow и activity.
Можно шифровать чувствительные данные перед отправкой в Temporal.
Можно посылать сигналы и запрашивать данные у работающих workflow.
Temporal гарантирует эксклюзивное выполнение workflow с данным id — нет проблемы с race conditions.
Неплохая документация, есть материалы на русском.
Поддержка namespace-ов.
Минусы Temporal
Риск развития микросервисного монолита
Хоть номинально все RPC вызовы и проходят через один Temporal кластер, но гибких возможностей выстраивать политику кто и что может, вроде как, нельзя. Поэтому сервисы практически бесконтрольно могут дёргать друг-друга.
Высокий порог входа
Разработчикам обязательно нужен онбординг и обучение — едва ли можно сходу разобраться в том, как эффективно “программировать” на Temporal.
Поскольку Temporal становится одним из основных компонентов инфраструктуры, то devops-ы должны знать все детали и особенности его работы в production, а также уметь быстро чинить проблемы.
Разные неприятности
Нужно следить за зоопарком воркеров — за статусом, за актуальностью кода и т.п. Это важно как для прода, так и для окружения разработки.
Код workflow должен быть детерминирован, однако встроенная поддержка версионирования для workflow и воркеров позволяет реализовать обратную совместимость.
Сигнатуры методов workflow и activity в идеале не должны меняться.
99% workflow будут запускаться асинхронно — нужно допиливать клиентов, чтобы статус процесса отображался корректно.
Оверхед на код — придётся писать больше кода, но во времена copilot-а это не такая уж и серьезная проблема.
Выводы
На уровне прототипа Temporal впечатляет универсальностью и надёжностью. Однако, без осознанного потребления может превратить нормальную распределённую систему в микросервисный монолит со всеми вытекающими.
Очень хочется попробовать — начать с каких-то простых задач и постепенно переходить к чему-то более серьёзному. Если в мы итоге заюзаем этот фреймворк, то я обязательно напишу о реальном опыте в проде.
Спасибо что дочитали!
Пока :)
P.S. Если среди вас есть те, кто уже применяет Temporal, то буду благодарен за кейсы в комментариях.
Статьи из серии
Первая часть про постановку задачи: https://habr.com/ru/articles/770122/
Вторая часть про Temporal: эта статья
Третья часть про TSQM: https://habr.com/ru/articles/776794/
