[core] framed-v2 stream wire format with per-writer markers#2731
[core] framed-v2 stream wire format with per-writer markers#2731VaguelySerious wants to merge 7 commits into
Conversation
Groundwork for single-request streaming stream writes. Adds an opt-in framed-v2 frame header carrying a per-writer marker ([writerId][seq]) to both the byte framer/unframer and the object serialize/deserialize streams, plus a shared marker codec. The reader strips the marker and dedupes replays by max-seq-per-writerId, so a frame recovery re-sends after it was already persisted is delivered exactly once. Not yet wired into any writer (no writerId is passed today), so there is no user-facing behavior change. Capability gating + the streaming segment writer + tail-match recovery follow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: c8ae6e5 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 |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: 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. |
🧪 E2E Test Results✅ All tests passed Summary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
FNV-1a (64-bit) over a seed string → 8-byte writerId. Callers pass a value stable across deterministic replays (a seeded STABLE_ULID), so the writerId is replay-stable without consuming the VM's seeded RNG (which would shift the sequence observed by user code). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e caller Keep the wire encoding in the world (as writeMulti does): writeStream now takes a ReadableStream of raw frames and applies the multi-chunk length prefix per frame via a TransformStream (frameMultiChunkStream), preserving backpressure. The caller streams frames; the world owns the format. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ager)
The segment manager that will drive WorkflowServerWritableStream's streaming
writes. Feeds raw frames into a sequence of long-lived streaming PUTs
("segments"), soft-closing each at min(~10s, maxFrames, byte budget) and on
idle, and finalizing on close(). A clean segment response commits every frame
in it (drop the unconfirmed buffer, advance the recovery anchor); an unclean
failure hands the unconfirmed frames to a recover() callback (wired in a later
commit with the tail-match + backoff logic).
Fully dependency-injected (transport, recover, clock, timers) and unit-tested
for ordering, maxFrames/time/idle soft-close, recovery, and abort. Not yet
wired into the writer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| ); | ||
| return run; | ||
| } | ||
|
|
recoverStreamTail reconstructs, after an unclean segment failure, which of a writer's in-flight frames the backend persisted, and replays only the rest: - Scans the persisted tail window [max(priorIndices)+1, tailIndex], matching the writer's OWN frames by the framed-v2 marker (concurrent writers share a stream, so a server index isn't attributable to one writer). - Handles the reserve-ahead race (tailIndex can point at a reserved-but- unpersisted chunk) with read-with-backoff 10/100/1000ms; a tail chunk still missing after the window is a real write failure, surfaced not skipped. - Replays frames with seq > max-persisted-seq on a fresh writeStream, bounded by maxAttempts (re-scans between attempts since a replay may partially persist). StreamSegmentWriter.recover now receives the prior clean segment's indices as the scan anchor. Both fully unit-tested; not yet wired into the writer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Landed so far (all behind the final wiring)
1.
framed-v2wire format — extendsframed-v1(length-prefixed) with a per-writer marker so a writer can find its own frames in a shared, interleaved tail during recovery:serialization/frame-marker.ts(buildFramedV2Frame/readFrameMarker/writerIdKey;FRAME_HEADER_SIZEsingle-sourced) +deriveWriterId(replay-deterministic, FNV-1a over a seeded ULID — does not consume VM RNG).seqperwriterId→ exactly-once delivery when recovery re-sends an already-persisted frame.2. Streaming transport (
world-vercel) —instrumentedFetchaccepts aReadableStreambody and setsduplex: 'half'(upload-only, not bidirectional). NewStreamer.writeStream(runId, name, chunks)opens one long-lived PUT (same v2 endpoint + multi-chunk wire format aswriteMulti, world owns per-frame framing) and returns the backendchunkIndices(the recovery anchor). Uses the global dispatcher + no timeout, mirroring the live-read: a consumed stream body can't be replayed, so recovery lives above the transport.3. Capability gate —
framedStreamMarkers(minVersion5.0.0-beta.27), gating framed-v2 + streaming writes together; below the cutoff, producers fall back toframed-v1+ per-batchwriteMulti.4. Segment manager —
serialization/segment-writer.tsStreamSegmentWriter: streams frames into a sequence of long-lived PUTs, soft-closing each atmin(~10s, maxFrames, byte budget)/ on idle / on close(); a clean response commits the segment (drop buffer, advance anchor), an unclean one hands unconfirmed frames to recovery. Fully dependency-injected; serialized segments; backpressure-honoring.5. Recovery —
serialization/segment-recovery.tsrecoverStreamTail: reads the persisted tail, matches this writer's own frames by marker, resumes after its highest already-persistedseq, handles the reserve-ahead race with read-with-backoff (10/100/1000ms → real error if still missing), replays only the un-persisted suffix, bounded retries.Still to come (the wiring)
Rewrite
WorkflowServerWritableStreamto use the segment manager when the world supportswriteStream; thread thewriterIdandframing='framed-v2'throughgetWritableand the object-stream read sites in lockstep; resolve cross-deployment/dashboard read discovery of the framing; server verification; integration + e2e (mid-segment disconnect → exactly-once/in-order, concurrent writers).Tests
Unit-tested per piece:
frame-marker,byte-stream-framing,serialization(framed-v2),capabilities,segment-writer,segment-recovery,streamer(writeStream),http-core(duplex). Fullsrc/unit suite green;pnpm typecheckclean. Changeset:@workflow/coreminor.