Ключевое обещание Temporal — workflow как обычный последовательный код, который переживает падения, — оплачено единственным, но жёстким ограничением: этот код должен быть строго детерминированным. При перезапуске Temporal проигрывает (replay) историю событий, и код обязан пройти по тому же пути, что и в прошлый раз, иначе рантайм обнаружит расхождение и остановит продвижение. Отсюда запреты, которые новичка удивляют: нельзя посмотреть на часы, нельзя бросить кубик, нельзя сходить в базу напрямую.
Эта статья — про то, почему детерминизм необходим, во что обходится проигрывание, как выглядит расхождение в жизни и какой инструментарий Temporal даёт взамен запрещённого.
В статье
- Зачем нужен детерминизм
- Что пишется в историю
- Как выглядит расхождение
- Контрпример, который срабатывает через раз, ничего не доказывает
- Сколько стоит проигрывание
- Что запрещено и чем заменяется
- Replay-тест: дешёвая защита
Зачем нужен детерминизм
Механика восстановления состояния устроена так: рантайм не хранит снимок переменных, он заново проигрывает ваш код по журналу событий. Результаты уже выполненных activity берутся из журнала, а не запрашиваются повторно.
Чтобы это работало, проигрывание должно приводить код в ту же точку. Если код при повторе пойдёт другой веткой — рантайм получит от него команды, которых нет в истории, или не получит тех, что там записаны. Состояние разъедется, и дальше двигаться нельзя.
Отсюда требование: тело workflow не имеет права зависеть ни от чего, чего нет в истории. Ни от часов, ни от случайности, ни от переменных окружения, ни от результата прямого запроса в базу.
Что пишется в историю
История — это последовательность событий: старт исполнения, планирование и результат каждой activity, запуск и срабатывание таймеров, поступившие сигналы, завершение. Единица продвижения — workflow task: воркер получает историю, проигрывает код, возвращает список новых команд, и они становятся новыми событиями.
Существенно, что в истории записаны именно команды и их результаты, а не значения ваших локальных переменных. Локальные переменные восстанавливаются как побочный эффект проигрывания — потому и требуется, чтобы проигрывание было воспроизводимым.
Как выглядит расхождение
Так — это настоящая ошибка со стенда серии:
$ go test ./02-determinism/... -run TestBrokenWorkflowFailsReplay -v 2>&1 \
| grep -o '\[TMPRL1100\].*'
[TMPRL1100] lookup failed for scheduledEventID to activityID:
scheduleEventID: 11, activityID: 11
Читается она непривычно, потому что расхождение обнаруживается не как «код пошёл не туда», а как несоответствие внутренних идентификаторов: рантайм ждал в этой точке одно событие, а код запросил другое.
Важная деталь, которая обсуждается в статье про версионированиеготовится, с 23 октября подробно: такая ошибка не завершает исполнение. Воркфлоу остаётся в состоянии Running, а workflow task падает по кругу с растущим номером попытки. Процесс жив и ждёт, пока появится код, совместимый с его историей.
Контрпример, который срабатывает через раз, ничего не доказывает
Первая версия демонстрации на стенде выглядела очевидной: workflow ветвился по time.Now() и rand, и от этого расхождение должно было возникать при проигрывании.
Оно возникало не всегда. Ветка при проигрывании совпадала с записанной чаще, чем расходилась, и первый прогон профиля дал чистое проигрывание сломанного воркфлоу. Тест, который должен был падать, прошёл.
Это стоит проговорить прямо, потому что ошибка типичная: контрпример, воспроизводящийся через раз, доказывает не то, что нужно. Из «иногда ломается» нельзя сделать вывод «ломается вот по этой причине» — с тем же успехом причиной может быть что угодно ещё.
В стенде источником недетерминизма сделано чтение внешнего изменяемого состояния — переменной окружения. Смысл нарушения ровно тот же: workflow читает то, чего нет в его истории. Разница в том, что теперь расхождение воспроизводится в ста случаях из ста, и утверждение статьи опирается на факт, а не на вероятность.
Сколько стоит проигрывание
Проигрывание не бесплатно, и его цена измерима отдельно от всего остального: на стенде реплеер работает по сохранённому файлу истории — без сервера и без сети. Двадцать итераций на точку.
| Событий в истории | Медиана проигрывания | На событие |
|---|---|---|
| 35 | 593 мкс | 16,9 мкс |
| 305 | 3 786 мкс | 12,4 мкс |
| 3 005 | 33 860 мкс | 11,3 мкс |
| 15 005 | 215 806 мкс | 14,4 мкс |
Рост близок к линейному — 11,3–14,4 микросекунды на событие на трёх старших точках. Пятнадцать тысяч событий проигрываются за 0,22 секунды, и это уже заметная величина, если проигрывание случается на каждой задаче.
Из этой прямой следуют сразу три вещи, которые в других статьях серии выглядят несвязанными:
- Зачем sticky-кэш. Число из предыдущей статьи — 2,6 миллисекунды на задачу при истории в четыре сотни событий — лежит на этой же прямой.
- Почему история не может расти бесконечно. Отсюда механизм Continue-As-New, разбираемый в статье про сообщения и дочерние воркфлоу.
- Почему длинные циклы опасны. Воркфлоу, обрабатывающий события в вечном цикле, наращивает историю линейно, а стоимость каждого восстановления растёт вместе с ней.
Что запрещено и чем заменяется
| Нельзя в теле workflow | Чем заменить | Почему |
|---|---|---|
time.Now() |
workflow.Now(ctx) |
время берётся из истории, при проигрывании то же самое |
rand |
workflow.SideEffect |
результат записывается в историю при первом исполнении |
| прямой HTTP или запрос к БД | activity | побочный эффект должен попасть в журнал |
time.Sleep |
workflow.Sleep, workflow.NewTimer |
таймер живёт на сервере и переживает падение воркера |
| обычные горутины и каналы | workflow.Go, каналы workflow |
порядок исполнения должен быть воспроизводим |
| обход map в цикле | упорядоченный обход | порядок обхода map в Go не определён |
| чтение переменных окружения, глобального состояния | параметры workflow, activity | внешнее состояние меняется между проигрываниями |
Общее правило проще перечня: всё, чего нет в истории, читать нельзя. Если значение нужно и оно приходит извне — его надо либо передать параметром, либо получить через activity, либо зафиксировать через SideEffect, чтобы оно попало в журнал.
Отдельно про SideEffect: он не делает недетерминированную операцию детерминированной. Он выполняет её один раз и записывает результат, а при проигрывании возвращает записанное. Это ровно то, что нужно для генерации идентификатора или случайного выбора, и совершенно не годится для того, что должно опрашивать внешний мир регулярно.
Replay-тест: дешёвая защита
Самое полезное следствие всей этой механики — возможность проверить совместимость кода с историей без сервера и без воркера. Реплеер берёт сохранённую историю и прогоняет по ней текущий код.
На стенде это два теста: история исправленного воркфлоу проигрывается чисто, история сломанного — падает с ошибкой выше. Оба выполняются за доли секунды и не требуют ничего, кроме файла.
Такой тест стоит держать в CI с историями реальных долгих исполнений: он ловит ломающие изменения до выката, а не после. Как именно ломающие изменения выглядят и почему они не всегда очевидны — предмет статьи про версионированиеготовится, с 23 октября.
Одна деталь про сам тест: пустая история проигрывается чисто всегда. Проверка, которая молча получила пустой файл, зелёная и бессмысленная — поэтому в стенде тест сначала убеждается, что событий больше нуля.
Демо и версии
Стенд: digital-cookbook/architecture/temporal, профиль 02-determinism — воспроизводимое расхождение, два replay-теста, замер цены проигрывания на историях в 35, 305, 3 005 и 15 005 событий.
Версии прогона: Temporal Server 1.29.7, Go SDK v1.46.0. Набор детерминированных примитивов и проверок зависит от версии SDK.
Документация и первоисточники
- Канон: Temporal — Workflow definition, детерминированные ограничения, Event History.
- Смежное на сайте: Temporal — обзор, durable execution — парадигма, архитектура Temporal, Activities вглубь, версионирование workflowготовится, с 23 октября, Event Sourcing на практике.
Комментарии