Dynamic Workflows: масштабируемая маршрутизация исполнения для каждого арендатора с нулевыми простоями

Когда мы впервые запустили Workers восемь лет назад, это была платформа, ориентированная напрямую на разработчиков. С годами мы расширили и масштабировали экосистему, так что платформы теперь могут не только строить на Workers напрямую, но и позволять своим клиентам отправлять код к нам через множество мультитенантных приложений. Теперь мы видим на Workers: приложения, где пользователи описывают, что им нужно, а ИИ пишет реализацию; мультитенантные SaaS, где бизнес-логика каждого клиента во время выполнения — это TypeScript, который платформа никогда раньше не видела; агенты, которые пишут и запускают собственные инструменты; CI/CD-продукты, где каждый репозиторий определяет свой собственный конвейер.

В прошлом месяце, когда мы запустили открытое бета-тестирование Dynamic Workers, мы предоставили этим платформам чистый примитив для стороны вычислений: передайте рантайму Workers некоторый код во время выполнения, получите обратно изолированный, изолированный Worker на той же машине за миллисекунды. Грани Durable Object расширили ту же идею на хранение — каждое динамически загружаемое приложение может иметь свою собственную базу данных SQLite, которая создается по запросу, а платформа находится перед ней как супервизор. Артефакты сделали то же самое для системы контроля версий: собственная для Git, версионированная файловая система, которую можно создавать десятками миллионов — по одной на агента, на сессию, на тенанта. Итак, у нас есть динамическое развертывание для хранения и контроля версий. Что дальше?

Сегодня мы объединяем долговечное выполнение и динамическое развертывание с Dynamic Workflows.

Разрыв между долговечным и динамическим выполнением

Cloudflare Workflows — это наш механизм долговечного выполнения. Он превращает функцию run(event, step) в программу, где каждый шаг переживает сбои, может спать часами или днями, может ожидать внешних событий и возобновляется ровно с того места, где остановился, когда изолят перерабатывается. Это правильный примитив для всего, что должно «продолжать работать» после одного запроса: процессы адаптации, конвейеры транскодирования видео, многоэтапное выставление счетов, длительные циклы агентов и — начиная с Workflows V2 — до 50 000 параллельных экземпляров и 300 новых экземпляров в секунду на аккаунт, переработанные для эпохи агентов.

Но у Workflows всегда было одно встроенное допущение: код рабочего процесса является частью вашего развертывания. В вашем wrangler.jsonc есть блок, который говорит: «когда механизм вызывает WORKFLOWS, запусти класс с именем MyWorkflow». Одна привязка, один класс. На развертывание.

Это прекрасно работает, если вы владеете всем кодом. Это нормально, если вы запускаете традиционное приложение.

Но это перестает работать, как только вы хотите позволить своему клиенту отправлять свой рабочий процесс.

Скажем, вы создаете платформу приложений, где ИИ пишет TypeScript для каждого тенанта. Скажем, вы управляете CI/CD-продуктом, где каждый репозиторий имеет свой собственный конвейер. Скажем, вы используете SDK агентов, где каждый агент пишет свой собственный долговечный план. В каждом из этих случаев рабочий процесс различен для каждого тенанта, каждого агента, каждого запроса. Нет единого класса для привязки.

Это та же проблема, которую Dynamic Workers решили для вычислений, а Durable Object Facets — для хранения. Просто мы еще не решили ее для долговечного выполнения.

Dynamic Workflows

@cloudflare/dynamic-workflows — это небольшая библиотека. Примерно 300 строк TypeScript. Она позволяет одному Worker — Worker Loader — направлять каждый вызов create() к коду другого тенанта и, что критически важно, заставляет механизм Workflows отправлять run(event, step) обратно к тому же коду, когда рабочий процесс фактически выполняется — секунды, часы или дни спустя.

Вот вся схема. Worker Loader:

import {
  createDynamicWorkflowEntrypoint,
  DynamicWorkflowBinding,
  wrapWorkflowBinding,
} from '@cloudflare/dynamic-workflows';

// Библиотека ищет этот класс в экспортах cloudflare:workers.
export { DynamicWorkflowBinding };

function loadTenant(env, tenantId) {
  return env.LOADER.get(tenantId, async () => ({
    compatibilityDate: '2026-01-01',
    mainModule: 'index.js',
    modules: { 'index.js': await fetchTenantCode(tenantId) },
    // Тенант видит это как обычную привязку Workflow.
    env: { WORKFLOWS: wrapWorkflowBinding({ tenantId }) },
  }));
}

// Зарегистрируйте это как class_name в wrangler.jsonc.
export const DynamicWorkflow = createDynamicWorkflowEntrypoint<Env>(
  async ({ env, metadata }) => {
    const stub = loadTenant(env, metadata.tenantId);
    return stub.getEntrypoint('TenantWorkflow');
  }
);

export default {
  fetch(request, env) {
    const tenantId = request.headers.get('x-tenant-id');
    return loadTenant(env, tenantId).getEntrypoint().fetch(request);
  },
};

Добавьте в ваш wrangler.jsonc:

"workflows": [
		{
			"name": "dynamic-workflow",
			"binding": "WORKFLOW",
			"class_name": "DynamicWorkflow"
		}
	]

Тенант пишет обычный, идиоматичный код Workflows. Он понятия не имеет, что его диспетчеризируют:

import { WorkflowEntrypoint } from 'cloudflare:workers';

export class TenantWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    return step.do('greet', async () => `Привет, ${event.payload.name}!`);
  }
}

export default {
  async fetch(request, env) {
    const instance = await env.WORKFLOWS.create({ params: await request.json() });
    return Response.json({ id: await instance.id });
  },
};

Вот и всё. Тенант вызывает env.WORKFLOWS.create(...) с тем, что выглядит как совершенно обычная привязка Workflow. Идентификаторы рабочих процессов, .status(), .pause(), повторные попытки, гибернация, долговечные шаги, step.sleep('24 hours'), step.waitForEvent() — всё работает как обычно.

Библиотека делает только одно: гарантирует, что когда механизм Workflows в конечном итоге проснется и вызовет run(event, step), он окажется внутри кода правильного тенанта.

Как это работает

Три слоя: механизм Workflows (платформа) сверху, ваш Worker Loader посередине, код вашего тенанта (Dynamic Worker) снизу. 

Introducing Dynamic Workflows: durable execution that follows the tenant

Когда запрос достигает Worker Loader, он направляет выполнение к правильному динамическому коду на лету. Остальная часть выполнения — это передача между этими тремя слоями слева направо во времени: запрос поступает, поднимается к механизму, сохраняется, а затем снова опускается.

Проход по потоку:

① → ② Вход в код тенанта. Worker Loader получает HTTP-запрос, определяет, какому тенанту он предназначен, загружает код этого тенанта через Worker Loader и пересылает запрос его default.fetch. env, который он передает тенанту, содержит WORKFLOWS: wrapWorkflowBinding({ tenantId }). Насколько это касается тенанта, это выглядит и работает как настоящая привязка Workflow.

③ Вверх к Worker Loader. Когда тенант вызывает env.WORKFLOWS.create({ params }), на самом деле это удаленный вызов процедуры (RPC) в Worker Loader — обернутая привязка является подклассом WorkerEntrypoint (DynamicWorkflowBinding), который специализирован рантаймом с метаданными тенанта во время загрузки. Поэтому вы должны export { DynamicWorkflowBinding } из вашего Worker Loader: рантайм строит заглушки для каждого тенанта, находя класс в экспортах cloudflare:workers. Привязки, пересекающие границу Dynamic Worker, должны быть заглушками RPC — простой объект { create, get } не может быть структурированно клонирован, а сырая привязка Workflow также не сериализуема.

Внутри Worker Loader обернутая привязка прозрачно перезаписывает полезную нагрузку:

тенант вызывает:  create({ params: { name: 'Alice' } })
                            │
                            ▼
механизм видит:   create({ params: {
                  __workerLoaderMetadata: { tenantId: 't-42' },
                  params: { name: 'Alice' }
               }})

④ Вверх к механизму. Затем Worker Loader вызывает .create() на настоящей привязке WORKFLOWS с конвертом в качестве параметров. С этого момента механизм Workflows берет управление. Он сохраняет event.payload — который теперь включает конверт — и планирует выполнение. Каждый раз, когда механизм позднее пробуждает рабочий процесс (будь то после 24-часового сна, сбоя или развертывания), метаданные передаются вместе с полезной нагрузкой, ожидая маршрутизации выполнения.

