perf(streams): avoid blocking first write on WebSocket (#4076)

## Summary & Motivation

While the first socket is still connecting, complete groups go over HTTP instead of parking on the handshake; the socket takes over once it opens. An HTTP-first write that fails poisons the writer rather than falling back, since its outcome may be unknown and replaying it over WS could duplicate a group.

## Test Plan

Tests added for transport switching, ordering against close, and the poisoned-writer path; the world-vercel suite passes locally.
This commit is contained in:
Alex Langenfeld
2026-09-11 11:43:31 -05:00
committed by GitHub
parent 86eb8229f8
commit 01fa7a4158
4 changed files with 172 additions and 35 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@workflow/world-vercel': patch
---
Send initial stream groups over HTTP while the WebSocket connects in the background.
+19 -8
View File
@@ -1,3 +1,4 @@
import type { Attributes } from '@opentelemetry/api';
import {
EntityConflictError,
PreconditionFailedError,
@@ -209,7 +210,8 @@ export async function writeStreamSessionOverHttp(
runId: string,
name: string,
chunks: (string | Uint8Array)[],
config?: APIConfig
config?: APIConfig,
attributes?: Attributes
): Promise<void> {
const httpConfig = await getHttpConfig(config);
httpConfig.headers.set('X-Stream-Multi', 'true');
@@ -229,11 +231,14 @@ export async function writeStreamSessionOverHttp(
logLabel: url.pathname,
spanName: 'workflow.stream.write',
durationAttribute: 'workflow.stream.write.chunk_rtt',
attributes: streamSpanAttributes({
runId,
name,
operation: 'write_multi',
}),
attributes: {
...streamSpanAttributes({
runId,
name,
operation: 'write_multi',
}),
...(offset === 0 ? attributes : undefined),
},
buildError: async (res) =>
createStreamRequestError('write', url, res, await res.text()),
});
@@ -277,8 +282,14 @@ export function createStreamer(config?: APIConfig): Streamer {
name,
writerId,
config,
(chunks) =>
writeStreamSessionOverHttp(runId, name, chunks, config),
(chunks, attributes) =>
writeStreamSessionOverHttp(
runId,
name,
chunks,
config,
attributes
),
() => closeStreamSessionOverHttp(runId, name, config)
);
},
@@ -114,16 +114,19 @@ async function decodeOne(raw: Uint8Array) {
}
const writerId = 'wrtr_01ARZ3NDEKTSV4RRFFQ69G5FAV';
const activeSessions: Array<{ dispose?(): void }> = [];
beforeEach(() => {
sockets.length = 0;
getVercelOidcToken.mockClear();
getVercelOidcToken.mockReset().mockResolvedValue(undefined);
injectTraceContextIntoHeaders.mockClear();
writeSpans.length = 0;
delete process.env.WORKFLOW_STREAMS_TRANSPORT;
});
afterEach(() => {
for (const session of activeSessions.splice(0)) session.dispose?.();
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -140,6 +143,7 @@ function makeSession(
writeHttp,
closeHttp
);
activeSessions.push(session);
return { session, writeHttp, closeHttp };
}
@@ -154,21 +158,49 @@ describe('v1 stream WebSocket writer lifecycle', () => {
expect(closeHttp).toHaveBeenCalledTimes(1);
});
it('tags the first session and connection write with phase timings', async () => {
it('sends immediately over HTTP while the initial socket connects, then switches to WS', async () => {
process.env.WORKFLOW_STREAMS_TRANSPORT = 'ws';
const { session } = makeSession();
const writing = session.write(0, ['one']);
let releaseHttp: (() => void) | undefined;
const httpPending = new Promise<void>((resolve) => {
releaseHttp = resolve;
});
const { session, writeHttp } = makeSession();
writeHttp.mockImplementationOnce(async () => httpPending);
const first = session.write(0, ['one']);
await vi.waitFor(() =>
expect(writeHttp).toHaveBeenCalledWith(
['one'],
expect.objectContaining({
'workflow.stream.ws.session_first_write': true,
'workflow.stream.ws.connecting_at_write': true,
'workflow.stream.ws.connection_attempt': 1,
})
)
);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].open();
const second = session.write(1, ['two']);
// Transport switching happens only after the HTTP group's outcome is
// known, so the later WS sequence can never overtake it.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(sockets[0].sent).toHaveLength(0);
releaseHttp?.();
await first;
await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(1));
expect((await decodeOne(sockets[0].sent[0])).meta).toMatchObject({
type: 'write',
chunkSeq: 1,
});
sockets[0].reply(
encodeFrame({ type: 'write_ack', reqId: 1 }, new Uint8Array())
);
await writing;
await second;
expect(writeSpans).toHaveLength(1);
expect(writeSpans[0]).toMatchObject({
'workflow.stream.ws.session_first_write': true,
'workflow.stream.ws.session_first_write': false,
'workflow.stream.ws.connection_first_write': true,
'workflow.stream.ws.connection_attempt': 1,
});
@@ -186,25 +218,14 @@ describe('v1 stream WebSocket writer lifecycle', () => {
expect(writeSpans[0][attribute]).toEqual(expect.any(Number));
expect(writeSpans[0][attribute]).toBeGreaterThanOrEqual(0);
}
const second = session.write(1, ['two']);
await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(2));
sockets[0].reply(
encodeFrame({ type: 'write_ack', reqId: 2 }, new Uint8Array())
);
await second;
expect(writeSpans[1]).toEqual({
'workflow.stream.transport': 'ws',
'workflow.stream.ws.req_id': 2,
});
});
it('carries the first write when the socket opens inside the budget', async () => {
it('uses WS for the first write when the socket is already open', async () => {
process.env.WORKFLOW_STREAMS_TRANSPORT = 'ws';
const { session, writeHttp } = makeSession();
const writing = session.write(0, ['one']);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].open();
const writing = session.write(0, ['one']);
await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(1));
sockets[0].reply(
encodeFrame({ type: 'write_ack', reqId: 1 }, new Uint8Array())
@@ -212,23 +233,100 @@ describe('v1 stream WebSocket writer lifecycle', () => {
await writing;
expect(writeHttp).not.toHaveBeenCalled();
expect(writeSpans[0]).toMatchObject({
'workflow.stream.ws.session_first_write': true,
'workflow.stream.ws.connection_first_write': true,
});
});
it('atomically tombstones to HTTP when the connect budget expires', async () => {
it('tombstones to HTTP when the background connect budget expires', async () => {
vi.useFakeTimers();
process.env.WORKFLOW_STREAMS_TRANSPORT = 'ws';
const { session, writeHttp } = makeSession();
const first = session.write(0, ['one']);
const second = session.write(1, ['two']);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
await vi.advanceTimersByTimeAsync(0);
expect(writeHttp).toHaveBeenCalledWith(
['one'],
expect.objectContaining({
'workflow.stream.ws.session_first_write': true,
'workflow.stream.ws.connecting_at_write': true,
})
);
await first;
await Promise.all([first, second]);
expect(writeHttp.mock.calls).toEqual([[['one']], [['two']]]);
await vi.advanceTimersByTimeAsync(250);
const second = session.write(1, ['two']);
await second;
expect(writeHttp).toHaveBeenCalledTimes(2);
expect(writeHttp.mock.calls[0]?.[0]).toEqual(['one']);
expect(writeHttp.mock.calls[1]).toEqual([['two']]);
expect(sockets[0].sent).toHaveLength(0);
expect(sockets[0].closed).toContainEqual([1000, 'connect budget expired']);
sockets[0].open();
expect(sockets[0].closed).toContainEqual([1000, 'HTTP fallback selected']);
});
it.each([
'open',
'decline',
] as const)('orders close behind an HTTP-first write when the socket ends in %s', async (outcome) => {
process.env.WORKFLOW_STREAMS_TRANSPORT = 'ws';
let releaseHttp: (() => void) | undefined;
const httpPending = new Promise<void>((resolve) => {
releaseHttp = resolve;
});
const { session, writeHttp, closeHttp } = makeSession();
writeHttp.mockImplementationOnce(async () => httpPending);
const writing = session.write(0, ['one']);
await vi.waitFor(() => expect(writeHttp).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(sockets).toHaveLength(1));
const closing = session.close();
if (outcome === 'open') {
sockets[0].open();
} else {
sockets[0].emit('unexpected-response', {}, {});
}
await new Promise((resolve) => setTimeout(resolve, 0));
expect(sockets[0].sent).toHaveLength(0);
expect(closeHttp).not.toHaveBeenCalled();
releaseHttp?.();
await writing;
if (outcome === 'open') {
await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(1));
expect((await decodeOne(sockets[0].sent[0])).meta).toMatchObject({
type: 'close',
});
sockets[0].reply(
encodeFrame({ type: 'close_ack', reqId: 1 }, new Uint8Array())
);
}
await closing;
expect(closeHttp).toHaveBeenCalledTimes(outcome === 'decline' ? 1 : 0);
});
it('never sends later work over WS after an HTTP-first write fails', async () => {
process.env.WORKFLOW_STREAMS_TRANSPORT = 'ws';
let rejectHttp: ((error: Error) => void) | undefined;
const httpPending = new Promise<void>((_resolve, reject) => {
rejectHttp = reject;
});
const { session, writeHttp } = makeSession();
writeHttp.mockImplementationOnce(async () => httpPending);
const first = session.write(0, ['one']);
await vi.waitFor(() => expect(writeHttp).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].open();
const second = session.write(1, ['two']);
const error = new Error('HTTP outcome unknown');
rejectHttp?.(error);
await expect(first).rejects.toBe(error);
await expect(second).rejects.toBe(error);
expect(sockets[0].sent).toHaveLength(0);
expect(writeHttp).toHaveBeenCalledTimes(1);
});
it('uses the same bounded decision when close is the first operation', async () => {
process.env.WORKFLOW_STREAMS_TRANSPORT = 'ws';
const { session, closeHttp } = makeSession();
@@ -594,7 +692,7 @@ describe('v1 stream WebSocket writer lifecycle', () => {
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
socket.reply(
encodeFrame(
{ type: 'drain', reason: 'max_duration', graceMs: 1 },
{ type: 'drain', reason: 'max_duration', graceMs: 10_000 },
new Uint8Array()
)
);
+25 -2
View File
@@ -206,7 +206,8 @@ class VercelStreamWriteSession implements StreamWriteSession {
private readonly writerId: StreamWriterId,
private readonly config: APIConfig | undefined,
private readonly writeHttp: (
chunks: (string | Uint8Array)[]
chunks: (string | Uint8Array)[],
attributes?: Attributes
) => Promise<void>,
private readonly closeHttp: () => Promise<void>
) {
@@ -229,6 +230,25 @@ class VercelStreamWriteSession implements StreamWriteSession {
timing: WriteTiming
): Promise<void> {
this.assertUsable();
if (this.mode === 'connecting' && this.connectionAttempt === 1) {
// Assign complete groups to HTTP while the initial socket opens in the
// background. The serial operation chain prevents later WS work from
// overtaking this request, and a failed HTTP request poisons the writer:
// its outcome may be unknown, so it must never be replayed over WS.
try {
await this.writeHttp(chunks, {
'workflow.stream.ws.session_first_write': timing.sessionFirstWrite,
'workflow.stream.ws.connection_attempt': this.connectionAttempt,
'workflow.stream.ws.connecting_at_write': true,
'workflow.stream.ws.session_to_write_ms':
timing.startedAt - this.sessionCreatedAt,
});
} catch (error) {
this.failUnknown(error);
throw this.poisonError;
}
return;
}
await this.transportDecision;
this.assertUsable();
if (this.mode === 'http') {
@@ -773,7 +793,10 @@ export function createStreamWriteSession(
name: string,
writerId: string,
config: APIConfig | undefined,
writeHttp: (chunks: (string | Uint8Array)[]) => Promise<void>,
writeHttp: (
chunks: (string | Uint8Array)[],
attributes?: Attributes
) => Promise<void>,
closeHttp: () => Promise<void>
): StreamWriteSession {
return new VercelStreamWriteSession(