-
Notifications
You must be signed in to change notification settings - Fork 336
fix: version stream tail cursors across worlds #3475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
69e8f56
b827cd8
5d22ad6
4f319cc
9d0aaa8
b9d4094
63b7a57
5d68d17
e15cc02
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@workflow/world': patch | ||
| '@workflow/world-local': patch | ||
| '@workflow/world-postgres': patch | ||
| '@workflow/world-vercel': patch | ||
| 'workflow': patch | ||
| --- | ||
|
|
||
| Return a checkpoint cursor after every non-empty open-stream chunk page, use the versioned Vercel API contract, and release matching Workflow and World package versions. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,12 @@ | ||
| import { EventEmitter } from 'node:events'; | ||
| import fs from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import type { | ||
| GetChunksOptions, | ||
| StreamChunksResponse, | ||
| Streamer, | ||
| StreamInfoResponse, | ||
| import { | ||
| type GetChunksOptions, | ||
| type StreamChunksResponse, | ||
| StreamCursorPositionSchema, | ||
| type Streamer, | ||
| type StreamInfoResponse, | ||
| } from '@workflow/world'; | ||
| import { monotonicFactory } from 'ulid'; | ||
| import { z } from 'zod'; | ||
|
|
@@ -349,42 +350,33 @@ export function createStreamer(basedir: string, tag?: string): Streamer { | |
| let startIndex = 0; | ||
| if (options?.cursor) { | ||
| try { | ||
| const decoded = JSON.parse( | ||
| Buffer.from(options.cursor, 'base64').toString('utf-8') | ||
| ); | ||
| startIndex = decoded.i; | ||
| startIndex = StreamCursorPositionSchema.parse( | ||
| JSON.parse(Buffer.from(options.cursor, 'base64').toString()) | ||
| ).i; | ||
| } catch { | ||
| startIndex = 0; | ||
| } | ||
| } | ||
|
|
||
| // Walk from startIndex, reading only the files we need. | ||
| // Files before the cursor are skipped entirely. | ||
| let streamDone = false; | ||
| let hasMore = false; | ||
| const resultChunks: { index: number; data: Uint8Array }[] = []; | ||
| let dataIndex = 0; // running count of data (non-EOF) files seen | ||
|
|
||
| for (const file of chunkFiles) { | ||
| for ( | ||
| let fileIndex = startIndex; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Note The Postgres streamer in this same PR gained real cursor validation (
Reusing the same schema shape you added for Postgres would make the two consistent and turn this into a clean restart-from-zero. |
||
| fileIndex < chunkFiles.length; | ||
| fileIndex++ | ||
| ) { | ||
| const file = chunkFiles[fileIndex]; | ||
| const ext = fileExtMap.get(file) ?? '.bin'; | ||
| const filePath = path.join(chunksDir, `${file}${ext}`); | ||
|
|
||
| // Before the cursor: only need to check EOF (1 byte), skip content | ||
| if (dataIndex < startIndex) { | ||
| if (isEofByte(await readFirstByte(filePath))) { | ||
| streamDone = true; | ||
| break; | ||
| } | ||
| dataIndex++; | ||
| continue; | ||
| } | ||
|
|
||
| // Collected enough data chunks — peek at the next file for EOF/hasMore | ||
| if (resultChunks.length >= limit) { | ||
| if (isEofByte(await readFirstByte(filePath))) { | ||
| streamDone = true; | ||
| } else { | ||
| // More data files exist beyond this page | ||
| dataIndex++; | ||
| hasMore = true; | ||
| } | ||
| break; | ||
| } | ||
|
|
@@ -396,23 +388,29 @@ export function createStreamer(basedir: string, tag?: string): Streamer { | |
| break; | ||
| } | ||
| resultChunks.push({ | ||
| index: dataIndex, | ||
| index: startIndex + resultChunks.length, | ||
| data: Uint8Array.from(chunk.chunk), | ||
| }); | ||
| dataIndex++; | ||
| } | ||
|
|
||
| // hasMore = we know there are data files beyond this page | ||
| const hasMore = | ||
| !streamDone && dataIndex > startIndex + resultChunks.length; | ||
| const nextIndex = startIndex + resultChunks.length; | ||
| const nextCursor = hasMore | ||
| ? Buffer.from(JSON.stringify({ i: nextIndex })).toString('base64') | ||
| : null; | ||
| if (!streamDone && startIndex >= chunkFiles.length) { | ||
| const file = chunkFiles.at(-1); | ||
| if (file) { | ||
| const ext = fileExtMap.get(file) ?? '.bin'; | ||
| streamDone = isEofByte( | ||
| await readFirstByte(path.join(chunksDir, `${file}${ext}`)) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| data: resultChunks, | ||
| cursor: nextCursor, | ||
| cursor: | ||
| resultChunks.length > 0 && (hasMore || !streamDone) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Note Moving from "walk every file, count data files" to "index directly from
A consumer polling until |
||
| ? Buffer.from( | ||
| JSON.stringify({ i: startIndex + resultChunks.length }) | ||
| ).toString('base64') | ||
| : null, | ||
| hasMore, | ||
| done: streamDone, | ||
| }; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.