Skip to content

fix(world-postgres): ignore stream rows written after the first EOF - #3712

Open
himself65 wants to merge 3 commits into
vercel:mainfrom
himself65:fix/world-postgres-read-after-eof
Open

fix(world-postgres): ignore stream rows written after the first EOF#3712
himself65 wants to merge 3 commits into
vercel:mainfrom
himself65:fix/world-postgres-read-after-eof

Conversation

@himself65

Copy link
Copy Markdown

Description

streams.get() in @workflow/world-postgres closes the controller on the EOF row during its catch-up loop and keeps iterating. If the stream has rows after its first EOF — a producer that retried its terminal write after a lost ACK or an overlapping attempt, so the frame was appended and the stream closed a second time — the next row hits controller.enqueue() on a closed controller. Node throws ERR_INVALID_STATE (Invalid state: Controller is already closed) out of start(), the stream errors, and every chunk still queued is discarded: the reader sees an empty, errored stream instead of the data written before the EOF.

We hit this in production: a long-running agent run pushed its output through writeToStream/closeStream; an ingest retry duplicated the terminal frame (data ×3, EOF ×3, data ×2, EOF ×2 in workflow_stream_chunks), and the workflow's finalize step — which reads the stream from 0 — persisted the run as failed with empty output even though all the data was there. On our instance 143 streams had rows after their EOF and 1809 had more than one EOF row. Reproduces on 4.3.3, 4.3.4 and main.

The fix tracks the first EOF and ignores every later row (data or EOF). It does not change behaviour for well-formed streams.

How did you test your changes?

  • New unit test packages/world-postgres/src/streamer.test.ts drives createStreamer against a fake pool/drizzle (the pg Client is stubbed so no LISTEN socket is opened). It writes five data rows, an EOF, then a duplicate data row + EOF, and expects the stream to drain exactly the five rows. Without the fix it rejects with TypeError: Invalid state: Controller is already closed.
  • Several data rows before the EOF are deliberate: the catch-up loop is synchronous, so all but the first sit in the controller's queue when the EOF closes it, and it is that non-empty queue that the post-EOF enqueue blows away. With a single queued chunk the consumer drains it first by microtask order and the failure does not reproduce.
  • tsc --noEmit and biome check on the package are clean (Biome's pre-existing noExcessiveCognitiveComplexity warning on enqueue goes from 16 to 20 with the extra guard; getChunks already warns at 21).

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR (@workflow/world-postgres: patch)
  • 🔒 DCO sign-off passes (run git commit --signoff on your commits)
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@himself65
himself65 requested a review from a team as a code owner August 21, 2026 18:28
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6a05ac4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@workflow/world-postgres Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

@himself65 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@karthikscale3 karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See inline comment.

controller.enqueue(new Uint8Array(msg.data));
}
if (msg.eof) {
closed = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for submitting a fix for this. One edge case remains: because the offset > 0 branch runs before this EOF handling, a positive startIndex can consume the first EOF as if it were a data chunk. With five data rows + EOF + retried data/EOF, get(..., 6) skips the first EOF and returns the post-EOF duplicate (or hangs if no later EOF arrives). Could we handle EOF before decrementing offset—or only apply the offset branch when !msg.eof—and add a regression test for a start index beyond the valid data count?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in d70e0e8 by exempting EOF rows from the offset branch (if (offset > 0 && !msg.eof)) — offsets count data chunks, matching getInfo's tailIndex, so the marker is never consumed. Added a regression test for a start index at (5) and past (6) the data count with a post-EOF duplicate and no trailing EOF; the 6 case hung until timeout before the fix.

@karthikscale3 karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See inline comment.

// index at or past the data count must still close the
// stream rather than consume the marker and then hang, or
// surface rows written after it.
if (offset > 0 && !msg.eof) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One remaining edge case: a negative startIndex is calculated below from chunks.length, which includes rows after the first EOF. For A…E, EOF, duplicate-E, get(..., -1) computes an offset from seven rows, then reaches the first EOF without returning the final valid chunk. Could we derive dataCount from the first EOF (for example, with findIndex) and add a startIndex = -1 regression test?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — fixed in 4319027: dataCount now comes from chunks.findIndex((c) => c.eof) (falling back to chunks.length with no EOF), so a negative start index resolves against the same rows enqueue delivers. Regression test added for -1 (→ e) and -2 (→ d, e) on A…E + EOF + duplicate-E; -1 returned nothing before the fix.

`streams.get()`'s catch-up loop closes the controller on the EOF row and
keeps iterating. A stream with rows after its first EOF — a producer that
retried its terminal write after a lost ACK or an overlapping attempt, so
the frame was appended and the stream closed again — then hits
`controller.enqueue()` on a closed controller. Node throws
ERR_INVALID_STATE ("Invalid state: Controller is already closed") out of
`start()`, the stream errors, and every chunk still queued is discarded:
a reader sees an empty, errored stream instead of the data that was
written before the EOF.

Track the first EOF and ignore everything after it. The unit test drives
`createStreamer` against a fake pool/drizzle with several data rows before
the EOF (with a single queued chunk the consumer drains it first by
microtask order and the failure does not reproduce).

Signed-off-by: Alex Yang <himself65@outlook.com>
Offsets count data chunks (`getInfo` reports tailIndex = dataCount - 1),
but the `offset > 0` branch ran before the EOF handling, so a start index
at or past the data count consumed the EOF marker as if it were data —
returning a post-EOF duplicate as live data, or never closing when no
later EOF existed. Exempt EOF rows from the offset skip and cover the
boundary (start index == data count and > data count) in the test.

Signed-off-by: Alex Yang <himself65@outlook.com>
…re the first EOF

The negative offset was computed from `chunks.length` (minus a trailing
EOF), which counts rows written after the first EOF marker. On A…E, EOF,
duplicate-E a `get(..., -1)` resolved against seven rows and closed at
the first EOF without ever returning the last valid chunk. Derive the
data count from the first EOF instead, matching what `enqueue` delivers.

Signed-off-by: Alex Yang <himself65@outlook.com>
@himself65
himself65 force-pushed the fix/world-postgres-read-after-eof branch from 4319027 to 6a05ac4 Compare August 22, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants