Workflow и детерминизм: event history, replay, что можно и нельзя

Магия «код продолжается с той же строки» держится на одном жёстком требовании: workflow-код должен быть строго детерминированным. Разбираем, что это значит на практике — почему нельзя time.Now(), rand(), прямой I/O, обход map в случайном порядке и потоки; как event history и replay восстанавливают состояние; что такое non-determinism error и откуда он берётся; и какой инструментарий workflow (таймеры, селекторы, side effects, детерминированные версии времени и случайности) заменяет запрещённое

Ключевое обещание Temporal — workflow как обычный последовательный код, который переживает падения, — оплачено единственным, но жёстким ограничением: этот код должен быть строго детерминированным. При перезапуске Temporal проигрывает (replay) историю событий, и код обязан пройти по тому же пути, что и в прошлый раз, иначе рантайм обнаружит расхождение и остановит продвижение. Отсюда запреты, которые новичка удивляют: нельзя посмотреть на часы, нельзя бросить кубик, нельзя сходить в базу напрямую.

Эта статья — про то, почему детерминизм необходим, во что обходится проигрывание, как выглядит расхождение в жизни и какой инструментарий Temporal даёт взамен запрещённого.

Workflow и детерминизм

В статье

Зачем нужен детерминизм

Механика восстановления состояния устроена так: рантайм не хранит снимок переменных, он заново проигрывает ваш код по журналу событий. Результаты уже выполненных 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.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии