Меня зовут Миша, я бэкенд‑разработчик в платформе Яндекс Еды, и сейчас я покажу немного настоящего кода процессинга заказа.
e, err := w.prepareExecutor(ctx, req)
if err != nil {
return nil, err
}
if err := e.CreateAndPay(); err != nil {
return e.HandleResult(err)
}
if err := e.InitializeNativeDelivery(); err != nil {
return e.HandleResult(err)
}
if err := e.WaitForOrderConfirmation(); err != nil {
return e.HandleResult(err)
}
if err := e.WaitDelivery(); err != nil {
return e.HandleResult(err)
}
return e.HandleResult(nil)
Это обычная Go‑функция, в которой запрограммирован весь процессинг заказа от начала до конца, то есть от оплаты до доставки пользователю. И всё это время процессинг спокойно переживает перезапуски сервисов, падения инстансов и временные отказы зависимостей. Бизнес‑логика всегда выполняется чётко, как написано.
Раньше для такого требовались: стейт‑машина с полудюжиной состояний, очереди и воркеры, обработчики на каждое событие и блокировки от race conditions. С тех пор как мы перенесли процессинг на Temporal, разработка существенно упростилась. Пользователь оплачивает заказ, ресторан его подтверждает и готовит, курьер забирает и привозит — ровно это и отражено в коде.
Но давайте с самого начала. В этой статье я расскажу о принципах работы Temporal: почему мы его выбрали как основу нового процессинга, в чём его сильные стороны и как изменилась наша жизнь после перехода.
Какую проблему мы решаем вообще?
Длящийся бизнес‑процесс по самой своей сути противоречит событийной модели веб‑разработки.

С одной стороны бизнес‑процесс: у него единая, обычно довольно линейная логика, в которой предусмотрены все возможные «что‑то идёт не так», где требуется какая‑то реакция. Очевидно, технические ошибки в духе «с первого раза запрос обработать не удалось, потому что данные ещё не доехали до целевого сервиса» никак не должны влиять на бизнес‑логику.
С другой стороны — HTTP‑вызовы от множества клиентов к небольшому числу серверов, каждый из которых должен правильно обработать данные для каждого клиента и сделать это достаточно быстро, — в пределах единиц секунд. Кроме того, сервисы, которые всё это делают, могут упасть самыми разными способами, но это никак не должно влиять на логику работы над заказом. Не забудем ещё и проблемы распределённых блокировок (допустим, если сообщение об отмене заказа пришло одновременно с сообщением о доставке).
Конечно, все мы умеем с этим жить: у нас есть принципы чистой архитектуры, есть стейт‑машины для валидации и смены состояний, есть ProcaaS, где можно строить довольно развесистые цепочки обработки событий с проверкой состояния вообще без написания кода. Но у всех этих компромиссов по впихиванию бизнес‑логики в маленькие быстрые обработчики событий есть цена: она размазывается тонким слоем по десяткам разных мест. И даже в идеальной архитектуре в доменном слое не будет такой функции, как на скриншоте в начале статьи; будут только отдельные обработчики на все возможные и невозможные состояния. В прошлой статье я уже рассказывал о том, как распутывал и «выпрямлял» бизнес‑логику, сокрытую во множестве отдельных обработчиков.

