Skip to content
7 changes: 7 additions & 0 deletions .changeset/batched-event-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
'@workflow/core': patch
---

Batched event writes: add the optional `events.createBatch` World API (ordered events, one durable write, per-event outcomes), implement it in `@workflow/world-vercel` against `POST /v4/runs/:runId/events/batch` (slot-identity runs only), and fold clean suspension fan-outs — eager `step_created` and `wait_created` writes — into batched writes in the runtime. On by default; disable with `WORKFLOW_BATCH_TRANSITIONS=0`.
71 changes: 71 additions & 0 deletions docs/content/docs/v5/changelog/batched-event-writes.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
title: Batched event writes
description: An optional World API (events.createBatch) that appends an ordered set of events in one durable write with per-event outcomes, and a suspension fan-out fold that uses it.
---

# Batched event writes (`events.createBatch`)

## Motivation

A workflow suspension that schedules several steps and waits previously wrote one event per entity — one `world.events.create` call per `step_created` and `wait_created`. Against a remote World each write is its own network round trip and its own crash boundary. Batching folds a suspension's schedule into **one durable write** with per-event outcomes, cutting request count and making the whole fan-out land atomically per attempt.

## The World spec addition

`Storage['events']` gains one **optional** method:

```ts
import type {
BatchEventRequest,
CreateEventBatchParams,
EventBatchResult,
} from '@workflow/world';

interface BatchCapableEvents {
createBatch?(
runId: string,
events: BatchEventRequest[],
params?: CreateEventBatchParams
): Promise<EventBatchResult>;
}
```

The supporting types, excerpted (canonical definitions live in `@workflow/world`):

{/* @skip-typecheck illustrative excerpts of the canonical @workflow/world types */}
```ts
interface BatchEventRequest {
/** The event — the same discriminated union the single `create` takes. */
event: CreateEventRequest;
/** Client event time; under slot identity, the source of the durable createdAt. */
occurredAt?: Date;
}

type BatchEventItemResult =
| { status: 200; event: Event; run?: WorkflowRun; step?: Step; wait?: Wait }
| { status: number; error: string; message: string };

interface EventBatchResult {
/** One entry per submitted event, in request order. */
results: BatchEventItemResult[];
}
```

The contract:

- **Ordered**: events land in the run's log in request order at consecutive slots. A concurrent writer may push the whole batch to slots above the caller's view; no skipped-event report accompanies the batch result, so a position-tracking caller compares committed slots against its expectation and reloads to observe what interleaved (its local view stays a strict prefix of the log — never a hole).
- **Per-event outcomes**: the batch is processed as a whole, and each event reports what its own single `create` would have returned — `200` plus the materialized entity, or the single-path status/code (`409`/`conflict` for an event an earlier delivery already applied). Callers reuse their single-path conflict handling per event.
- **Idempotent on retry — for entity-conditioned shapes**: creates, terminal transitions, and the born-running pair are each guarded by their own entity condition, so retrying a batch of them that (partially) committed converges to per-event `409`s with nothing written twice. A standalone bare `step_started` or a `step_retrying` re-patches its step instead of converging, so `world-vercel` only auto-retries batches whose every event is retry-convergent (everything the runtime folds today is), and rejects `hook_received` in a batch outright.
- **Method presence is the capability declaration.** A World that doesn't implement it keeps the single-event path; a World that implements it must make each attempt atomic (a lost race leaves nothing behind). `world-vercel` implements it against `POST /v4/runs/:runId/events/batch` (slot-identity runs only, i.e. specVersion ≥ 6). `world-local` and `world-postgres` deliberately do not — batching buys nothing for a local write.
- **Not batchable** (Worlds reject the request): `run_created`, `run_started`, `run_cancelled`, `hook_created`, `hook_disposed`, `attr_set`, and multiple events targeting one entity — except `step_created` followed by `step_started` for the same step, which creates the step born-running.

## The runtime integration (suspension fan-out fold)

**On by default.** The suspension handler folds a **clean fan-out** — the suspension's eager `step_created` and `wait_created` writes — into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes, no hook writes, and no resilient step dispatch; everything else keeps the single-event path byte-for-byte. Lazy-inline steps keep deferring their `step_created` to the lazy start exactly as before.

