mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@workflow/world-vercel': patch
|
||||
---
|
||||
|
||||
Send initial stream groups over HTTP while the WebSocket connects in the background.
|
||||
@@ -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()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user