Использование платформы Temporal позволяет полностью снять это противоречие и писать код так, будто каждый заказ обрабатывается одной функцией от начала до конца, однопоточно и синхронно, а сам обработчик можно просто поставить на паузу в ожидании таймера или другого события. Для этих удобств нужны две вещи — работающий сервер Temporal, который можно развернуть самостоятельно или подключиться к Temporal Cloud, а также добавить в проект Temporal SDK (есть для C#, Java, Go, PHP, Python, Ruby, Typescript).
Как выглядит код с Temporal
Для примера покажу небольшой фрагмент бизнес‑логики, когда ресторан уже готовит заказ, а курьер уже едет за ним. Нам нужно знать, когда ресторан приготовил заказ и когда курьер его забрал, чтобы отправить уведомления пользователю, записать точное время для аналитики и так далее.
В реальности всегда сначала заказ готов, а потом его забирает курьер. Но сообщения о смене статуса приходят из разных источников: первое от ресторана, а второе от курьера. И здесь что‑то может пойти не так, и первое сообщение может прийти позже или вовсе затеряться.
Тем не менее, если оно не сильно опаздывает, то имеет смысл его дождаться — чтобы зафиксировать время, которое ресторан в нём сообщает. Если же мы его не дождёмся, то тогда в качестве времени, когда заказ был приготовлен, мы запишем время, когда курьер его забрал (и пометим событие «синтезированным» для аналитики).
То же самое в виде схемы:

Temporal позволяет нам написать код, который буквально отражает эту схему:
// ждём любого из сигналов
workflow.Await(ctx, func() bool {
return !receiver.Cooked.IsEmpty() ||
!receiver.Taken.IsEmpty() ||
!receiver.Arrived.IsEmpty() ||
!receiver.Delivered.IsEmpty()
})
if receiver.Cooked.IsEmpty() { // если пришёл не тот, кого ждём
timeout := df.timeoutProvider.GetTimeout("disordered_signals_timeout", 10*time.Second)
workflow.AwaitWithTimeout(ctx, timeout, func() bool { return !receiver.Cooked.IsEmpty() })
}
var cookedSignal messages.Cooked
if !receiver.Cooked.IsEmpty() { // успел прийти сигнал Cooked
cookedSignal = receiver.Cooked
} else { // не успел дойти
cookedSignal = messages.Cooked{OrderNr: data.OrderNr, OccurredAt: workflow.Now(ctx), Origin: messages.OriginSythesized}
}
// обработка
df.A.AddOrderStateEventFromMessage(ctx, cookedSignal)
df.TrackingUpserter.UpsertTrackingEvent(ctx, cookedSignal)
Код прост для восприятия и точно соответствует схеме. Просто попробуйте представить, сколько разных дополнительных проверок и приседаний пришлось бы сделать для реализации такой логики в event‑driven подходе.
А теперь давайте посмотрим, как в целом организован код при разработке с Temporal.
Workflow и activity
Для начала несколько терминов.
Workflow — это функция, которая реализует бизнес‑процесс. Её смысл — превращать входные данные в выполненный или отменённый заказ. У нас в Еде один основной workflow на процессинг заказа, а также несколько вспомогательных, например, при изменении состава заказа (когда магазин заменяет товары или уточняет вес у весовых).
Workflow‑функция как бы может выполняться бесконечно долго — пока заказ не будет обработан. Поэтому проверяем все данные мы только в самом начале, и весь нужный контекст сохраняется в локальных переменных. Отдельная стейт‑машина тоже оказывается не нужна: текущее состояние заказа — это просто место в коде, которое сейчас исполняется. Можно ещё и запустить какую‑то функцию в фоне (по типу go func).
Сам workflow не выполняет никаких «реальных действий», то есть ввода‑вывода. Он содержит в себе чистую бизнес‑логику, реализованную в чистых функциях. Все «реальные действия» выполняются в так называемых Activity.
Activity — это асинхронная задача, которую ставит workflow. Например, у нас одна из activity как раз занимается сохранением текущего состояния заказа в БД, другая — отправкой в сервис трекинга, чтобы у пользователя обновилась информация, и так далее. Activity также может возвращать результат, например, у нас есть activity, которая запрашивает информацию о том, какие действия нужно сделать при отмене заказа (предусмотрено множество разных вариантов на все случаи жизни).
Иногда какой‑то сетевой вызов может завершиться с ошибкой, и такие проблемы часто лечатся простым повтором. Поэтому если выполнение activity падает, то Temporal повторяет её заданное количество раз. Чтобы не плодить дубли при повторных вызовах, activity должна быть идемпотентной.
Если лимит повторных попыток исчерпан или ошибка помечена как non‑retryable — это уже бизнес‑проблема, для которой нужно решение на уровне workflow. Например, падение activity с отправкой данных в трекинг заказа не так страшно, а вот если заказ создаётся в ресторане, который отключён, — тут уж заказ придётся отменять.
Благодаря такому разделению в коде workflow хорошо видна сама бизнес‑логика, а имплементация действий вынесена в activity. Кроме того, такая изоляция помогает отказоустойчивости: при написании бизнес‑логики совершенно не нужно заботиться о том, что какое‑то действие может успешно отработать не с первого раза. Если это что‑то не слишком важное, то можно просто вызвать activity и не обрабатывать возврат.
С точки зрения кода вызовы activity из workflow выглядят почти как обычные асинхронные вызовы. Temporal SDK реализует их имеющимися средствами языка: на Python через async, PHP через генераторы, на Typescript через промисы, на.NET через таски и тому подобное
Схема взаимодействия такова:

Temporal SDK даёт разработчику удобный интерфейс, где workflow и activity — это обычные функции (или классы), но с некоторыми ограничениями. Чтобы понять эти ограничения, рассмотрим, благодаря какой именно магии такие удобства оказываются возможными.
Hello world на Temporal
Взято из документации Temporal.
// Определение функции activity
func Greet(ctx context.Context, name string) (string, error) {
return fmt.Sprintf("Hello %s", name), nil
}
// Определение функции workflow
func SayHelloWorkflow(ctx workflow.Context, name string) (string, error) {
// устанавливаем таймаут исполнения activity
ao := workflow.ActivityOptions{
StartToCloseTimeout: time.Second * 10,
}
ctx = workflow.WithActivityOptions(ctx, ao)
var result string
// выполняем activity и ожидаем результата вызова
err := workflow.ExecuteActivity(ctx, Greet, name).Get(ctx, &result)
if err != nil {
return "", err
}
// возвращаем результат
return result, nil
}
func main() {
// подключаемся к серверу/облаку
c, err := client.Dial(client.Options{})
if err != nil {
log.Fatalln("Unable to create client", err)
}
defer c.Close()
// создаём воркер и для activity, и для workflow разом
w := worker.New(c, "my-task-queue", worker.Options{})
// регистрируем activity и workflow в воркере
w.RegisterWorkflow(greeting.SayHelloWorkflow)
w.RegisterActivity(greeting.Greet)
// запускаем воркер
err = w.Run(worker.InterruptCh())
if err != nil {
log.Fatalln("Unable to start worker", err)
}
}
Как это работает
Разумеется, никаких «бесконечно долго работающих» функций здесь нет, а возможность их писать так, будто они работают сколь угодно долго, достижима благодаря прокачанному Event Sourcing. Суть этого подхода: хранение истории изменений какой‑либо сущности вместо того, чтобы хранить конечное состояние. Таким образом, имея журнал событий, можно всегда восстановить состояние на любой момент времени.
Этот подход идеален для бизнес‑логики: ведь действия, которые нужно предпринять, полностью определяются схемой/инструкцией и зависят только от «бизнес‑важных» событий. Они, безусловно, должны присутствовать в истории событий, и только их мы и учитываем в коде workflow.
Напомню, что в коде workflow нет никакого взаимодействия с «внешним миром», а есть только общение с Temporal SDK, который как раз и прячет внутри себя чтение и запись истории событий.

Исполнение бизнес‑логики работает следующим образом:
Сервис чекаута отправляет в сервер Temporal сигнал «запустить workflow» со всеми данными по заказу.
Temporal запускает новый workflow, то есть обработку бизнес‑логики с самого начала.
Каждый раз, когда нам нужно что‑то сделать (например, инициализировать платёж), бизнес‑логика ставит задачу (activity), которая записывается в историю событий.
Когда бизнес‑логика требует результата выполнения задачи, workflow засыпает в ожидании активации.
Тем временем activity worker выполняет поставленные задачи. Результат выполнения записывается в историю событий.
Новые события в истории (в том числе и внешние сигналы) вызывают активацию workflow, функция ожидания возвращает результат из истории событий.
Выполнение бизнес‑логики может продолжиться.
В такой схеме исполнение workflow фактически никак не зависит от того, происходит ли прямо сейчас исполнение activity или же просто SDK отдаёт результат уже выполненной activity из истории. Это означает, что можно в любой момент прервать ожидание workflow, а когда придёт какое‑то новое событие, быстренько восстановить состояние, пользуясь историей.

Ожидающие workflow никакой вычислительной нагрузки не создают, а только занимают место в базе данных с историей. Восстановление их действительно быстрое из‑за того, что никакого ввода‑вывода в коде workflow не происходит, и, пользуясь уже готовыми результатами выполнения activity, код доходит до того места, где остановился в прошлый раз.
Детерминизм и воспроизводимость
Благодаря механизму повторного воспроизведения бизнес‑логики (replay) мы и можем писать код workflow так, как будто он исполняется неограниченно долго. Но для этого каждый replay должен приводить к одним и тем же результатам, и именно это накладывает определённые ограничения на код, который мы вообще можем писать в workflow‑функции.
Более точно — одна и та же история событий должна порождать одни и те же действия в одном и том же порядке. Это согласуется со здравым смыслом: если бизнес‑логика предусматривает определённую реакцию на, допустим, опоздание курьера, то действия всегда должны быть одни и те же. В терминологии Temporal это называется детерминизм.
Это значит, что всякие меняющиеся обстоятельства, например, результаты матчинга экспериментов, полученные значения конфигурации, просто случайные числа и даже текущее время должны записываться в историю событий. Текущее время, впрочем, записывается в историю событий автоматически, а вот для всего прочего существует специальная обёртка под названием side effect: при первом исполнении кода конфиг будет честно прочитан и результат его будет записан в историю событий, а при перепроигрывании вместо реального запроса будет использовано готовое значение из истории.
var countryName string
if err := workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} {
name, err := countryCache.getCountryName(countryCode, locale)
if err != nil {
workflow.GetLogger(ctx).Error("Failed to get country name", "error", err)
return ""
}
return name
}).Get(&countryName); err != nil {
return "", err
}
return CountryName
Нарушение детерминизма (Temporal SDK автоматически следит за этим) приведёт к остановке выполнения workflow. С точки зрения здравого смысла это правильное решение: если бизнес‑логика при повторном воспроизведении даёт какой‑то другой результат, то безопаснее всего остановиться и не делать ничего. Вывести workflow из такого состояния можно разными способами, и самый простой из них — reset, то есть удаление истории с определённого места, и все действия после него будут запущены заново.
Здесь может возникнуть вопрос: а как же детерминизм бизнес‑логики сочетается с её обновлением? В Temporal есть встроенный механизм версионирования, который позволяет держать в коде старый и новый варианты одновременно, пока ни одного старого работающего workflow не останется.
Observability из коробки
В комплекте с Temporal есть веб‑интерфейс «админки», в котором очень удобно прямо вживую смотреть за выполняющимися workflow. У нас в процессинге заказа может быть более сотни различных действий, и удобное визуальное представление значительно облегчает отладку и исследование.
На экране workflow доступна вообще вся информация из истории событий: когда задача была поставлена, когда началось и закончилось выполнение, какие параметры были на входе и на выходе, какие side effects были записаны и когда, какие входящие сигналы поступили в workflow и так далее. Кроме графика, можно также посмотреть все действия списком в полном или компактном представлении, или сырую историю событий в JSON.

