Skip to content

Durable run streams lose acknowledged chunks, and the live read blocks forever at the hole #4306

Open

Description

@rpinckne

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:

  1. streams.getChunks (the paginated listing) skips the missing indices, and
    streams.getInfo still reports the original tail, so the loss is visible
    only as gaps in index.
  2. 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:

  • workflow 5.0.0-beta.53
  • @workflow/core 5.0.0-beta.53
  • @workflow/world 5.0.0-beta.36
  • @workflow/world-vercel 5.0.0-beta.48
  • @workflow/errors 5.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.getInfo reports tailIndex: 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=0 delivers 339 frames (indices 0 to 338),
    then nothing until the server ends the response about 121 s later.
  • startIndex=339 delivers 0 frames until the server ends the response about
    121 s later.
  • startIndex=340 resumes 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

  1. rpinckne commented on Sep 23, 2026

    @rpinckne
    Author

    Another data point, this time on appends that get no response at all.

    Setup: @workflow/core 5.0.0-beta.55 and @workflow/world-vercel 5.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.

    • Two appends of about 100 B each got no response headers. Both failed with UND_ERR_HEADERS_TIMEOUT, after 11,210 ms and 11,301 ms. They were on two different runs' _user streams, 35 s apart.
    • Neither chunk was stored. Reading both streams back afterwards, the chunk is missing.
    • Stream writes otherwise run at about 83 ms p50 and 384 ms p99. These stalls look like lost requests, not a slow tail.
    • The rate is about 1 in 7,000 to 10,000 writes, and hits come in bursts. Earlier releases show the same signature (core beta.53, 11,025 ms), so this is not a regression in the current betas.

    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.

  2. pranaygp commented on Sep 23, 2026

    @pranaygp
    Contributor

    (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

    • Chunk loss: I couldn't reproduce this locally. World Local and World Postgres assign chunk indices from storage order, so a hole can't form there. It only shows up under concurrent load on the Vercel World. Your run IDs and missing indices are the right evidence for the platform team.
    • Reader stall at a hole: reproduced against a world that serves the pattern you describe: chunks 0–1 are delivered, chunk 2 is missing, the tail is 5, and every read at index 2 ends without a frame. createReconnectingFramedStream reopened 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.
    • Streams never report closed: reproduced on World Local with a fresh run. A step writes 3 chunks and calls releaseLock() without close(), and the run completes. Afterwards streams.getInfo still reports done: false, and run.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, call close() on the writable after the last write, as a workaround.

    Related issues

    Fix

    #4334 changes the reader side and passes CI. When consecutive read sessions end without delivering a frame even though streams.getInfo reports a chunk at the resume index, the read now fails after 3 sessions instead of 50. The error is a StreamError that names the missing index and the tail, so you can resume with startIndex set to index + 1. The threshold can be tuned with WORKFLOW_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 message start part) 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 workflow dep 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"
      }
    }
  3. rpinckne commented on Sep 25, 2026

    @rpinckne
    Author

    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/core 5.0.0-beta.55, @workflow/world-vercel 5.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.getInfo tails against a full streams.getChunks listing for runs from three launches:

    launch runs started together chunks missing (long runs)
    2026-09-24, several small batches 3 to 14 0.02 to 0.31%
    2026-09-24, one launch 235 4.9 to 5.8%
    2026-09-25, one launch 285 13.3 to 14.7%

    For the 2026-09-25 launch, 170 of 321 streams have holes: 365,445 of 3,098,200 indices are missing (11.8%).

    • The loss is a time window. From about two minutes after launch until the burst eased (17:46 to 17:51 UTC on 9/24, 19:24 to 19:35 UTC on 9/25), the store dropped a fifth to a third of every writer's chunks. Text deltas, tool results, heartbeats and step markers were all affected. Chunks written after the window have no holes.
    • Chunks are dropped one at a time. Hole lengths fall off geometrically at the local loss rate, as independent per-chunk drops would. A lost multi-chunk PUT or a truncated tail would look different.
    • Examples (tail index, missing, separate holes, first missing):
      • wrun_41M3D0AA8J0GQXA9GK3ZJ3EVFK: 132373, 15,582, 9,049, 339
      • wrun_41M3D0A9QA0GQ1YAV2HFX6AHQG: 141124, 14,582, 6,118, 145
      • wrun_41M3D0ABKJ0GX5A6SJK70NTM4V: 101961, 13,334, 6,850, 0

    A 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, step step_01M3D0A29D50EMEXMAK3NPPA7Y, 19:27 UTC). The step then retried and repeated the provider call.

    Questions for the platform team:

    1. Is there a per-project append rate or capacity limit behind this? If appends above it return 2xx and are dropped later, a 429 or other backpressure at write time would let writers slow down instead of losing data.
    2. Until it's fixed, is there a write pattern that avoids the window, such as fewer and larger appends or a cap on concurrent writers per project?
  4. rpinckne commented on Sep 25, 2026

    @rpinckne
    Author

    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.

    • 124 holed runs replayed complete between 19:25 and 19:49 UTC. Every text, reasoning and tool-input delta the live reader had received was present: 524,872 deltas in total. A streams.getChunks listing of the same runs at 22:23 UTC shows 127,689 of those indices missing.
      • Example: wrun_41M3D0A6090GXWCNJMM87WX7QQ replayed complete at 19:32:12 UTC. At 22:23 UTC it had tail index 29337 with 10,644 indices missing.
    • 46 runs replayed later (19:33 to 20:04 UTC) were already short at replay time (124,925 deltas), and shorter still by 22:23 (169,916).
    • Re-listing three runs at about 22:50 UTC gave the same counts as at 22:23. The loss happened in the hours after the runs, then stopped.

    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:

    1. What happens to a stream's chunks between acknowledgement and long-term storage? Can anything remove or rewrite chunks after a run completes?
    2. Is the platform team investigating this as data loss? Happy to share more run ids and exact index lists.
  5. alangenfeld commented on Sep 29, 2026

    @alangenfeld
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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