Temporal по языкам: Go, Java, TypeScript, Python, .NET

У Temporal официальные SDK для Go, Java, TypeScript, Python, .NET, PHP, Ruby — и хотя модель одна (детерминированный workflow + activity), способ выражения детерминизма в каждом языке свой: Go приносит собственную корутинную модель, Java — планировщик поверх потоков, TypeScript подменяет таймеры и Date.now в изолированном окружении, Python перехватывает asyncio. Разбираем, как один и тот же процесс выглядит на пяти SDK, что из этого следует для выбора языка под durable-компонент — и какие сюрпризы ждут при упаковке воркера в образ. С живым один-в-один прогоном на пяти языках против одного сервера

Модель Temporal одна на все языки: детерминированный workflow вызывает activity с побочными эффектами. Но «детерминированный workflow» в Go, TypeScript и Java выражается по-разному — потому что по-разному устроены конкурентность и время в самих языках.

Эта финальная статья серии показывает Temporal с языкового угла: как один процесс выглядит на пяти SDK, чем именно отличается достижение детерминизма и что из этого следует при выборе языка. Все пять воркеров прогнаны живьём против одного сервера.

Temporal по языкам

В статье

Один сценарий, пять реализаций

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

У каждого языка на стенде своя task queue. Важно не перепутать причину: это архитектурное решение стенда, а не требование платформы.

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

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

Язык Версия SDK Итог прогона
Go v1.46.0 allocated
Java 1.30.1 allocated
TypeScript 1.22.0 allocated
Python 1.31.0 allocated
.NET 1.18.0 allocated

Версии здесь не украшение: паритет возможностей между SDK неравномерен и меняется от релиза к релизу. Всё, что сказано ниже, проверено на этих версиях.

Как достигается детерминизм в каждом SDK

Требование одно — код процесса должен проигрываться воспроизводимо. Способы его обеспечить принципиально разные.

Go — собственная корутинная модель. SDK приносит свои примитивы поверх горутин: workflow.Sleep, workflow.Selector, каналы workflow, workflow.Go. Обычные горутины, time.Sleep и select в теле процесса использовать нельзя — они не проигрываются.

sel := workflow.NewSelector(ctx)
sel.AddReceive(workflow.GetSignalChannel(ctx, "confirm"),
    func(c workflow.ReceiveChannel, _ bool) { c.Receive(ctx, &approved) })
sel.AddFuture(workflow.NewTimer(timerCtx, 60*time.Second), func(workflow.Future) {})
sel.Select(ctx)

Java — планировщик поверх потоков. Каждый workflow исполняется в настоящих Java-потоках, но порядок их работы задаёт детерминированный планировщик SDK: в каждый момент выполняется один поток workflow. Workflow.sleep и Workflow.await приостанавливают workflow через этот планировщик — поток паркуется и отдаёт управление. Ожидание не занимает CPU, но у закешированного workflow припаркованные потоки остаются, и это цена sticky-кэша в Java.

Workflow.sleep(Duration.ofSeconds(2));
boolean got = Workflow.await(Duration.ofSeconds(60), () -> approved != null);

TypeScript — изолированное окружение. Здесь самое интересное отличие: обычные конструкции не запрещены, а подменены. Date.now() и Math.random() внутри процесса работают — но возвращают детерминированные значения, а таймеры и промисы перехвачены рантаймом.

await sleep('2 seconds');
const got = await condition(() => approved !== undefined, '60 seconds');

Python — перехваченный event loop. asyncio.sleep внутри процесса превращается в таймер Temporal; настоящего сна не происходит.

await asyncio.sleep(2)
await workflow.wait_condition(lambda: self._approved is not None,
                              timeout=timedelta(seconds=60))

.NET — свои конструкции. Workflow.DelayAsync вместо Task.Delay, Workflow.WaitConditionAsync вместо самодельного ожидания, Workflow.UtcNow вместо DateTime.UtcNow.

await Workflow.DelayAsync(TimeSpan.FromSeconds(2));
var got = await Workflow.WaitConditionAsync(() => approved != null,
                                            TimeSpan.FromSeconds(60));

Запрещено или подменено — разница практическая

Go и Java идут путём запрета: запрещённое либо не работает, либо ломает проигрывание. TypeScript и Python — путём подмены: привычный код работает, но делает другое, чем в обычной программе.

У подмены эргономика приятнее — писать можно как обычно. Но и цена своя: нарушение не бросается в глаза при чтении кода. Строка Date.now() в TypeScript-процессе выглядит подозрительно для того, кто читает её глазами, а работает корректно; в Go аналогичная строка выглядит так же подозрительно и корректно не работает. Знать, какой из двух режимов у вашего SDK, стоит до, а не после.

Что это значит для выбора языка

Durable-компонент не обязан быть написан на языке основного сервиса: он общается с ним через обычные вызовы, а не через общий процесс. Отсюда критерии выбора:

  • Зрелость SDK. Go и Java — исторически первые и наиболее полные. Остальные догоняют, и на конкретной версии отдельные возможности могут отсутствовать.
  • Экспертиза команды. Дисциплина детерминизма даётся тем, кто понимает модель, а не тем, кто знает синтаксис.
  • Тестовый инструментарий. Промотка времени и replay-тесты есть во всех пяти, но удобство различается.

Полиглотная реальность нормальна: процесс на одном языке, activity-воркеры на других. Тот же угол зрения, что в статьях про клиенты Kafka по языкам и обработку ошибок по языкам.

Что эта статья не сравнивает

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

Для выбора нужна матрица, которой здесь нет: поддержка версионирования и worker versioning, зрелость Update, тестовый инструментарий и промотка времени, кодеки полезной нагрузки и шифрование, перехватчики, Nexus, настройки ёмкости воркера. Каждый из этих пунктов различается между SDK сильнее, чем синтаксис таймера, и меняется от релиза к релизу — поэтому сверять их стоит по документации под конкретную версию, а не по обзорной статье.

Упаковка: два сюрприза из прогона

Пять воркеров стенда собираются в официальных образах своих языков, и Java-воркер потребовал двух исправлений. Оба относятся к упаковке, а не к Temporal, и оба стоят упоминания — потому что проявляются только в собранном виде.

Первое: толстый jar рвёт gRPC. Собранный maven-assembly-plugin воркер падал так:

$ docker logs lang-java 2>&1 | grep -A2 'Could not find policy'
# (образ воркера собран maven-assembly-plugin — до перехода на shade)
java.lang.IllegalStateException: Could not find policy 'round_robin'.
Make sure its implementation is either registered to LoadBalancerRegistry
or included in META-INF/services/io.grpc.LoadBalancerProvider

Причина в том, что gRPC регистрирует провайдеры балансировки через механизм сервисов Java, а assembly при склейке зависимостей не объединяет файлы META-INF/services — одноимённые файлы затирают друг друга. Лечится переходом на maven-shade-plugin с ServicesResourceTransformer, который эти файлы сливает.

Ловушка неприятна тем, что в среде разработки, где зависимости лежат отдельными jar, всё работает. Проявляется она только после упаковки.

Второе: версия SDK перестаёт читаться. Обычный способ узнать версию библиотеки — манифест её пакета — в толстом jar не работает: классы объединены в один артефакт, и Implementation-Version зависимости теряется. Пришлось класть версию в ресурс, заполняемый при сборке.

Мелочь, но показательная: в логе воркера версия SDK — не украшение, а то, по чему потом восстанавливают, на чём именно был получен результат.

Демо и версии

Стенд: digital-cookbook/architecture/temporal, профиль 07-languages — один сценарий на пяти SDK против одного сервера, каждый воркер собирается в официальном образе своего языка.

Версии прогона: Temporal Server 1.29.7, Go SDK v1.46.0, Java SDK 1.30.1, TypeScript SDK 1.22.0, Python SDK 1.31.0, .NET SDK 1.18.0.

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

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

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

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