Модель Temporal одна на все языки: детерминированный workflow вызывает activity с побочными эффектами. Но «детерминированный workflow» в Go, TypeScript и Java выражается по-разному — потому что по-разному устроены конкурентность и время в самих языках.
Эта финальная статья серии показывает Temporal с языкового угла: как один процесс выглядит на пяти SDK, чем именно отличается достижение детерминизма и что из этого следует при выборе языка. Все пять воркеров прогнаны живьём против одного сервера.
В статье
- Один сценарий, пять реализаций
- Как достигается детерминизм в каждом SDK
- Что это значит для выбора языка
- Упаковка: два сюрприза из прогона
Один сценарий, пять реализаций
Сценарий одинаков во всех пяти: вызвать 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, сообщения, версионирование и эксплуатацию — и закончили тем, что всё это выглядит узнаваемо на любом из пяти языков.
Документация и первоисточники
- Канон: Temporal SDKs, Go SDK, Java SDK, TypeScript SDK, Python SDK, .NET SDK.
- Смежное на сайте: Temporal — обзор, детерминизм workflow, эксплуатация Temporal, клиенты Kafka по языкам, обработка ошибок по языкам.
Обсуждение в Telegram
Присоединиться →