Repository navigation
Description
Summary
Chunks that a run's default user stream acknowledged, and that a live reader
received at write time, are later missing from the stream. The result is a
permanent hole in the chunk index sequence. Two consequences:
streams.getChunks(the paginated listing) skips the missing indices, and
streams.getInfostill reports the original tail, so the loss is visible
only as gaps inindex.- The live read (
GET /v3/runs/:runId/stream/:name?startIndex=N, used by
Run.getReadable({ startIndex })) stops at the first missing index of a
stream that is not closed. It delivers nothing more until the server ends
the response at its maximum duration, about 121 seconds later. Reconnecting
from the same index blocks again, so a reader that starts at or before a hole
can never reach the tail. The core reconnecting reader
(createReconnectingFramedStream) retries that index indefinitely.
Versions
From our lockfile at the time of the incident:
workflow5.0.0-beta.53@workflow/core5.0.0-beta.53@workflow/world5.0.0-beta.36@workflow/world-vercel5.0.0-beta.48@workflow/errors5.0.0-beta.21
Our deployment carries local patches to these packages. The one on the stream
path bounds stream PUTs with a per-request wire deadline over HTTP/1.1 and never
replays an ambiguous append. It does not touch reads, and the chunks below were
acknowledged (the PUT returned 2xx) and delivered live before they went missing.
Evidence
Production burst of 108 concurrent runs on one project, 2026-09-23 03:35 to
03:47 UTC, several streaming runs per function instance.
Example: wrun_41M3659GYK0GZ2DP8ARBXR83JA (769 separate holes, 832 indices)
- Live read during the run, 03:35:06 to 03:49:05 UTC: one connection, no
reconnects, every frame delivered in order through the run's terminal frame. - Afterwards:
streams.getInforeportstailIndex: 19841; paging
streams.getChunks(limit 1000) returns 19,010 chunks, so 832 indices are
missing. The first missing indices are 339, 424, 470. GET /v3/.../stream?startIndex=0delivers 339 frames (indices 0 to 338),
then nothing until the server ends the response about 121 s later.startIndex=339delivers 0 frames until the server ends the response about
121 s later.startIndex=340resumes and runs to the next missing index.- A replay attempt after the run delivered the first 154 projected events in
122.9 s and could not progress further.
Timing: a shorter run from the same burst replayed completely at 03:37 UTC and
later stalled at a hole on a fresh replay. The chunks were durable and readable
first, and disappeared afterwards; this is not a write that never landed.
A second campaign on a later deployment (2026-09-23 05:39 to 06:15 UTC), which
overlapped a second 108-run burst from 05:48, shows the same pattern: 7 of 18 streams now have holes
(340 missing of 41,534 chunks). Two of them replayed every index through their
terminal right after the run and lost chunks afterwards:
wrun_41M36CF0G60GJN4M233414KTVE: full replay of 3,475 events after the run;
now tail 3627 with indices 3377, 3443, 3444 and 3600 missing.wrun_41M36CEZ3Y0GMP3RN4AXT73N3B: full replay of 5,901 events after the run;
now tail 6082 with 61 single-index holes, first at 3313.
Nearly every hole is a single index between two consecutive deltas of the same
tool call or text block, which one writer appends in order, often in one
multi-chunk PUT. The client never sends an index (HTTP PUT bodies carry only
length-prefixed chunks; we do not enable the WebSocket stream writer). Streams written at lower concurrency have no holes: 10 runs
from 04:38 to 05:40 UTC (about 20 to 30 concurrent runs on the project) span
1,143,156 chunks with none missing, and a 5-run campaign from 04:39 to 05:17
spans 26,807 with none missing. The loss appears to scale with concurrent
write load.
Expected
- An acknowledged chunk stays readable for the stream's retention period.
- If a chunk can be lost, the live read should not wait forever on a missing
index below the stream's known tail. It could skip to the next existing index,
or return an error that names the missing range, so a reader can resume from
the next existing chunk instead of timing out repeatedly.
Related question: streams never report closed
Every completed run we sampled (7 of 7, 3 to 45 minutes after completion, tails
227 to 3273) reports done: false from streams.getInfo, for example
wrun_41M36CEW2X0GR5K7Q447Q5V2TE (tail 227), wrun_41M3667J960GNT1TZYAJF7160M
(tail 272) and wrun_41M369KEVY0GKW44KP95C11SC1 (tail 3273). Our steps release
their writers and never call close(), relying on the documented behavior
("Streams are automatically closed when the workflow run completes"). We could
not find a client-side close at run completion in @workflow/core beta.53, so
we assume the close is server-side. Is it expected to happen for runs completed
through run_completed? A closed stream would let isVerifiedComplete accept a
clean EOF and let the live read end at the tail instead of waiting for the
server's maximum duration.
Our mitigation
Readers keep one Run.getReadable read per connection. When it stalls or ends
below the tail streams.getInfo reported, they page streams.getChunks, which
lists only existing chunks and never blocks, decode the pages with the SDK's own
deserializer, skip and count the missing indices, and resume the live read after
the tail. getChunks has no start index, so reaching a hole at index N costs
N / 1000 pages from the first chunk; during the burst our read-only scanner also
received HTTP 429 on every stream it listed. A startIndex (or an index-seeking
cursor) on getChunks would make this cheap. This routes around the stall but
cannot restore the lost content.
Activity
rpinckne commentedon Sep 23, 2026
Another data point, this time on appends that get no response at all.
Setup:
@workflow/core5.0.0-beta.55 and@workflow/world-vercel5.0.0-beta.50, on the HTTP stream transport (not WebSocket). Each stream PUT has a wire deadline of 10 s plus 1 s per 64 KiB of body. The deadline is armed only once the request holds a connection, so pool wait is not counted.UND_ERR_HEADERS_TIMEOUT, after 11,210 ms and 11,301 ms. They were on two different runs'_userstreams, 35 s apart.Why it matters: without a response, the client cannot tell whether the server kept the body. Until writer/sequence dedupe lands (#2731), an append that times out cannot be retried safely, because a late commit plus a resend duplicates bytes. The only safe choice today is to fail the step, and that loses the whole run.
Can a request the client has already abandoned still commit on the server later? That answer decides whether dropping it and continuing is safe.
pranaygp commentedon Sep 23, 2026
(AI)
Severity: S1
Acknowledged chunks go missing after they were durable and readable, so this is data loss. Any live reader that starts at or before a hole stalls: each session lasts the server's max response duration (~2 min), and the SDK reconnects to the same index up to 50 times before it errors, which is well over an hour per hole. The loss appears to scale with concurrent write load. It happens in the Vercel World's storage, outside this repo, so the SDK can't restore the data. What the SDK can fix is how a reader behaves at a hole.
Reproduction
createReconnectingFramedStreamreopened index 2 fifty times and then failed with a generic "exceeded maximum reconnection attempts". This is covered by the new tests in the PR below.releaseLock()withoutclose(), and the run completes. Afterwardsstreams.getInfostill reportsdone: false, andrun.getReadable()delivers the 3 chunks and then waits indefinitely. Nothing in the SDK closes a run's streams when the run finishes, so the docs' "automatically closed when the workflow run completes" is currently inaccurate for every World. Until that changes, callclose()on the writable after the last write, as a workaround.Related issues
(writerId, seq)markers so readers can dedupe resent chunks. Whether a request the client abandoned can still commit later is a question for the maintainers.Fix
#4334 changes the reader side and passes CI. When consecutive read sessions end without delivering a frame even though
streams.getInforeports a chunk at the resume index, the read now fails after 3 sessions instead of 50. The error is aStreamErrorthat names the missing index and the tail, so you can resume withstartIndexset to index + 1. The threshold can be tuned withWORKFLOW_FRAMED_STREAM_MAX_STALLS_BELOW_TAIL. Readers waiting at the tail for new data behave as before, and sessions that deliver data make no extra metadata requests. The reader deliberately doesn't skip a hole on its own, because silently dropping a chunk (such as a UI messagestartpart) can corrupt consumer state. This doesn't restore lost chunks or fix the storage-side loss, so this issue stays open for that.To see if this fix solves your issue, could you try to install the pre-release in your package.json? Just replace the
workflowdep like so:{ "dependencies": { "workflow": "https://workflow-tarballs-bwxk2rih5.labs.vercel.dev/workflow.tgz", "@workflow/core": "https://workflow-tarballs-bwxk2rih5.labs.vercel.dev/workflow-core.tgz" } }rpinckne commentedon Sep 25, 2026
New data on the storage-side loss: it happens in a window after a large concurrent launch, and its rate grows with launch width.
Setup:
@workflow/core5.0.0-beta.55,@workflow/world-vercel5.0.0-beta.50, HTTP stream transport. No writer saw a failed or unanswered append, and a live reader received the chunks before they went missing.We compared
streams.getInfotails against a fullstreams.getChunkslisting for runs from three launches:For the 2026-09-25 launch, 170 of 321 streams have holes: 365,445 of 3,098,200 indices are missing (11.8%).
wrun_41M3D0AA8J0GQXA9GK3ZJ3EVFK: 132373, 15,582, 9,049, 339wrun_41M3D0A9QA0GQ1YAV2HFX6AHQG: 141124, 14,582, 6,118, 145wrun_41M3D0ABKJ0GX5A6SJK70NTM4V: 101961, 13,334, 6,850, 0A related effect in the same window: appends slowed enough that one step whose model call had already finished hit core's drain timeout (
Timed out draining step stream writes after 30000ms, stepstep_01M3D0A29D50EMEXMAK3NPPA7Y, 19:27 UTC). The step then retried and repeated the provider call.Questions for the platform team:
rpinckne commentedon Sep 25, 2026
A correction to my last comment, with stronger evidence: these chunks are lost after they were durable and readable, so this isn't write-time capacity. Please disregard my question about an append rate limit.
For the 2026-09-25 launch, each run's stream was read twice: once live while the run ran, and once as a full replay from index 0 right after it finished.
streams.getChunkslisting of the same runs at 22:23 UTC shows 127,689 of those indices missing.wrun_41M3D0A6090GXWCNJMM87WX7QQreplayed complete at 19:32:12 UTC. At 22:23 UTC it had tail index 29337 with 10,644 indices missing.So the chunks were acknowledged, delivered live, and readable in a full replay after the run, then removed within roughly 2.5 hours. That points to something after the write path (replication, compaction, retention or a failover), not to the appends. It also rules out the client: an append that was never acknowledged couldn't have been served in a complete replay.
Questions:
alangenfeld commentedon Sep 29, 2026
Hey @rpinckne thanks for the thorough detailed report. I'm sorry for this data loss you incurred and am working to prevent it from happening again.
The run ids are sufficient if you want to report any new instances you observe.