Версионирование workflow: patching, worker versioning, безопасные изменения

Самая недооценённая дисциплина Temporal: нельзя просто поменять код workflow, у которого есть запущенные экземпляры — при replay новой логики на старой истории рантайм упрётся в non-determinism error. Разбираем, почему так, где именно проходит граница ломающего изменения (она не там, где кажется), как чинит patching/GetVersion и почему преждевременное снятие патча ломает всё заново. Плюс worker versioning с build IDs и то, как встроить версионирование в деплой до первого инцидента, а не после

Из детерминизма вытекает следствие, которое обычно осознают уже в проде: код workflow нельзя просто взять и поменять, если у него есть запущенные экземпляры. Долгий процесс мог стартовать неделю назад на старой версии; когда воркер перезапустится и начнёт проигрывать его историю новой логикой, пути разойдутся.

Эта статья — про то, как менять workflow безопасно. И начинается она с того, что граница ломающего изменения проходит не там, где кажется: первая версия стенда к этой статье не смогла воспроизвести ни одного расхождения, хотя код менялся по-настоящему.

Версионирование workflow

В статье

Почему нельзя просто поменять код

Запущенный процесс хранит историю, записанную старой логикой. Проигрывание новым кодом должно выдать ту же последовательность команд, что уже записана. Если код выдаёт другую — рантайм останавливает продвижение.

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

Где на самом деле проходит граница

Интуиция подсказывает: «изменил код workflow — сломал запущенные экземпляры». Это неверно, и проверяется это легко.

В первой версии стенда новый шаг добавлялся после длинной паузы, во время которой подменялся воркер. Все три сценария завершились штатно, без единой ошибки. Изменение было настоящим — добавлялся реальный вызов activity, — а расхождения не возникло.

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

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

Отсюда практический вывод: безопасность изменения определяется не самим изменением, а положением каждого живого экземпляра относительно него.

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

Из этого следует практическая классификация:

Изменение Ломает запущенные
добавлен, убран или переставлен вызов activity в участке, который экземпляр уже прошёл да
изменён порядок таймеров или запусков дочерних процессов в пройденном участке да
то же изменение, но экземпляр до него ещё не дошёл нет — новый код исполнится там впервые
изменена логика внутри activity (не workflow) нет
добавлен новый обработчик сигнала нет

Третья строка — та, из-за которой выкатка выглядит успешной. Опасность не в том, что «сломается позже»: этот экземпляр не сломается вовсе. Опасность в том, что одно и то же изменение по-разному действует на экземпляры, находящиеся в разных точках, и зелёный результат на молодых процессах ничего не говорит про долгие, ушедшие дальше.

Отсюда правило проверки: смотреть не на то, «прошла ли выкатка», а на то, есть ли открытые исполнения, уже прошедшие изменённый участок. Это запрос к visibility, а не наблюдение за дашбордом.

Что происходит при расхождении

Три сценария со стенда, во всех подменяется воркер во время паузы:

Сценарий Статус процесса Попытка задачи Упоминаний расхождения в логе
правка без патча Running 4 8
та же правка под GetVersion Completed 0 0
патч снят слишком рано Running 5 10

Первое, что здесь стоит заметить, — колонка статуса. Расхождение не завершает процесс: он остаётся в состоянии Running, а workflow task падает по кругу с растущим номером попытки.

Это принципиально отличается от «упало и умерло». Процесс жив, состояние цело, и он будет ждать сколько угодно долго, пока не появится код, совместимый с его историей. То есть починка возможна выкаткой — данные при этом не теряются.

Обратная сторона: без мониторинга это состояние легко не заметить. Процесс формально работает, метрики старта в порядке, а прогресса нет. Что за этим следить, разбирается в статье про эксплуатациюготовится, с 26 октября.

Починка: GetVersion

GetVersion ставит в код именованный маркер и решает, какой веткой идти, по истории в этой самой точке:

  • если исполнение проигрывает историю и маркера в ней нет — значит, этот участок записан кодом без патча, и возвращается DefaultVersion, то есть старая ветка;
  • если точка достигнута впервые, вживую, — возвращается новая версия, и в историю записывается маркер.
if workflow.GetVersion(ctx, "extra-check-v2",
        workflow.DefaultVersion, 1) != workflow.DefaultVersion {
    // новая ветка — для тех, кто дошёл до этой точки впервые уже с патчем
    if err := workflow.ExecuteActivity(ctx, acts.CheckAvailability, resource).Get(ctx, &extra); err != nil {
        return "", err
    }
}

Момент времени, когда экземпляр стартовал, здесь ни при чём. Долгий процесс, запущенный до патча, но не успевший дойти до этой точки, получит новую версию и пойдёт новой веткой — он ничем не отличается от свежего. Старую ветку получит только тот, у кого этот участок в истории уже записан.

На стенде тот же сценарий, что ломался без патча, под GetVersion доходит до конца: Completed, ноль упоминаний расхождения.

Существенно, что маркер сам по себе — событие в истории. Добавление GetVersion к запущенному процессу безопасно именно потому, что рантайм умеет вставлять этот маркер в проигрывание, не считая его расхождением.

Второй контрпример: патч сняли слишком рано

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