Per-event `409`s are tolerated the same way the single path tolerates `EntityConflictError` (a concurrent delivery already created the entity); any other per-event failure fails the suspension write the way a single-path rejection would.

**Escape hatch:** set `WORKFLOW_BATCH_TRANSITIONS=0` (or `false`) to disable batching and restore the exact prior one-write-per-event path — see [`WORKFLOW_BATCH_TRANSITIONS`](/docs/configuration/worlds#workflow_batch_transitions).

## Follow-up

The deferred sequential transition — holding `step_completed(N)` across the replay turn and committing `[step_completed(N), step_created(N+1), step_started(N+1)]` as one batch at the next lazy start — builds on this contract and ships separately.
3 changes: 2 additions & 1 deletion docs/content/docs/v5/changelog/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"resilient-start",
"lazy-event-creation",
"turbo-mode",
"step-message-ownership"
"step-message-ownership",
"batched-event-writes"
],
"defaultOpen": false
}
8 changes: 8 additions & 0 deletions docs/content/docs/v5/configuration/worlds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,14 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an
- Default: `1000`
- Maximum stream chunks written in one Vercel World request. Larger batches are split.

### `WORKFLOW_BATCH_TRANSITIONS`

- Surface: environment variable
- Default: on
- Set to `0` (or `false`) to **disable** batched event writes — the escape hatch that restores the exact prior one-write-per-event path.

When enabled (the default), a suspension's eager `step_created` and `wait_created` writes fold into batched `events.createBatch` calls (one durable write with per-event outcomes) on Worlds that implement the optional batch API. The fold only engages when the World implements `events.createBatch` (the Vercel World does; Local and Postgres do not), the run's spec version supports slot identity (≥ 6), and the suspension carries no attribute writes, hook writes, or resilient step dispatch — everything else keeps the single-event path unchanged, so disabling is only needed as an operational escape hatch. Batches are capped at 32 events; larger fan-outs commit in successive batches. See the [batched event writes changelog](/docs/changelog/batched-event-writes) for the World API contract.

### `WORKFLOW_EVENTS_TRANSPORT`

- Factory option: none
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/runtime/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,39 @@ export function isResilientStepDispatchEnabled(): boolean {
return process.env.WORKFLOW_RESILIENT_STEP_DISPATCH === '1';
}

/**
* Whether batched event transitions are enabled: the suspension handler folds
* a clean fan-out's `step_created` + `wait_created` writes into one
* `world.events.createBatch` call (one durable write, per-event outcomes)
* instead of one write per event. Only engages when the World implements the
* optional `events.createBatch` AND the run is on slot identity
* (specVersion >= 6) AND the suspension carries no attribute/hook writes and
* no resilient step dispatch — everything else keeps the single-event path
* byte-for-byte.
*
* Reads `process.env.WORKFLOW_BATCH_TRANSITIONS` lazily. Default **ON**;
* disabled only by an explicit `'0'` / `'false'` (case-insensitive) — the
* operator escape hatch that restores the exact prior one-write-per-event
* path, mirroring `WORKFLOW_TURBO`'s kill-switch shape.
*/
export function isBatchTransitionsEnabled(): boolean {
const raw = process.env.WORKFLOW_BATCH_TRANSITIONS;
if (raw === undefined || raw === '') return true;
return !(raw === '0' || raw.toLowerCase() === 'false');
}

/**
* Ceiling on events per `createBatch` call from the batched fan-out fold.
* Mirrors the server's transaction budgets with a comfortable margin: each
* fan-out event costs 2 transaction items server-side (entity + event row)
* against the 100-item DynamoDB cap, and inline payloads count against a
* 768 KB byte budget — 32 events stays well under both, and a fan-out larger
* than this simply commits in successive batches (split batches lose
* cross-batch atomicity, which is exactly today's per-event-write crash
* surface — every batch still converges on retry via per-event 409s).
*/
export const MAX_BATCH_FANOUT_EVENTS = 32;

const warnedMaxEventsValues = new Set<string>();

/**
Expand Down
Loading
Loading