Activities вглубь: таймауты, ретраи, heartbeat, идемпотентность

Весь I/O в Temporal живёт в activity — и именно на activity держится надёжность процесса. Разбираем четыре таймаута (ScheduleToStart, StartToClose, ScheduleToClose, Heartbeat) и что каждый ловит; retry-политики и non-retryable ошибки; heartbeat для долгих задач — и почему он возобновляет работу не с точки остановки, а с более ранней; почему идемпотентность activity целиком на вашей стороне (ретрай вызовет её повторно) и во что обходится её отсутствие; local activities и их цена в событиях истории. Это статья про то, что делает workflow надёжным, а не просто запущенным

Workflow в Temporal детерминирован и потому бесполезен сам по себе — он не может сходить в сеть или базу. Вся реальная работа живёт в activity. И именно на activity держится надёжность: Temporal ретраит их по политике, следит за таймаутами, умеет возобновлять долгие задачи. Но ровно поэтому activity — то место, где приходится думать про идемпотентность: ретрай вызовет вашу функцию повторно, и если она не идемпотентна, повтор задвоит эффект.

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

Activities вглубь

В статье

Почему I/O только в activity

Тело workflow детерминировано: оно проигрывается заново при каждом восстановлении. Если бы оно ходило в сеть напрямую, каждый повтор дёргал бы внешнюю систему заново.

Activity — обычная функция без ограничений детерминизма. Её вызов и результат попадают в историю: при проигрывании результат берётся из журнала, а сама функция не вызывается. Граница проходит здесь: workflow решает, что делать; activity делает.

Четыре таймаута

Таймаут Что ограничивает Что ловит
ScheduleToStart ожидание задачи в очереди до подхвата воркером нехватку ёмкости воркеров
StartToClose одно выполнение activity зависание самой работы
ScheduleToClose всё время с учётом ретраев общий бюджет шага
Heartbeat промежуток между heartbeat долгой activity зависание внутри долгой работы

Temporal требует, чтобы был задан хотя бы один из двух: StartToClose или ScheduleToClose. Без них зависшая работа не была бы обнаружена никогда — ретраи по умолчанию бесконечны, и задача осталась бы в состоянии «выполняется» столько, сколько проживёт воркер.

Из двух допустимых полезнее обычно StartToClose, и разница между ними стоит того, чтобы её понимать. ScheduleToClose ограничивает весь шаг целиком, вместе с ретраями: зависшая попытка съест весь бюджет, и следующей уже не будет. StartToClose ограничивает одну попытку, поэтому зависание обнаруживается быстро и ретрай успевает случиться. Практика: задавать StartToClose всегда, а ScheduleToClose добавлять сверху, когда у шага есть осмысленный общий дедлайн.

Для долгих activity StartToClose перестаёт быть детектором зависания: он заведомо велик, потому что работа действительно длинная. Единственный, кто здесь работает, — HeartbeatTimeout: если activity перестала подавать признаки жизни, её попытка считается неудачной, не дожидаясь общего лимита.

ScheduleToStart — единственный из четырёх, который говорит не о самой работе, а о ёмкости. Как он растёт при недонастроенном воркере, разбирается в статье про эксплуатациюготовится, с 26 октября.

Ретраи и что они делают с побочным эффектом

Retry-политика задаёт начальный интервал, коэффициент роста, максимальный интервал и максимум попыток. Разумный дефолт — экспоненциальный backoff; опасен он только в сочетании с отсутствующими таймаутами.

Отдельно стоит помечать ошибки, которые ретраить бессмысленно: ошибка валидации, отказ по бизнес-правилу, ответ внешнего сервиса вида «такого объекта нет» или «доступ запрещён». Иначе Temporal будет добросовестно долбить заведомо безнадёжный вызов, пока не исчерпает бюджет.

Соблазнительное правило «весь класс 4xx — не ретраить» здесь неверно, и ошибка дорогая. 429 Too Many Requests — прямая просьба повторить позже, и не повторять её означает потерять работу на ровном месте; 408 Request Timeout тоже транзиентен. Их надо ретраить, желательно с уважением к заголовку Retry-After. Неретраибельны из 4xx скорее 400, 403, 404, 422 — то есть те, где повтор того же запроса не может дать другого ответа. Критерий не в номере, а в вопросе: может ли тот же вызов позже завершиться иначе.

Но главный вопрос ретраев — не как их настроить, а что происходит с уже сделанной работой.

Идемпотентность: цена отсутствия

Реальный сбой почти никогда не выглядит как «ничего не произошло». Гораздо чаще так: работа выполнена, а подтверждение до вызывающего не дошло. Сеть оборвалась после записи, процесс умер после коммита, таймаут сработал раньше ответа.

