[core] Fix getWritable() chunk reordering when called repeatedly per step#2086
[core] Fix getWritable() chunk reordering when called repeatedly per step#2086VaguelySerious wants to merge 2 commits into
Conversation
Repeat calls to `getWritable()` from the same step previously spawned independent TransformStream + flushablePipe pairs that all flushed to the same (runId, name) on the server. On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locally the world-local filesystem path made the race invisible. Cache the writable + pipe state per (runId, namespace) in the step context so repeat calls share one serial sink. Each call still registers a per-call guard in ctx.ops so the step waits for every caller's writes to flush before completing, matching the prior multi-pipe semantics. Closes #2058 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 40106be The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🧪 E2E Test Results✅ All tests passed Summary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
|
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
TooTallNate
left a comment
There was a problem hiding this comment.
Should we memoize the getWritable() return value instead? Seems simpler to me.
|
@TooTallNate doing now |
Drop the per-call guard mechanism and `pollSharedWritableLock` helper. With memoization, the cached pipe's single `state.promise` is sufficient: in the loop pattern `await writer.write(chunk)` already blocks until each chunk is flushed (the sink awaits the scheduled flush before resolving), so by the time the loop ends `pendingOps === 0` and the final `releaseLock()` lets `pollWritableLock` resolve the state. One writable, one pipe, one ops entry per `(runId, namespace)`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes #2058, where calling
getWritablemultiple times in the same function causes chunk re-ordering without any user-facing warnings. I'm not sure if this is the right approach or we should just throw an error or warn.The issue
Each call to
getWritable()constructed a freshTransformStream+WorkflowServerWritableStream+ backgroundflushablePipe(...), all flushing to the same(runId, name). On Vercel the 50-100ms HTTP write latency turned the inter-pipe race into deterministic reordering — most visibly when callers acquired a fresh writer per chunk (e.g. an AI SDK text-delta loop). Locallyworld-localmade it look fine because filesystem writes are effectively instant.The fix
(runId, namespace)within a step context so repeat calls togetWritable()no longer spawn racing pipes.ctx.opsso the step still waits for every caller's writes to drain.