Одно важное замечание: относитесь к метаданным как к подсказке маршрутизации, а не как к авторизации. Тенант может прочитать их через instance.status(). Не помещайте туда секреты.

⑤ → ⑥ Механизм возвращается вниз. Когда механизм готов выполнить шаг, он вызывает .run(event, step) на классе, который вы зарегистрировали в wrangler.jsonc — том, который вы получили от createDynamicWorkflowEntrypoint. Этот класс распаковывает конверт, передает метаданные обратному вызову loadRunner, который вы написали, и пересылает распакованное событие в то, что возвращает этот обратный вызов.

Обратный вызов — это место, где происходит всё самое интересное, и он полностью ваш. Получите последний исходный код арендатора из R2. Проверьте его тарифный план и выберите регион. Подключите tail Worker для ведения журнала каждого арендатора. Соберите TypeScript на лету с помощью @cloudflare/worker-bundler. В обычном случае вы просто передаёте управление Worker Loader:

const stub = env.LOADER.get(tenantId, () => loadTenantCode(tenantId));
return stub.getEntrypoint('TenantWorkflow');

Worker Loader кеширует по идентификатору, поэтому рабочий процесс, выполняющий много шагов в течение многих часов, повторно использует один и тот же динамический Worker на всех шагах. Когда изолят в конечном итоге вытесняется, следующий step.do() снова загружает код и продолжает работу — рабочий процесс арендатора не замечает, что что-то произошло. Динамический Worker запускается за однозначные миллисекунды, используя несколько мегабайт памяти, поэтому накладные расходы на диспетчеризацию практически равны нулю. У вас может быть миллион арендаторов, каждый со своим уникальным кодом рабочего процесса, каждый лениво запускается на границе шага, где он нужен, и ни один из них ничего не стоит в простое.

Запасной выход

Если вы хотите сами создать подкласс WorkflowEntrypoint — добавить логирование вокруг run(), настроить наблюдаемость для каждого арендатора или передать пользовательское состояние — библиотека предоставляет низкоуровневый примитив dispatchWorkflow, на котором построен createDynamicWorkflowEntrypoint:

import { dispatchWorkflow } from '@cloudflare/dynamic-workflows';

export class MyDynamicWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    return dispatchWorkflow(
      { env: this.env, ctx: this.ctx },
      event,
      step,
      ({ metadata, env }) => loadRunnerForTenant(env, metadata),
    );
  }
}

Всё остальное — идентификаторы, пауза/возобновление, sendEvent, повторные попытки — передаётся реальному движку Workflows без изменений.

Dynamic Workers — это примитив

Отойдите на секунду от конкретики. Каждая интересная строка этой библиотеки — это либо обёртка вокруг .create() на исходящей стороне, либо обёртка вокруг WorkflowEntrypoint на входящей стороне. Вся реальная работа — запуск кода арендатора, его изоляция, маршрутизация RPC через границу, кеширование изолята, гибернация между шагами — выполняется Dynamic Workers под капотом.

Вот в чём настоящая история, и она гораздо масштабнее, чем Workflows

Dynamic Workers — это примитив, который поглощает всё. Durable Object Facets — это тот же шаблон, применённый к Durable Objects. Dynamic Workflows — это тот же шаблон, применённый к WorkflowEntrypoint. Каждый из них представляет собой небольшой слой клея-обёртки между статической привязкой, которая у вас всегда была, и динамической версией, которую вы теперь можете предоставить своим клиентам.

И мы не останавливаемся на Workflows. Каждая привязка, которую в настоящее время предоставляет Workers, обзаводится динамическим аналогом — очереди, где каждый производитель поставляет свой собственный обработчик, кеши, базы данных, хранилища объектов, привязки AI и серверы MCP, где каждый арендатор приносит свои собственные инструменты. Всё, что вы сегодня привязываете к Worker, вы скоро сможете привязывать динамически: распределяя по арендаторам, агентам, запросам с нулевой стоимостью простоя.

Экономика единицы такого платформенного решения, честно говоря, абсурдна. Запуск мультитенантного продукта раньше означал предоставление каждому клиенту собственного контейнера, собственной базы данных, собственного диска, собственного планировщика и сборку всего этого с помощью оркестрационного клея, сервисных сеток и выматывающей математики биллинга. Многие из этих приложений должны поддерживать как минимум тысячи клиентов; максимум — миллионы. На Dynamic Workers и всём, что строится поверх них, неактивные арендаторы стоят примерно ноль, а активные арендаторы используют одно и то же оборудование через мультитенантность на уровне изолятов. Нижняя планка падает на несколько порядков. Платформа, которая раньше была рассчитана на тысячи платящих клиентов, теперь может разумно обслуживать десятки миллионов.

Что это открывает

Агентные платформы, которые планируют как инженеры

Кодинг-агенты — OpenCode, Claude Code, Codex, Pi — в последний год доказывают, что LLM гораздо лучше справляются с написанием кода, чем с последовательными вызовами инструментов. Cloudflare Agents SDK и Project Think расширяют это понимание на долговременное выполнение: с помощью примитивов вроде файберов и субагентов долгосрочный план агента может пережить сбои, гибернацию и повторные развёртывания без ведома пользователя.

Dynamic Workflows — это компонент, который позволяет этому плану быть полноценным Cloudflare Workflow — то, что агент буквально пишет, а платформа буквально выполняет, с полным механизмом долговременного выполнения за кулисами. Функция run(event, step), которую модель написала минуту назад, где каждый step.do(...) независимо повторяем, каждый step.sleep('24 hours') засыпает бесплатно, и каждый step.waitForEvent(...) ожидает бесконечно, пока человек одобрит следующее действие. Агент пишет рабочий процесс; платформа выполняет его; ни один из них не должен знать заранее, как выглядит план.

SDK и фреймворки, где пользователь предоставляет логику

Если вы выпускаете фреймворк, в котором ваш клиент пишет функцию run(event, step) — UI-конструктор рабочих процессов, инструмент визуальной автоматизации, система расширений для каждого арендатора, low-code инструмент для неразработчиков — Dynamic Workflows теперь является примитивом, который заставляет это работать без компромиссов. Вы вызываете wrapWorkflowBinding({ tenantId }) один раз, передаёте результат их коду как WORKFLOWS, и каждый созданный ими экземпляр рабочего процесса автоматически маркируется, направляется обратно и выполняется в их песочнице. Фреймворк владеет Worker Loader; пользователь владеет рабочим процессом; никому нет дела до другого.

CI/CD на скорости примитива

Вот вариант использования, который нас больше всего вдохновляет.

Любая существующая CI/CD-платформа, по сути, является диспетчером файлов конфигурации для каждого репозитория: "выполнить эти шаги, в таком порядке, с этими секретами, кешировать эти директории, загрузить эти артефакты." У каждого репозитория свой конвейер. У каждой ветки может быть свой вариант. Каждый pull request порождает экземпляр этого конвейера, который должен выполниться до конца, пережить сбой машины, повторить ненадёжный шаг, передать логи, приостановиться для утверждений и сохранить результаты.

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

Вот как выглядит CI-конвейер, когда это просто код, который ваш клиент поставляет вместе со своим репозиторием — скажем, в .cloudflare/ci.ts. Сам рабочий процесс реален; хелперы runInSandbox() / summarise() / привязка GitHub ниже — это предоставляемый платформой клей, то, что вы поставляете один раз в своём диспетчере:

import { WorkflowEntrypoint } from 'cloudflare:workers';