На стенде это воспроизведено буквально: activity пишет строку в таблицу и после записи возвращает ошибку. Две первые попытки падают, третья успешна.

Activity Строк эффекта на один логический платёж
без ключа идемпотентности 3
с ключом идемпотентности 1

Три строки — это не «баг Temporal». Это ровно то, о чём договорились: рантайм обещает выполнить шаг хотя бы раз, а не ровно один раз. Сколько раз выполнится ваш побочный эффект — определяется вашим кодом.

Починка в стенде — уникальный индекс по ключу идемпотентности и вставка с игнорированием конфликта. Существенная деталь: ключ выводится из идентификатора работы, а не генерируется внутри activity. Сгенерированный внутри был бы новым на каждой попытке и не защищал бы ни от чего — типичная ошибка, которая выглядит как реализованная идемпотентность.

Это та же связка, что разобрана в статьях про гарантии доставки и идемпотентность и Outbox: «хотя бы раз» плюс дедупликация по ключу даёт наблюдаемое «ровно раз».

Heartbeat возобновляет не с точки остановки

Долгая activity сообщает о прогрессе через heartbeat; при падении воркера новая попытка читает последний heartbeat и продолжает оттуда, а не с нуля. Так это обычно и описывают — «продолжит с последней точки».

Числа со стенда говорят иначе. Импорт из шестидесяти шагов, воркер убивается посередине:

Величина Значение
Прогресс в таблице к моменту убийства 34 из 60
Точка, с которой продолжила новая попытка 20
Итог 60 из 60

Разрыв в четырнадцать шагов — не ошибка стенда. SDK троттлит отправку heartbeat: слать его на каждый шаг было бы дорого, поэтому наружу уходит не каждое значение. Сервер хранит последнее полученное, а оно старше фактического прогресса.

Отсюда точная формулировка, которую стоит держать вместо привычной: heartbeat даёт возобновление примерно оттуда, а не оттуда. Практический вывод жёсткий — шаг долгой activity обязан быть идемпотентным, иначе часть работы выполнится дважды при каждом возобновлении. В нашем случае четырнадцать шагов повторились; таблица это пережила только потому, что запись в ней идёт с обновлением по ключу.

Local activity и её цена

Local activity выполняется прямо в процессе воркера, минуя полный путь через matching. Цена вызова видна в двух измерениях сразу.

Вид Время на вызов Событий истории на вызов
обычная 84 314–90 755 мкс 6,05
local 428–442 мкс 1,05

Отношение по времени — 192–212×, по событиям истории — 5,76×.

Эти две величины надо читать по-разному, и это важнее самих чисел.

Отношение по событиям истории от машины не зависит, но зависит от устройства workflow. Шесть событий на обычный вызов складываются из двух половин. Три — сама activity: ActivityTaskScheduled, ActivityTaskStarted, ActivityTaskCompleted. Ещё три — workflow task, который будит workflow после её завершения, чтобы код пошёл дальше: WorkflowTaskScheduled, WorkflowTaskStarted, WorkflowTaskCompleted. Local activity выполняется внутри уже идущего workflow task и оставляет в истории одну запись маркера. Величина воспроизвелась до второго знака во всех трёх раундах и на другой машине будет той же — но только для этой схемы: вызовы стенда идут последовательно, и каждая обычная activity порождает свой workflow task. Если запускать несколько activities параллельно, их завершения могут обработаться одним workflow task, и на вызов придётся меньше шести событий; local activity, наоборот, в долгой серии будит workflow task по таймауту. Так что 6,05 против 1,05 — результат для последовательных вызовов, а не константа. Преимущество local activity по объёму истории при этом сохраняется в любой схеме: activity-событий у неё нет вовсе.

Отношение по времени зависит от стенда сильно. В нём сидят сетевые обмены, а на этом хосте каждый из них стоит сотни миллисекунд. На другой машине разрыв будет другим — качественно тот же, количественно нет.

Отсюда и область применения: local activity оправдана для коротких частых вызовов, где накладная полного пути сопоставима с самой работой. Платой становится то, что такая activity не переживает падение воркера как отдельная задача — она часть workflow task, и при потере воркера будет выполнена заново.

Демо и версии

Стенд: digital-cookbook/architecture/temporal, профиль 03-activities — задвоение эффекта при ретрае и его починка ключом, heartbeat с убийством воркера, замер local против обычной activity.

Версии прогона: Temporal Server 1.29.7, PostgreSQL 18.4, Go SDK v1.46.0. Дефолты таймаутов и поведение heartbeat зависят от версии SDK.

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

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

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

Комментарии