Durable execution: почему код должен переживать падения

Прежде чем нырять в Temporal, стоит понять парадигму, которую он реализует: durable execution — выполнение, которое не теряется при падении процесса. Разбираем идею честно: что значит «код продолжается с той же строки после краха», как это даёт replay event history, откуда парадигма выросла (Cadence → Temporal) и чем durable execution отличается от очередей, saga-оркестраторов и BPM. Плюс ландшафт: Temporal, Cadence, Restate, DBOS, AWS Step Functions, Azure Durable Functions — кто на что и где граница применимости

Обзорную статью про Temporal мы уже писали — но Temporal лишь одна из реализаций парадигмы, у которой есть имя: durable execution. Идея радикально простая на словах и нетривиальная на деле — сделать так, чтобы выполнение программы не терялось при падении процесса. Не «сохранили чекпоинт и с него продолжили», а именно «код продолжается будто с той же строки», через дни и перезапуски, как если бы процесс никогда не падал.

Эта статья открывает серию «Temporal: durable execution вглубь» с концептуальной рамки: что такое durable execution, за счёт чего оно работает, откуда выросло и чем отличается от очередей, saga и BPM. Дальше в серии — как именно это устроено и эксплуатируется в Temporal; здесь — почему парадигма вообще существует и где её граница.

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 стенда.

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

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

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

Комментарии