export class CIPipeline extends WorkflowEntrypoint {
  async run(event, step) {
    const { repo, sha, branch, pr } = event.payload;

    // Fork an isolated copy of the repo at this commit. Seconds, not minutes.
    const workspace = await step.do('checkout', () =>
      this.env.ARTIFACTS.fork(repo, { sha })
    );

    await step.do('install', () => runInSandbox(workspace, ['pnpm', 'install']));

    // Each parallel step is independently retryable.
    const [lint, test, build] = await Promise.all([
      step.do('lint',  () => runInSandbox(workspace, ['pnpm', 'lint'])),
      step.do('test',  () => runInSandbox(workspace, ['pnpm', 'test'])),
      step.do('build', () => runInSandbox(workspace, ['pnpm', 'build'])),
    ]);

    if (pr) {
      await step.do('comment', () =>
        this.env.GITHUB.commentOnPR(repo, pr, summarise({ lint, test, build }))
      );
    }

    // Workflow hibernates until approval arrives. No VM held open.
    if (branch === 'main') {
      await step.waitForEvent('approval', { type: 'deploy-approval', timeout: '24 hours' });
      await step.do('deploy', () => runInSandbox(workspace, ['pnpm', 'deploy']));
    }
  }
}

Платформа владеет диспетчером. Она принимает вебхук, определяет, из какого репозитория он пришёл, загружает класс CIPipeline этого репозитория как Dynamic Worker и передаёт запуск в Dynamic Workflows. Платформа не знает, что находится в конвейере. Ей и не нужно. Она выполняет долговременную функцию, которая живёт в репозитории клиента.

А теперь выстроим, что на самом деле делает каждый шаг:

Артефакты предоставляют каждому репозиторию Git-нативную версионированную файловую систему, размещенную на глобально распределенной сети Cloudflare. ArtifactFS "гидратирует" дерево лениво, так что даже многогигабайтный репозиторий готов к работе за несколько секунд — а fork() дает каждому запуску CI его собственную изолированную копию без налога git clone.

  • Dynamic Workers запускают каждый легковесный шаг (линтер, форматирование, проверка типов, сборка) в изолированной песочнице, которая запускается за миллисекунды, на той же машине, где находятся данные репозитория. Никакого выделения ВМ, загрузки образов или холодного старта.

  • Dynamic Workflows объединяет весь запуск. Шаги повторяемы и устойчивы. Запуск бесплатно переходит в спячку при ожидании утверждений. Состояние и прогресс сохраняются при развертывании, вытеснении и сбоях.

  • Sandboxes обрабатывают тяжелые случаи — шаг, которому нужен docker build, набор интеграционных тестов, требующих запуска Postgres, компиляция Rust, которая требует 8 ядер. Снимки в R2 означают, что даже они запускаются с теплого старта за пару секунд.

  • Традиционный запуск CI для среднего JS-репозитория выглядит примерно так: выделить ВМ (15-30 с) → загрузить базовый образ (10 с) → git clone (10 с) → npm ci (30-60 с) → запустить тесты (собственно работа) → завершить. Несколько минут процедуры до первого запуска теста, и вы платите за всю ВМ все это время.

    Тот же конвейер на этом стеке выглядит так: edge-форк репозитория (секунды) → каждый шаг запускает свежий изолят или песочницу, восстановленную из снимка, за миллисекунды → выполняет реальную работу → уходит в спячку. Ничто не должно стартовать с холода. Ничто не должно быть подготовлено заранее. Ничто не должно поддерживаться в горячем состоянии. Репозиторий не перемещается — вычисления приходят к нему.

    CI никогда не был таким быстрым, и причина в том, что ни один из этих примитивов не существовал вместе в одном месте. Теперь они существуют.

    Попробуйте

    @cloudflare/dynamic-workflows распространяется по лицензии MIT и доступен на npm сегодня:

    npm install @cloudflare/dynamic-workflows

    Он работает поверх Dynamic Workers, который находится в открытой бете на платном тарифе Workers. Репозиторий включает рабочий пример — интерактивную игровую площадку в браузере, где вы пишете класс TenantWorkflow, нажимаете Run и наблюдаете за выполнением шагов с логами в реальном времени и пошаговым списком, который загорается по мере фиксации каждого step.do(). Клонируйте его, разверните, покажите коллеге.

    Если вы создаете платформу, SDK, фреймворк или продукт CI/CD и хотите дать своим клиентам собственные рабочие процессы без запуска их кода в вашем процессе: это примитив, который мы создали для вас. Если вы создаете агентов, которые пишут устойчивые планы, это примитив, который делает эти планы настоящими рабочими процессами (Workflows). Если вы просто наблюдаете за всем этим, и вам кажется интересным строить что-то поверх: мы будем рады увидеть, что вы создадите.