Обзорную статью про Temporal мы уже писали — но Temporal лишь одна из реализаций парадигмы, у которой есть имя: durable execution. Идея радикально простая на словах и нетривиальная на деле — сделать так, чтобы выполнение программы не терялось при падении процесса. Не «сохранили чекпоинт и с него продолжили», а именно «код продолжается будто с той же строки», через дни и перезапуски, как если бы процесс никогда не падал.
Эта статья открывает серию «Temporal: durable execution вглубь» с концептуальной рамки: что такое durable execution, за счёт чего оно работает, откуда выросло и чем отличается от очередей, saga и BPM. Дальше в серии — как именно это устроено и эксплуатируется в Temporal; здесь — почему парадигма вообще существует и где её граница.
В статье
- Проблема: где именно теряется состояние
- Что такое durable execution
- Один процесс тремя способами
- Механизм: детерминизм, журнал, replay
- Откуда выросло
- Чем отличается от соседей
- Где durable execution избыточно
Проблема: где именно теряется состояние
Возьмём процесс, который есть почти везде: провизионинг ресурса. Проверить доступность, зарезервировать, дождаться подтверждения оператора, выделить. Если подтверждения нет — снять бронь.
CheckAvailability → Reserve → пауза → ждём подтверждения
├─ да → Allocate
└─ нет → CancelReservation
Написанный в лоб, этот процесс живёт в памяти одного процесса. Падение между Reserve и Allocate не просто теряет прогресс — оно оставляет систему в состоянии, о котором больше никто не знает: бронь во внешней системе сделана, а знания о ней нет. Это хуже, чем потерять работу целиком: работа частично выполнена, и восстановить это можно только сверкой вручную.
На стенде серии наивная реализация убивается сигналом в момент паузы. Счётчик выполненных Allocate после убийства — ноль. Процесс не дошёл до последнего шага и никогда не дойдёт: единственное место, где хранилось знание о брони, — локальная переменная в мёртвом процессе.
Что такое durable execution
Durable execution — это когда рантайм берёт на себя сохранение хода выполнения, а код при этом остаётся обычным последовательным кодом.
Ключевое слово — «остаётся». Персистентность многошагового процесса можно сделать и руками: завести таблицу состояний, писать в неё после каждого шага, при старте подбирать незавершённые. Разница не в том, что durable execution умеет что-то принципиально невозможное, а в том, кто пишет эту механику.
Один процесс тремя способами
На стенде серии один и тот же провизионинг реализован трижды, и все три ломаются одинаково — убийством процесса в середине.
| Реализация | Что после убийства |
|---|---|
| Наивный воркер: состояние в памяти | Allocate не выполнен ни разу, прогресс потерян |
| Самодельный автомат на таблице Postgres | шаг сохранён (reserved), перезапуск довёл до allocated |
| Temporal | второй воркер продолжил, не переисполнив сделанное |
Средняя строка — не поражение самодельного решения. Оно работает: прогресс пережил падение. Вопрос в цене, и цена видна не в замерах, а в объёме кода.
Чтобы автомат пережил падение, в нём пришлось написать:
- таблицу состояний со схемой перехода;
- запись состояния после каждого шага;
- чтение состояния при старте и подбор незавершённого;
- персистентный таймер: момент возобновления паузы тоже надо хранить в базе, иначе пауза не переживёт перезапуск;
- цикл опроса, который проверяет, не пора ли продолжать.
Каждая новая ветка процесса означает правку схемы и правку цикла. Это и есть накладная, которую durable execution снимает.
В реализации на Temporal ничего из перечисленного нет — там только сам процесс. Проверить это можно построчно: код всех трёх вариантов лежит в стенде digital-cookbook/architecture/temporal, профиль 00-paradigm.
Что именно не повторяется
Главное свойство видно в логе. Первый воркер выполнил CheckAvailability и Reserve и был убит. Второй воркер — новый процесс — подхватил то же исполнение, и в его логе нет ни CheckAvailability, ни Reserve: обе величины равны нулю. Их результаты взяты из журнала на сервере, а не получены заново.
Это важнее, чем «процесс продолжился». Если бы шаги переисполнялись, бронь была бы создана дважды, и durable execution оказался бы source of duplication вместо решения.
Но у этой гарантии есть граница, и её нужно видеть. Replay не повторяет Activity, завершение которой уже записано в историю. Запись появляется, только когда воркер сообщил серверу результат. Между внешним эффектом и этим подтверждением есть окно: Activity создала бронь во внешней системе, а воркер упал раньше, чем передал результат, — в истории завершения нет, и после таймаута сервер запустит Activity снова. Эффект произойдёт дважды. В стенде воркер убит между шагами, после записи результатов, поэтому окно не задето; убей его посреди Reserve — и повтор был бы. Поэтому Activity с внешними эффектами должны быть идемпотентными: ключ идемпотентности на стороне внешней системы (например, идентификатор исполнения плюс имя шага), проверка «уже сделано» перед действием или операция, которую безопасно повторить. Temporal гарантирует, что записанный шаг не переисполнится, а не то, что внешний эффект случится ровно один раз.
Падение сервера, а не только воркера
Демонстрации «убили воркер» недостаточно: она не отвечает на вопрос, где живёт состояние. На стенде гасятся все четыре роли Temporal сразу, а через десять секунд поднимаются обратно. Исполнение продолжается и доходит до конца — потому что история лежит в PostgreSQL, а не в памяти сервера.
Отсюда сразу же честная цена парадигмы: durable execution требует stateful-компонента с надёжным хранилищем. Это не бесплатная магия, а перенос ответственности с вашего кода на чужой, у которого есть своя база, свои требования к throughput и свои процедуры эксплуатации. Разбору этого посвящена следующая статья серии.
Механизм: детерминизм, журнал, replay
Механика в одном абзаце: каждый значимый шаг записывается в журнал событий; при перезапуске рантайм заново проигрывает код по этому журналу, и шаги, завершение которых записано, не выполняются повторно — результат берётся из записи (шаг, прерванный до записи завершения, может выполниться снова — отсюда требование идемпотентности выше). Чтобы проигрывание давало тот же путь, код обязан быть детерминированным: никакого чтения часов, случайности и прямого ввода-вывода в теле процесса.
Это тот же принцип, что в Event Sourcing — восстановление состояния проигрыванием истории, — применённый не к данным, а к выполнению кода.
Проигрывание не бесплатно, и его цена измерима: она растёт линейно с длиной истории. Числа и разбор ограничений — в статье про детерминизм и replay.
Откуда выросло
Парадигма выросла из практики: в Uber построили Cadence — оркестратор для многошаговых процессов, которые не влезали ни в очереди, ни в cron. Основатели Cadence затем сделали форк и развили его в Temporal.
Существенно здесь то, что durable execution оформился в отдельный класс инструментов, а не в очередную библиотеку поверх очереди. За Temporal и Cadence появились Restate, DBOS, а облака предложили свои реализации — AWS Step Functions и Azure Durable Functions.
Подробная таблица ландшафта с осями выбора — код против DSL, self-hosted против managed, привязка к облаку — уже разобрана в обзорной статье; повторять её здесь незачем.
Чем отличается от соседей
Сравнение имеет смысл вести не по списку возможностей, а по одному вопросу: что остаётся написать вам.
Очереди задач. Очередь даёт доставку, ретраи и dead-letter. Она не даёт последовательности: связать «сначала бронь, потом выделение, а если не подтвердили — откат» вы будете сами, и состояние процесса будете хранить сами. Очередь — про одну задачу; durable execution — про процесс из многих.
Saga-оркестратор на таблице состояний. Это ровно то, что написано в среднем варианте выше. Durable execution убирает именно этот слой: таблицу, цикл опроса, персистентный таймер. Сама идея компенсации при этом никуда не девается — она остаётся вашей бизнес-логикой, просто перестаёт требовать собственного движка.
BPM/BPMN-движки вроде Camunda. Процесс как графическая модель против процесса как обычный код. Модель выигрывает, когда процесс рисуют и обсуждают неразработчики; код выигрывает, когда процесс живёт в репозитории, ревьюится и тестируется как остальной код.
Где durable execution избыточно
Граница проходит там же, где и накладная.
- Одношаговые задачи. Отправить письмо, пересчитать превью, инвалидировать кеш — здесь достаточно очереди с ретраями. Durable execution добавит stateful-компонент и не даст ничего взамен.
- Процесс, укладывающийся в одну транзакцию. Если все шаги идут в одной базе и атомарность обеспечивается транзакцией, оркестратор не нужен.
- Latency-критичные пути. Каждый шаг процесса — это запись в журнал и сетевой обмен. Для пути, где важны единицы миллисекунд, это неприемлемо.
И общая цена, которую стоит назвать прямо: ещё один stateful-компонент в эксплуатации, кривая обучения у команды и дисциплина детерминизма, нарушение которой даёт ошибки, проявляющиеся не сразу. Вся серия дальше — про то, как эта цена выглядит вблизи.
Демо и версии
Стенд: digital-cookbook/architecture/temporal, профиль 00-paradigm — три реализации одного процесса, убийство воркера и убийство всего сервера.
Версии прогона: Temporal Server 1.29.7, PostgreSQL 18.4, Elasticsearch 8.19.5, Go SDK v1.46.0. Все числа серии — в FIXTURES.md стенда.
Документация и первоисточники
- Канон: Temporal — What is Temporal, Temporal — understanding Temporal, Uber Engineering — Cadence.
- Ландшафт: Restate, DBOS, AWS Step Functions, Azure Durable Functions.
- Смежное на сайте: Temporal — обзор (ворота), Event Sourcing на практике, saga на практике, очереди задач, распределённый cron, матрица решений по событийным паттернам.
Комментарии