На стенде это третий сценарий: Running, попытка 5, десять упоминаний расхождения. Код при этом выглядит безупречно — одна ветка, никаких маркеров, ничего лишнего. Именно так и должен выглядеть корректно снятый патч.

Почему это ломает не всех

Тонкость, из-за которой сценарий пришлось строить из трёх версий подряд, а не из двух.

Экземпляр, прошедший точку GetVersion уже с патчем, записал в историю новую версию и пошёл новой веткой. Для него удаление кода без изменения поведения безобидно: код делает ровно то же самое, что записано.

Ломается тот, чья история содержит старую ветку. Код после снятия патча знает только новую — и расходится с записанным. Поэтому цепочка на стенде выглядит как v1 → v3 → v4: экземпляр проходит участок на версии без патча, затем встречает версию с патчем, и только после этого — версию, где патча уже нет.

Что здесь на самом деле проверяется

Правило «снимать патч можно, когда старых не осталось» выглядит как проверка. Но и правило, и код опираются на одно и то же допущение — что старых больше нет. Если допущение неверно, ошибутся оба: и разработчик, решивший, что пора, и код, написанный исходя из этого решения.

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

Worker versioning

Альтернативный подход: воркеры помечаются build ID, и Temporal маршрутизирует задачи исполнения к совместимой версии воркера. Ветвления внутри кода не требуется — версия живёт на уровне пула воркеров.

Компромисс простой:

  • patching — тонкий контроль в коде, но код обрастает ветками, и их надо снимать (см. выше, чем это чревато);
  • worker versioning — код чище, но нужно управлять пулами воркеров, их жизненным циклом и совместимостью версий.

Worker versioning заметно менялся между версиями сервера и SDK, поэтому здесь особенно важно смотреть документацию под свою версию, а не общие статьи.

Как встроить в деплой

Дисциплина та же, что в zero-downtime деплое и миграциях — expand-contract, но в четыре шага, а не в три:

  1. Expand. Ввести патч: GetVersion(ctx, id, workflow.DefaultVersion, 1), обе ветки в коде. Выкатить.

  2. Ждать закрытия. Пока не завершатся все открытые исполнения, записанные до патча. Это снимает риск для живых процессов — но ещё не разрешает трогать код.

  3. Ждать retention и отказаться от старой ветки. Пока истории этих исполнений не выйдут за срок хранения. Только после этого старую ветку удаляют, а вызов сужают:

    // допустимо ТОЛЬКО когда историй без маркера больше не существует
    workflow.GetVersion(ctx, "extra-check-v2", 1, 1)
    

    Суженный вызов — уже не ветвление, а защитная проверка. Если история без маркера всё-таки встретится — при reset, в забытом replay-тесте, — код упадёт с явной ошибкой «эта версия больше не поддерживается», а не с безликим расхождением команд.

  4. Убрать вызов целиком — когда защитная проверка больше не нужна. Условие то же, что на шаге 3: историй без маркера не осталось. Истории с маркером удалению не мешают: SDK пропускает маркер версии, которому в коде нет вызова, если остальная последовательность команд совпадает. В стенде это контрольный сценарий: экземпляр, прошедший точку на v3 с маркером, после подмены на v4 без вызова доходит до конца без единого расхождения. В самом Go SDK этот случай закрыт тестом TestReplayWorkflowHistory_GetVersionRemoved.

Два ожидания на шагах 2 и 3 — разные, и их постоянно сливают в одно. Это и есть самая дорогая ошибка в теме.

Обратная ошибка тоже встречается: ждать, пока за retention выйдут истории с маркером. Это ожидание не кончается — каждое новое исполнение проходит через GetVersion и записывает маркер заново, и срок отодвигается вместе с ним.

Почему закрытия исполнений недостаточно. Форма GetVersion(ctx, id, 1, 1) объявляет: «версии по умолчанию для этого изменения больше не существует». История, записанная до патча, маркера не содержит, то есть соответствует именно DefaultVersion — и такой код её отвергнет. Живым процессам это уже безразлично, они закрыты. Но закрытая история никуда не делась: её проигрывают replay-тесты в CI, её проигрывает сервер при сбросе исполнения (reset), к ней обращаются при разборе инцидентов. Пока такая история лежит в пределах retention, код обязан уметь её проиграть.

Отсюда практическое следствие: срок ожидания на шаге 3 задаётся настройкой retention вашего namespace, а не ощущением «прошло достаточно». Если retention — тридцать суток, значит от завершения последнего pre-patch исполнения до сужения вызова должно пройти тридцать суток.

И отдельное ограничение, о котором легко забыть: идентификатор изменения нельзя переиспользовать. Даже после полного удаления вызова тот же changeID для нового патча брать нельзя — в старых историях он может означать совсем другое ветвление.

Между шагами стоит держать replay-тест против исторических данных в CI. Стоит он доли секунды и не требует сервера.

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

Демо и версии

Стенд: digital-cookbook/architecture/temporal, профиль 05-versioning — четыре версии одного процесса, три сценария подмены воркера, включая цепочку из трёх версий для снятого патча, и контрольный сценарий v3 → v4 для истории с маркером.

Версии прогона: Temporal Server 1.29.7, Go SDK v1.46.0. Worker versioning менял API и статус между версиями — проверяйте под свою.

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

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

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

Комментарии