Skip to content

Stream writes carry no idempotency key, so a retried step re-appends its chunks #3387

Description

@fantix

streams.write() is a bare append — no chunk index, no dedupe key — so when a step is retried, every chunk it already wrote gets written again and readers see the duplicates. The only mitigation available to users today is "keep your chunks idempotent."

Why it happens

The stream id is a pure function of the run id, so a retried step reopens the same stream instead of a fresh one:

export function getWorkflowRunStreamId(runId: string, namespace?: string) {
const streamId = `${runId.replace('wrun_', 'strm_')}_user`;
if (!namespace) {
return streamId;
}
// Base64 encode the namespace to handle special characters that may not be allowed in Redis keys
const encodedNamespace = Buffer.from(namespace, 'utf-8').toString(
'base64url'
);
return `${streamId}_${encodedNamespace}`;
}

The step-side getWritable() pipes straight into WorkflowServerWritableStream(runId, name)world.streams:

const serverWritable = new WorkflowServerWritableStream(
runId,
name,
ctx.runReadyBarrier
);

Those writes are plain side effects — nothing in the event log gates or replays them — so a re-executed step body re-emits from the top.

And the world API has nowhere to put a dedupe key:

streams: {
write(
runId: string,
name: string,
chunk: string | Uint8Array
): Promise<void>;

On the wire it is PUT /v2/runs/<runId>/streams/<name> with the raw chunk as the body:

async write(
runId: string | Promise<string>,
name: string,
chunk: string | Uint8Array
) {
// Await runId if it's a promise to ensure proper flushing
const resolvedRunId = await runId;
const httpConfig = await getHttpConfig(config);
const url = getStreamUrl(name, resolvedRunId, httpConfig);
const response = await instrumentedFetch({
method: 'PUT',
url: url.toString(),
body: chunk,
headers: httpConfig.headers,
dispatcher: getStreamDispatcher(config),
timeoutMs: null,
logLabel: url.pathname,
spanName: 'workflow.stream.write',
durationAttribute: 'workflow.stream.write.chunk_rtt',
attributes: streamSpanAttributes({
runId: resolvedRunId,
name,
operation: 'write',
}),
buildError: async (res) =>
createStreamRequestError('write', url, res, await res.text()),
});
// Drain the (empty) response so undici can release the pooled connection.
await response.text();
},

Users cannot route around it, either. getWritable() inside a workflow returns only a handle — the actual IO has to happen in a step, and the step is exactly the retry unit:

export function getWritable<W = any>(
options: WorkflowWritableStreamOptions = {}
): WritableStream<W> {
const { namespace } = options;
const name = (globalThis as any)[WORKFLOW_GET_STREAM_ID](namespace);
return Object.create(globalThis.WritableStream.prototype, {
[STREAM_NAME_SYMBOL]: {
value: name,
writable: false,
},
});
}

It is also reachable without a step retry

writeMulti pages at MAX_CHUNKS_PER_REQUEST and the caller re-sends the whole buffer when a later page fails. That is already acknowledged in a comment:

// Send in pages of MAX_CHUNKS_PER_REQUEST to stay within the
// server's per-batch limit (MAX_CHUNKS_PER_BATCH).
// Note: for batches spanning multiple pages, atomicity is relaxed —
// earlier pages may persist while a later page fails. The caller
// retains the full buffer on error, so chunks from successful pages
// will be re-sent on retry, producing duplicates. This is acceptable
// because the alternative (400 on all >1000 chunk flushes) is worse,
// and the scenario requires a network failure mid-batch.

Note: for batches spanning multiple pages, atomicity is relaxed — earlier pages may persist while a later page fails. The caller retains the full buffer on error, so chunks from successful pages will be re-sent on retry, producing duplicates. This is acceptable because the alternative (400 on all >1000 chunk flushes) is worse, and the scenario requires a network failure mid-batch.

So any flush over 1000 chunks that hits a mid-batch network failure duplicates too, without a step retry being involved.

What existing work covers, and what it does not

#2731 (with vercel/workflow-server#584) introduces framed-v2 (writerId, seq) markers and read-side dedupe. That dedupe is scoped to a single writer's lifetime — its purpose is letting a writer resend admitted-but-unconfirmed frames across a reconnect or a WebSocket → PUT fallback, per the PR: "redelivering the writer's admitted-but-unconfirmed frames first (markers dedupe any overlap)".

A retried step mints a new writer, so the overlap is not recognized and this case stays open. The markers do look like the right substrate for a fix, though: what is missing is an identity that is stable across step attempts — the way correlationId is for step events — rather than per writer instance.

Not documented

The streaming docs only state that a stream error does not trigger a producer retry:

When a step returns a stream, the step is considered successful once it returns, even if the stream later encounters an error. The workflow won't automatically retry the step. The consumer of the stream must handle errors gracefully. For more on retry behavior, see [Errors and Retries](/docs/foundations/errors-and-retries).

Nothing says that a retry for any other reason re-emits chunks. Even if the semantics stay as they are, that is worth writing down.

Origin

This came up reviewing the Python SDK's streaming port, vercel/vercel-py#256, whose description carries the caveat "Retries re-stream. A step that fails halfway already wrote what it wrote. Keep chunks idempotent." @msullivan noted it should be the framework's job and needs raising upstream:

vercel/vercel-py#256 (comment)

I think that streams ought to have a way to have an idempotency key, but we'll need to raise this upstream I think. In temporal, workflows themselves can write to streams, and so they must be doing some idempotency thing for it. In workflow, the workflow needs to have a step do the [write], but the step might get retried

Filing here rather than in vercel-py because the gap is in the protocol, not in any one SDK: Python is faithfully mirroring what TypeScript does.


🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions