Files
vercel__workflow/.changeset/writable-group-commit.md
Karthik Kalyan b610c46f81 perf(core): path-independent stream write batching (group commit in the server writable) (#3078)
* perf(core): move stream write batching into WorkflowServerWritableStream (group commit)

Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.

The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
  bound counts buffered AND in-request chunks
  (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
  meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
  default 8 MiB, documented in runtime-tuning). A full buffer applies
  backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
  while a request is in flight accumulate and form the next writeMulti
  group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
  as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
  close() drains before closing; flushablePipe adopts the barrier so
  lock-release completion (step completion) still means 'everything
  written is durable'; abort() DRAINS the accepted prefix (never
  closing) so a producer error after acked writes cannot lose data —
  native pipeTo aborts the sink on source failure; and a failed pipe
  drains before settling so a step failure is not persisted ahead of
  the emitted prefix. A dispatch failure retains the group, poisons
  the sink, and surfaces at the next write/close/drain.

flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.

Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): re-dispatch a chunk buffered in the request settle gap

Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(core): document abort-drain boundedness and terminal-run conflict handling

Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(core): poll instead of fixed sleeps for dispatch assertions

The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:02:35 -07:00

250 B

@workflow/core
@workflow/core
patch

Stream write batching now lives in the server writable itself (group commit), so raw ReadableStreams piped across workflow/step boundaries batch the same as getWritable() instead of sending one request per chunk.