Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/world-postgres-read-after-eof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-postgres': patch
---

Fix `readFromStream` erroring the whole stream — and dropping every chunk still queued — when rows were written after the stream's first EOF marker (e.g. a producer that retried its terminal write). Rows past the first EOF are now ignored.
141 changes: 141 additions & 0 deletions packages/world-postgres/src/streamer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Pool } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import type { Drizzle } from './drizzle/index.js';
import { createStreamer } from './streamer.js';

// `listenChannel` opens a dedicated LISTEN client; the live-tail leg is not
// under test here, so stub it out rather than open a socket.
vi.mock('pg', () => ({
Client: class {
async connect() {}
async query() {}
on() {}
removeListener() {}
async end() {}
},
}));

type Row = { id: string; eof: boolean; data: Buffer };

/** The one query the catch-up loop runs: select(...).from(...).where(...).orderBy(...). */
function fakeDrizzle(rows: Row[]): Drizzle {
const chain = {
from: () => chain,
where: () => chain,
orderBy: async () => rows,
limit: async () => rows,
};
return { select: () => chain } as unknown as Drizzle;
}

async function drain(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let out = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
return out;
}

describe('streams.get()', () => {
it('ignores rows written after the first EOF instead of erroring the stream', async () => {
// Several data rows before the EOF, on purpose: the catch-up loop is
// synchronous, so all but the first sit in the controller's queue when
// the EOF closes it. A post-EOF `enqueue` then threw out of `start()`,
// which errors the stream and drops that queue — with a single queued
// chunk the consumer happens to drain it first and the bug hides.
const rows: Row[] = ['a', 'b', 'c', 'd', 'e'].map((text, i) => ({
id: `chnk_0${i}`,
eof: false,
data: Buffer.from(`${text}\n`),
}));
rows.push(
{ id: 'chnk_10', eof: true, data: Buffer.alloc(0) },
// A retried terminal write: the frame appended again, the stream
// closed again.
{ id: 'chnk_11', eof: false, data: Buffer.from('e\n') },
{ id: 'chnk_12', eof: true, data: Buffer.alloc(0) }
);
const streamer = createStreamer(
{ options: {} } as unknown as Pool,
fakeDrizzle(rows)
);
try {
const stream = await streamer.streams.get('run_1', 'stream-1', 0);
await expect(drain(stream)).resolves.toBe('a\nb\nc\nd\ne\n');
} finally {
await streamer.close();
}
});

it.each([
5, 6,
])('a start index at or past the data count (%i) closes at the first EOF instead of consuming it', async (startIndex) => {
// Offsets count data chunks (`getInfo` reports tailIndex = dataCount - 1),
// so the EOF marker must never be skipped as if it were data: before this
// guard the marker was swallowed, the post-EOF duplicate came back as
// live data, and with no later EOF the stream never closed.
const rows: Row[] = ['a', 'b', 'c', 'd', 'e'].map((text, i) => ({
id: `chnk_0${i}`,
eof: false,
data: Buffer.from(`${text}\n`),
}));
rows.push(
{ id: 'chnk_10', eof: true, data: Buffer.alloc(0) },
{ id: 'chnk_11', eof: false, data: Buffer.from('e\n') }
);
const streamer = createStreamer(
{ options: {} } as unknown as Pool,
fakeDrizzle(rows)
);
try {
const stream = await streamer.streams.get(
'run_1',
'stream-1',
startIndex
);
await expect(drain(stream)).resolves.toBe('');
} finally {
await streamer.close();
}
}, 5_000);

it.each([
[-1, 'e\n'],
[-2, 'd\ne\n'],
])(
'a negative start index (%i) counts data chunks up to the first EOF only',
async (startIndex, expected) => {
// The negative offset used to resolve against every row, including the
// ones after the first EOF, so `-1` on A…E + EOF + duplicate-E skipped
// the last valid chunk and returned nothing.
const rows: Row[] = ['a', 'b', 'c', 'd', 'e'].map((text, i) => ({
id: `chnk_0${i}`,
eof: false,
data: Buffer.from(`${text}\n`),
}));
rows.push(
{ id: 'chnk_10', eof: true, data: Buffer.alloc(0) },
{ id: 'chnk_11', eof: false, data: Buffer.from('e\n') }
);
const streamer = createStreamer(
{ options: {} } as unknown as Pool,
fakeDrizzle(rows)
);
try {
const stream = await streamer.streams.get(
'run_1',
'stream-1',
startIndex
);
await expect(drain(stream)).resolves.toBe(expected);
} finally {
await streamer.close();
}
},
5_000
);
});
31 changes: 24 additions & 7 deletions packages/world-postgres/src/streamer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,18 +361,34 @@ export function createStreamer(pool: Pool, drizzle: Drizzle): PostgresStreamer {
let lastChunkId = '';
let offset = startIndex ?? 0;
let buffer = [] as StreamChunkEvent[] | null;
// Set by the first EOF marker. A producer that retries a
// terminal write (lost ACK, overlapping attempts) can append
// data and EOF rows after it; `enqueue`/`close` on the already
// closed controller would throw out of `start()`, erroring the
// stream and discarding every chunk still queued, so rows past
// the first EOF are ignored instead.
let closed = false;

function enqueue(msg: {
id: string;
data: Uint8Array;
eof: boolean;
}) {
if (closed) {
return;
}

if (lastChunkId >= msg.id) {
// already sent or out of order
return;
}

if (offset > 0) {
// The EOF marker is not a data chunk (`getInfo`'s tailIndex
// excludes it), so it never counts toward `offset`: a start
// 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.

offset--;
return;
}
Expand All @@ -381,6 +397,7 @@ export function createStreamer(pool: Pool, drizzle: Drizzle): PostgresStreamer {
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.

controller.close();
}
lastChunkId = msg.id;
Expand Down Expand Up @@ -408,13 +425,13 @@ export function createStreamer(pool: Pool, drizzle: Drizzle): PostgresStreamer {
.where(and(eq(streams.streamId, name)))
.orderBy(streams.chunkId);

// Resolve negative offset relative to the data chunk count
// (excluding the trailing EOF marker, if present)
// Resolve negative offset relative to the data chunk count: the
// rows before the first EOF marker. Rows after it (a retried
// terminal write) are ignored by `enqueue`, so they must not
// count here either.
if (typeof offset === 'number' && offset < 0) {
const dataCount =
chunks.length > 0 && chunks[chunks.length - 1].eof
? chunks.length - 1
: chunks.length;
const firstEof = chunks.findIndex((chunk) => chunk.eof);
const dataCount = firstEof === -1 ? chunks.length : firstEof;
offset = Math.max(0, dataCount + offset);
}

Expand Down