Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В первой, теоретической, части статьи мы разобрали зачем нужны Workflow, где они применяются и какие способы их реализации существуют. Наша компания занимается разработкой энтерпрайз-софта — для нас это больная тема. Поэтому мы давно искали инструмент, который позволит легко вписывать новые шаги в любую схему, не ломая существующую бизнес-логику. Нашли и на его основе сделали свою новую разработку. Теперь давайте перейдём к более практической части и разберем, на что способен Temporal PHP SDK.
Меня зовут Антон Титов. Я более 15 лет занимаюсь коммерческой разработкой. Являюсь соавтором Spiral Framework, RoadRunner и Cycle ORM. Основной стек: PHP и Golang.
Обзор Temporal
Система очень сложная, поэтому быстро рассказать все детали не получится, но тот кто хочет узнать больше, может посмотреть презентацию от авторов Temporal. А вот краткая история:
Максим стоял у истоков разработки AWS-облака. Он автор системы SQS, SNS, SWF. Он был в первых командах, которые распиливали Амазон и делали, чтобы внутренняя система масштабировалась и была доступной для всеобщего использования. Второй Автор — Самар, делал то же самое в Microsoft в Azure. Он написал Service bus и систему Durable Function. То есть, занимался реализацией Workflow-подобных движков, но уже на проприетарной основе.
Чуть позже они оба попали в Uber и написали там Cadence. Потом сделали его опенсорсным, форкнули и открыли свою компанию. Сейчас очень быстро растут и увеличивают клиентскую базу.
Temporal пользуется огромное количество компаний, естественно, не только PHP-шных. Все назвать, по понятным причинам не могу, но их реально много.
Если вы знакомы с HeshCorp, то у этих крутых ребят, которые разработали Vault и Consul на инфраструктуре — под капотом тоже Temporal. Точнее, более старая версия Cadence. Верификация в комьюнити прошла очень неплохая.
В общем, Temporal — это очень сложная система с большим количеством микросервисов:
Интересный нюанс в том, что эта система может работать в качестве одного бинарного файла. То есть, в ваш проект можно поместить весь Temporal в виде одного Docker-контейнера. Но если вы захотите уйти на масштаб, то сможете его независимо деплоить и масштабировать. Либо вообще можно сделать много кластеров, которые будут синхронизироваться между собой:
Также в Temporal есть UI, который позволяет полностью наблюдать всю историю — ошибки, данные — и дебажить ваш Workflow. Еще есть граф выполнения, который показывает какое действие сколько чего заняло, и куча инструментов для того, чтобы это профилировать, обрабатывать и смотреть.
Workflow — особенности реализации
В общих чертах, этот движок устроен следующим образом. Давайте посмотрим на схему и определение нашего Workflow.
Есть два основных понятия — это WorkflowDefinition и таски. Внутри Temporal таски — это просто обыкновенные задачи: обработай файл, закажи доставку, сделай polling, сходи в БД. Они называются activity. Это основная рабочая лошадка внутри системы Temporal или вашего кода. Вы можете писать любой код, блокирующий любые библиотеки — ограничений нет.
Первая часть Workflow Definition или просто Workflow — это и есть тот кусок кода, который занимается координацией действий. Он принимает решение, что же нам делать дальше. Это делает не Temporal, а именно ваш код.
Проще говоря — вы, как движок, обращаетесь к Workflow Definition и спрашиваете, какой будет следующий шаг. То есть, что нужно делать, а движок отвечает: «Я хочу, чтобы ты сделал задачу №1». Temporal эту задачу каким-то образом шедулит, балансирует, делает ретраи, получает ответ, отдает его обратно WorkflowDefinition и спрашивает, что делать дальше.
Правда, под капотом всё немного сложнее.
Шагов ГОРАЗДО больше
Чтобы всё это красиво балансировалось, шардилось партишенами с мультикластерами, нельзя делать задачу чисто через базу. Поэтому в Temporal, как в самописных системах, есть внутренние очереди:
При постановке задач в activity, все они уходят в очередь, балансируются, аллоцируются на ваш воркер и после этого возвращаются обратно в WorkflowDefinition код или пользователю.
Также есть подсистема таймеров. Они очень важны в отказоустойчивых системах, потому что всегда что-то будет зависать в коде и отваливаться — а система должна это трекать. Например, вы не сможете в Temporal зашедулить activity, не сообщив системе, через какое время оно ожидаемо выполнится. Это обязательное условие! Если оно будет провалено, то Temporal скажет — что-то пошло не так, давайте сделаем решедулинг.
Координация действий вне движка
В итоге мы получаем систему для вас, как для разработчика. Например, задача activity task для вас — это просто сделай что-то, дай мне результат. Для Temporal это — остановка задачи в очередь, создание таймера, ожидание ответа, балансировка и возвращение результата вам:
Аналогичная система есть в Workflow task. То есть вопрос вашему коду, что делать дальше — тоже балансируется и масштабируется на ваши воркеры. Но из-за того, что этот код гораздо проще и обычно не требует работы с БД, его гораздо проще масштабировать.
Всё просто?
На самом деле не всё так просто. Проблема таких систем заключается в следующем. Чтобы выполнить одну простую задачу на масштабе, мы должны не просто записать в базу ее состояние, но также отправить ее в очередь и в таймер. И все это надо сделать атомарно, чтобы в случае падения все продолжило работать. Это та самая проблема, которая гарантированно выплывет при написании кода руками.
Допустим, мы открываем транзакцию в БД и отправляем задачу в RabbitMQ. После того как она ушла, мы пробуем закрыть транзакцию в БД, но база ложится и закрыть транзакцию не получается. В итоге в очередь летит таска и балансируется, но база про это не знает. Получается неконсистентный state и всё падает.
Можно сделать по-другому. Сначала записать ее в базу, а затем отправить в очередь. Тогда state будет в базе, но из-за того, что задача не была пошедулена, система полностью зависнет. Это решается обычными двухфазными коммитами. Но кто знаком с ними, понимает, что они похожи на регулярные выражения. Если вы решаете проблему двухфазным коммитом, то вы решаете уже две проблемы.
Чтобы всего этого избежать, в Temporal сделали очень хитрый финт ушами.
Transactional outbox (transfer queue) + шардинг
Вместо того чтобы опираться на распределенные транзакции, они полностью опираются на БД, используя Transactional outbox (ящик исходящих):
Вместо того чтобы писать данные в две системы сразу, они пишут их в одну базу, а потом отдельным воркером фоновым процессом вычитывают данные из исходящей таблицы и шедулят ее в месседж-брокер. В случае, если запись в месседж-брокер провалилась, то сообщение останется в этой таблице. То есть, система гарантированно защищена от at least once. А месседж-брокер может масштабироваться независимо от системы.
В результате мы имеем:
Атомарную историю (аудит-лог) всех наших выполнений, которая хранится в базе Temporal;
Восстановление из любой точки;
Восстановление в любой момент времени;
Защиту от уборщицы. Поскольку вся эта история атомарно шедулится, то в случае падения Temporal всегда восстановит правильное состояние нашей системы.
Но хватит уже про Temporal, поговорим теперь про PHP.
Интегрируем Temporal в PHP
У нас был RoadRunner 2.0 и application-сервер для PHP на Golang в качестве движка. А у Temporal есть Golang SDK, также написанный на Go и решающий все проблемы по координации. Транспортный слой мы реализовали через Goridge и Protobuf, а обертку — через ReactPHP-промисы. Получилась интересная свадьба:
То есть, мы интегрировали Temporal внутрь RoadRunner на слое, где мы работаем с Workflow в виде колбэков (сделай что-то и дай мне ответ). Авторы Temporal специально дали нам такое API, потому что мы маппаем ответы во внутреннее представление и отправляем в наш воркер-пул. В итоге можем отправлять как activity, так и Workflow в нашем PHP-коде.
Самое интересное, что в этом случае все ваши данные остаются в вашем коде. Вы можете вообще полностью шифровать payload, но движок RoadRunner о них вообще ничего не будет знать. Координация происходит через асинхронный PHP, поэтому в одном PHP-процессе будет много Workflow, что позволяет хорошо масштабироваться. В одном процессе можно обрабатывать 10000 параллельных оркестрирующих задач.
Activity
Основная часть, где вы будете писать код — это activity.
Это синхронная задача, написанная на обыкновенном блокирующем PHP. Она определяется activity интерфейс атрибутом, который есть и в v7, и в v8. Внутри вы делаете какую-то полезную нагрузку. Там находятся ваши базы, и обработка файлов. Можно выкидывать ошибки, делать хердбиты. Самое интересное, что activity можно писать не только на PHP, а, например, на Java, Golang или любом другом SDK. Они все кросссовместимы.
Workflow
Это уже поинтереснее штучка — это объект, занимающийся координацией дальнейших шагов, асинхронный PHP:
Он должен быть написан в детерминированной форме и всегда вести себя одинаково на одинаковые входные данные. То есть, вам запрещено использовать sleep, io, БД, но они здесь и не нужны. Это чистая выжимка вашего бизнес-процесса. Вы просто пишете задачи, ставите их в очередь, а ключевым словом yield ожидаете получения их ответа. Естественно, эта система отказоустойчива:
Здесь мы определили, что хотим работать с DemoActivityInterface с предыдущего фрагмента кода и сказали, что хотим: получить таймаут 10 минут, выполнить первую задачу, вторую задачу и в итоге получить ответ. Но, если в тот момент пока мы ждали ответа, пришла уборщица и выключила сервер activity 1, то всё упадет. Так почему же эта штука отказоустойчивая?
На самом деле всё, что происходит под капотом, для вас будет полностью прозрачным. Если Workflow упадет на этом шаге, то Temporal поднимет такой же Workflow в другом воркере или на другом сервере. Тут и произойдет магия.
Вы уже говорили RoadRunner Temporal, что хотите сделать activity sayHi, а история предыдущей версии Workflow уже известна — и есть готовый ответ. Поэтому вместо повторного выполнения, вы сразу получите мгновенный ответ из истории предыдущего выполнения. Грубо говоря, это будет полноценный реплей — ваш код переиграет заново, но он ничего не будет делать, а развернется в текущий state. Поэтому даже при отключении сервера ваш код будет восстанавливаться именно в момент точки отказа.
PHP SDK (1.0 Stable)
Мы у себя в итоге взяли RoadRunner и написали PHP SDK для наших проектов. На текущий момент у нас есть первая версия, стабильная с точки зрения API-интерфейсов. Поэтому мы можем выполнять следующие задачи:
Асинхронные задачи
Во-первых, это асинхронный PHP-код, то есть можно выполнять activity асинхронно:
Мы поставили два activity в очередь (hello, by). После чего заблочились на первой, но поскольку вторая уже выполнялась, ответ от них должен прийти одновременно.
Еще можно использовать полноценные примитивы ReactPHP, то есть, подождать любую из выполненных activity или все сразу, и писать корутины:
Мы создали замыкание, внутри которого код идет по своим правилам, и Workflow::async, чтобы что-то внутри него делать. После его создания можно выполнять другие задачи, другие корутины, вложенные корутины и блокирующий код. А чтобы получить результат, нужно просто сделать yield.
Таймеры
Есть возможность сделать таймеры, чтобы Workflow засыпал на какой-то промежуток времени:
Причем промежуток не ограничен. Workflow выгрузится из памяти, а когда сработает таймер — загрузится обратно и продолжит выполняться с той же самой точки. Это удобно, когда вы делаете поллинги или системы агрегации данных.
Каскадная отмена задач
Очень интересный примитив Temporal, который навеян реальными бизнес-проблемами — это отмена:
Предположим, вы создали 50 задач внешних API, а клиент захотел их отменить. В обычных языках такого понятия нет, но в Temporal это возможно. Можно отменить как задачу, уже поставленную в очередь, так и полноценные корутины. Через те же самые хердбиты можно доставить в наш воркер информацию, что нужно прекратить выполнение этой задачи.
То, что уже успело выполниться — конечно будет готово. Но те задачи, что еще висят в очереди, аккуратно отменятся. То есть систему не надо будет гонять впустую. Особенно это касается задач, которые выполняются очень длительное время.
Сигналы
Это возможность влиять на поведение Workflow уже после его запуска:
По сути, вы просто добавляете какой-то метод, говоря, что он сигнальный — и он вызывается из клиентского SDK (с Java, Golang, PHP). Более того, в PHP можно использовать интерфейс Workflow и работать с ним как с прокси-объектом. Достаточно сказать, что вы хотите добавить данные или выйти из Workflow — и на этом всё. Выглядит это как обычная переменная.
Внутри Workflow можно запустить бесконечный цикл и сказать, что вы хотите подождать, пока не изменится состояние инпута. Например, он станет не пустым, либо пока не придет сигнал на exit.
Таким образом, можно создавать интересные ситуации. Например, запускать большую транзакцию и потом скидывать на нее финансы от нескольких пользователей. Потом просто один раз коммититимся и делаем единовременный перевод. Это очень удобно, если вы пишете какой-то маркетплейс, и вам необходимо координировать действия в распределенной сложной бизнес-транзакции.
Запросы состояния
Также есть query. Они позволяют напрямую читать содержимое переменных в ваш Workflow, прямо из объекта PHP, и отдавать его обратно в какой-то интерфейс — UI или клиента.
В этом примере, мы запустили Workflow (WorkflowMethod) и поставили внутреннюю переменную message Hello. Затем заснули на 20 секунд, и после этого изменили переменную на By. Если сделать query запрос к данному Workflow до 20 с, вы получите одно значение, а после — уже другое. Также можно делать запросы к закрытым Workflow, которые закончились.
Параллельно с этим у Temporal есть интеграция из коробки с ElasticSearch. Поэтому, если вы хотите материализовывать ваши данные, то можете скидывать их прямо в поисковый индекс.
Бесконечные Workflow
Представим ситуацию, что нам необходимо реализовать систему подписки. И у нас есть пользователи, с которых нужно списывать деньги каждые 30 дней. Им нужно посылать уведомления, а в случае ошибки — всё отменять.
Чтобы реализовать такое, обычно поднимают крон-задачу и пишут пару табличек в БД с темпами, из которых уже выбирают пользователей. Нужные помечаются, им отсылаются уведомления на почту. Всё это масштабируется не очень хорошо, потому что возникают race condition, блокировки, и в принципе поднимать один крон на миллион пользователей тяжеловато.
Temporal предлагает сделать миллион кронов — по одному на каждого пользователя. В итоге у каждого есть отдельный бизнес-процесс, который полностью контролирует его подписку. Он просто засыпает, а через 30 дней просыпается и выполняет нужные действия. Что и позволяет реализовывать даже очень сложную бизнес-логику — премиальный период или пост-период. При этом вы можете ловить запрос об отмене, после чего просто вычищать данные из БД — всё очень удобно.
Вот, что вам потребуется, чтобы сделать для полноценной системы подписок с 30-дневным периодом:
Каскадная обработка ошибок
Если ваша activity упала, вы узнаете все подробности: почему это случилось, на какой линии и был ли это таймаут, failure, сетевая задержка или другая проблема. Все это ловится try/catch. Если вы используете Workflow, вызывающий Workflow на другом языке — например, на Java, который вызывает activity на Golang — у ошибки будет полный стек-трейс.
То есть вы поймете, что Workflow PHP упал на линии 15, потому что внутри Workflow на Java на линии 25 был вызван Golang-код, который упал на другой линии.
Это очень удобно при дебаге сложных распределенных систем на микросервисах.
Саги
Раз у нас есть try/catch, то мы вполне можем делать саги — системы постановки задачи и ее компенсации:
Более того, мы даже можем потом открыть код этой саги — и это буквально 20 строчек. Там нет ничего уникального, это просто использование дефолтных примитивов, которые вы можете писать сами. Всё, что нужно сделать — просто поставить задачу, добавить в очередь под компенсацию и в случае любой ошибки вызвать compensate.
Саги можно гонять по компенсациям последовательно или параллельно. Это классическая задача — вы хотите забронировать отель, машину или самолет, но на последнем шаге ничего не забронировалось. Саги дают возможность сделать компенсацию внутри вашего кода.
Что дальше?
Мы не собираемся останавливаться на этом. Планов по доработке очень много, у нас большой roadmap по дальнейшему развитию:
Версию 1.1.0 мы планируем выпустить до конца года, как и реализовать тестовый фреймворк с Activity-моками. Мы хотим сделать так, чтобы вы могли прямо в PHP-unity проверить каждый шаг вашего Workflow: замокать каждый activity, все данные и проверить все таймауты.
Еще мы планируем динамический роутинг задач для сложных пользовательских процессов и локальные Activity, которые не будут бегать в сервер Temporal, а смогут напрямую общаться с вашим локальным кодом. Это удобно для реализации мелких задач. Например, вы забираете конфиг у Workflow с БД и не хотите, чтобы это оркестрировалась распределенно.
Также мы собираемся поднять перфоманс и оптимизировать узкие места. Сделать больше примеров интеграций в разные фреймворки. Уже есть тестовые сборки от ребят под Laravel и Symfony.
Мы хотим, чтобы при обработке большого файла, например, картинки, если вам нужно отресайдить его в 5 версий, вы могли сказать системе, чтобы его обрабатывал один воркер на одной файловой системе. В итоге вместо того, чтобы гонять его по сети, вы сможете обращаться к локальному хранилищу напрямую.
Мы собираемся сделать, чтобы все работало из коробки. Если вы делаете маленькое приложение и не хотите поднимать Docker, то можете взять БД и RoadRunner.
Если вы заинтересовались системой распределенных Workflow и хотите попробовать ее у себя, то у Temporal огромное количество документации и примеров. Есть сэмпловый репозиторий с системой подписки и трехчасовой воркшоп на YouTube про использование и дебагу всего этого дела. Пользуйтесь и делитесь своими впечатлениями.
Если вы давно раздумывали, не выступить ли с докладом на PHP Russia, то вот он — знак! Программный комитет конференции продлевает приём докладов до 14 июня. Сама конференция PHP Russia 2022 состоится 12 и 13 сентября в Москве (билеты). Подробнее о том, какие темы будут особенно интересны, читайте здесь.
Посмотрите записи онлайн-встреч спикеров с Программным комитетом, там вы найдёте ответы на многие вопросы: в декабре 2021 и в апреле 2022. Если вопросы всё же останутся, то пишите координатору ПК — Мариам Киреевой.
Спикерам конференции помогают в подготовке к выступлению, оплачивают дорогу и проживание в отеле (если вы из другого города). Перед конференцией будет препати-ужин, где спикеры и члены ПК знакомятся и общаются. Дело за вами!