Из админки можно послать сигнал в workflow, отменить его или терминировать (это разные действия), сделать reset или массовый reset и даже запустить новый workflow с заданными параметрами. Можно скачать историю выполнения конкретного workflow для локальной отладки или загрузить свою JSON‑ину для того, чтобы удобно посмотреть сохранённую историю.
На экране списка всех выполняющихся workflow их можно искать, фильтровать по различным параметрам; и есть даже возможность задать дополнительные атрибуты для поиска workflow, которые устанавливаются прямо из кода. Так мы, например, можем прямо в интерфейсе админки вывести текущий статус заказа и его тип и искать по ним.
Observability, которую предоставляет Temporal из коробки, этим не ограничивается. И сам сервер Temporal, и SDK, работающий на стороне сервиса, отдают довольно много полезных метрик, а если их не хватает — собственные метрики можно навесить практически на что угодно. SDK предоставляет возможность вставить запись метрик в любой тип взаимодействия между пользовательским кодом и кодом Temporal.

Temporal предоставляет готовые пресеты дашбордов для Grafana, а Grafana Cloud из коробки интегрируется с Temporal Cloud.
Цена использования
Temporal решает проблемы элегантно, но это не вполне бесплатно.
Затраты на инфраструктуру. Temporal — это не просто библиотека, а целая платформа, включающая в себя как минимум один сервер + БД. Компоненты Temporal‑кластера можно масштабировать по отдельности, в зависимости от нагрузки, и даже построить структуру из нескольких кластеров. Temporal очень подробно пишет события, и если их действительно много, то понадобится хорошая производительная отдельная БД. У нас размер истории событий по одному заказу составляет примерно 200–300 Кб, поэтому старые workflow просто вычищаются из базы. Есть ещё Temporal Cloud, на который потратить денег придётся больше, а времени — меньше.
Затраты на обучение. Парадигма Temporal — это не так просто, как кажется. Поначалу вообще не понимаешь, как с этим жить, но потом становится сильно проще. В одной из следующих статей я постараюсь рассказать обо всех подводных камнях, на которые мы налетали, и покажу, как обходить эти сложности.
Дополнительные тайминги. Задержки, которые вносит Temporal, довольно небольшие. Например, от постановки activity до начала её выполнения может пройти до 100 мс, но благодаря удобству управления из workflow можно проще и безопасней выполнять больше работы параллельно — так что общие тайминги у нас уменьшились.
Вместо заключения
Парадигма разработки, которую предлагает Temporal, идеально легла на процессинг заказов Еды. Также Temporal используется и в нашем новом направлении — бронировании столиков в ресторанах.
Вам тоже подойдёт Temporal, если ваши бизнес‑процессы:
Содержат много шагов с ветвлениями, ожидания, таймеры и прочие элементы логики, не укладывающиеся в простые обработчики событий. Temporal позволяет писать распределённый асинхронный код так, как будто он выполняется локально и однопоточно.
Требуют координации работы нескольких сервисов (возможно, на разных технологиях). С помощью Temporal SDK можно бесшовно склеить сервисы практически на всех популярных языках программирования в единый бизнес‑процесс.
Должны надёжно выполняться во что бы то ни стало и не терять состояние в случае ошибок. Механизм истории событий позволяет возобновить прерванный в случае сбоя процесс ровно с того же места, где он остановился, а автоматические повторы выполнения activity позволяют писать надёжно выполняющуюся логику поверх ненадёжной сети.
Вам не подойдёт, если:
Вся логика укладывается в простые операции. Если не нужно проверять и восстанавливать объёмный контекст для обработки запроса, а более долгие операции легко укладываются в обычные очереди, то Temporal будет избыточным.
Задержки порядка десятков миллисекунд критичны для бизнес‑процесса.
Не хочется добавлять ещё одну зависимость и тратить ресурсы на развёртывание и поддержку инфраструктуры Temporal.
В следующих сериях я расскажу, как мы смогли постепенно перенести процессинг из PHP‑монолита в микросервис на Go, а также поделюсь разными полезными приёмами, которые мы нашли в процессе освоения Temporal.
sshmakov
Как насчет вложенных процессов, поддерживаются? Или все задачи должны быть расписаны в одном плоском процессе?
Если я правильно прочитал, то запущенный workflow измениться не может. Так?
А что делать, если его надо изменить?
Перезапуск сервера Temporal приводит к остановке процессов, или они восстанавливаются в той же точке с тем же контекстом?
m03r Автор
Да, есть Child Workflow
Тут довольно тонкий момент. Некоторые изменения — например, изменение продолжительности таймера, или изменение аргументов вызова Activity — не приводят к недетерминизму (вот здесь подробнее). Кроме того, мы вольны менять код activity вообще в любой момент, на них требования детерминизма не распространяются
Тут возможны разные варианты действий в зависимости от конкретной ситуации. Если воркфлоу паникует и не может пройти какой-то шаг, то здесь просто поможет релиз исправленного кода воркфлоу. Если workflow завис в бесконечном ожидании, то после выкатки новой версии кода можно его reset-нуть, и бизнес-логика будет выполняться заново (вместе со всеми activity) с того места, на который reset-нули. В принципе в большинстве случаев reset должен помочь — даже есть воркфлоу пошёл неправильно и уже закончился, мы всё равно сможем его reset-нуть.
По сути своей reset — это terminate исходного воркфлоу и копирование какой-то части истории в новый, который и продолжит выполняться
Всё восстановится в той же самой точке, потому что в истории событий в БД хранится вся необходимая информация, все остальные компоненты, в общем, stateless. Так, например, можно спокойно остановить сервер, обновить его, и запустить заново, единственным эффектом будет задержка выполнения действий на время этой операции