Batched event writes

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:

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):

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;
  /** Per-event compute attribution, same as the single create's CreateEventParams. */
  computeInstanceId?: string;
}

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 409s 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 for slot-identity runs with specVersion >= 6. world-local and world-postgres deliberately do not because batching provides no benefit 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 16 events. The server's transaction budgets allow more; 16 is a latency choice (see Per-chunk continuation). Chunks of a larger fan-out commit concurrently: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions, not commit order, carry correctness. The fold only engages when the World implements createBatch, the run is on slot identity, and the suspension carries no attribute writes; everything else keeps the single-event path byte-for-byte. A suspension that also creates or disposes hooks still folds: hook writes are not batchable, so they go through the single-event path concurrently with the fold rather than ahead of it.

Per-chunk continuation. Each chunk's follow-on work starts the moment that chunk commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged. A queued step's message therefore waits on two hops in series, its chunk's commit and then its chunk's batched publish, and both grow with the chunk's size, so smaller chunks start a wide fan-out's queued branches sooner. More chunks also means more concurrent batch writes per run, and from about 16 of them the pair chunk, and with it the first inline branch, slows down. The 16-event cap is where production measurements balanced the two: on 64- and 128-branch fan-outs the last branch started about 90 to 200 ms sooner than at 32 events, with no first-branch cost at those widths.

Pre-claimed inline pairs. When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent [step_created, step_started] pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain step_created and wait_created chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 16-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. The exception is a lone inline step in a suspension that creates a hook: the runtime never starts a body before its claim settles while a hook is being created, and a lazy claim could only be sent after the hook write committed, so the step's pair is folded instead and its claim commits concurrently with the hook write. A plain partition of exactly one step_created or wait_created beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.

Per-event 409s 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. A batch carrying a step_started (that is, any batch with inline pairs) is not retried in-process on a transport blip: a pair's 409 cannot be told apart from the caller's own earlier attempt having committed it, so recovery goes through queue redelivery instead, where the step's ownership stamp routes it back to the same invocation.

createBatch is optional, so today only the Vercel World folds at all: every other World keeps the single-event path and never sends a pair.

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.

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.